Navigation

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

FieldTypeDescription
id*stringUnique event identifier, prefixed `evt_`. Use this as your dedupe key.
object*stringAlways `"event"`.
type*stringDotted-lowercase event type (e.g. `bill.created`, `link.completed`).
api_version*stringDate-stamped envelope version (e.g. `2026-04-29`).
created*numberUnix epoch seconds when the event occurred.
environment*stringOne of `dev`, `staging`, `prod`.
data.object*objectMinimal resource snapshot (`id`, `object`, plus routing keys). See per-event tables below.
request.id*string | nullOriginating request id, when the event was caused by an API call.
request.idempotency_key*string | nullEchoes the originating request's `Idempotency-Key` header when present.
Example envelope
JSON
{
  "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

TypeResourceFires when
bill.createdbillA 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.updatedbillA bill's metadata changed (status, amount, due date). Refetch via the bill endpoint for the new state.
bill.deletedbillA bill was deleted. The follow-up GET will return 404; record the deletion and acknowledge with 200.
bill.paidbillA 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_paidbillA 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_revertedbillA 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.observedobserved_paymentA 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.completedlinkA link is fully established and ready to retrieve bills. Fires once per link, after the user finishes the Connect flow.
link.updatedlinkA link's metadata changed (e.g. user-visible label, status). Refetch the link to see the new state.
link.expiredlinkA link's credentials are no longer valid (biller-side password change, MFA-required reauth, etc.). Prompt the user to re-link.
link.disconnectedlinkA 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.createdrequest_to_linkA 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.cancelledrequest_to_linkA request-to-link was cancelled before linking completed.
biller-onboarding-request.submittedbiller_onboarding_requestA 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.failedbiller_onboarding_requestA biller onboarding request could not be completed. The follow-up GET returns the failure reason in the resource body.
webhook.replay-requestedwebhook_dead_letterAn 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

FieldTypeDescription
id*stringBill id, prefixed `bill_`.
object*stringAlways `"bill"`.
user_id*stringThe `client_user_id` you supplied at link creation.
biller_id*stringThe biller this bill is for.

Follow-up to fetch the full resource: GET /v1/bills/:id — authenticate with link-scoped Bearer.

Example bill.created
JSON
{
  "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

FieldTypeDescription
id*stringBill id.
object*stringAlways `"bill"`.

Follow-up to fetch the full resource: GET /v1/bills/:id — authenticate with link-scoped Bearer.

Example bill.updated
JSON
{
  "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

FieldTypeDescription
id*stringThe deleted bill id.
object*stringAlways `"bill"`.
Example bill.deleted
JSON
{
  "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

FieldTypeDescription
id*stringBill id, prefixed `bill_`.
object*stringAlways `"bill"`.
user_id*stringThe `client_user_id` you supplied at link creation.
biller_id*stringThe biller this bill is for.
account_link_id*stringThe link that produced this bill.
account_idstring | nullBiller-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
JSON
{
  "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

FieldTypeDescription
id*stringBill id.
object*stringAlways `"bill"`.
user_id*stringThe `client_user_id` you supplied at link creation.
biller_id*stringThe biller this bill is for.
account_link_id*stringThe link that produced this bill.
account_idstring | nullBiller-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
JSON
{
  "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

FieldTypeDescription
id*stringBill id.
object*stringAlways `"bill"`.
user_id*stringThe `client_user_id` you supplied at link creation.
biller_id*stringThe biller this bill is for.
account_link_id*stringThe link that produced this bill.
account_idstring | nullBiller-side account id when the link enumerates multiple accounts.
previous_status*stringThe status before the revert, one of `paid`, `partially_paid`.
revert_reason*stringOne of `next_statement_shows_balance`, `manual_admin`, `observed_payment_reversed`.
reverted_at*stringISO-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
JSON
{
  "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

FieldTypeDescription
id*stringObserved-payment id (deterministic hash, stable across re-scrapes). Prefixed `opay_`.
object*stringAlways `"observed_payment"`.
user_id*stringThe `client_user_id` supplied at link creation.
biller_id*stringThe biller this payment was made to.
account_link_id*stringThe account-link the payment was observed under.
account_idstring | nullBiller-side account id when the link enumerates multiple accounts.
amount*stringDecimal string in `currency` units (e.g. `"123.45"`).
currency*stringISO-4217 currency code (e.g. `"USD"`).
payment_date*stringISO-8601 date `YYYY-MM-DD` of when the biller posted the payment.
status*stringOne of `POSTED`, `PENDING`, `FAILED`, `UNKNOWN`.
source*stringHow the payment was observed. One of `SCRAPED`, `MANUAL_ENTRY`, `API_REPORTED`.
confirmation_numberstring | nullBiller-side confirmation/reference number when surfaced.
payment_method_labelstring | nullBiller-side payment-method label (e.g. `"Visa ****1234"`), when surfaced.
bill_idstring | nullLinked bill id when the observed payment matches a known bill.
scraped_at*stringISO-8601 UTC instant when the payment row was scraped.
Example payment.observed
JSON
{
  "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

FieldTypeDescription
id*stringLink id, prefixed `link_`.
object*stringAlways `"link"`.
user_id*stringThe `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
JSON
{
  "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

FieldTypeDescription
id*stringLink id.
object*stringAlways `"link"`.
user_id*stringThe `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
JSON
{
  "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

FieldTypeDescription
id*stringLink id.
object*stringAlways `"link"`.
user_id*stringThe `client_user_id` from link creation.
biller_idstringThe biller this link was for. Optional.

Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.

Example link.expired
JSON
{
  "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

FieldTypeDescription
id*stringLink id.
object*stringAlways `"link"`.
user_id*stringThe `client_user_id` from link creation.
biller_idstringThe biller this link was for. Optional.

Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.

Example link.disconnected
JSON
{
  "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

FieldTypeDescription
id*stringRequest-to-link id, prefixed `rtl_`.
object*stringAlways `"request_to_link"`.
user_id*stringThe `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
JSON
{
  "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

FieldTypeDescription
id*stringRequest-to-link id.
object*stringAlways `"request_to_link"`.
user_id*stringThe `client_user_id` from RTL creation.
biller_idstringThe 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
JSON
{
  "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

FieldTypeDescription
id*stringOnboarding request id, prefixed `bor_`.
object*stringAlways `"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
JSON
{
  "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

FieldTypeDescription
id*stringOnboarding request id.
object*stringAlways `"biller_onboarding_request"`.

Follow-up to fetch the full resource: GET /v1/biller/onboarding/:id — authenticate with IAM.

Example biller-onboarding-request.failed
JSON
{
  "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

FieldTypeDescription
id*stringDead-letter id, prefixed `wdl_`.
object*stringAlways `"webhook_dead_letter"`.
Example webhook.replay-requested
JSON
{
  "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 }
}