> ## Documentation Index
> Fetch the complete documentation index at: https://docs.spritz.finance/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Track settlement and status changes asynchronously.

Money movement is asynchronous. An off-ramp is requested, then converts, then settles
to a bank account over time. Webhooks let Spritz notify your backend the moment
something changes, so you don't have to poll.

Webhooks are configured per integrator, so you manage them with your integrator
credentials (HMAC). See [Authentication](/guides/authentication).

<Note>
  Webhooks are **global, not per user**. You subscribe once and receive events for all
  of your users. There's no need to register a webhook per user, and you don't pass a
  user API key when managing webhooks. These are integrator endpoints, so they use your
  integrator signing headers only.
</Note>

## Register an endpoint

Create a webhook by pointing it at an HTTPS URL on your backend and listing the events
you want. Use `"*"` to subscribe to every current and future event.

```bash theme={null}
curl -X POST https://platform.spritz.finance/v1/integrator/webhooks \
  -H "Content-Type: application/json" \
  # plus your integrator signing headers (see Authentication)
  -d '{
    "url": "https://api.example.com/webhooks/spritz",
    "events": ["onramp.created", "achDebit.authorized", "achDebit.deliveryProgress", "achDebit.delivered", "achDebit.refunded", "achDebit.returned"]
  }'
```

```json theme={null}
{
  "id": "6a43ac369288351e982157c4",
  "url": "https://api.example.com/webhooks/spritz",
  "events": ["onramp.created", "achDebit.authorized", "achDebit.deliveryProgress", "achDebit.delivered", "achDebit.refunded", "achDebit.returned"]
}
```

You can list, update, and delete webhooks too:

| Method   | Path                                  | Purpose                                             |
| -------- | ------------------------------------- | --------------------------------------------------- |
| `GET`    | `/v1/integrator/webhooks`             | List your webhooks                                  |
| `POST`   | `/v1/integrator/webhooks`             | Create a webhook                                    |
| `PATCH`  | `/v1/integrator/webhooks/{webhookId}` | Update a webhook's events                           |
| `DELETE` | `/v1/integrator/webhooks/{webhookId}` | Delete a webhook                                    |
| `POST`   | `/v1/integrator/webhook-secret`       | Set the secret used to sign deliveries              |
| `GET`    | `/v1/integrator/webhooks/deliveries`  | Inspect delivery outcomes and recover missed events |

## Events

| Event                          | Fires when                                                                                   |
| ------------------------------ | -------------------------------------------------------------------------------------------- |
| `account.created`              | An account (bank account, card, bill) is created                                             |
| `account.updated`              | An account's details change                                                                  |
| `account.deleted`              | An account is removed                                                                        |
| `payment.created`              | A payment (off-ramp) is initiated                                                            |
| `payment.updated`              | A payment's details change                                                                   |
| `payment.completed`            | A payment completes                                                                          |
| `payment.refunded`             | A payment is refunded                                                                        |
| `verification.status.updated`  | A user's identity verification status changes                                                |
| `capabilities.updated`         | A user's capabilities change (for example on-ramp becomes active)                            |
| `onramp.created`               | An on-ramp record is created after a deposit is authorized                                   |
| `onramp.updated`               | An on-ramp's status, delivery, or reversal details change                                    |
| `onramp.completed`             | Crypto delivery completes. For ACH debit, the bank can still return the debit later          |
| `achDebitReturn.created`       | An ACH debit return is recorded                                                              |
| `achDebitReturn.updated`       | An ACH debit return's details change                                                         |
| `achDebit.authorized`          | The user authorizes a linked-bank deposit                                                    |
| `achDebit.deliveryProgress`    | A new partial amount of crypto is confirmed onchain                                          |
| `achDebit.delivered`           | The full crypto principal is confirmed onchain                                               |
| `achDebit.refunded`            | Spritz initiates the deposit's bank refund                                                   |
| `achDebit.returned`            | The bank returns the debit and its user action is classified                                 |
| `offramp.confirmed`            | Off-ramp milestone: crypto payment confirmed on-chain, payout created                        |
| `offramp.inFlight`             | Off-ramp milestone: payout submitted to the banking rail                                     |
| `offramp.completed`            | Off-ramp milestone: fiat delivered                                                           |
| `offramp.failed`               | Off-ramp milestone: a leg failed terminally                                                  |
| `offramp.refunded`             | Off-ramp milestone: funds returned                                                           |
| `offramp.reversed`             | Off-ramp milestone: a settled payout was clawed back                                         |
| `onrampCredit.depositDetected` | Credit on-ramp milestone: fiat arrived at the auto-ramp account (rolls out with the emitter) |
| `onrampCredit.completed`       | Credit on-ramp milestone: crypto delivered                                                   |
| `onrampCredit.failed`          | Credit on-ramp milestone: conversion failed                                                  |
| `onrampCredit.reversed`        | Credit on-ramp milestone: the fiat deposit was clawed back                                   |
| `onrampCredit.refunded`        | Credit on-ramp milestone: the fiat deposit was returned                                      |

