Webhooks
Payments settle in the background, so Billy pushes the outcome to you rather than making you poll for it.
Events
| Event | Fires when |
|---|---|
payment.awaiting_payment | A collect payment is waiting for the end-user at checkout. |
payment.processing | Funds are secured and the bill is being posted. |
payment.succeeded | The biller accepted the bill. The receipt is in reference. |
payment.failed | The payment could not be completed. Money has been returned. |
payment.refunded | A settled payment was refunded, fully or partially. |
topup.confirmed | A top-up landed and your float was credited. |
wallet.low_balance | Your spendable float fell to or below your threshold. |
Setting up an endpoint
Add endpoints from the partner dashboard under Webhook Endpoints. Choose which events each endpoint receives — you can run several (say, one for payments, one for float alerts).
Each endpoint gets its own signing secret, shown once when you create it. Store it somewhere safe; if you lose it, rotate the endpoint and update your side.
What a delivery looks like
POST https://your-app.example/webhooks/billy
Content-Type: application/json
Billy-Event-Id: evt_01kxjehb1aa3ztdy315q0ffbdh
Billy-Timestamp: 1752570797
Billy-Signature: t=1752570797,v1=5f8d0c…
{
"id": "evt_01kxjehb1aa3ztdy315q0ffbdh",
"type": "payment.succeeded",
"created_at": "2026-07-15T08:33:18+00:00",
"data": {
"id": "01kxjehb1aa3ztdy315q0ffbdh",
"status": "succeeded",
"funding_source": "wallet",
"biller": { "id": "01KTQ…", "code": "PLDT", "name": "PLDT" },
"amount": "1500.00",
"convenience_fee": "20.00",
"platform_fee": "25.00",
"customer_total": "1545.00",
"currency": "PHP",
"reference": "ECPAY-X8MGLSTUPS",
"external_customer_ref": "app-user-8f3a"
}
}
| Header | What it's for |
|---|---|
Billy-Event-Id | Unique id for this event. Your deduplication key. Also in the body as id. |
Billy-Timestamp | Unix seconds when the delivery was signed. |
Billy-Signature | The signature to verify. See below. |
The envelope is always id, type, created_at, and data. Switch on type; data holds the resource — the same shape the API returns for it.
Verify every event
Your webhook URL is public, so anyone can POST to it. The signature is what proves an event came from Billy — verify it before you act on anything.
The Billy-Signature header carries a timestamp and a signature:
Billy-Signature: t=1752570797,v1=5f8d0c…
v1 is an HMAC-SHA256 of {timestamp}.{raw request body}, keyed with your endpoint's signing secret. To verify: split the header, recompute, and compare.
// PHP
[$t, $v1] = sscanf($request->header('Billy-Signature'), 't=%[^,],v1=%s');
$expected = hash_hmac('sha256', $t . '.' . $request->getContent(), $secret);
if (! hash_equals($expected, $v1)) {
abort(400);
}
// Reject anything too old to blunt replay attempts.
if (abs(time() - (int) $t) > 300) {
abort(400);
}
# Python
import hmac, hashlib, time
parts = dict(p.split("=", 1) for p in request.headers["Billy-Signature"].split(","))
expected = hmac.new(secret.encode(), f'{parts["t"]}.{request.data.decode()}'.encode(),
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts["v1"]):
abort(400)
if abs(time.time() - int(parts["t"])) > 300:
abort(400)
hash_equals, compare_digest) — not ==.Responding
Return any 2xx to acknowledge. Anything else counts as a failure and will be retried.
Acknowledge fast and do the real work afterwards — queue it, don't process inline. Billy gives up on a slow endpoint, and a timeout is treated the same as a rejection.
Retries
A delivery is attempted up to 5 times, backing off between attempts:
attempt 1 → 10s → attempt 2 → 30s → attempt 3 → 2m → attempt 4 → 5m → attempt 5
After that the delivery is left failed. You have two ways to recover it: re-deliver it by hand from the dashboard, or reconcile by re-reading the resource — e.g. GET /payments/{id}, whose status is always the source of truth. Automated integrations usually poll for anything they didn't hear about; the manual re-delivery is handy for one-offs.
Deliver at-least-once: deduplicate
A retry can arrive after your endpoint already succeeded — a response lost in transit still looks like a failure to Billy. Assume you will see the same event more than once and make handling idempotent: record the Billy-Event-Id you've processed and ignore repeats.
Events may also arrive out of order. Trust the status in the payload over the order of arrival, and never move a payment backwards out of a final state.
The delivery log
Every attempt is recorded — the event payload Billy sent, the HTTP status your endpoint returned, and the attempt count. Browse it under Webhook deliveries in the dashboard, where you can inspect a payload and re-deliver a failed one. Or read it over the API:
GET /webhooks/deliveries?status=failed
This is the first place to look when you think you missed an event: it will tell you whether Billy sent it and what your endpoint said back.