Custom Billing
Connect your own billing panel: the endpoints to build, how requests are signed, and examples.
If you run your own billing panel rather than one Frabs connects to directly (WHMCS, Blesta, HostBill, WISECP, FOSSBilling, Paymenter, HostFact, BILLmanager or Ubersmith), Custom Billing connects it to Frabs. You add a few HTTPS endpoints to your panel, following this page, and Frabs calls them. It can then:
- Name the customer behind each VPS automatically, with no linking by hand.
- Open a ticket for the customer, with what was measured and what was done.
- Add a note to the customer's account.
- Suspend and unsuspend their service when your rules say so.
Build only the endpoints you want. Frabs offers only the jobs your panel supports, and you choose which ones it uses.
How it works
Frabs is the client and your panel is the server. Every call is an HTTPS request from Frabs to an address under the API Address you enter, for example https://billing.example.com/frabs:
Requests and answers are JSON. Frabs waits up to 15 seconds for an answer, uses HTTPS only and does not follow redirects.
Check every request is from Frabs
You choose a signing secret: a long random string, entered both in Frabs and in your panel. Frabs signs every request with it, the same way as its webhooks:
The body is the raw request body exactly as received, and an empty string for GET /ping. In your panel:
- 1Read the raw body before parsing it.
- 2Work out
HMAC-SHA256(secret, timestamp + "." + body)in hex. - 3Compare it with the signature after
sha256=using a constant-time comparison. - 4Refuse requests whose timestamp is more than 5 minutes old, so a captured request cannot be replayed.
- 5Answer 401 if anything does not match. Frabs shows that as a signing secret problem.
How to answer
Answer with a 2xx status and a JSON body. To report a problem, answer a 4xx or 5xx status, or {"ok": false, "error": "…"}. Frabs shows your error message on the incident and on the integration, so make it readable.
- 401 or 403: Frabs tells you to check the signing secret.
- 404 on an endpoint: Frabs tells you that endpoint is not built yet. On
/lookup, 404 means the VPS is not one of yours. - Anything that is not JSON is shown as such, with the status.
GET /ping
Used by Test Connection. Answer with your panel's name and the endpoints you built:
Answer
{
"ok": true,
"name": "Northwind Billing",
"supports": ["lookup", "ticket", "note", "suspend", "unsuspend"]
}POST /lookup
Frabs asks who owns a VPS: the first time an incident needs its customer, and in the background for new incidents. It sends the hypervisor's own ID for the VPS (its UUID, or the VMID on Proxmox), its IP addresses and the node's name:
Request
{
"uuid": "6661c686-c90b-4d43-9fd8-136ad44cd316",
"ips": ["203.0.113.25", "2001:db8::25"],
"node": "lon-pve-04"
}Match on the UUID first, then the IPs. Answer with the customer and the service the VPS belongs to:
Answer
{
"customer": { "id": "1042", "name": "Sam Carter", "email": "[email protected]" },
"service_id": "5531"
}If it is not one of yours, answer 404 or {"found": false}. Frabs adds the customer to Customers with your ID as their billing ID, links the VPS and its service, and asks again at most once an hour for a VPS it could not find.
POST /ticket
Open a ticket for the customer. Frabs sends the subject and body it would put in any help desk, and the incident it is about:
Request
{
"customer_id": "1042",
"service_id": "5531",
"email": "[email protected]",
"name": "Sam Carter",
"subject": "Abuse detected on your VPS (#4127)",
"body": "We detected outbound network activity from your VPS ...",
"urgent": true,
"incident": { "number": 4127, "url": "https://panel.frabs.net/incidents/…", "detector": "udp_flood", "severity": "critical" }
}Answer
{ "ticket_id": "T-8812" }The ticket ID is shown on the incident.
POST /note
Add a note to the customer's account:
Request
{
"customer_id": "1042",
"service_id": "5531",
"subject": "Frabs incident #4127: UDP Flood from 203.0.113.25",
"body": "...",
"incident": { "number": 4127, "url": "https://panel.frabs.net/incidents/…", "detector": "udp_flood", "severity": "critical" }
}Answer
{ "ok": true }POST /suspend and /unsuspend
Suspend or unsuspend the service. Suspending only happens when your rules allow it, or when someone with the suspend permission chooses it on an incident:
POST /suspend
{
"service_id": "5531",
"customer_id": "1042",
"reason": "Frabs incident #4127: UDP Flood from 203.0.113.25",
"incident": { "number": 4127, "url": "https://panel.frabs.net/incidents/…", "detector": "udp_flood", "severity": "critical" }
}POST /unsuspend
{ "service_id": "5531", "customer_id": "1042", "incident": { "number": 4127, ... } }Answer
{ "ok": true }{"ok": true}.Example: PHP
frabs.php
<?php
$secret = getenv('FRABS_SIGNING_SECRET');
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_FRABS_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_FRABS_SIGNATURE'] ?? '';
$want = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($want, $sig) || abs(time() - (int)$ts) > 300) {
http_response_code(401); exit(json_encode(['ok' => false, 'error' => 'Bad signature']));
}
header('Content-Type: application/json');
$in = json_decode($body ?: '{}', true);
switch (basename(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))) {
case 'ping':
echo json_encode(['ok' => true, 'name' => 'My Billing', 'supports' => ['lookup', 'suspend', 'unsuspend']]); break;
case 'lookup':
$svc = find_service_by_uuid_or_ip($in['uuid'], $in['ips']); // your own query
if (!$svc) { http_response_code(404); echo json_encode(['found' => false]); break; }
echo json_encode(['customer' => ['id' => $svc['client_id'], 'name' => $svc['client_name'], 'email' => $svc['client_email']], 'service_id' => $svc['id']]); break;
case 'suspend': suspend_service($in['service_id'], $in['reason']); echo json_encode(['ok' => true]); break;
case 'unsuspend': unsuspend_service($in['service_id']); echo json_encode(['ok' => true]); break;
default: http_response_code(404); echo json_encode(['ok' => false, 'error' => 'Not built']);
}Example: Node.js
frabs.js (Express)
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
app.use("/frabs", express.raw({ type: "*/*" }), (req, res, next) => {
const body = req.body?.length ? req.body.toString("utf8") : "";
const ts = req.get("x-frabs-timestamp") ?? "";
const want = Buffer.from("sha256=" + createHmac("sha256", process.env.FRABS_SIGNING_SECRET).update(ts + "." + body).digest("hex"));
const got = Buffer.from(req.get("x-frabs-signature") ?? "");
if (want.length !== got.length || !timingSafeEqual(want, got) || Math.abs(Date.now() / 1000 - Number(ts)) > 300)
return res.status(401).json({ ok: false, error: "Bad signature" });
req.json = body ? JSON.parse(body) : {};
next();
});
app.get("/frabs/ping", (req, res) => res.json({ ok: true, name: "My Billing", supports: ["lookup", "ticket"] }));
app.post("/frabs/lookup", async (req, res) => {
const svc = await findServiceByUuidOrIp(req.json.uuid, req.json.ips); // your own query
if (!svc) return res.status(404).json({ found: false });
res.json({ customer: { id: svc.clientId, name: svc.clientName, email: svc.clientEmail }, service_id: svc.id });
});
app.post("/frabs/ticket", async (req, res) => res.json({ ticket_id: await openTicket(req.json) }));
app.listen(8080);Connect it in Frabs
- 1In the panel open Integrations › Custom Billing.
- 2Enter your API Address: the address the endpoints sit under, without a trailing slash.
- 3Enter the same Signing Secret your panel checks with.
- 4Choose Test Connection. Frabs calls
/pingand shows what your panel answered. - 5Under Use For, tick the jobs your panel supports. Each job uses one integration, so untick a job here if another integration does it.
Then decide when Frabs uses it: open Protection, choose a level, and add Open Support Ticket or Suspend VPS. Naming the customer happens by itself whenever lookup is ticked.
When something goes wrong
- Every failed call is shown on the incident and on the integration, with your panel's own error message.
- A 401 or 403 almost always means the two signing secrets differ, or your check reads the parsed body instead of the raw one.
- If a customer is not named, check your
/lookupmatches on the UUID shown on the VPS's page in Frabs.