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 status changed. The `change` field on `data.object` discriminates the sub-type so subscribers can branch without a round-trip GET for every update. |
| bill.deleted | bill | A bill was deleted. The follow-up GET will return 404; record the deletion and acknowledge with 200. |
| link.completed | link | A link is fully established and ready to retrieve bills. Fires once per link, after the user finishes the Connect flow. |
| connection.ready | link | The first successful bill retrieval for a link completed — the connection is fully warmed up and bills are available. Fires exactly once per link, after `link.completed` and alongside the first `bill.created` events. Use it to flip "Syncing your bills…" UX to a ready state. |
| link_token.completed | link | A BACKGROUNDED connect flow completed server-side: the user chose "continue in background" on the hosted connect page, account discovery finished, all discovered accounts were auto-selected, and BillerAPI exchanged the public token internally. The resulting link already exists and bill retrieval is starting — no client-side exchange is needed (the public token is never exposed on this path). Interactive flows do NOT fire this event; they keep the `link.completed` surface driven by your own `POST /v1/link-tokens/exchange` call. |
| link.session_finished | link | A HOSTED connect session finished. Fires when you mint a link token with `hosted: {}` and redirect the user to the returned `hosted_url` (BillerAPI's standalone hosted connect page). `status` is `completed` — the user finished and BillerAPI already exchanged the public token SERVER-SIDE, so `link_id` is populated and no client-side exchange is needed (the public token is never exposed) — or `exited` — the user closed/abandoned the page (`exit_reason` = `user_closed` | `error`). It does NOT fire on token expiry (that is a `link.disconnected`). Pair it with `connection.ready` / `bill.created` to know when bills are available. |
| link.disconnected | link | A link became unusable. The `reason` field on `data.object` discriminates the cause. All three reasons may require a user-facing action to recover access. |
| link_request.created | link_request | A link request was created and has a known biller. Use this to track when a server-initiated linking flow begins (e.g. before the user has been sent the Connect URL). Invariant: the payload always carries a non-null `biller_id`. |
| link_request.cancelled | link_request | A link request was cancelled before linking completed. A `reason` field distinguishes the cancellation type. |
| onboarding.submitted | onboarding_request | A request to onboard a new biller was accepted. Watch for the matching `onboarding.failed` if the biller integration cannot be built. |
| onboarding.completed | onboarding_request | A biller onboarding request finished successfully — the biller integration is now available and links can be created against it. |
| onboarding.failed | onboarding_request | A biller onboarding request could not be completed. The follow-up GET returns the failure reason in the resource body. |
| customer.message.created | customer_message | Your service sent a message to a customer via POST /customers/{user_aid}/messages and it was accepted. The push notification + in-app inbox row land in BillEBox for the customer. v1 in-app channel only; email/SMS deferred. Two categories: marketing (opt-in required) and account_update (default opt-in; opt-out only). |
| customer.message.complaint | customer_message | A customer tapped Report on a `customer.message.created` you previously sent. BillerAPI has (a) added the (clientId, customer_user_aid) pair to your suppression list — future sends to this customer return HTTP 200 with `delivery_status: "suppressed"` — and (b) revoked the customer's marketing consent. You should mirror the suppression in your own CRM and respect it across all your channels (email, SMS, mail) to stay on the right side of CAN-SPAM / TCPA / state UDAP rules. |
| 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. |
| pay.scheduled | payment_attempt | A payment attempt was scheduled and is being processed (covers both the initial schedule and the submitted-awaiting-confirmation transition — both deliver as `pay.scheduled`). Fetch GET /api/pay/v1/payments/:attemptId for the full record. Note: payment execution is currently gated off — that endpoint returns 501 (PAYMENT_EXECUTION_NOT_AVAILABLE) until the pay pillar is enabled. |
| pay.succeeded | payment_attempt | A payment attempt succeeded. Carries the confirmation number and amount when available so you can reconcile without a round-trip. |
| pay.failed | payment_attempt | A payment attempt failed. The `code` field carries a machine-readable failure reason when available. |
| pay.escalated | payment_attempt | A payment attempt was escalated for manual review (e.g. an unexpected biller-portal state). The `reason` field describes why. |
| insight.created | insight | A new insight was generated for one of your users — a bill/biller-level observation such as an anomaly, duplicate charge, new biller, overdue risk, savings opportunity, or a line-item-level finding (new fee, line-item spike, usage anomaly). Minimal-with-refetch: fetch GET /v1/insights/:id for the full evidence, reasoning, and proposed actions. The `proposed_actions` array is client-agnostic (`action_type` + `label` + `params`) — never an executable command. New insight `type` values may be added over time; treat an unrecognized `type` as a generic insight rather than rejecting the event. The `evidence_json` on the fetched record carries type-specific keys: `bill_anomaly` → `new_total`, `median_total`, `ratio`, `threshold_ratio`, `sample_size`, `currency`; `duplicate_charge` → `amount`, `currency`, `matched_bill_id`, `days_apart`, `window_days`; `new_biller` → `biller_id`, `merchant_name`, `amount`, `currency`; `overdue_risk` → `due_date`, `days_until_due`, `window_days`, `status`, `amount`, `currency`; `new_fee` → `description`, `kind`, `signed_amount`, `currency`; `line_item_spike` → `description`, `signed_amount`, `median_amount`, `ratio`, `threshold_ratio`, `currency`; `usage_anomaly` → `description`, `usage_quantity`, `usage_unit`, `median_quantity`, `ratio`, `threshold_ratio`. Amounts are signed magnitudes (a credit/payment/adjustment reads negative); `ratio` is `current / median`. |
| insight.updated | insight | An insight's lifecycle status changed (snoozed, dismissed, or resolved). The `status` field on `data.object` carries the new state so you can reconcile your projection without a round-trip GET. Fetch GET /v1/insights/:id for the full record. |
| biller.unsupported | biller | The platform marked a biller as unsupported (e.g. the biller's website changed in a way that makes automation impossible). Prompt the affected user to manage this biller manually until the integration is restored. |
| 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 status changed. The `change` field on `data.object` discriminates the sub-type so subscribers can branch without a round-trip GET for every update.
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. |
| status* | string | Current bill status (e.g. `PAID`, `PENDING`, `PARTIALLY_PAID`). |
| change* | string | Sub-type discriminator. One of `paid`, `partially_paid`, `status_reverted`, `statement_refreshed`, `match_status`. |
| paid_reason | string | (change=`paid` or `partially_paid`) Signal that caused the paid transition. |
| paid_at | string | (change=`paid` or `partially_paid`) ISO-8601 UTC instant of the paid transition. |
| previous_status | string | (change=`status_reverted`) Status before the revert — one of `paid`, `partially_paid`. |
| revert_reason | string | (change=`status_reverted`) One of `next_statement_shows_balance`, `manual_admin`, `observed_payment_reversed`. |
| reverted_at | string | (change=`status_reverted`) ISO-8601 UTC instant of the revert. |
| match_status | string | (change=`match_status`) Current match result (e.g. `MATCHED`, `UNMATCHED`). |
| matched_payment_ref | string | null | (change=`match_status`) Reference to the matched observed payment, when present. |
Follow-up to fetch the full resource: GET /v1/bills/:id — authenticate with link-scoped Bearer.
change values at a glance: paid — bill flipped to PAID (+paid_reason, paid_at); partially_paid — partial payment received (+paid_reason, paid_at); status_reverted — bill reverted from PAID/PARTIALLY_PAID to PENDING (+previous_status, revert_reason, reverted_at); statement_refreshed — statement re-extracted, refetch for new balance/due date; match_status — payment-matching result changed (+match_status, matched_payment_ref).
PAID is not terminal. A bill.updated with change='status_reverted' may follow within 24 h if the nightly reconciliation job finds the prior bill still in previous_balance on the next statement. Surface this prominently to end users.
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",
"user_id": "user_42",
"biller_id": "test_electric_company",
"status": "PAID",
"change": "paid",
"paid_reason": "STATEMENT_CREDIT",
"paid_at": "2026-04-21T12:05:00Z"
}
},
"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 }
}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 (API key).
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 }
}connection.ready
The first successful bill retrieval for a link completed — the connection is fully warmed up and bills are available. Fires exactly once per link, after `link.completed` and alongside the first `bill.created` events. Use it to flip "Syncing your bills…" UX to a ready state.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Link id, prefixed `link_`. |
| object* | string | Always `"link"`. |
| link_id* | string | Same value as `id`, carried explicitly for symmetry with `bill.*` payloads. |
| user_id | string | The `client_user_id` from link creation, when available. |
| biller_id | string | The biller this link retrieves bills from. |
| bill_count | number | Bills created in the first run, when cheaply available. Omitted otherwise — do not treat absence as zero. |
| first_sync_completed_at* | string | ISO-8601 timestamp of the first successful retrieval run. |
Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.
Example connection.ready
{
"id": "evt_01HX...",
"object": "event",
"type": "connection.ready",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": {
"object": {
"id": "link_01HX5...",
"object": "link",
"link_id": "link_01HX5...",
"user_id": "user_42",
"biller_id": "test_electric_company",
"first_sync_completed_at": "2026-07-01T12:00:00.000Z"
}
},
"request": { "id": null, "idempotency_key": null }
}link_token.completed
A BACKGROUNDED connect flow completed server-side: the user chose "continue in background" on the hosted connect page, account discovery finished, all discovered accounts were auto-selected, and BillerAPI exchanged the public token internally. The resulting link already exists and bill retrieval is starting — no client-side exchange is needed (the public token is never exposed on this path). Interactive flows do NOT fire this event; they keep the `link.completed` surface driven by your own `POST /v1/link-tokens/exchange` call.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Link token id, prefixed `lt_`. |
| object* | string | Always `"link_token"`. |
| link_token_id* | string | Same value as `id`, carried explicitly. |
| link_id* | string | The persistent link created by the server-side exchange — treat it exactly like the `link_id` you would have received from `POST /v1/link-tokens/exchange`. |
| user_id | string | The `client_user_id` from link-token creation, when available. Stamped opportunistically — route on `link_token_id` / `link_id`. |
| biller_id | string | The biller this link connects to. Optional. |
| completion_mode* | string | How the flow completed. Currently always `auto_all_accounts` (every discovered account was selected). |
Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.
Example link_token.completed
{
"id": "evt_01HX...",
"object": "event",
"type": "link_token.completed",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": {
"object": {
"id": "lt_01HX5...",
"object": "link_token",
"link_token_id": "lt_01HX5...",
"link_id": "link_01HX5...",
"user_id": "user_42",
"biller_id": "test_electric_company",
"completion_mode": "auto_all_accounts"
}
},
"request": { "id": null, "idempotency_key": null }
}link.session_finished
A HOSTED connect session finished. Fires when you mint a link token with `hosted: {}` and redirect the user to the returned `hosted_url` (BillerAPI's standalone hosted connect page). `status` is `completed` — the user finished and BillerAPI already exchanged the public token SERVER-SIDE, so `link_id` is populated and no client-side exchange is needed (the public token is never exposed) — or `exited` — the user closed/abandoned the page (`exit_reason` = `user_closed` | `error`). It does NOT fire on token expiry (that is a `link.disconnected`). Pair it with `connection.ready` / `bill.created` to know when bills are available.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Link token id, prefixed `lt_`. |
| object* | string | Always `"link_token"`. |
| link_token_id* | string | Same value as `id`, carried explicitly. |
| status* | string | `completed` (interactive completion, after the server-side exchange) or `exited` (user closed/abandoned). |
| link_id | string | The persistent link created by the server-side exchange. Present on the `completed` path only. |
| exit_reason | string | `user_closed` or `error`. Present on the `exited` path only. |
| biller_id | string | The biller this link connects to. Optional. |
| user_id | string | The `client_user_id` from link-token creation, when available. Stamped opportunistically. |
Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.
Example link.session_finished
{
"id": "evt_01HX...",
"object": "event",
"type": "link.session_finished",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": {
"object": {
"id": "lt_01HX5...",
"object": "link_token",
"link_token_id": "lt_01HX5...",
"status": "completed",
"link_id": "link_01HX5...",
"biller_id": "test_electric_company",
"user_id": "user_42"
}
},
"request": { "id": null, "idempotency_key": null }
}link.disconnected
A link became unusable. The `reason` field on `data.object` discriminates the cause. All three reasons may require a user-facing action to recover access.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Link id. May be empty for pre-completion rejections (no Link aggregate created yet). |
| object* | string | Always `"link"`. |
| user_id* | string | The `client_user_id` from link creation. |
| reason* | string | Disconnect cause. One of `expired`, `credentials_rejected`, `revoked`. |
| credential_rejection_reason | string | (reason=`credentials_rejected`) Fine-grained sub-reason: `INVALID_CREDENTIALS` or `CONNECTION_DEGRADED`. |
| link_token_id | string | (reason=`credentials_rejected`, pre-completion only) Link token id when no Link aggregate existed yet. |
| link_request_id | string | (reason=`credentials_rejected`, pre-completion only) Request-to-link id. |
| biller_id | string | The biller this link was for. Optional. |
Follow-up to fetch the full resource: GET /v1/link/:id — authenticate with IAM.
reason values at a glance: expired — access token lifetime ended; user can reconnect; credentials_rejected — stored credentials rejected; user must re-enter credentials (recoverable without a full re-link); check credential_rejection_reason for sub-reason (INVALID_CREDENTIALS vs CONNECTION_DEGRADED); revoked — user unlinked or biller revoked consent; terminal — prompt user to start a new link if they want to reconnect.
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",
"reason": "revoked"
}
},
"request": { "id": null, "idempotency_key": null }
}link_request.created
A link request was created and has a known biller. Use this to track when a server-initiated linking flow begins (e.g. before the user has been sent the Connect URL). Invariant: the payload always carries a non-null `biller_id`.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Request-to-link id, prefixed `rtl_`. |
| object* | string | Always `"link_request"`. |
| user_id* | string | The `client_user_id` supplied at RTL creation. |
| biller_id* | string | The biller this link request targets. |
Follow-up to fetch the full resource: GET /v1/link/request-to-link/:id — authenticate with IAM.
Example link_request.created
{
"id": "evt_01HX...",
"object": "event",
"type": "link_request.created",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": {
"object": {
"id": "rtl_01HX5...",
"object": "link_request",
"user_id": "user_42",
"biller_id": "test_electric_company"
}
},
"request": { "id": "req_01HX...", "idempotency_key": null }
}link_request.cancelled
A link request was cancelled before linking completed. A `reason` field distinguishes the cancellation type.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Request-to-link id. |
| object* | string | Always `"link_request"`. |
| user_id* | string | The `client_user_id` from RTL creation. |
| biller_id | string | The biller this RTL targeted. Optional. |
| reason | string | Cancellation reason. One of `user_cancelled`, `expired`, `admin_cancelled`. |
Follow-up to fetch the full resource: GET /v1/link/request-to-link/:id — authenticate with IAM.
Example link_request.cancelled
{
"id": "evt_01HX...",
"object": "event",
"type": "link_request.cancelled",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": {
"object": {
"id": "rtl_01HX5...",
"object": "link_request",
"user_id": "user_42",
"biller_id": "test_electric_company",
"reason": "user_cancelled"
}
},
"request": { "id": null, "idempotency_key": null }
}onboarding.submitted
A request to onboard a new biller was accepted. Watch for the matching `onboarding.failed` if the biller integration cannot be built.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Onboarding request id, prefixed `bor_`. |
| object* | string | Always `"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 onboarding.submitted
{
"id": "evt_01HX...",
"object": "event",
"type": "onboarding.submitted",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": { "object": { "id": "bor_01HX5...", "object": "onboarding_request" } },
"request": { "id": "req_01HX...", "idempotency_key": null }
}onboarding.completed
A biller onboarding request finished successfully — the biller integration is now available and links can be created against it.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Onboarding request id. |
| object* | string | Always `"onboarding_request"`. |
| biller_id* | string | The id of the biller that was onboarded. |
Follow-up to fetch the full resource: GET /v1/biller/onboarding/:id — authenticate with IAM.
Example onboarding.completed
{
"id": "evt_01HX...",
"object": "event",
"type": "onboarding.completed",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": { "object": { "id": "bor_01HX5...", "object": "onboarding_request", "biller_id": "test_electric_company" } },
"request": { "id": null, "idempotency_key": null }
}onboarding.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 `"onboarding_request"`. |
Follow-up to fetch the full resource: GET /v1/biller/onboarding/:id — authenticate with IAM.
Example onboarding.failed
{
"id": "evt_01HX...",
"object": "event",
"type": "onboarding.failed",
"api_version": "2026-04-29",
"created": 1714387260,
"environment": "prod",
"data": { "object": { "id": "bor_01HX5...", "object": "onboarding_request" } },
"request": { "id": null, "idempotency_key": null }
}customer.message.created
Your service sent a message to a customer via POST /customers/{user_aid}/messages and it was accepted. The push notification + in-app inbox row land in BillEBox for the customer. v1 in-app channel only; email/SMS deferred. Two categories: marketing (opt-in required) and account_update (default opt-in; opt-out only).
data.object fields
| Field | Type | Description |
|---|---|---|
| message_id* | string | Server-assigned UUID for this message. Stable across retries. |
| client_id* | string | Your client id (the same id that authenticated the send). |
| customer_user_aid* | string | The `user_aid` you sent to (your stable per-customer identifier). |
| biller_id* | string | The biller this message is sent on behalf of. |
| category* | string | `marketing` or `account_update`. |
| subject* | string | Push notification title body. Max 140 chars. |
| body* | string | Message body the customer sees inside the BillEBox inbox. Max 2048 chars. |
| created_at* | string | ISO-8601 UTC instant of acceptance. |
Example customer.message.created
{
"id": "evt_01J3...",
"object": "event",
"type": "customer.message.created",
"api_version": "2026-05-31",
"created": 1716123456,
"environment": "prod",
"data": {
"object": {
"message_id": "msg_01J3...",
"client_id": "client_acme",
"customer_user_aid": "user_42",
"biller_id": "test_electric_company",
"category": "account_update",
"subject": "Your January statement is ready",
"body": "Your January statement for account ****1234 is now available. Tap to view.",
"created_at": "2026-05-31T14:30:00Z"
}
},
"request": { "id": null, "idempotency_key": null }
}customer.message.complaint
A customer tapped Report on a `customer.message.created` you previously sent. BillerAPI has (a) added the (clientId, customer_user_aid) pair to your suppression list — future sends to this customer return HTTP 200 with `delivery_status: "suppressed"` — and (b) revoked the customer's marketing consent. You should mirror the suppression in your own CRM and respect it across all your channels (email, SMS, mail) to stay on the right side of CAN-SPAM / TCPA / state UDAP rules.
data.object fields
| Field | Type | Description |
|---|---|---|
| message_id* | string | The message id the customer reported. Stable across retries. |
| client_id* | string | Your client id. |
| customer_user_aid* | string | The complaining customer (your stable per-customer identifier). |
| biller_id* | string | The biller the reported message was sent on behalf of. |
| reason* | string | Customer-selected reason: `spam`, `misleading`, `never_signed_up`, or `other`. |
| note | string | Optional free-text note from the customer (max 280 chars). |
| complained_at* | string | ISO-8601 UTC instant the complaint was recorded. |
Example customer.message.complaint
{
"id": "evt_01J5...",
"object": "event",
"type": "customer.message.complaint",
"api_version": "2026-05-31",
"created": 1716123900,
"environment": "prod",
"data": {
"object": {
"message_id": "msg_01J3...",
"client_id": "client_acme",
"customer_user_aid": "user_42",
"biller_id": "test_electric_company",
"reason": "spam",
"note": "Stop sending me offers, I just wanted my bill.",
"complained_at": "2026-05-31T14:35: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 }
}pay.scheduled
A payment attempt was scheduled and is being processed (covers both the initial schedule and the submitted-awaiting-confirmation transition — both deliver as `pay.scheduled`). Fetch GET /api/pay/v1/payments/:attemptId for the full record. Note: payment execution is currently gated off — that endpoint returns 501 (PAYMENT_EXECUTION_NOT_AVAILABLE) until the pay pillar is enabled.
data.object fields
| Field | Type | Description |
|---|---|---|
| payment_attempt_id* | string | The payment attempt id. |
| bill_id | string | null | The bill the attempt is paying. |
| user_id | string | null | The `client_user_id` the attempt is for, when present on the source event. |
Follow-up to fetch the full resource: GET /api/pay/v1/payments/:attemptId — authenticate with IAM (gated — returns 501 until pay is enabled).
Example pay.scheduled
{
"id": "evt_01HX...",
"object": "event",
"type": "pay.scheduled",
"api_version": "2026-04-29",
"created": 1714387400,
"environment": "prod",
"data": {
"object": {
"payment_attempt_id": "patt_01HX5...",
"bill_id": "bill_01HX...",
"user_id": "user_42"
}
},
"request": { "id": null, "idempotency_key": null }
}pay.succeeded
A payment attempt succeeded. Carries the confirmation number and amount when available so you can reconcile without a round-trip.
data.object fields
| Field | Type | Description |
|---|---|---|
| payment_attempt_id* | string | The payment attempt id. |
| bill_id | string | null | The bill that was paid. |
| amount | string | null | Decimal string in `currency` units, when available. |
| currency | string | null | ISO-4217 currency code, when available. |
| confirmation_number | string | null | Biller-side confirmation/reference number, when available. |
Follow-up to fetch the full resource: GET /v1/payment-attempts/:id — authenticate with IAM.
Example pay.succeeded
{
"id": "evt_01HX...",
"object": "event",
"type": "pay.succeeded",
"api_version": "2026-04-29",
"created": 1714387460,
"environment": "prod",
"data": {
"object": {
"payment_attempt_id": "patt_01HX5...",
"bill_id": "bill_01HX...",
"amount": "123.45",
"currency": "USD",
"confirmation_number": "CNF-998877"
}
},
"request": { "id": null, "idempotency_key": null }
}pay.failed
A payment attempt failed. The `code` field carries a machine-readable failure reason when available.
data.object fields
| Field | Type | Description |
|---|---|---|
| payment_attempt_id* | string | The payment attempt id. |
| bill_id | string | null | The bill the attempt was paying. |
| code | string | null | Machine-readable failure code, when available. |
Follow-up to fetch the full resource: GET /v1/payment-attempts/:id — authenticate with IAM.
Example pay.failed
{
"id": "evt_01HX...",
"object": "event",
"type": "pay.failed",
"api_version": "2026-04-29",
"created": 1714387520,
"environment": "prod",
"data": {
"object": {
"payment_attempt_id": "patt_01HX5...",
"bill_id": "bill_01HX...",
"code": "insufficient_funds"
}
},
"request": { "id": null, "idempotency_key": null }
}pay.escalated
A payment attempt was escalated for manual review (e.g. an unexpected biller-portal state). The `reason` field describes why.
data.object fields
| Field | Type | Description |
|---|---|---|
| payment_attempt_id* | string | The payment attempt id. |
| bill_id | string | null | The bill the attempt was paying. |
| reason | string | null | Why the attempt was escalated, when available. |
Follow-up to fetch the full resource: GET /v1/payment-attempts/:id — authenticate with IAM.
Example pay.escalated
{
"id": "evt_01HX...",
"object": "event",
"type": "pay.escalated",
"api_version": "2026-04-29",
"created": 1714387580,
"environment": "prod",
"data": {
"object": {
"payment_attempt_id": "patt_01HX5...",
"bill_id": "bill_01HX...",
"reason": "unexpected_portal_state"
}
},
"request": { "id": null, "idempotency_key": null }
}insight.created
A new insight was generated for one of your users — a bill/biller-level observation such as an anomaly, duplicate charge, new biller, overdue risk, savings opportunity, or a line-item-level finding (new fee, line-item spike, usage anomaly). Minimal-with-refetch: fetch GET /v1/insights/:id for the full evidence, reasoning, and proposed actions. The `proposed_actions` array is client-agnostic (`action_type` + `label` + `params`) — never an executable command. New insight `type` values may be added over time; treat an unrecognized `type` as a generic insight rather than rejecting the event. The `evidence_json` on the fetched record carries type-specific keys: `bill_anomaly` → `new_total`, `median_total`, `ratio`, `threshold_ratio`, `sample_size`, `currency`; `duplicate_charge` → `amount`, `currency`, `matched_bill_id`, `days_apart`, `window_days`; `new_biller` → `biller_id`, `merchant_name`, `amount`, `currency`; `overdue_risk` → `due_date`, `days_until_due`, `window_days`, `status`, `amount`, `currency`; `new_fee` → `description`, `kind`, `signed_amount`, `currency`; `line_item_spike` → `description`, `signed_amount`, `median_amount`, `ratio`, `threshold_ratio`, `currency`; `usage_anomaly` → `description`, `usage_quantity`, `usage_unit`, `median_quantity`, `ratio`, `threshold_ratio`. Amounts are signed magnitudes (a credit/payment/adjustment reads negative); `ratio` is `current / median`.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Insight id, prefixed `insight_`. |
| object* | string | Always `"insight"`. |
| user_id* | string | The `client_user_id` (user_aid) the insight is scoped to. |
| account_link_id | string | null | The account link the insight relates to, when applicable. |
| biller_id | string | null | The biller the insight relates to, when applicable. |
| bill_id | string | null | The bill the insight relates to, when applicable. |
| type* | string | One of `bill_anomaly`, `duplicate_charge`, `new_biller`, `overdue_risk`, `savings_opportunity`, `new_fee`, `line_item_spike`, `usage_anomaly`. New values may be added; handle unknown types gracefully. Full evidence (`evidence_json`) is type-specific — fetch GET /v1/insights/:id. |
| severity* | string | One of `info`, `warn`, `urgent`. |
Follow-up to fetch the full resource: GET /v1/insights/:id — authenticate with IAM.
Example insight.created
{
"id": "evt_01HX...",
"object": "event",
"type": "insight.created",
"api_version": "2026-04-29",
"created": 1714387320,
"environment": "prod",
"data": {
"object": {
"id": "insight_01HX5...",
"object": "insight",
"user_id": "user_42",
"account_link_id": "alink_01HX...",
"biller_id": "test_electric_company",
"bill_id": "bill_01HX...",
"type": "duplicate_charge",
"severity": "warn"
}
},
"request": { "id": null, "idempotency_key": null }
}insight.updated
An insight's lifecycle status changed (snoozed, dismissed, or resolved). The `status` field on `data.object` carries the new state so you can reconcile your projection without a round-trip GET. Fetch GET /v1/insights/:id for the full record.
data.object fields
| Field | Type | Description |
|---|---|---|
| id* | string | Insight id, prefixed `insight_`. |
| object* | string | Always `"insight"`. |
| user_id* | string | The `client_user_id` (user_aid) the insight is scoped to. |
| account_link_id | string | null | The account link the insight relates to, when applicable. |
| status* | string | New lifecycle status. One of `OPEN`, `SNOOZED`, `DISMISSED`, `RESOLVED`. |
Follow-up to fetch the full resource: GET /v1/insights/:id — authenticate with IAM.
Example insight.updated
{
"id": "evt_01HX...",
"object": "event",
"type": "insight.updated",
"api_version": "2026-04-29",
"created": 1714387380,
"environment": "prod",
"data": {
"object": {
"id": "insight_01HX5...",
"object": "insight",
"user_id": "user_42",
"account_link_id": "alink_01HX...",
"status": "DISMISSED"
}
},
"request": { "id": null, "idempotency_key": null }
}biller.unsupported
The platform marked a biller as unsupported (e.g. the biller's website changed in a way that makes automation impossible). Prompt the affected user to manage this biller manually until the integration is restored.
data.object fields
| Field | Type | Description |
|---|---|---|
| biller_id* | string | The biller that was marked unsupported. |
Follow-up to fetch the full resource: GET /v1/billers/:id — authenticate with IAM.
Example biller.unsupported
{
"id": "evt_01HX...",
"object": "event",
"type": "biller.unsupported",
"api_version": "2026-04-29",
"created": 1714387200,
"environment": "prod",
"data": {
"object": {
"biller_id": "test_electric_company"
}
},
"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 }
}