Subscribe to `"*"` to receive all of these, including events added later.

## Receiving a delivery

Spritz sends a `POST` to your URL with a JSON body identifying what changed:

```json theme={null}
{
  "userId": "63d12d3b577fab6c6382136e",
  "id": "6368e3a3ec516e9572bbd23b",
  "event": "onramp.completed"
}
```

| Field    | Always present | Meaning                                 |
| -------- | -------------- | --------------------------------------- |
| `event`  | yes            | Which event fired, from the table above |
| `userId` | yes            | The user the event concerns             |
| `id`     | no             | The resource that changed               |

`id` is omitted for events whose subject is the user rather than a separate resource —
`capabilities.updated` is the case you are most likely to meet. Key your handler off
`event` and treat `id` as optional:

<Note>
  Generic payloads carry no occurrence id and no timestamp, so two deliveries of the
  same event on the same resource can be byte-identical (and a replay produces an
  identical signature). That's why the dedupe guidance is what it is: treat deliveries
  as triggers, keep handlers idempotent, and reconcile from the API instead of trying to
  distinguish occurrences.
</Note>

```ts theme={null}
const { event, userId, id } = payload

switch (event) {
  case "onramp.completed":
    await refreshOnRamp(id!)
    break
  case "capabilities.updated":
    // No `id` — the subject is the user.
    await refreshUser(userId)
    break
}
```

The body tells you which resource changed, not its full state. Treat it as a trigger:
fetch the resource from the API to get authoritative state before acting on it.

For `achDebitReturn.*`, `id` is the public `dr_...` return ID. Fetch it with
`GET /v1/integrator/ach-debit/returns/{id}`. Then use its `depositId` and `sourceId` to
refresh the deposit and funding source.

For `onramp.*`, fetch the on-ramp. When its `source` contains a `depositId`, fetch
`GET /v1/deposits/{depositId}` with the user's authorization. These generic resource
events keep your UI and local state current. **Do not send ACH push notifications from
generic `onramp.*` or `achDebitReturn.*` events.** A retry can produce the same current
state, so state comparison alone cannot identify one notification occurrence.

