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

# Going live

> What to have in place before you point real volume at the API.

Work through this before your first live payout. Most of it is about what happens
when something goes wrong, because that is the part integrations usually skip.

## Prerequisites

<Steps>
  <Step title="KYB approved">
    Your compliance profile reads `APPROVED`. A payout attempt returns
    `403 COMPLIANCE_NOT_APPROVED` until it does.
  </Step>

  <Step title="A refund destination per settlement currency">
    `GET /v1/refund-destinations` returns one for every currency you will settle
    in. Confirm the bank rail shows `is_verified: true`; for crypto, re-check the
    address and network by eye — nothing else can.
  </Step>

  <Step title="A live key with the right scopes">
    Separate from your test key, and carrying only the scopes each system needs.
    See [Get access](/get-access#choosing-scopes).
  </Step>

  <Step title="A registered webhook endpoint">
    `GET /v1/webhooks/config` returns your URL **and** `has_secret: true`. Without a
    secret, deliveries arrive unsigned.
  </Step>
</Steps>

## Correctness

<AccordionGroup>
  <Accordion title="Idempotency keys are persisted, not generated inline" icon="key">
    The key must be stored on your own record **before** the API call, so a retry
    after a crash reuses it. A key generated inside the request scope is lost with
    the process that made it — which is precisely the case it exists to protect.

    See [Idempotency](/guides/idempotency#choosing-a-key).
  </Accordion>

  <Accordion title="You read amounts rather than recomputing them" icon="calculator">
    Take `settlement_amount`, `total_fee_amount`, and `exchange_rate` from the
    payout or the quote. Never derive them from a rate and a fee percentage — a
    figure that disagrees with ours becomes a reconciliation dispute.
  </Accordion>

  <Accordion title="Every amount is paired with its currency" icon="coins">
    Your database columns should mirror ours. A payout spans up to three
    currencies; storing a bare `amount` guarantees someone eventually reads it in
    the wrong denomination.
  </Accordion>

  <Accordion title="Provider codes come from the API" icon="list">
    You call `GET /v1/banks` rather than shipping a hardcoded list. Codes are set
    upstream and change without a version bump on our side.
  </Accordion>

  <Accordion title="Beneficiary names are institution-confirmed" icon="user-check">
    You pass the `destination_account_name` returned by `/v1/banks/resolve`, not
    what your user typed.
  </Accordion>
</AccordionGroup>

## Webhook handling

<AccordionGroup>
  <Accordion title="Signatures are verified against the raw body" icon="signature">
    Before any JSON parsing, with a constant-time comparison, and with a timestamp
    tolerance to block replay. Unverified requests are rejected.
  </Accordion>

  <Accordion title="Handlers acknowledge fast and process async" icon="bolt">
    Return `2xx` within 10 seconds. Real work goes on a queue. A slow inline handler
    times out and produces duplicate deliveries.
  </Accordion>

  <Accordion title="Events are deduplicated on event_id" icon="clone">
    Not on `created_at`, not on arrival order. The same event can arrive more than
    once.
  </Accordion>

  <Accordion title="Unknown event types are ignored, not thrown on" icon="circle-question">
    New types ship without a version bump. A handler that throws on the unfamiliar
    starts burning retries the day one appears.
  </Accordion>

  <Accordion title="Out-of-order deliveries cannot move state backwards" icon="arrow-right-arrow-left">
    Treat `data.status` as the truth and ignore an event that would regress a record
    you have already advanced.
  </Accordion>

  <Accordion title="You know refund events carry a payout object" icon="triangle-exclamation">
    `refund.*` payloads have `"object": "payout"` and `data.id` is the payout ID.
    Fetch amounts from `GET /v1/payouts/{id}/refunds`.
    See [Webhook events](/reference/events).
  </Accordion>
</AccordionGroup>

## Failure paths you must handle

These are the ones that cost money when they are missed.

| Situation                             | What you must do                                                                                          |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `payout.expired`                      | Release the order. No money moved, and the payout will never advance.                                     |
| `payout.failed` after you paid        | Expect `payout.refunded` and the `refund.*` sequence. Do not re-create the payout until the refund lands. |
| `refund.failed`                       | Alert a human. Your money is undelivered and needs a corrected destination.                               |
| Underpaid settlement                  | The payout holds in `PENDING_PAYMENT` past its expiry. Contact support; do not create a second payout.    |
| Instruction not yet generated         | `404` on the instruction is normal for a few seconds. Wait for the webhook.                               |
| Webhook endpoint down past 5 attempts | The delivery is `DEAD` and never resent. Reconcile by reading payouts back.                               |

<Warning>
  There is no cancel endpoint and no webhook replay endpoint. An unwanted payout
  lapses to `EXPIRED` on its own; missed events are recovered by reading state, not
  by asking us to resend. Build both recovery paths before you need them.
</Warning>

## Reconciliation

Do not rely on webhooks alone as your source of truth. Run a periodic sweep that
reads state back and repairs anything your handler missed.

```typescript theme={null}
// Every 15 minutes: reconcile anything not in a terminal state
const stale = await db.payouts.findMany({
  where: {
    status: { notIn: ['SUCCESSFUL', 'FAILED', 'EXPIRED'] },
    updated_at: { lt: minutesAgo(15) },
  },
});

for (const record of stale) {
  const payout = await spendin.getPayout(record.spendin_payout_id);
  await db.payouts.update(record.id, {
    status: payout.status,
    settlement_amount: payout.settlement_amount,
  });
}
```

The `status_timeline` on a payout is the authoritative history — every transition,
in order, with the reason. Reconcile against it rather than against webhook arrival
times.

## Alerting

| Alert on                                       | Why                                                     |
| ---------------------------------------------- | ------------------------------------------------------- |
| Any `refund.failed`                            | A merchant — you — is out of pocket                     |
| Payouts stuck in `PENDING_PAYMENT` past expiry | Usually an underpayment holding funds                   |
| Payouts stuck in `PROCESSING` over an hour     | Should be rare; worth a support ticket                  |
| Webhook deliveries reaching `DEAD`             | Your endpoint is dropping events                        |
| Rising `LIQUIDITY_ENGINE_UNAVAILABLE`          | Upstream trouble; pricing and dispatch degrade together |
| Any `COMPLIANCE_NOT_APPROVED` in live traffic  | A configuration regression, never transient             |

## Operational limits

| Thing                        | Value                                              |
| ---------------------------- | -------------------------------------------------- |
| Instruction expiry           | 30 minutes (crypto); configurable on the bank rail |
| Quote TTL                    | \~30 seconds                                       |
| Idempotency key retention    | 24 hours                                           |
| Dispatch attempts per payout | 3                                                  |
| Refund attempts              | 3                                                  |
| Webhook delivery attempts    | 5, exponential backoff from 10s                    |
| Webhook response timeout     | 10 seconds                                         |
| Page size                    | 1–100, default 20                                  |

Read `expires_at` off the instruction and the quote rather than hardcoding the
first two — they are configurable per account.

## Last checks

<Steps>
  <Step title="Run one real payout end to end">
    Small amount, real beneficiary, on the corridor you will use most. Watch every
    webhook arrive and confirm your records match `GET /v1/payouts/{id}`.
  </Step>

  <Step title="Force a failure deliberately">
    Let an instruction expire without paying it, and confirm your system handles
    `payout.expired` by releasing the order rather than retrying forever.
  </Step>

  <Step title="Confirm no key is in client code">
    Grep your frontend bundles and repos for `sk_live`. The key authorises payouts.
  </Step>

  <Step title="Verify request IDs reach your logs">
    Send `X-Request-Id` and confirm it lands next to your own record. It is the
    first thing support will ask for.
  </Step>
</Steps>
