Webhooks

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 confirmations · 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.
producer_service*stringService that produced the event.
correlation_id*string | nullCross-service correlation id when available.
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,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "bill_01HX5...",
      "object": "bill",
      "user_id": "user_42",
      "biller_id": "sb_utility"
    }
  },
  "request": { "id": null, "idempotency_key": null }
}

All deliveries are signed with the BillerAPI-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. The creation status is authoritative; subscribers typically refetch the full resource via GET /v1/bills/:id to get the amount, due date, and statement.
bill.updatedbillA 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_import.updatedbill_importA bill import moved to a new lifecycle state. Use lifecycle_version to ignore stale or duplicate deliveries, and refetch the import before presenting a review or commit action.
bill.deletedbillA bill was deleted. The follow-up GET will return 404; record the deletion and acknowledge with 200.
link.completedlinkA link is fully established and ready to retrieve bills. Fires once per link, after the user finishes the Connect flow.
connection.readylinkThe 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.completedlinkA 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/:public_token/exchange` call.
link.session_finishedlinkA 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.disconnectedlinkA 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.failure_reason_correctedlinkAn operator corrected the recorded failure reason on a terminal link failure. Fires after the fact — the link state itself does not change; only the classification of why it failed. Use this to update any failure-reason you persisted from an earlier `link.disconnected` event.
link_request.createdlink_requestA 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.updatedlink_requestA link request changed after creation, including requested-biller resolution.
link_request.cancelledlink_requestA link request was cancelled before linking completed. A `reason` field distinguishes the cancellation type.
customer.message.createdcustomer_messageYour 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.complaintcustomer_messageA 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.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.
pay.scheduledpayment_attemptA 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 /v1/pay/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.succeededpayment_attemptA payment attempt succeeded. Carries the confirmation number and amount when available so you can reconcile without a round-trip.
pay.failedpayment_attemptA payment attempt failed. The `code` field carries a machine-readable failure reason when available.
pay.escalatedpayment_attemptA payment attempt was escalated for manual review (e.g. an unexpected biller-portal state). The `reason` field describes why.
insight.createdinsightA 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.updatedinsightAn 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.unsupportedbillerThe 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-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.
sandbox.claimedsandboxAn anonymous claimable sandbox (created via `POST /v1/sandboxes`) was claimed and attached to a real account. The sandbox client id is unchanged; its API key is rotated as part of the claim, so refresh any stored key material after this event. This event starts firing once the claim flow ships (the wire contract is published ahead of it so integrations can subscribe in advance).
sandbox.expiringsandboxAn unclaimed anonymous sandbox is 7 days from its 60-day expiry. Claim it at the claim URL from the original `POST /v1/sandboxes` response to keep the sandbox and its data; otherwise the sandbox client and its API key are deleted at expiry and any remaining sandbox data becomes permanently inaccessible (the key stops authenticating at the expiry instant). Note: like every webhook event, delivery requires a registered webhook subscription — an unclaimed sandbox usually has none, so the primary expiry signals are the optional `prefill.email` reminder and this event on platform-level integrations.

bill.created

A new bill was extracted for a linked account. The creation status is authoritative; 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.
statusstringAuthoritative status at creation (for example `PENDING` or `PAID`).

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

When status is PAID, the related bill.updated event with change='paid' is an idempotent confirmation. Keep processing both events and dedupe by event id.

Example bill.created
JSON
{
  "id": "evt_01HX9K7MZQR4F3X5V2W8N1B2C3",
  "object": "event",
  "type": "bill.created",
  "api_version": "2026-04-29",
  "created": 1714387200,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "bill_01HX5...",
      "object": "bill",
      "user_id": "user_42",
      "biller_id": "sb_utility",
      "status": "PAID"
    }
  },
  "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

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.
status*stringCurrent bill status (e.g. `PAID`, `PENDING`, `PARTIALLY_PAID`).
change*stringSub-type discriminator. One of `paid`, `partially_paid`, `status_reverted`, `statement_refreshed`, `match_status`.
paid_reasonstring(change=`paid` or `partially_paid`) Signal that caused the paid transition.
paid_atstring(change=`paid` or `partially_paid`) ISO-8601 UTC instant of the paid transition.
previous_statusstring(change=`status_reverted`) Status before the revert — one of `paid`, `partially_paid`.
revert_reasonstring(change=`status_reverted`) One of `next_statement_shows_balance`, `manual_admin`, `observed_payment_reversed`.
reverted_atstring(change=`status_reverted`) ISO-8601 UTC instant of the revert.
match_statusstring(change=`match_status`) Current match result (e.g. `MATCHED`, `UNMATCHED`).
matched_payment_refstring | 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
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "bill.updated",
  "api_version": "2026-04-29",
  "created": 1714387260,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "bill_01HX5...",
      "object": "bill",
      "user_id": "user_42",
      "biller_id": "sb_utility",
      "status": "PAID",
      "change": "paid",
      "paid_reason": "STATEMENT_CREDIT",
      "paid_at": "2026-04-21T12:05:00Z"
    }
  },
  "request": { "id": null, "idempotency_key": null }
}

bill_import.updated

A bill import moved to a new lifecycle state. Use lifecycle_version to ignore stale or duplicate deliveries, and refetch the import before presenting a review or commit action.

data.object fields

FieldTypeDescription
id*stringBill import id.
object*stringAlways `"bill_import"`.
bill_import_id*stringThe bill import whose lifecycle changed.
client_id*stringOwning client id.
client_user_id*stringOwning client user id.
status*stringCurrent lifecycle state: `NEEDS_REVIEW`, `READY_TO_COMMIT`, `POSSIBLE_DUPLICATE`, `REJECTED`, `COMMITTED`, or `EXPIRED`.
lifecycle_version*numberMonotonic version for ordering and deduplication.
committed_bill_idstringCreated bill id when the import is committed.
updated_at*stringISO-8601 UTC instant of the lifecycle change.
Example bill_import.updated
JSON
{
  "id": "evt_01J...",
  "object": "event",
  "type": "bill_import.updated",
  "api_version": "2026-04-29",
  "created": 1784815200,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": "req_01J...",
  "data": {
    "object": {
      "id": "bimp_01J...",
      "object": "bill_import",
      "bill_import_id": "bimp_01J...",
      "client_id": "client_01J...",
      "client_user_id": "user_42",
      "status": "READY_TO_COMMIT",
      "lifecycle_version": 3,
      "updated_at": "2026-07-23T14:00:00Z"
    }
  },
  "request": { "id": "req_01J...", "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,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "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

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 (API key).

Example link.completed
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "link.completed",
  "api_version": "2026-04-29",
  "created": 1714387200,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "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

FieldTypeDescription
id*stringLink id, prefixed `link_`.
object*stringAlways `"link"`.
link_id*stringSame value as `id`, carried explicitly for symmetry with `bill.*` payloads.
user_idstringThe `client_user_id` from link creation, when available.
biller_idstringThe biller this link retrieves bills from.
bill_countnumberBills created in the first run, when cheaply available. Omitted otherwise — do not treat absence as zero.
first_sync_completed_at*stringISO-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
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "connection.ready",
  "api_version": "2026-04-29",
  "created": 1714387260,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "link_01HX5...",
      "object": "link",
      "link_id": "link_01HX5...",
      "user_id": "user_42",
      "biller_id": "sb_utility",
      "first_sync_completed_at": "2026-07-01T12:00:00.000Z"
    }
  },
  "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

FieldTypeDescription
id*stringLink token id, prefixed `lt_`.
object*stringAlways `"link_token"`.
link_token_id*stringSame value as `id`, carried explicitly.
status*string`completed` (interactive completion, after the server-side exchange) or `exited` (user closed/abandoned).
link_idstringThe persistent link created by the server-side exchange. Present on the `completed` path only.
exit_reasonstring`user_closed` or `error`. Present on the `exited` path only.
biller_idstringThe biller this link connects to. Optional.
user_idstringThe `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
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "link.session_finished",
  "api_version": "2026-04-29",
  "created": 1714387260,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "lt_01HX5...",
      "object": "link_token",
      "link_token_id": "lt_01HX5...",
      "status": "completed",
      "link_id": "link_01HX5...",
      "biller_id": "sb_utility",
      "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