For `payment.*`, `id` is the off-ramp id — fetch `GET /v1/off-ramps/{id}` with the user's
authorization (or your HMAC credentials acting for them). A typical off-ramp fires
`payment.created` and `payment.updated` when it's created, `payment.updated` as it moves
through the rails, and `payment.completed` when fiat lands. **There is no
`payment.failed` event** — a failure arrives as `payment.updated`, so never infer state
from the event name; read the off-ramp's `status`. You'll also see `account.updated`
fire on the destination bank account as its state changes. For user-facing
notifications, prefer the [`offramp.*` milestone events](#off-ramp-milestone-events) —
they carry copy-ready snapshots; use `payment.*` only to keep local state in sync.

## Off-ramp milestone events

The six `offramp.*` events are the notification contract for crypto-to-fiat: one event
per user-meaningful moment, each with an immutable snapshot taken at the transition. The
identity and handling contract is identical to `achDebit.*` — stable `eventId` across
retries and replays, monotonic per-off-ramp `sequence`, durable-inbox handling (see the
[handler rules](#ach-debit-communication-events) above).

<Note>
  These events are **rolling out now**: you can subscribe to them today, and they start
  firing as the emitters deploy. Until then, `payment.*` remains the way to track
  off-ramp progress.
</Note>

```json theme={null}
{
  "schemaVersion": "1",
  "eventId": "6a99b7bacc18094dfe644ba6",
  "sequence": 3,
  "occurredAt": "2026-09-03T18:16:01.000Z",
  "userId": "63d12d3b577fab6c6382136e",
  "integratorId": "6a9995f184f0eaa795879343",
  "event": "offramp.completed",
  "id": "6a99b80613a0c37251651788",
  "milestone": "completed",
  "offRamp": {
    "id": "6a99b80613a0c37251651788",
    "quoteId": "6a99b7bacc18094dfe644ba6",
    "status": "completed",
    "input": { "amount": "0.02", "token": "USDC", "chain": "base" },
    "output": {
      "amount": "0.01",
      "currency": "USD",
      "rail": "ach_standard",
      "accountId": "6a9996a7ff61211ef039d166",
      "accountName": "Chase Checking ••6789"
    },
    "fees": { "amount": "0.01", "currency": "USD" },
    "transaction": {
      "hash": "0xb4af…6357",
      "explorerUrl": "https://basescan.org/tx/0xb4af…6357"
    },
    "failureMessage": null
  }
}
```

| `event`             | `milestone` | The moment                                                              | Suggested user notification                                                                                                                          |
| ------------------- | ----------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `offramp.confirmed` | `confirmed` | The crypto payment is confirmed on-chain and the fiat payout is created | "Payment received — your payout to {accountName} has started"                                                                                        |
| `offramp.inFlight`  | `in_flight` | The payout is submitted to the banking rail                             | "{amount} {currency} is on its way to {accountName}" — name the rail's [timing](/guides/timing), never promise a deadline the rail doesn't guarantee |
| `offramp.completed` | `completed` | Fiat delivered to the destination                                       | "Your money has arrived"                                                                                                                             |
| `offramp.failed`    | `failed`    | A leg failed terminally                                                 | "The payout couldn't be completed — we'll make it right" + support/retry CTA                                                                         |
| `offramp.refunded`  | `refunded`  | Funds returned                                                          | "This payout was refunded"                                                                                                                           |
| `offramp.reversed`  | `reversed`  | A settled payout was clawed back by the rail                            | "This payout was returned by the bank — contact support"                                                                                             |

The event and milestone pair is fixed. `quoteId` links the off-ramp to its quote (null
for auto-ramp-address deposits, which have no quote). `transaction` carries the funding
transaction's hash and explorer link once known. `failureMessage` is reserved for
public-safe failure copy and is currently always `null` — branch on the milestone, not
on absent detail.

<Note>
  The `onrampCredit.*` family (`depositDetected`, `completed`, `failed`, `reversed`,
  `refunded`) is the same contract for auto-ramp-account funding deposits — fiat in,
  crypto out. Same rollout: subscribable now, fires as the emitter deploys.
</Note>

## ACH debit communication events

The five `achDebit.*` events are the push-notification contract. Unlike generic
resource events, each delivery contains an immutable milestone snapshot:

```json theme={null}
{
  "schemaVersion": "1",
  "eventId": "0198e4a8-70b2-7c11-a9a3-2104b3d7eaf1",
  "sequence": 2,
  "occurredAt": "2026-08-26T12:01:00.000Z",
  "userId": "63d12d3b577fab6c6382136e",
  "event": "achDebit.deliveryProgress",
  "id": "dep_01K3NQ4QGM7Y5ZP0DRW1M4V8NC",
  "milestone": "delivery_progress",
  "deposit": {
    "id": "dep_01K3NQ4QGM7Y5ZP0DRW1M4V8NC",
    "asset": "USDC",
    "destinationAddressDisplay": "9xQeWv…wM9R",
    "principalAmountUsd": "500.00",
    "totalDebitAmountUsd": "506.00",
    "confirmedReleasedAmountUsd": "100.00",
    "debitStatus": "submitted",
    "releaseStatus": "partial",
    "releaseDecisionMode": "early_partial",
    "instantPortionUsd": "100.00",
    "settlementPortionUsd": "400.00",
    "userFeeUsd": "6.00"
  },
  "fundingSource": {
    "id": "fs_01K3NQ4QGM7Y5ZP0DRW1M4V8NC",
    "institutionName": "Chase",
    "accountMask": "6789"
  },
  "achReturn": null
}
```

`eventId` identifies one notification occurrence and stays unchanged across delivery
retries and replays. `sequence` starts at `1` and increases for each communication
milestone on that deposit. Deliveries are still at least once and can arrive out of
order. `id`, `deposit.id`, and `fundingSource.id` are the same public IDs used by the
deposit and funding-source APIs.

Each `achDebit.*` payload is versioned and fully typed in the OpenAPI contract. These
fields are always present:

| Object                     | Required fields                                                                                                                                                                                                                         |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Top level                  | `schemaVersion`, `eventId`, `sequence`, `occurredAt`, `userId`, `event`, `id`, `milestone`, `deposit`, `fundingSource`, `achReturn`                                                                                                     |
| `deposit`                  | `id`, `asset`, `destinationAddressDisplay`, `principalAmountUsd`, `totalDebitAmountUsd`, `confirmedReleasedAmountUsd`, `debitStatus`, `releaseStatus`, `releaseDecisionMode`, `instantPortionUsd`, `settlementPortionUsd`, `userFeeUsd` |
| `fundingSource`            | `id`, `institutionName`, `accountMask`                                                                                                                                                                                                  |
| `achReturn`, when returned | `id`, `amountUsd`, `cryptoStateAtReturn`, `userAction`                                                                                                                                                                                  |

The event and milestone pair is fixed:

| `event`                     | `milestone`         | `achReturn`              |
| --------------------------- | ------------------- | ------------------------ |
| `achDebit.authorized`       | `authorized`        | `null`                   |
| `achDebit.deliveryProgress` | `delivery_progress` | `null`                   |
| `achDebit.delivered`        | `delivered`         | `null`                   |
| `achDebit.refunded`         | `refunded`          | `null`                   |
| `achDebit.returned`         | `returned`          | Required return snapshot |

Your webhook handler must durably accept the event before returning `2xx`:

1. Begin a database transaction.
2. Insert `eventId` into a webhook inbox with a unique constraint. If it already
   exists, commit and return `2xx` without enqueueing another push.
3. Compare `sequence` with the highest accepted sequence for `id`. If it is lower or
   equal, record it as stale, commit, and return `2xx`.
4. Update the deposit's highest sequence and insert one push-outbox row keyed by
   `eventId` in the same transaction.
5. Commit and return `2xx`. A separate worker sends the push.

This is a small generic inbox/outbox, not ACH-specific state inference. Use `eventId`
as the push provider's idempotency or collapse key when the provider supports one.
Never mark an event accepted only in memory.

<Warning>
  HTTP delivery cannot guarantee that an external push provider displays a message
  exactly once. The stable event ID, sequence guard, transactional push outbox, and
  provider idempotency key close every duplicate path your integration can control.
</Warning>

Use the immutable event snapshot for push copy. Refetch the deposit separately for the
current UI; a later state must not change the meaning of an earlier notification.
See [ACH debit user experience](/guides/ach-debit-user-experience) for the exact copy.

## Verifying signatures

Each delivery is signed so you can confirm it came from Spritz and wasn't modified in
transit. Set a webhook secret with `POST /v1/integrator/webhook-secret`, then verify
the `Signature` header against the raw request body using HMAC-SHA256. Always verify
against the raw body, before parsing JSON.

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto"

function verifySpritzWebhook(rawBody: string, signature: string, secret: string) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex")
  // The header is the lowercase hex digest, optionally `sha256=`-prefixed.
  const presented = signature.replace(/^sha256=/, "")
  const a = Buffer.from(expected, "utf8")
  const b = Buffer.from(presented, "utf8")
  return a.length === b.length && timingSafeEqual(a, b)
}

// In your handler, using the raw (unparsed) body:
const signature = request.headers["signature"]
if (!signature || !verifySpritzWebhook(rawBody, signature, WEBHOOK_SECRET)) {
  throw new Error("Invalid webhook signature")
}
```

## Retries and delivery outcomes

Return a `2xx` as soon as you have durably accepted the event. Spritz does not retry a
`4xx`. Spritz retries a `5xx` or timeout twice, for three attempts total. Those attempts
happen within roughly 17 seconds; there is no long-lived redelivery queue. A delivery-log
record summarizes the final outcome after those attempts; it is not one record per
attempt.

Webhook configuration is cached by delivery workers and can take about 60 seconds to
propagate. Wait before using a newly registered endpoint or changed secret in a test.

Inspect outcomes with `GET /v1/integrator/webhooks/deliveries`. Results are newest first
and cursor-paginated. Spritz retains each delivery record and its exact payload for at
least 30 days. It remains recovery history, not permanent event storage. Store accepted
events durably, run delivery reconciliation at least every 24 hours, and reconcile again
immediately after your receiver recovers from an outage. Follow cursors until no more
records remain.

```json theme={null}
{
  "data": [
    {
      "event": "achDebitReturn.created",
      "webhookUrl": "https://api.example.com/webhooks/spritz",
      "payload": {
        "event": "achDebitReturn.created",
        "userId": "63d12d3b577fab6c6382136e",
        "id": "dr_01JV7Q8M4Y8K6N2Z5P3R1T9W0X"
      },
      "success": false,
      "responseStatus": 503,
      "timestamp": "2026-08-20T19:30:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

Read `error` before interpreting `responseStatus`:

| Shape                                               | Meaning                                                                                                      | What to do                                                                                                   |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `success: true`, no `error`                         | Your endpoint returned `2xx`.                                                                                | No delivery action needed. Refresh the resource idempotently.                                                |
| `success: false`, no `error`, with `responseStatus` | Your endpoint answered with that `4xx` or final `5xx`.                                                       | Fix the handler. Reconcile the resource from the API because retries are exhausted.                          |
| `success: false`, with `error`                      | Spritz received no usable response. `500` or `504` is Spritz's classification, not your endpoint's response. | Treat delivery as unknown. The endpoint may still have processed it. Reconcile and keep handlers idempotent. |
| `success: false`, with neither field                | A legacy record has no recorded outcome.                                                                     | Reconcile the resource; there is no delivery diagnosis available.                                            |

`payload` is the exact body Spritz sent and signed. Delivery history is diagnostic, not
a queue. After a final generic resource-event failure, recover through the resource read
API instead of waiting for another delivery. A current resource read cannot reconstruct a
missed historical communication occurrence. For a failed `achDebit.*` delivery, pass the
delivery record's exact `payload` through the same durable inbox handler as a live event;
its stable `eventId` makes that replay safe.

## Best practices

<AccordionGroup>
  <Accordion title="Respond fast, process later">
    Acknowledge with a `2xx` immediately and hand off to a queue. Slow responses can
    trigger retries and duplicate processing.
  </Accordion>

  <Accordion title="Make handlers idempotent">
    Retries mean the same payload can arrive more than once. The same resource can also
    produce several legitimate `updated` events, so do not permanently deduplicate on
    `id`. Fetch current state and make applying it safe to repeat. See
    [Idempotency](/guides/idempotency).

    ACH communication events are the exception: deduplicate them permanently on their
    stable `eventId`. Different milestones for the same deposit have different event
    IDs and increasing `sequence` values.
  </Accordion>

  <Accordion title="Don't rely on event order">
    Events are dispatched concurrently and are **not ordered**. `onramp.completed` can
    arrive before `onramp.created` for the same on-ramp, and does so most often when the
    two transitions happen close together.

    The payload carries no timestamp, so order cannot be reconstructed from the event
    alone. Key your handler off the resource `id`, fetch current state from the API, and
    make each handler safe to run regardless of what has already been processed for that
    resource — including the case where the first event you ever see for a resource is
    its last one.

    For `achDebit.*` communication events, atomically ignore a sequence that is not
    greater than the highest sequence you already accepted for that deposit. This
    prevents a delayed partial-delivery event from producing a push after full delivery.
  </Accordion>

  <Accordion title="Treat events as triggers, not truth">
    On receipt, fetch the current resource from the API to get authoritative state
    rather than relying solely on the event body.
  </Accordion>

  <Accordion title="Reconcile after an outage">
    Webhooks are notifications, not your only record. Page through the relevant public
    list endpoint after downtime. For linked-bank deposits, page through
    `GET /v1/deposits/`; this endpoint is user-scoped, not integrator-scoped, so an
    integrator-wide recovery must iterate your own user roster and authorize each
    user's read. For ACH returns, use
    `GET /v1/integrator/ach-debit/returns`. Then inspect
    `GET /v1/integrator/webhooks/deliveries` if you need to diagnose delivery or recover
    an exact missed `achDebit.*` communication payload. Run this reconciliation at least
    every 24 hours; delivery payloads are guaranteed for 30 days. Do not synthesize a
    historical push from current resource state.
  </Accordion>
</AccordionGroup>
