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:

EndpointMethodFrabs uses it to
/pingGETTest the connection (required)
/lookupPOSTFind the customer and service behind a VPS
/ticketPOSTOpen a ticket for the customer
/notePOSTAdd a note to the customer's account
/suspendPOSTSuspend the customer's service
/unsuspendPOSTUnsuspend it

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:

HeaderValue
x-frabs-timestampUnix time in seconds when the request was sent
x-frabs-signaturesha256= then the hex HMAC-SHA256 of timestamp.body with your secret

The body is the raw request body exactly as received, and an empty string for GET /ping. In your panel:

  1. 1Read the raw body before parsing it.
  2. 2Work out HMAC-SHA256(secret, timestamp + "." + body) in hex.
  3. 3Compare it with the signature after sha256= using a constant-time comparison.
  4. 4Refuse requests whose timestamp is more than 5 minutes old, so a captured request cannot be replayed.
  5. 5Answer 401 if anything does not match. Frabs shows that as a signing secret problem.
You can also allow only Frabs' address, shown on the integration in your panel, but the signature check is what matters.

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 }
Both should be safe to call twice: suspending a suspended service, or unsuspending an active one, should still answer {"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

  1. 1In the panel open Integrations › Custom Billing.
  2. 2Enter your API Address: the address the endpoints sit under, without a trailing slash.
  3. 3Enter the same Signing Secret your panel checks with.
  4. 4Choose Test Connection. Frabs calls /ping and shows what your panel answered.
  5. 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 /lookup matches on the UUID shown on the VPS's page in Frabs.