Webhooks
BillerAPI sends webhook events to notify your application when key actions occur. Configure your webhook endpoint and signing secret in the Client Portal.
See also: Webhooks integration guide · Webhook dedupe · 404 after a webhook
The webhook envelope
Every webhook delivery shares the same envelope. The event-specific payload lives at data.object and varies per type (see the per-event tables below).
Top-level fields
| Field | Type | Description |
|---|---|---|
| id* | string | Unique event identifier, prefixed `evt_`. Use this as your dedupe key. |
| object* | string | Always `"event"`. |
| type* | string | Dotted-lowercase event type (e.g. `bill.created`, `link.completed`). |
| api_version* | string | Date-stamped envelope version (e.g. `2026-04-29`). |
| created* | number | Unix epoch seconds when the event occurred. |
| environment* | string | One of `dev`, `staging`, `prod`. |
| data.object* | object | Minimal resource snapshot (`id`, `object`, plus routing keys). See per-event tables below. |
| request.id* | string | null | Originating request id, when the event was caused by an API call. |
| request.idempotency_key* | string | null | Echoes the originating request's `Idempotency-Key` header when present. |
Example envelope
{
"id": "evt_01HX9K7MZQR4F3X5V2W8N1B2C3",
"object": "event",
"type": "bill.created",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": {
"object": {
"id": "bill_01HX5...",
"object": "bill",
"user_id": "user_42",
"biller_id": "test_electric_company"
}
},
"request": { "id": null, "idempotency_key": null }
}All deliveries are signed with the BillButler-Signature: t=<unix>,v1=<hex> header (HMAC-SHA256 over <t>.<raw_body>, 5-minute replay window). See the integration guide for verification recipes.
Event types
| Type | Resource | Fires when |
|---|---|---|
| bill.created | bill | A new bill was extracted for a linked account. Subscribers typically refetch the full resource via GET /v1/bills/:id to get the amount, due date, and statement. |
| bill.updated | bill | A bill's metadata changed (status, amount, due date). Refetch via the bill endpoint for the new state. |
| bill.deleted | bill | A bill was deleted. The follow-up GET will return 404; record the deletion and acknowledge with 200. |
| bill.paid | bill | A bill flipped to PAID. Source signal (in-platform payment attempt, statement-credit arithmetic, or matched observed payment) is intentionally not on the wire — this is a minimal-with-refetch event. Pull `GET /v1/bills/:id` to get the new status + `paid_at`. The full audit trail (which signal, evidence id, confidence) is on `GET /v1/bills/:id/transitions`. |
| bill.partially_paid | bill | A bill received a partial observed payment that does not cover the full amount. Status flipped to PARTIALLY_PAID with cumulative observed amount applied. The next matching signal (statement credit, additional observed payment) may push it to PAID. Refetch via `GET /v1/bills/:id` for the current `partial_observed_amount`. |
| bill.status_reverted | bill | A bill was reverted from PAID/PARTIALLY_PAID back to PENDING. Most commonly fires when the nightly reconciliation job sees the next statement still has the prior bill in `previous_balance`, indicating the matcher was wrong. This is the one webhook in the bill family that includes an explicit `previous_status` and `revert_reason` on the wire — a follow-up refetch can show "PAID" again, so the reason has to travel with the event. |
| payment.observed | observed_payment | A historical payment was observed on the biller's portal (Payment History tab). Distinct from a payment your service initiated — this is what the biller already recorded. The `id` is stable across webhook retries and re-scrapes (server-side deterministic SHA-256). No ordering guarantee with `bill.created`; consumers must tolerate either order. Field policy is additive-only; ignore unknown fields. |
| link.completed | link | A link is fully established and ready to retrieve bills. Fires once per link, after the user finishes the Connect flow. |
| link.updated | link | A link's metadata changed (e.g. user-visible label, status). Refetch the link to see the new state. |
| link.expired | link | A link's credentials are no longer valid (biller-side password change, MFA-required reauth, etc.). Prompt the user to re-link. |
| link.disconnected | link | A link was actively disconnected (by the user or via API). Stop billing the user for this link and remove its UI state. |
| request-to-link.created | request_to_link | A request-to-link was created. Use this to track when a server-initiated linking flow begins (e.g. before the user has been sent the Connect URL). |
| request-to-link.cancelled | request_to_link | A request-to-link was cancelled before linking completed. |
| biller-onboarding-request.submitted | biller_onboarding_request | A request to onboard a new biller (biller-scoped, not user-scoped) was accepted. Watch for the matching `biller-onboarding-request.failed` if the biller integration cannot be built. |
| biller-onboarding-request.failed | biller_onboarding_request | A biller onboarding request could not be completed. The follow-up GET returns the failure reason in the resource body. |
| webhook.replay-requested | webhook_dead_letter | An operator triggered a manual replay of a dead-lettered webhook. Useful as an audit signal; clients usually do not need to handle this. |
bill.created
A new bill was extracted for a linked account. Subscribers typically refetch the full resource via GET /v1/bills/:id to get the amount, due date, and statement.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Bill id, prefixed `bill_`. |
| object* | string | Always `"bill"`. |
| user_id* | string | The `client_user_id` you supplied at link creation. |
| biller_id* | string | The biller this bill is for. |
Follow-up to fetch the full resource: GET /v1/bills/:id — authenticate with link-scoped Bearer.
Example bill.created
{
"id": "evt_01HX9K7MZQR4F3X5V2W8N1B2C3",
"object": "event",
"type": "bill.created",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": {
"object": {
"id": "bill_01HX5...",
"object": "bill",
"user_id": "user_42",
"biller_id": "test_electric_company"
}
},
"request": { "id": null, "idempotency_key": null }
}bill.updated
A bill's metadata changed (status, amount, due date). Refetch via the bill endpoint for the new state.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Bill id. |
| object* | string | Always `"bill"`. |
Follow-up to fetch the full resource: GET /v1/bills/:id — authenticate with link-scoped Bearer.
Example bill.updated
{
"id": "evt_01HX...",
"object": "event",
"type": "bill.updated",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": { "object": { "id": "bill_01HX5...", "object": "bill" } },
"request": { "id": null, "idempotency_key": null }
}bill.deleted
A bill was deleted. The follow-up GET will return 404; record the deletion and acknowledge with 200.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | The deleted bill id. |
| object* | string | Always `"bill"`. |
Example bill.deleted
{
"id": "evt_01HX...",
"object": "event",
"type": "bill.deleted",
"api_version": "2026-04-29",
"created": 1714387300,
"environment": "prod",
"data": { "object": { "id": "bill_01HX5...", "object": "bill" } },
"request": { "id": null, "idempotency_key": null }
}bill.paid
A bill flipped to PAID. Source signal (in-platform payment attempt, statement-credit arithmetic, or matched observed payment) is intentionally not on the wire — this is a minimal-with-refetch event. Pull `GET /v1/bills/:id` to get the new status + `paid_at`. The full audit trail (which signal, evidence id, confidence) is on `GET /v1/bills/:id/transitions`.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Bill id, prefixed `bill_`. |
| object* | string | Always `"bill"`. |
| user_id* | string | The `client_user_id` you supplied at link creation. |
| biller_id* | string | The biller this bill is for. |
| account_link_id* | string | The link that produced this bill. |
| account_id | string | null | Biller-side account id when the link enumerates multiple accounts. |
Follow-up to fetch the full resource: GET /v1/bills/:id — authenticate with link-scoped Bearer.
Companion events: subscribe together with bill.partially_paid and bill.status_reverted. PAID is no longer a terminal state — see Status reversal semantics below. A successful match flips a previously-PAID bill back to PENDING via bill.status_reverted if a later statement reveals the prior bill is still owed.
Example bill.paid
{
"id": "evt_01HX...",
"object": "event",
"type": "bill.paid",
"api_version": "2026-04-29",
"created": 1714387400,
"environment": "prod",
"data": {
"object": {
"id": "bill_01HX5...",
"object": "bill",
"user_id": "user_42",
"biller_id": "test_electric_company",
"account_link_id": "alink_01HX...",
"account_id": null
}
},
"request": { "id": null, "idempotency_key": null }
}bill.partially_paid
A bill received a partial observed payment that does not cover the full amount. Status flipped to PARTIALLY_PAID with cumulative observed amount applied. The next matching signal (statement credit, additional observed payment) may push it to PAID. Refetch via `GET /v1/bills/:id` for the current `partial_observed_amount`.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Bill id. |
| object* | string | Always `"bill"`. |
| user_id* | string | The `client_user_id` you supplied at link creation. |
| biller_id* | string | The biller this bill is for. |
| account_link_id* | string | The link that produced this bill. |
| account_id | string | null | Biller-side account id when the link enumerates multiple accounts. |
Follow-up to fetch the full resource: GET /v1/bills/:id — authenticate with link-scoped Bearer.
Companion events: subscribe together with bill.paid and bill.status_reverted. A PARTIALLY_PAID bill that subsequently receives a covering signal will also emit bill.paid.
Example bill.partially_paid
{
"id": "evt_01HX...",
"object": "event",
"type": "bill.partially_paid",
"api_version": "2026-04-29",
"created": 1714387450,
"environment": "prod",
"data": {
"object": {
"id": "bill_01HX5...",
"object": "bill",
"user_id": "user_42",
"biller_id": "test_electric_company",
"account_link_id": "alink_01HX...",
"account_id": null
}
},
"request": { "id": null, "idempotency_key": null }
}bill.status_reverted
A bill was reverted from PAID/PARTIALLY_PAID back to PENDING. Most commonly fires when the nightly reconciliation job sees the next statement still has the prior bill in `previous_balance`, indicating the matcher was wrong. This is the one webhook in the bill family that includes an explicit `previous_status` and `revert_reason` on the wire — a follow-up refetch can show "PAID" again, so the reason has to travel with the event.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Bill id. |
| object* | string | Always `"bill"`. |
| user_id* | string | The `client_user_id` you supplied at link creation. |
| biller_id* | string | The biller this bill is for. |
| account_link_id* | string | The link that produced this bill. |
| account_id | string | null | Biller-side account id when the link enumerates multiple accounts. |
| previous_status* | string | The status before the revert, one of `paid`, `partially_paid`. |
| revert_reason* | string | One of `next_statement_shows_balance`, `manual_admin`, `observed_payment_reversed`. |
| reverted_at* | string | ISO-8601 UTC instant of the revert. |
Follow-up to fetch the full resource: GET /v1/bills/:id — authenticate with link-scoped Bearer.
Breaking mental-model change: PAID is no longer terminal. Subscribers that previously treated bill.paid as the end of a bill\'s lifecycle should now expect a possiblebill.status_reverted follow-up within 24h. Surface this prominently to end users — louder than the original paid flip — so they can verify with the biller.
Example bill.status_reverted
{
"id": "evt_01HX...",
"object": "event",
"type": "bill.status_reverted",
"api_version": "2026-04-29",
"created": 1714387500,
"environment": "prod",
"data": {
"object": {
"id": "bill_01HX5...",
"object": "bill",
"user_id": "user_42",
"biller_id": "test_electric_company",
"account_link_id": "alink_01HX...",
"account_id": null,
"previous_status": "paid",
"revert_reason": "next_statement_shows_balance",
"reverted_at": "2026-04-29T03:15:00Z"
}
},
"request": { "id": null, "idempotency_key": null }
}payment.observed
A historical payment was observed on the biller's portal (Payment History tab). Distinct from a payment your service initiated — this is what the biller already recorded. The `id` is stable across webhook retries and re-scrapes (server-side deterministic SHA-256). No ordering guarantee with `bill.created`; consumers must tolerate either order. Field policy is additive-only; ignore unknown fields.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Observed-payment id (deterministic hash, stable across re-scrapes). Prefixed `opay_`. |
| object* | string | Always `"observed_payment"`. |
| user_id* | string | The `client_user_id` supplied at link creation. |
| biller_id* | string | The biller this payment was made to. |
| account_link_id* | string | The account-link the payment was observed under. |
| account_id | string | null | Biller-side account id when the link enumerates multiple accounts. |
| amount* | string | Decimal string in `currency` units (e.g. `"123.45"`). |
| currency* | string | ISO-4217 currency code (e.g. `"USD"`). |
| payment_date* | string | ISO-8601 date `YYYY-MM-DD` of when the biller posted the payment. |
| status* | string | One of `POSTED`, `PENDING`, `FAILED`, `UNKNOWN`. |
| source* | string | How the payment was observed. One of `SCRAPED`, `MANUAL_ENTRY`, `API_REPORTED`. |
| confirmation_number | string | null | Biller-side confirmation/reference number when surfaced. |
| payment_method_label | string | null | Biller-side payment-method label (e.g. `"Visa ****1234"`), when surfaced. |
| bill_id | string | null | Linked bill id when the observed payment matches a known bill. |
| scraped_at* | string | ISO-8601 UTC instant when the payment row was scraped. |
Example payment.observed
{
"id": "evt_01HX...",
"object": "event",
"type": "payment.observed",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": {
"object": {
"id": "opay_01HX5...",
"object": "observed_payment",
"user_id": "user_42",
"biller_id": "test_electric_company",
"account_link_id": "alink_01HX...",
"account_id": null,
"amount": "123.45",
"currency": "USD",
"payment_date": "2026-04-15",
"status": "POSTED",
"source": "SCRAPED",
"confirmation_number": "CNF-998877",
"payment_method_label": "Visa ****1234",
"bill_id": null,
"scraped_at": "2026-04-29T14:30:00Z"
}
},
"request": { "id": null, "idempotency_key": null }
}link.completed
A link is fully established and ready to retrieve bills. Fires once per link, after the user finishes the Connect flow.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Link id, prefixed `link_`. |
| object* | string | Always `"link"`. |
| user_id* | string | The `client_user_id` you supplied at link creation. |
Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM (X-Client-ID + X-Client-Secret).
Example link.completed
{
"id": "evt_01HX...",
"object": "event",
"type": "link.completed",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": {
"object": {
"id": "link_01HX5...",
"object": "link",
"user_id": "user_42"
}
},
"request": { "id": null, "idempotency_key": null }
}link.updated
A link's metadata changed (e.g. user-visible label, status). Refetch the link to see the new state.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Link id. |
| object* | string | Always `"link"`. |
| user_id* | string | The `client_user_id` you supplied at link creation. |
Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.
Example link.updated
{
"id": "evt_01HX...",
"object": "event",
"type": "link.updated",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": { "object": { "id": "link_01HX5...", "object": "link", "user_id": "user_42" } },
"request": { "id": null, "idempotency_key": null }
}link.expired
A link's credentials are no longer valid (biller-side password change, MFA-required reauth, etc.). Prompt the user to re-link.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Link id. |
| object* | string | Always `"link"`. |
| user_id* | string | The `client_user_id` from link creation. |
| biller_id | string | The biller this link was for. Optional. |
Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.
Example link.expired
{
"id": "evt_01HX...",
"object": "event",
"type": "link.expired",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": {
"object": {
"id": "link_01HX5...",
"object": "link",
"user_id": "user_42",
"biller_id": "test_electric_company"
}
},
"request": { "id": null, "idempotency_key": null }
}link.disconnected
A link was actively disconnected (by the user or via API). Stop billing the user for this link and remove its UI state.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Link id. |
| object* | string | Always `"link"`. |
| user_id* | string | The `client_user_id` from link creation. |
| biller_id | string | The biller this link was for. Optional. |
Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.
Example link.disconnected
{
"id": "evt_01HX...",
"object": "event",
"type": "link.disconnected",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": {
"object": {
"id": "link_01HX5...",
"object": "link",
"user_id": "user_42",
"biller_id": "test_electric_company"
}
},
"request": { "id": null, "idempotency_key": null }
}request-to-link.created
A request-to-link was created. Use this to track when a server-initiated linking flow begins (e.g. before the user has been sent the Connect URL).
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Request-to-link id, prefixed `rtl_`. |
| object* | string | Always `"request_to_link"`. |
| user_id* | string | The `client_user_id` supplied at RTL creation. |
Follow-up to fetch the full resource: GET /v1/link/request-to-link/:id — authenticate with IAM.
Example request-to-link.created
{
"id": "evt_01HX...",
"object": "event",
"type": "request-to-link.created",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": {
"object": {
"id": "rtl_01HX5...",
"object": "request_to_link",
"user_id": "user_42"
}
},
"request": { "id": "req_01HX...", "idempotency_key": null }
}request-to-link.cancelled
A request-to-link was cancelled before linking completed.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Request-to-link id. |
| object* | string | Always `"request_to_link"`. |
| user_id* | string | The `client_user_id` from RTL creation. |
| biller_id | string | The biller this RTL targeted. Optional. |
Follow-up to fetch the full resource: GET /v1/link/request-to-link/:id — authenticate with IAM.
Example request-to-link.cancelled
{
"id": "evt_01HX...",
"object": "event",
"type": "request-to-link.cancelled",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": {
"object": {
"id": "rtl_01HX5...",
"object": "request_to_link",
"user_id": "user_42",
"biller_id": "test_electric_company"
}
},
"request": { "id": null, "idempotency_key": null }
}biller-onboarding-request.submitted
A request to onboard a new biller (biller-scoped, not user-scoped) was accepted. Watch for the matching `biller-onboarding-request.failed` if the biller integration cannot be built.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Onboarding request id, prefixed `bor_`. |
| object* | string | Always `"biller_onboarding_request"`. |
Follow-up to fetch the full resource: GET /v1/biller/onboarding/:id — authenticate with IAM.
This event is biller-scoped — it carries no user_id. A single biller onboarding can serve any number of users that subsequently link to it.
Example biller-onboarding-request.submitted
{
"id": "evt_01HX...",
"object": "event",
"type": "biller-onboarding-request.submitted",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": { "object": { "id": "bor_01HX5...", "object": "biller_onboarding_request" } },
"request": { "id": "req_01HX...", "idempotency_key": null }
}biller-onboarding-request.failed
A biller onboarding request could not be completed. The follow-up GET returns the failure reason in the resource body.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Onboarding request id. |
| object* | string | Always `"biller_onboarding_request"`. |
Follow-up to fetch the full resource: GET /v1/biller/onboarding/:id — authenticate with IAM.
Example biller-onboarding-request.failed
{
"id": "evt_01HX...",
"object": "event",
"type": "biller-onboarding-request.failed",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": { "object": { "id": "bor_01HX5...", "object": "biller_onboarding_request" } },
"request": { "id": null, "idempotency_key": null }
}webhook.replay-requested
An operator triggered a manual replay of a dead-lettered webhook. Useful as an audit signal; clients usually do not need to handle this.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Dead-letter id, prefixed `wdl_`. |
| object* | string | Always `"webhook_dead_letter"`. |
Example webhook.replay-requested
{
"id": "evt_01HX...",
"object": "event",
"type": "webhook.replay-requested",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": { "object": { "id": "wdl_01HX5...", "object": "webhook_dead_letter" } },
"request": { "id": "req_01HX...", "idempotency_key": null }
}