FieldTypeDescription
id*stringLink id. May be empty for pre-completion rejections (no Link aggregate created yet).
object*stringAlways `"link"`.
user_id*stringThe `client_user_id` from link creation.
reason*stringDisconnect cause. One of `expired`, `credentials_rejected`, `revoked`.
credential_rejection_reasonstring(reason=`credentials_rejected`) Fine-grained sub-reason: `INVALID_CREDENTIALS` or `CONNECTION_DEGRADED`.
link_token_idstring(reason=`credentials_rejected`, pre-completion only) Link token id when no Link aggregate existed yet.
link_request_idstring(reason=`credentials_rejected`, pre-completion only) Request-to-link id.
biller_idstringThe 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
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "link.disconnected",
  "api_version": "2026-04-29",
  "created": 1714387200,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "link_01HX5...",
      "object": "link",
      "user_id": "user_42",
      "reason": "revoked"
    }
  },
  "request": { "id": null, "idempotency_key": null }
}

link.failure_reason_corrected

An operator corrected the recorded failure reason on a terminal link failure. Fires after the fact — the link state itself does not change; only the classification of why it failed. Use this to update any failure-reason you persisted from an earlier `link.disconnected` event.

data.object fields

FieldTypeDescription
object*stringAlways `"link"`.
user_idstringThe `client_user_id` from link creation.
target_family*stringWhich reason field was corrected: `credentials_rejected_reason` or `reaped_reason`.
corrected_reason*stringThe new reason value, drawn from the vocabulary of the target family.
correction_version*integerMonotonic correction counter for this link failure, starting at 1. A later version supersedes earlier corrections.
corrected_at*stringISO-8601 timestamp of when the correction was applied.
target_original_reasonstringThe originally recorded reason value that was corrected. Null when unavailable.
original_credentials_rejected_reasonstring(target_family=`credentials_rejected_reason`) Snapshot of the original credential-rejection sub-reason. Null when unavailable.
original_reaped_reasonstring(target_family=`reaped_reason`) Snapshot of the original reap reason. Null when unavailable.
link_request_idstringRequest-to-link id, when the corrected failure occurred pre-completion (no Link aggregate yet).
Example link.failure_reason_corrected
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "link.failure_reason_corrected",
  "api_version": "2026-04-29",
  "created": 1714387200,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "object": "link",
      "user_id": "user_42",
      "target_family": "credentials_rejected_reason",
      "corrected_reason": "CONNECTION_DEGRADED",
      "correction_version": 1,
      "corrected_at": "2026-04-29T12:00:00.000Z",
      "target_original_reason": "INVALID_CREDENTIALS",
      "original_credentials_rejected_reason": "INVALID_CREDENTIALS",
      "original_reaped_reason": null
    }
  },
  "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

