# Webhooks

cryptoinfra pushes events to HTTPS endpoints you register. Delivery is signed, retried, and
**at-least-once** — which shapes everything below.

## Registering an endpoint

`POST /v1/developer/webhooks` with `{ "url": "https://…" }`. HTTPS only, public hostnames
only, at most **5** endpoints per merchant. Every event goes to **every enabled endpoint**
— there is no per-event subscription.

The response contains `endpoint.secret`. **It is returned once, there, and never again.**
Store it before you close the connection. `POST /v1/developer/webhooks/{id}/regenerate`
issues a new one — also shown once — and the old secret stops verifying immediately, so
rotate during a window you can watch.

`POST /v1/developer/webhooks/{id}/test` sends a signed test event through the real
dispatcher, so it exercises your actual verification path rather than a simulation. It
accepts an `event_type` so you can rehearse a specific shape.

The full [Developer](developer) reference covers listing, enabling, disabling and deleting
endpoints.

## The complete event list

These 9 events are the **complete** set. Nothing else is ever emitted.

| Event | Fires when |
| --- | --- |
| [`deposit.credited`](webhook-events#deposit-credited) | A deposit cleared confirmations and was credited |
| [`withdrawal.confirmed`](webhook-events#withdrawal-confirmed) | A payout confirmed on-chain |
| [`withdrawal.failed`](webhook-events#withdrawal-failed) | A payout failed and the hold was refunded |
| [`card.kyc`](webhook-events#card-kyc) | A card holder's KYC status changed |
| [`card.status`](webhook-events#card-status) | A card's status changed (also emitted when an issue order settles) |
| [`card.topup`](webhook-events#card-topup) | A card top-up order reached a terminal state (confirmed or failed) |
| [`card.withdraw`](webhook-events#card-withdraw) | A card→wallet withdrawal order reached a terminal state (confirmed or failed) |
| [`card.transaction`](webhook-events#card-transaction) | A card transaction was created |
| [`card.wallet_otp`](webhook-events#card-wallet-otp) | A live 3DS wallet OTP is available (notification only — the code is NOT in the payload) |

Full payload schemas and examples for each are on the generated
[Webhook events](webhook-events) page.

## What emits nothing

> **Warning** **Swaps and exchanges emit NO webhook.** There is no `swap.completed` and no
> `exchange.completed`. A flow that waits for one waits forever.

Poll the order read instead, while `status` is non-terminal:

| Flow | Poll this |
| --- | --- |
| Swaps **and** exchanges | `GET /v1/swap/orders/{id}` |

It is the authoritative read. It has no event, and will not get one.

## Delivery mechanics

Each delivery is a `POST` with two headers:

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
x-cryptoinfra-event: deposit.credited
x-cryptoinfra-signature: 9f2b…   (hex hmac-sha256 of the raw body)
```

- **Timeout:** 10 seconds per attempt.
- **Success:** any `2xx`. Anything else — including a redirect — is a failure.
- **Redirects are not followed.** Register the final URL.
- **Retries:** exponential backoff, `min(2^attempts × 30s, 1h)` — roughly 60s, 2m, 4m, 8m,
  16m, 32m, then hourly — abandoned as failed after **8 attempts**.

Acknowledge with a `2xx` immediately and do the work asynchronously. If your handler takes
longer than 10 seconds, the delivery is recorded as failed and retried, and you will
process the same event again.

## Verifying the signature

`x-cryptoinfra-signature` is the hex HMAC-SHA256 of the **exact raw request bytes**, keyed
with that endpoint's secret.

> **Important** Verify over the raw body, **before** parsing. Re-serialising the parsed JSON
> produces different bytes (key order, whitespace, number formatting) and the signature will
> not match.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

const app = express();

// give the handler the raw bytes, not a parsed object
app.post('/webhooks/cryptoinfra', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = createHmac('sha256', process.env.CRYPTOINFRA_WEBHOOK_SECRET)
    .update(req.body)           // Buffer of the exact bytes received
    .digest('hex');
  const given = req.get('x-cryptoinfra-signature') ?? '';

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(given, 'utf8');
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  const event = req.get('x-cryptoinfra-event');
  const payload = JSON.parse(req.body.toString('utf8'));

  enqueue(event, payload);      // do the work off the request path
  res.sendStatus(200);          // acknowledge fast
});
```

```python
import hmac, hashlib
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/cryptoinfra")
def cryptoinfra_webhook():
    raw = request.get_data()                      # exact bytes, unparsed
    expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    given = request.headers.get("x-cryptoinfra-signature", "")
    if not hmac.compare_digest(expected, given):
        abort(401)

    event = request.headers.get("x-cryptoinfra-event")
    enqueue(event, request.get_json())
    return "", 200
```

Always compare in constant time. Reject anything that fails, and reject anything with a
missing signature header — never fall through to processing it.

## Replay and idempotency on your side

Delivery is at-least-once. You **will** receive the same event twice: after a timeout that
actually succeeded, after a `5xx` on your side, after a network blip.

There is no delivery-id header. **Deduplicate on the pair
(`x-cryptoinfra-event`, entity id)** — the entity id being `depositId`, `withdrawalId`,
`card_id`, `order_id` or the equivalent field on that payload.

```sql
create table webhook_seen (
  event_type text not null,
  entity_id  text not null,
  seen_at    timestamptz not null default now(),
  primary key (event_type, entity_id)
);
```

Insert with `on conflict do nothing`; if no row was inserted, you have already handled this
one — acknowledge `200` and stop. Make the handler itself idempotent too: crediting a user
should be safe to attempt twice even if the dedupe table is lost.

Note that a *status-transition* event can legitimately arrive more than once with different
content — `card.status` fires on each change. Key those on
(`event`, entity id, new status) rather than on the entity alone.

## Webhooks are hints; the poll is truth

Even where a webhook exists, it is a notification that something *may* have changed — not
the record of what changed. The read endpoints are authoritative:

- `GET /v1/withdrawals/{id}`
- `GET /v1/swap/orders/{id}`
- `GET /v1/cards/orders/{order_id}`
- `GET /v1/users/{externalId}/balances`

Before you credit a customer, issue a refund, or tell anyone that money has moved,
reconcile against one of those. A webhook that never arrives — because your endpoint was
down for longer than the retry window — must not be able to strand money in your system.
Run a reconciliation sweep over non-terminal records regardless of what did or did not
arrive.

## A handler checklist

- Read the **raw bytes** before any body parser touches them.
- Verify the HMAC in constant time; reject on mismatch or a missing header.
- Return `2xx` in well under 10 seconds; queue the work.
- Deduplicate on (`event`, entity id) — and make the handler idempotent anyway.
- Never trust the payload as final: reconcile against the resource read.
- Store the secret like a credential; rotate it through `/regenerate` when it leaks.
