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

# Webhooks

> Events we send when a payment or a top-up changes, how to verify them, and how delivery works.

We `POST` a JSON event to every active endpoint you [registered](/api-reference/webhooks/create-webhook). Answer any `2xx` within 10 seconds; do the work afterwards. A payment sends an event when it ends `completed`, `failed` or `cancelled`; one `rejected` at once sends none — the `POST /payouts` answer is its outcome.

| Event | When |
| - | - |
| `payout.completed` | The merchant was paid. Final. Sent also when `POST /payouts` already answered `completed`. |
| `payout.failed` | The network refused the payment. The held USDT is released. `data.error.code` says why — see [Failure codes](#failure-codes). |
| `payout.cancelled` | It ended before paying; nothing was debited. `data.error.code` says why — see [Failure codes](#failure-codes). |
| `deposit.pending` | We saw a USDT transfer to your top-up address; it is still confirming on-chain. Nothing is credited yet. |
| `deposit.credited` | The transfer confirmed and its amount was credited to your wallet. Final. |
| `deposit.in_review` | The transfer reached your address but could not be credited automatically (`review_code` says why). Rampa resolves it; `deposit.credited` follows. |
| `webhook.test` | Sent only by [`POST /webhooks/{id}/test`](/api-reference/webhooks/test-webhook), at once and never retried. `data` is `{"webhook_id": …}`. Use it to check your receiver and your signature check. |

## Envelope

`id` is the event's: dedupe on it. `data` of a `payout.*` event is the payment, as `GET /payouts/{id}` returns it — `data.id` is its `po_` id. `data` of a `deposit.*` event is the deposit — the transfer, its `amount` and its `status` (`pending`, `credited` or `in_review`); `data.id` is its `dep_` id.

```json payout.completed theme={null}
{
  "id": "5b2e9c1a-7d34-4f0e-8a61-2c9d3e4f5a6b",
  "type": "payout.completed",
  "event_version": 1,
  "occurred_at": "2026-09-27T14:03:02.000Z",
  "recorded_at": "2026-09-27T14:03:02.140Z",
  "data": {
    "id": "po_9Fz3Kd7RwP2mX8cT4nB6vQ1h",
    "type": "qr",
    "status": "completed",
    "transaction_id": "W-1001",
    "resolution_id": "qrr_8KqT2mWx5ZbN7cJ4hRvP1sLd",
    "quote": {
      "id": "6f0c2a8e-4b7d-4f1e-9a51-3c2d8e7b9a10",
      "source": { "currency": "USDT", "amount": "101.500000" },
      "target": { "currency": "BOB", "amount": "1000.00" },
      "rate": "9.9500",
      "fee": { "currency": "USDT", "amount": "1.000000" },
      "expires_at": "2026-09-27T14:05:00.000Z"
    },
    "source": { "currency": "USDT", "amount": "101.500000" },
    "target": { "currency": "BOB", "amount": "1000.00" },
    "qr": { "amount_mode": "closed", "merchant": { "name": "Café del Prado", "bank_code": "1014" } },
    "error": null,
    "completed_at": "2026-09-27T14:02:12.000Z",
    "created_at": "2026-09-27T14:02:11.000Z"
  }
}
```

```json deposit.credited theme={null}
{
  "id": "c8a1f2e3-4d5b-4c6a-9e7f-8a9b0c1d2e3f",
  "type": "deposit.credited",
  "event_version": 1,
  "occurred_at": "2026-09-27T13:58:20.000Z",
  "recorded_at": "2026-09-27T13:58:20.000Z",
  "data": {
    "id": "dep_7Hq2Lx9TbR4mW1cZ8kP3sN6v",
    "chain": "tron",
    "tx_hash": "9f1c2e7a4b3d5c6e8f0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6",
    "transfer_index": 0,
    "address": "TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb",
    "from_address": "TA4fxk7CkPHeyNbrXv6WfWtxoR49uQyw9z",
    "currency": "USDT",
    "amount": "1000.000000",
    "status": "credited",
    "review_code": null,
    "created_at": "2026-09-27T13:57:40.000Z",
    "credited_at": "2026-09-27T13:58:20.000Z"
  }
}
```

## Failure codes

A payment that ended unpaid carries `error` — `{"code": "…", "message": "…"}` — once it is `failed`, `rejected` or `cancelled`, otherwise `null`: in the `POST /payouts` answer, in `GET /payouts/{id}`, and as `data.error` on `payout.failed` and `payout.cancelled`. Branch on the code to tell the end user what happened; show the message if you like. New codes may be added: treat a code you don't know by the status — nothing was debited unless it says otherwise.

| `error.code` | Status | What happened / what to do |
| - | - | - |
| `QUOTE_AMOUNT_MISMATCH` | `cancelled` | The QR is for another amount than the quote. Nothing was debited; the quote can still be used for the right QR. |
| `QUOTE_EXPIRED` | `cancelled` | The quote expired before the payment. Nothing was debited. Get a new quote and show it to the end user. |
| `QUOTE_ALREADY_USED` | `cancelled` | Another payment already used this quote. Nothing was debited. Get a new quote. |
| `INSUFFICIENT_BALANCE` | `cancelled` | Your available USDT is below the quote's source.amount. Nothing was debited. Top up and quote again. |
| `INSUFFICIENT_FUNDS` | `cancelled` | Liquidity on our side, not your wallet. Nothing was debited. Try again later; tell Rampa if it persists. |
| `AMOUNT_EXCEEDS_PROVIDER_LIMIT` | `cancelled` | Above the per-payment cap. Nothing was debited. |
| `PAYMENT_CANCELLED` | `cancelled` | It ended before it was paid. Nothing was debited. |
| `QR_REFUSED` | `rejected` | The network refused the QR. Nothing was debited. Ask the end user to scan again. |
| `PAYMENT_FAILED` | `failed` | The network refused the payment. The held USDT is released. The end user can try again with a new quote and transaction\_id. |

```json payout.failed theme={null}
{
  "id": "7e4d2b1c-9a8f-4e3d-b2c1-0f9e8d7c6b5a",
  "type": "payout.failed",
  "event_version": 1,
  "occurred_at": "2026-09-27T14:03:05.000Z",
  "recorded_at": "2026-09-27T14:03:05.120Z",
  "data": {
    "id": "po_9Fz3Kd7RwP2mX8cT4nB6vQ1h",
    "type": "qr",
    "status": "failed",
    "transaction_id": "W-1001",
    "error": {
      "code": "PAYMENT_FAILED",
      "message": "The payment did not go through. The held USDT is released."
    },
    "completed_at": null,
    "…": "the rest of the payment, as in payout.completed"
  }
}
```

## Verify the signature

Every delivery carries `X-Rampa-Signature: t=<unix seconds>,v1=<hex>` and `X-Rampa-Event-Id`. `v1` is HMAC-SHA256 of `"<t>.<raw body>"` with your endpoint's secret. Verify over the **raw bytes**, compare in constant time, and reject stale timestamps.

```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the exact bytes you received — verify BEFORE parsing JSON.
export function verifyRampaSignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // stale: replay

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```python Python theme={null}
import hmac, hashlib, time

def verify_rampa_signature(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    t, v1 = int(parts.get("t", 0)), parts.get("v1", "")
    if not t or abs(time.time() - t) > tolerance:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

## Delivery

* **At least once.** An event can arrive more than once. Dedupe on the event `id` (also in `X-Rampa-Event-Id`).
* **Order is not guaranteed.** Trust the payment's `status`, not arrival order; when in doubt, `GET` the payment.
* **Retries.** A non-2xx, a timeout or a redirect is retried with backoff (30 s, doubling, up to 1 h), up to 8 attempts.
* **Auto-pause.** After 5 events in a row exhaust their retries, the endpoint is paused. Events keep queueing for it. Fix it — [`POST /webhooks/{id}/test`](/api-reference/webhooks/test-webhook) checks — then [`POST /webhooks/{id}/reactivate`](/api-reference/webhooks/reactivate-webhook): what was queued is sent, and so is every delivery of the last 7 days that ran out of retries.

> [!WARNING]
>
> **Sandbox note.** While the sandbox rollout finishes, webhook delivery may be off. Every flow also works by polling `GET /payouts/{id}`, `GET /wallets` and `GET /wallets/transactions` — build polling as your fallback either way.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.