FieldTypeDescription
message_id*stringServer-assigned UUID for this message. Stable across retries.
client_id*stringYour client id (the same id that authenticated the send).
customer_user_aid*stringThe `user_aid` you sent to (your stable per-customer identifier).
biller_id*stringThe biller this message is sent on behalf of.
category*string`marketing` or `account_update`.
subject*stringPush notification title body. Max 140 chars.
body*stringMessage body the customer sees inside the BillEBox inbox. Max 2048 chars.
created_at*stringISO-8601 UTC instant of acceptance.
Example customer.message.created
JSON
{
  "id": "evt_01J3...",
  "object": "event",
  "type": "customer.message.created",
  "api_version": "2026-05-31",
  "created": 1716123456,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "message_id": "msg_01J3...",
      "client_id": "client_acme",
      "customer_user_aid": "user_42",
      "biller_id": "sb_utility",
      "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

FieldTypeDescription
message_id*stringThe message id the customer reported. Stable across retries.
client_id*stringYour client id.
customer_user_aid*stringThe complaining customer (your stable per-customer identifier).
biller_id*stringThe biller the reported message was sent on behalf of.
reason*stringCustomer-selected reason: `spam`, `misleading`, `never_signed_up`, or `other`.
notestringOptional free-text note from the customer (max 280 chars).
complained_at*stringISO-8601 UTC instant the complaint was recorded.
Example customer.message.complaint
JSON
{
  "id": "evt_01J5...",
  "object": "event",
  "type": "customer.message.complaint",
  "api_version": "2026-05-31",
  "created": 1716123900,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "message_id": "msg_01J3...",
      "client_id": "client_acme",
      "customer_user_aid": "user_42",
      "biller_id": "sb_utility",
      "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

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,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "opay_01HX5...",
      "object": "observed_payment",
      "user_id": "user_42",
      "biller_id": "sb_utility",
      "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 /v1/pay/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

FieldTypeDescription
payment_attempt_id*stringThe payment attempt id.
bill_idstring | nullThe bill the attempt is paying.
user_idstring | nullThe `client_user_id` the attempt is for, when present on the source event.

Follow-up to fetch the full resource: GET /v1/pay/payments/:attemptId — authenticate with IAM (gated — returns 501 until pay is enabled).

Example pay.scheduled
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "pay.scheduled",
  "api_version": "2026-04-29",
  "created": 1714387400,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "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

FieldTypeDescription
payment_attempt_id*stringThe payment attempt id.
bill_idstring | nullThe bill that was paid.
amountstring | nullDecimal string in `currency` units, when available.
currencystring | nullISO-4217 currency code, when available.
confirmation_numberstring | nullBiller-side confirmation/reference number, when available.

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

Example pay.succeeded
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "pay.succeeded",
  "api_version": "2026-04-29",
  "created": 1714387460,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "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

FieldTypeDescription
payment_attempt_id*stringThe payment attempt id.
bill_idstring | nullThe bill the attempt was paying.
codestring | nullMachine-readable failure code, when available.

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

Example pay.failed
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "pay.failed",
  "api_version": "2026-04-29",
  "created": 1714387520,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "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

FieldTypeDescription
payment_attempt_id*stringThe payment attempt id.
bill_idstring | nullThe bill the attempt was paying.
reasonstring | nullWhy the attempt was escalated, when available.

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

Example pay.escalated
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "pay.escalated",
  "api_version": "2026-04-29",
  "created": 1714387580,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "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

FieldTypeDescription
id*stringInsight id, prefixed `insight_`.
object*stringAlways `"insight"`.
user_id*stringThe `client_user_id` (user_aid) the insight is scoped to.
account_link_idstring | nullThe account link the insight relates to, when applicable.
biller_idstring | nullThe biller the insight relates to, when applicable.
bill_idstring | nullThe bill the insight relates to, when applicable.
type*stringOne 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*stringOne of `info`, `warn`, `urgent`.

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

Example insight.created
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "insight.created",
  "api_version": "2026-04-29",
  "created": 1714387320,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "insight_01HX5...",
      "object": "insight",
      "user_id": "user_42",
      "account_link_id": "alink_01HX...",
      "biller_id": "sb_utility",
      "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

FieldTypeDescription
id*stringInsight id, prefixed `insight_`.
object*stringAlways `"insight"`.
user_id*stringThe `client_user_id` (user_aid) the insight is scoped to.
account_link_idstring | nullThe account link the insight relates to, when applicable.
status*stringNew 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
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "insight.updated",
  "api_version": "2026-04-29",
  "created": 1714387380,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "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

FieldTypeDescription
id*stringBiller id of the biller that was marked unsupported.
object*stringAlways `"biller"`.

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

Example biller.unsupported
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "biller.unsupported",
  "api_version": "2026-04-29",
  "created": 1714387200,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": {
    "object": {
      "id": "sb_utility",
      "object": "biller"
    }
  },
  "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,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": { "object": { "id": "wdl_01HX5...", "object": "webhook_dead_letter" } },
  "request": { "id": "req_01HX...", "idempotency_key": null }
}

