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 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 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.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/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_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.cancelledlink_requestA link request was cancelled before linking completed. A `reason` field distinguishes the cancellation type.
onboarding.submittedonboarding_requestA request to onboard a new biller was accepted. Watch for the matching `onboarding.failed` if the biller integration cannot be built.
onboarding.completedonboarding_requestA biller onboarding request finished successfully — the biller integration is now available and links can be created against it.
onboarding.failedonboarding_requestA biller onboarding request could not be completed. The follow-up GET returns the failure reason in the resource body.
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 /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.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.

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 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,
  "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

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 }
}

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,
  "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

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,
  "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.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,
  "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

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,
  "environment": "prod",
  "data": {
    "object": {
      "id": "link_01HX5...",
      "object": "link",
      "user_id": "user_42",
      "reason": "revoked"
    }
  },
  "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

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

FieldTypeDescription
id*stringOnboarding request id.
object*stringAlways `"onboarding_request"`.
biller_id*stringThe 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
JSON
{
  "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

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

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

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

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,
  "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

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,
  "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

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 }
}

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

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 /api/pay/v1/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,
  "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

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,
  "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

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,
  "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

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,
  "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

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,
  "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

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,
  "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

FieldTypeDescription
biller_id*stringThe biller that was marked unsupported.

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,
  "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

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 }
}
Was this page helpful?