Billy billy Beta Docs

Webhooks

Payments settle in the background, so Billy pushes the outcome to you rather than making you poll for it.

Events

EventFires when
payment.awaiting_paymentA collect payment is waiting for the end-user at checkout.
payment.processingFunds are secured and the bill is being posted.
payment.succeededThe biller accepted the bill. The receipt is in reference.
payment.failedThe payment could not be completed. Money has been returned.
payment.refundedA settled payment was refunded, fully or partially.
topup.confirmedA top-up landed and your float was credited.
wallet.low_balanceYour 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"
  }
}
HeaderWhat it's for
Billy-Event-IdUnique id for this event. Your deduplication key. Also in the body as id.
Billy-TimestampUnix seconds when the delivery was signed.
Billy-SignatureThe 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)
Sign the raw body exactly as received. Parsing the JSON and re-encoding it will change the bytes and the signature will never match. Compare with a constant-time function (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.