sandbox.claimed

An anonymous claimable sandbox (created via `POST /v1/sandboxes`) was claimed and attached to a real account. The sandbox client id is unchanged; its API key is rotated as part of the claim, so refresh any stored key material after this event. This event starts firing once the claim flow ships (the wire contract is published ahead of it so integrations can subscribe in advance).

data.object fields

FieldTypeDescription
id*stringThe sandbox client id that was claimed.
object*stringAlways `"sandbox"`.
Example sandbox.claimed
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "sandbox.claimed",
  "api_version": "2026-04-29",
  "created": 1714387200,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": { "object": { "id": "client_01HX...", "object": "sandbox" } },
  "request": { "id": "req_01HX...", "idempotency_key": null }
}

sandbox.expiring

An unclaimed anonymous sandbox is 7 days from its 60-day expiry. Claim it at the claim URL from the original `POST /v1/sandboxes` response to keep the sandbox and its data; otherwise the sandbox client and its API key are deleted at expiry and any remaining sandbox data becomes permanently inaccessible (the key stops authenticating at the expiry instant). Note: like every webhook event, delivery requires a registered webhook subscription — an unclaimed sandbox usually has none, so the primary expiry signals are the optional `prefill.email` reminder and this event on platform-level integrations.

data.object fields

FieldTypeDescription
id*stringThe expiring sandbox client id.
object*stringAlways `"sandbox"`.
Example sandbox.expiring
JSON
{
  "id": "evt_01HX...",
  "object": "event",
  "type": "sandbox.expiring",
  "api_version": "2026-04-29",
  "created": 1714387200,
  "producer_service": "billerapi.webhooks-service",
  "correlation_id": null,
  "data": { "object": { "id": "client_01HX...", "object": "sandbox" } },
  "request": { "id": "req_01HX...", "idempotency_key": null }
}
Was this page helpful?