# Webhooks (IPN)

**payitgo's webhooks are its IPN** (instant payment notifications): whenever
one of your payments changes, payitgo sends an HTTPS `POST` to your server.
**The webhook secret is the signing key** (what some gateways call the IPN
secret): every
notification is signed with it, so your server can prove the request came from
payitgo and was not changed on the way.

Fulfil orders from these notifications. They are signed, retried for over a
day until your server acknowledges them, and arrive even if the customer
closed the browser.

## How it works

Your account has **two webhooks: one for test mode and one for live mode.**
Each has:

| | |
|---|---|
| **A URL** | Where every notification of that mode is `POST`ed. Optional: `null` until you set it |
| **A signing secret** | 64 hex characters. Made automatically, it signs every notification of that mode. Read it any time; the account owner can roll it |

A payment can also name its **own** `webhook_url` when it is created: that
payment's notifications go there **instead of** the account's URL, signed
with the same secret.

| Notification | Goes to |
|---|---|
| `payment.*` and `settlement.*` of a payment created with `webhook_url` | That payment's `webhook_url` |
| `payment.*` and `settlement.*` of any other payment | The webhook URL of the payment's mode |
| `payout.*` | The live webhook URL (payouts are live only) |
| `ping` (a test) | The webhook URL of the key's mode |

Your API key picks the mode: with your test key you read and change the test
webhook, with your live key the live one. Test notifications say
`"livemode": false` and are signed with the test secret.

If a notification has nowhere to go (no `webhook_url` on the payment and no
URL for the mode), nothing is sent, and nothing is sent later either: setting
a URL afterwards does not replay old events. Set your URL before you take
payments. To catch up, read the payments with
[`GET /v1/payments`](/docs/payments#list-payments).

## Set it up

**1. Read your webhook and its secret** with your API key:

```bash
curl -s https://api.payitgo.co/v1/webhook -H "Authorization: Bearer $PAYITGO_KEY"
```

```json
{
  "webhook": {
    "livemode": false,
    "url": null,
    "secret": "6e86b3f1…a8418ae0",
    "created_at": "2026-10-10T07:48:44.128Z",
    "updated_at": "2026-10-10T07:48:44.128Z"
  }
}
```

Store `secret` on your server, next to your API key (for example as
`PAYITGO_WEBHOOK_SECRET`). It is the same for every notification of this mode.

**2. Set the URL** your server receives notifications on:

```bash
curl -s -X PUT https://api.payitgo.co/v1/webhook \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://acme.example/payitgo/ipn"}'
```

**3. Send yourself a test** and check that your handler verifies it and
answers `200`:

```bash
curl -s -X POST https://api.payitgo.co/v1/webhook/test -H "Authorization: Bearer $PAYITGO_KEY"
```

Do the same with your live key before going live: the live webhook has its
own URL and its own secret.

## The webhook API

All four routes take your API key and act on the webhook of the key's mode.
The dashboard has the same routes under `/merchant/webhook`, plus rolling the
secret ([Account and team](/docs/account#webhook)).

| Method | Path | What it does |
|---|---|---|
| `GET` | [`/v1/webhook`](#read-the-webhook) | Read the URL and the signing secret |
| `PUT` | [`/v1/webhook`](#set-the-url) | Set the URL. Safe to retry |
| `DELETE` | [`/v1/webhook`](#remove-the-url) | Remove the URL; the secret is kept |
| `POST` | [`/v1/webhook/test`](#send-a-test) | Send a signed `ping` to the URL |

### The webhook object

```json
{
  "livemode": false,
  "url": "https://acme.example/payitgo/ipn",
  "secret": "6e86b3f1…a8418ae0",
  "created_at": "2026-10-10T07:48:44.128Z",
  "updated_at": "2026-10-10T07:48:44.251Z"
}
```

| Field | Type | Description |
|---|---|---|
| `livemode` | boolean | `false` for the test webhook, `true` for the live one |
| `url` | string or `null` | Where the mode's notifications are posted. `null` when none is set |
| `secret` | string | The signing secret of the mode: 64 hex characters. Verify every notification with it |
| `created_at` | string | When the webhook was first made (the first time it was read) |
| `updated_at` | string | When the URL or the secret last changed |

### Read the webhook

`GET /v1/webhook` · API key (storefront keys too)

No parameters. `200 OK` with `{"webhook": {…}}`, as in [step 1](#set-it-up).
The webhook and its secret are made the first time they are read, so this
always succeeds.

### Set the URL

`PUT /v1/webhook` · API key (not a storefront key)

| Field | Type | Required | Description |
|---|---|---|---|
| `url` | string | yes | An absolute `https://` URL on a public host that accepts `POST`. Any path and query, such as `https://acme.example/payitgo/ipn?shop=eu` |

```bash
curl -s -X PUT https://api.payitgo.co/v1/webhook \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://acme.example/payitgo/ipn"}'
```

`200 OK`:

```json
{
  "webhook": {
    "livemode": false,
    "url": "https://acme.example/payitgo/ipn",
    "secret": "6e86b3f1…a8418ae0",
    "created_at": "2026-10-10T07:48:44.128Z",
    "updated_at": "2026-10-10T07:48:44.251Z"
  }
}
```

Setting the URL it already has changes nothing (not even `updated_at`), so the
request is **safe to retry**. A new URL takes effect at once, also for
notifications already waiting to be retried. The secret does not change.

| Status | Message | Cause |
|---|---|---|
| `400` | `url is required` | No `url`, or an empty one |
| `400` | `url must be an absolute http(s) URL` | Not an absolute URL (`shop.example/ipn`), or one with a user name or password in it |
| `400` | `url must use https` | An `http://` URL |
| `400` | `url host does not resolve` | The host name has no DNS record |
| `400` | `url must point at a public host` | `localhost`, or a private, loopback or link-local address |
| `400` | `invalid request body` | The body is not JSON |
| `401` | `missing api key` · `invalid api key` | [Authentication](/docs/authentication#api-key-errors) |
| `403` | `a storefront's key cannot change the account's webhook: set webhook_url on its payments instead` | A [storefront key](/docs/authentication#storefront-keys) |
| `500` | `could not set the webhook` | Retry |

### Remove the URL

`DELETE /v1/webhook` · API key (not a storefront key)

Stops posting the mode's notifications to its URL. Payments created with their
own `webhook_url` still get theirs. The secret is kept.

```bash
curl -s -X DELETE https://api.payitgo.co/v1/webhook -H "Authorization: Bearer $PAYITGO_KEY"
```

`200 OK`:

```json
{
  "webhook": {
    "livemode": false,
    "url": null,
    "secret": "6e86b3f1…a8418ae0",
    "created_at": "2026-10-10T07:48:44.128Z",
    "updated_at": "2026-10-10T07:49:29.926Z"
  }
}
```

Removing a URL that is already removed changes nothing. Notifications already
waiting to be retried to the account's URL fail while it has none (`no webhook
url is set for this mode`), and keep being retried on the
[schedule](#retries). Errors: `403` for a storefront key, `500 could not
remove the webhook`.

### Send a test

`POST /v1/webhook/test` · API key (storefront keys too)

Queues a signed `ping` notification to the mode's URL, so you can watch one
arrive and check your verification. It is sent within about 5 seconds.

```bash
curl -s -X POST https://api.payitgo.co/v1/webhook/test -H "Authorization: Bearer $PAYITGO_KEY"
```

`202 Accepted`, with the [delivery](#the-delivery-object) on its way:

```json
{
  "delivery": {
    "id": "3b7f1d9a5c2e8f4a6d0b2c1e",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "livemode": false,
    "target": "account",
    "url": "https://acme.example/payitgo/ipn",
    "event_id": "5c0e1f9a2b7d4e3c8a6f1b2d",
    "event_type": "ping",
    "payload": "{\"created_at\":\"2026-10-10T09:12:44.512837Z\",\"data\":{\"message\":\"pong\"},\"id\":\"5c0e1f9a2b7d4e3c8a6f1b2d\",\"livemode\":false,\"type\":\"ping\"}",
    "status": "pending",
    "attempts": 0,
    "max_attempts": 7,
    "next_attempt_at": "2026-10-10T09:12:44.513627769Z",
    "created_at": "2026-10-10T09:12:44.513627769Z"
  }
}
```

| Status | Message | Cause |
|---|---|---|
| `400` | `no webhook url is set for this mode: set one first` | The mode has no URL |
| `500` | `could not queue the test` | Retry |

Each call sends one more `ping`. A ping is retried like any notification if
your server does not answer `2xx`.

### The delivery object

A delivery is one notification on its way to one URL, with every attempt
recorded. The test returns one; the dashboard lists them
([delivery history](#delivery-history-and-redelivery)).

| Field | Type | Description |
|---|---|---|
| `id` | string | The delivery's id. Sent as the `X-Webhook-Id` header, the same on every attempt |
| `merchant_id` | string | Your account |
| `livemode` | boolean | `false` for a delivery of a test event |
| `target` | string | `account` (the mode's webhook URL, **as it is at each attempt**) or `payment` (the payment's own `webhook_url`) |
| `url` | string | Where it was last posted, updated on every attempt. Before the first attempt, where it is going. Each attempt's URL is also in `history[].url` |
| `event_id` | string | The event: the `id` in the body |
| `event_type` | string | The event type, such as `payment.confirmed` |
| `payload` | string | The exact body posted, as a string |
| `status` | string | `pending` (waiting for its next attempt), `sending` (being sent now), `delivered` (your server answered `2xx`), `failed` (all 7 attempts failed) or `resolved` (a failed delivery payitgo's team closed) |
| `attempts` | integer | Attempts made so far |
| `max_attempts` | integer | `7` |
| `next_attempt_at` | string | When the next attempt is due |
| `last_status_code` | integer, optional | Your server's last HTTP status |
| `last_error` | string, optional | Why the last attempt failed, such as `endpoint answered 500` |
| `last_attempt_at` | string, optional | When the last attempt was made |
| `delivered_at` | string, optional | When your server answered `2xx` |
| `resolved_at`, `resolution_note` | string, optional | When and why a failed delivery was closed |
| `created_at` | string | When the delivery was made |
| `history` | array, optional | Every attempt, redelivery and resolution, oldest first (the latest 50) |

Each `history` entry:

| Field | Description |
|---|---|
| `at` | When |
| `url` | Where the attempt was posted |
| `signed` | The `t` of its signature |
| `status_code` | Your server's HTTP status |
| `error` | Why it failed, if it did |
| `response` | The start of your server's answer (up to 500 characters) |
| `millis` | How long your server took |
| `note` | For a redelivery or resolution: who did it, such as `redelivered by merchant you@acme.example` |

## Per-payment webhook URL

Pass `webhook_url` when you [create a payment](/docs/payments#create-a-payment)
and that payment's notifications go there **instead of** the account's URL:

```bash
curl -s https://api.payitgo.co/v1/payments \
  -H "Authorization: Bearer $PAYITGO_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"order_id": "ORD-1003", "amount_usd": 30, "token": "USDT_TRC20", "webhook_url": "https://acme.example/orders/ORD-1003/ipn"}'
```

- It receives the payment's `payment.*` events and the `settlement.*` events
  of its deposits. Nothing about that payment goes to the account's URL.
- It is signed with **the same secret** as the account's URL in that mode:
  verify it the same way.
- It must be `https://` on a public host, checked when the payment is created
  (`400 webhook_url: url must use https`, and the other URL errors above) and
  again before each attempt.
- The payment object shows it as `webhook_url`.
- It is fixed at creation: a [retried create](/docs/payments#safe-retries) for
  the same open order returns the payment unchanged.

Use it when different payments belong to different backends: one per store (a
[storefront key](/docs/authentication#storefront-keys) cannot change the
account's URL, so a store sets `webhook_url` on its payments), one per tenant
of your platform, or a callback per order. The [libraries](/docs/sdks#a-payments-own-webhook-url)
can add it to every payment a client creates.

A notification sent to a payment's own URL looks exactly like any other. This
one was captured from a test payment:

```json
{
  "created_at": "2026-10-10T08:03:13.934193672Z",
  "data": {
    "id": "2d9b6e14a7c3f0851e4b9a6c",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1003",
    "status": "expired",
    "deposit_address": "TGs8Nx3kV6qR1wD9mP4hB7cL2fJ5tYzEaH",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": false,
    "crypto_amount": "30",
    "received_amount": "0",
    "usd_amount": "30",
    "amount_due_usd": "30",
    "fees_paid_by_customer": false,
    "network_fee": "3.212009",
    "network_fee_usd": "3.22",
    "settlement": "direct",
    "received_usd": "0",
    "rate_locked": "1",
    "deposits": [],
    "expires_at": "2026-10-10T08:02:43.856Z",
    "created_at": "2026-10-10T07:47:43.855Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TGs8Nx3kV6qR1wD9mP4hB7cL2fJ5tYzEaH",
    "webhook_url": "https://acme.example/orders/ORD-1003/ipn"
  },
  "id": "e8a1c3f5b7d9e2a4c6f8b0d1",
  "livemode": false,
  "type": "payment.expired"
}
```

## The signing secret

- **Where to find it:** `GET /v1/webhook` with your API key (any key of the
  mode, storefront keys included), or `GET /merchant/webhook?mode=test` in the
  dashboard (owner or developer).
- **One per mode.** The test secret signs test notifications, the live secret
  live ones. Use the secret of the same mode as the key.
- **Rolling it:** the account owner calls
  `POST /merchant/webhook/roll-secret?mode=live` ([how](/docs/account#roll-the-signing-secret)).
  The new secret signs every notification from then on, retries of older ones
  included, and the old one stops working at once. To roll without missing a
  notification, deploy code that accepts either secret, roll, store the new
  one, then remove the old.
- **Keep it secret.** Anyone who has it can forge notifications. Keep it on
  your server, never in a browser or app, and roll it if it may have leaked.

## The notification

payitgo sends one `POST` per event:

```
POST /payitgo/ipn HTTP/1.1
Host: acme.example
Content-Type: application/json
User-Agent: payitgo-webhooks/1.0
X-Webhook-Id: 8e3c1a7f5d9b2e4c6a0f1d3b
X-Webhook-Event: payment.expired
X-Webhook-Attempt: 1
X-Webhook-Signature: t=1791619398,v1=eecab6f6b8af5b15421c86d330ed4be369200c10ebb8524e33b30d8bf9e65c08
Content-Length: 959

{"created_at":"2026-10-10T08:03:13.937152328Z","data":{"id":"6c4a2e8f0b1d3f5a7c9e2b4d","merchant_id":"3f8a1c27b94e05d6a2c71e90","order_id":"ORD-1004","status":"expired","deposit_address":"TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX","token":"USDT_TRC20","chain":"tron","livemode":false,"crypto_amount":"49.9","received_amount":"0","usd_amount":"49.9","amount_due_usd":"49.9","fees_paid_by_customer":false,"network_fee":"3.212009","network_fee_usd":"3.22","settlement":"direct","received_usd":"0","rate_locked":"1","deposits":[],"expires_at":"2026-10-10T08:02:43.72Z","created_at":"2026-10-10T07:47:43.719Z","network_fee_note":"Final in about a minute.","qr_payload":"TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX","description":"2 x Acme T-shirt","metadata":{"customer_id":"cus_42"},"customer_email":"jane@example.com","success_url":"https://acme.example/thanks","cancel_url":"https://acme.example/cart"},"id":"9d4b2f7e1c6a3e08b5d2f1c4","livemode":false,"type":"payment.expired"}
```

### Headers

| Header | Example | Meaning |
|---|---|---|
| `Content-Type` | `application/json` | The body is JSON |
| `User-Agent` | `payitgo-webhooks/1.0` | Identifies payitgo's sender |
| `X-Webhook-Id` | `8e3c1a7f5d9b2e4c6a0f1d3b` | The [delivery](#the-delivery-object). The same on every retry and redelivery of this notification |
| `X-Webhook-Event` | `payment.expired` | The event type, the same as `type` in the body: route on it without parsing |
| `X-Webhook-Attempt` | `1` | Which attempt this is, 1 to 7. Starts again at 1 after a redelivery |
| `X-Webhook-Signature` | `t=1791619398,v1=eecab6…5c08` | `t` is the Unix time the attempt was signed; `v1` is the signature. See [verifying](#verifying-the-signature) |

### Body

The body is one line of JSON (shown indented here):

```json
{
  "created_at": "2026-10-10T08:03:13.937152328Z",
  "data": {
    "id": "6c4a2e8f0b1d3f5a7c9e2b4d",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1004",
    "status": "expired",
    "deposit_address": "TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": false,
    "crypto_amount": "49.9",
    "received_amount": "0",
    "usd_amount": "49.9",
    "amount_due_usd": "49.9",
    "fees_paid_by_customer": false,
    "network_fee": "3.212009",
    "network_fee_usd": "3.22",
    "settlement": "direct",
    "received_usd": "0",
    "rate_locked": "1",
    "deposits": [],
    "expires_at": "2026-10-10T08:02:43.72Z",
    "created_at": "2026-10-10T07:47:43.719Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX",
    "description": "2 x Acme T-shirt",
    "metadata": { "customer_id": "cus_42" },
    "customer_email": "jane@example.com",
    "success_url": "https://acme.example/thanks",
    "cancel_url": "https://acme.example/cart"
  },
  "id": "9d4b2f7e1c6a3e08b5d2f1c4",
  "livemode": false,
  "type": "payment.expired"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | The event's id: 24 hex characters, treated as an opaque string. The same on every retry and redelivery: **deduplicate on it** |
| `type` | string | What happened: one of the [events](#events) |
| `livemode` | boolean | `false` for a test event. Never fulfil a real order from a test event |
| `created_at` | string | When the event happened (RFC 3339, UTC) |
| `data` | object | What it happened to: the [payment](/docs/payments#the-payment-object) for `payment.*`, the [settlement](/docs/settlement#following-settlements) for `settlement.*`, the [payout](/docs/account#the-payout-object) for `payout.*`, `{"message": "pong"}` for `ping`. Every amount in a coin has its [dollar value](#amounts-in-dollars) next to it |

The payment in `data` is the payment object as `GET /v1/payments/{id}` returns
it at that moment, except that it never carries `checkout_token` or
`token_options`. A simulated test payment's carries its `simulation`, and its
deposits `"simulated": true` ([Test mode](/docs/testing#what-a-simulated-payment-looks-like)).

The keys come in alphabetical order at the top level, and the body has no
spaces. Do not rely on either: always verify the raw bytes you received, and
parse them as ordinary JSON.

### Events

| Type | Sent when | `data` |
|---|---|---|
| `payment.partially_paid` | Part of the amount arrived; the customer can still send the rest before `expires_at` | Payment |
| `payment.detecting` | The full amount arrived; waiting for confirmations. **Not final** | Payment |
| `payment.confirmed` | **Final: the money is yours. Fulfil the order** | Payment |
| `payment.underpaid` | The payment closed with only part of the amount (at the deadline, or a late deposit that was still not enough) | Payment |
| `payment.expired` | The deadline passed and nothing arrived (or the customer never chose a coin) | Payment |
| `payment.failed` | Funds were seen but never became final | Payment |
| `payment.wrong_asset` | A deposit in another coin than the payment asked for arrived ([wrong asset](/docs/payments#when-the-customer-pays-with-the-wrong-asset)) | Payment |
| `payment.late_deposit` | A deposit arrived after the payment had closed; it is settled like any other ([late payments](/docs/payments#late-payments)) | Payment |
| `settlement.completed` | Direct settlement: your share of a deposit reached your wallet; `data.tx_id` is the transfer (for a deposit converted across networks, the exchange's payout to your wallet; a test settlement completes on paper, with no `tx_id`) | Settlement |
| `settlement.failed` | Direct settlement: your share could not be sent after repeated tries; payitgo's team retries it or returns it to your balance | Settlement |
| `payout.approved` | payitgo approved a payout you requested | Payout |
| `payout.rejected` | payitgo rejected a payout; the amount is back in your balance (`data.review_note` says why) | Payout |
| `payout.sent` | A payout is on chain; `data.tx_id` is the transaction | Payout |
| `ping` | You sent a [test](#send-a-test) | `{"message": "pong"}` |

No event is sent when a payment is created. One deposit can raise two events
at once: a late deposit that completes a payment raises `payment.detecting`
and `payment.late_deposit`, both with the same payment. Every event type has a
full example at the [end of this page](#example-notifications).

## Amounts in dollars

The amounts in a coin in `data` come with their dollar values, so you can
book a BTC or ETH payment in dollars without looking up a price. Dollar values are
at the rate fixed at the time (the payment's locked rate, the settlement, the
payout request), not today's price. Like every amount, they are decimal
strings without trailing zeros (`"0.5"` is $0.50).

The [payment](/docs/payments#amounts-what-is-asked-what-is-due-what-arrived):

| In the coin | In dollars |
|---|---|
| `crypto_amount` | `amount_due_usd` |
| `received_amount` | `received_usd` |
| `network_fee` | `network_fee_usd` |
| `deposits[].amount` | `deposits[].usd_value` (at `rate_locked`, or at the market price for another coin) |
| | `rate_locked`: the dollar price of one `token`, locked when the coin was chosen |

The [settlement](/docs/settlement#the-settlement-object):

| In the coin | In dollars |
|---|---|
| `amount` | `usd_value` |
| `service_fee` | `service_fee_usd` |
| `network_fee` | `network_fee_usd` |
| `conversion_fee` | `conversion_fee_usd` |
| `net` | `net_usd`: `usd_value` less the three fees in dollars, so the dollar figures add up |
| | `usd_rate`: the dollar price of one `token` used (`usd_value` ÷ `amount`, to 8 decimals) |

The [payout](/docs/account#the-payout-object):

| In the coin | In dollars |
|---|---|
| `amount` | `amount_usd` |
| `send_amount` | `send_amount_usd` |
| `network_fee` | `network_fee_usd` |
| | `usd_rate`: the coin's dollar price when the payout was requested |

A BTC deposit of 0.00085 to a payment locked at $62,400 settles with (other fields left out):

```json
{
  "token": "BTC",
  "amount": "0.00085",
  "service_fee": "0.0000085",
  "network_fee": "0.0000185",
  "conversion_fee": "0",
  "net": "0.000823",
  "usd_value": "53.04",
  "usd_rate": "62400",
  "service_fee_usd": "0.53",
  "network_fee_usd": "1.15",
  "conversion_fee_usd": "0",
  "net_usd": "51.36"
}
```

For USDT and USDC, `usd_rate` is `"1"` and each dollar figure is the coin
amount rounded to cents.

## Verifying the signature

Verify every notification before you trust it. The libraries do it in one
call; this section shows what that call does, so you can do it in any
language.

### With a library

| Language | Call |
|---|---|
| Node.js | `payitgo.webhooks.constructEvent(rawBody, req.get("X-Webhook-Signature"))` |
| Python | `client.webhooks.construct_event(raw_body, request.headers["X-Webhook-Signature"])` |
| PHP | `$payitgo->webhooks->constructEvent($rawBody, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'])` |
| Go | `client.Webhooks.ConstructEvent(body, r.Header.Get("X-Webhook-Signature"))` |
| Dart | `payitgo.webhooks.constructEvent(rawBody, request.headers['x-webhook-signature'])` |

They use the client's `webhookSecret` option, or a secret you pass as the third
argument, check the signature and the timestamp, and return the parsed event.
A forged, changed or stale notification throws a signature error: answer
`400`. Full examples: [Libraries](/docs/sdks).

### The recipe

```
X-Webhook-Signature: t=<unix seconds>,v1=<hex>
v1 = hex( HMAC-SHA256( key = secret, message = t + "." + raw_body ) )
```

1. Read the **raw** request body as bytes, before any JSON parsing. A body
   parsed and serialized again is not the same bytes, and will not verify.
2. Split the header on `,` and each part on the first `=`: take `t` and `v1`.
   If either is missing, reject.
3. Compute HMAC-SHA256 with your mode's secret as the key (the whole string
   as you read it, as UTF-8 bytes) over
   `t`, then `.`, then the raw body. Encode it as lowercase hex.
4. Compare it with `v1` in **constant time**. If they differ, reject.
5. Reject if `t` is more than **5 minutes** away from your clock, so a captured
   notification cannot be replayed later. Each attempt is signed afresh, so a
   retry always carries a current `t`.
6. Only now parse the body as JSON.

Answer `400` to a notification that fails; payitgo retries it, and it shows in
your delivery history with `endpoint answered 400`.

**Check your code against this example.** With this (made-up) secret,
timestamp and body, your code must produce this `v1`:

| | |
|---|---|
| secret | `a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48` |
| `t` | `1791623565` |
| raw body (133 bytes) | `{"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}` |
| `v1` | `1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b` |

From a shell:

```bash
SECRET='a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48'
T='1791623565'
BODY='{"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex
# SHA2-256(stdin)= 1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b
```

(Your code will reject this example as too old: skip the timestamp check when
you test with it.)

### Node.js (Express)

```js
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.PAYITGO_WEBHOOK_SECRET;
const TOLERANCE = 300; // seconds

function verify(rawBody, header) {
  const parts = Object.fromEntries(
    (header || "").split(",").map((p) => {
      const i = p.indexOf("=");
      return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
    }),
  );
  if (!parts.t || !parts.v1) return false;
  const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
  return Math.abs(Date.now() / 1000 - Number(parts.t)) <= TOLERANCE;
}

const app = express();

// express.raw keeps the body as bytes. Do not let express.json() run first on this route.
app.post("/payitgo/ipn", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req.body, req.get("X-Webhook-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // answer first, work after
  await handleEvent(event); // see "Handle events safely"
});
```

### Python (Flask)

```python
import hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["PAYITGO_WEBHOOK_SECRET"].encode()
TOLERANCE = 300  # seconds

def verify(raw_body: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t or not v1:
        return False
    expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        return False
    return t.isdigit() and abs(time.time() - int(t)) <= TOLERANCE

@app.post("/payitgo/ipn")
def ipn():
    raw = request.get_data()  # the raw bytes; in Django use request.body
    if not verify(raw, request.headers.get("X-Webhook-Signature", "")):
        return "", 400
    event = json.loads(raw)
    handle_event(event)  # see "Handle events safely"
    return "", 200
```

### PHP

```php
<?php
$secret = getenv('PAYITGO_WEBHOOK_SECRET');
$raw = file_get_contents('php://input'); // in Laravel: $request->getContent()
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $part) {
    [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
    $parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);

if ($t === '' || $v1 === '' || !hash_equals($expected, $v1) || abs(time() - (int) $t) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);
handleEvent($event); // see "Handle events safely"
http_response_code(200);
```

### Go

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verify(secret, header string, body []byte) bool {
	var t, v1 string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "t":
			t = v
		case "v1":
			v1 = v
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || v1 == "" {
		return false
	}
	if d := time.Now().Unix() - ts; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}

func ipn(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil || !verify(os.Getenv("PAYITGO_WEBHOOK_SECRET"), r.Header.Get("X-Webhook-Signature"), body) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	var event struct {
		ID       string          `json:"id"`
		Type     string          `json:"type"`
		Livemode bool            `json:"livemode"`
		Data     json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(body, &event); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	handleEvent(event.ID, event.Type, event.Livemode, event.Data) // see "Handle events safely"
	w.WriteHeader(http.StatusOK)
}
```

### Dart (shelf)

```dart
import 'dart:convert';
import 'dart:io';

import 'package:crypto/crypto.dart';
import 'package:shelf/shelf.dart';

bool verify(String secret, String? header, List<int> body) {
  final parts = <String, String>{};
  for (final p in (header ?? '').split(',')) {
    final i = p.indexOf('=');
    if (i > 0) parts[p.substring(0, i).trim()] = p.substring(i + 1).trim();
  }
  final t = parts['t'], v1 = parts['v1'];
  final ts = t == null ? null : int.tryParse(t);
  if (ts == null || v1 == null) return false;
  final expected =
      Hmac(sha256, utf8.encode(secret)).convert([...utf8.encode('$t.'), ...body]).toString();
  if (expected.length != v1.length) return false;
  var diff = 0;
  for (var i = 0; i < expected.length; i++) {
    diff |= expected.codeUnitAt(i) ^ v1.codeUnitAt(i); // constant time
  }
  final age = DateTime.now().millisecondsSinceEpoch ~/ 1000 - ts;
  return diff == 0 && age.abs() <= 300;
}

Future<Response> ipn(Request request) async {
  final body = await request.read().expand((chunk) => chunk).toList();
  final secret = Platform.environment['PAYITGO_WEBHOOK_SECRET']!;
  if (!verify(secret, request.headers['x-webhook-signature'], body)) {
    return Response(400);
  }
  final event = jsonDecode(utf8.decode(body)) as Map<String, dynamic>;
  await handleEvent(event); // see "Handle events safely"
  return Response.ok('');
}
```

### Keep the raw body

Most verification failures come from a framework parsing the body first:

| Framework | Raw body |
|---|---|
| Express | `express.raw({ type: "application/json" })` on the route, before any `express.json()` |
| Next.js (route handler) | `await request.text()` |
| Fastify | `addContentTypeParser('application/json', { parseAs: 'buffer' }, …)` for the route |
| Flask | `request.get_data()` |
| Django | `request.body` |
| FastAPI / Starlette | `await request.body()` |
| Laravel | `$request->getContent()` |
| Rails | `request.raw_post` |
| Go | `io.ReadAll(r.Body)` |

## Answering and retries

### Answer quickly

Answer any **2xx** status within **10 seconds**. The body of your answer is
ignored (its first 500 characters are kept in the delivery history, to help
you debug). Do slow work, such as emails or calls to other services, after
answering or in a queue.

Anything else counts as a failure: another status, no answer within 10
seconds, a connection or TLS error. **Redirects are not followed**: a `301` or
`302` (for example from `http` to `https`, or a missing trailing slash) is a
failure, so set the exact final URL.

### Retries

A failed attempt is retried on this schedule:

| Attempt | When |
|---|---|
| 1 | Within about 5 seconds of the event |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes after attempt 2 |
| 4 | 30 minutes after attempt 3 |
| 5 | 2 hours after attempt 4 |
| 6 | 6 hours after attempt 5 |
| 7 | 24 hours after attempt 6 |

After the seventh failure, about 33 hours after the event, the delivery is
marked `failed` and payitgo stops. You can [redeliver](#delivery-history-and-redelivery)
it at any time. The dashboard's `failed_webhooks` counts these
([Dashboard](/docs/account#dashboard)).

Every attempt is signed afresh (a new `t`), carries the same `X-Webhook-Id`
and body, and increases `X-Webhook-Attempt`. A notification to the account's
URL goes to the URL **as it is at that attempt**: if you change your webhook
URL, pending retries follow it.

## Handle events safely

- **Deduplicate on the event `id`.** The same notification can arrive more
  than once: a retry after your answer was lost or slow, or a redelivery.
  Record each `id` you processed, and do nothing the second time.
- **Make fulfilment idempotent too.** Check the order's state before acting,
  so a second `payment.confirmed` does not ship twice.
- **Do not rely on order.** Notifications can arrive out of order after
  retries (a `payment.detecting` after its `payment.confirmed`). Use
  `data.status` as the truth, or fetch the payment with
  [`GET /v1/payments/{id}`](/docs/payments#retrieve-a-payment).
- **Check `livemode`.** In production, act only on `"livemode": true`.
- **Match the order.** Find the order by `data.order_id`, and compare
  `data.usd_amount` with your own records.
- **Fulfil only on `payment.confirmed`.** `payment.detecting` means the money
  is on its way, not final.

A handler, in outline:

```js
async function handleEvent(event) {
  if (await db.processedEvents.has(event.id)) return;          // duplicate
  if (event.type === "payment.confirmed" && event.livemode) {
    const order = await db.orders.find(event.data.order_id);
    if (order && order.status !== "paid" && order.totalUsd === event.data.usd_amount) {
      await db.orders.markPaid(order.id, { paymentId: event.data.id });
    }
  }
  await db.processedEvents.add(event.id);
}
```

## Testing your endpoint

- **A ping:** `POST /v1/webhook/test` ([above](#send-a-test)), or
  `POST /merchant/webhook/test` from the dashboard. This is what arrived for a
  real one:

  ```
  POST /payitgo/ipn HTTP/1.1
  Content-Type: application/json
  User-Agent: payitgo-webhooks/1.0
  X-Webhook-Id: 3b7f1d9a5c2e8f4a6d0b2c1e
  X-Webhook-Event: ping
  X-Webhook-Attempt: 1
  X-Webhook-Signature: t=1791623565,v1=1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b
  Content-Length: 133

  {"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}
  ```

  (signed with the example secret above, so you can replay it against your
  handler with the timestamp check off).
- **Real payment events:** make a test payment and
  [simulate](/docs/testing#simulate-a-payment) an outcome: its webhooks arrive
  a few seconds apart, sent by the same processing as live coins
  ([outcomes and their events](/docs/testing#outcomes)). Create one and
  simulate nothing to get `payment.expired` 15 minutes later.
- **On your laptop:** payitgo only posts to public `https://` URLs. Expose your
  local server with a tunnel (ngrok, Cloudflare Tunnel or similar) and set the
  tunnel's URL as your **test** webhook URL.
- **Your error handling:** answer `500` once and watch the retry arrive a
  minute later with `X-Webhook-Attempt: 2`; change one byte of a captured
  body and check that your handler answers `400`.

## Delivery history and redelivery

The dashboard API keeps every notification, with every attempt and your
server's answer ([routes](/docs/account#webhook)):

```bash
curl -s 'https://api.payitgo.co/merchant/webhooks/deliveries?mode=test&status=failed' -H "Authorization: Bearer $ACCESS_TOKEN"
curl -s https://api.payitgo.co/merchant/webhooks/deliveries/1a7c3e9f5b2d8046e3c1a9f7 -H "Authorization: Bearer $ACCESS_TOKEN"
curl -s -X POST https://api.payitgo.co/merchant/webhooks/deliveries/1a7c3e9f5b2d8046e3c1a9f7/redeliver -H "Authorization: Bearer $ACCESS_TOKEN"
```

A redelivery starts the delivery again: attempts back to 0, up to seven more
tries, signed with the current secret and sent to the current URL. Here is a
delivery whose first attempt got a `500`; the webhook URL was then changed and
the delivery redelivered, and it arrived:

```json
{
  "delivery": {
    "id": "1a7c3e9f5b2d8046e3c1a9f7",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "livemode": false,
    "target": "account",
    "url": "https://acme.example/payitgo/ipn",
    "event_id": "7f1d3b5a9c2e4f6a8b0d1c3e",
    "event_type": "ping",
    "payload": "{\"created_at\":\"2026-10-10T07:49:05.988803785Z\",\"data\":{\"message\":\"pong\"},\"id\":\"7f1d3b5a9c2e4f6a8b0d1c3e\",\"livemode\":false,\"type\":\"ping\"}",
    "status": "delivered",
    "attempts": 1,
    "max_attempts": 7,
    "next_attempt_at": "2026-10-10T07:49:22.827Z",
    "last_status_code": 200,
    "last_attempt_at": "2026-10-10T07:49:23.934Z",
    "delivered_at": "2026-10-10T07:49:23.934Z",
    "created_at": "2026-10-10T07:49:05.989Z",
    "history": [
      { "at": "2026-10-10T07:49:08.934Z", "url": "https://legacy.acme.example/payitgo/ipn", "signed": "1791618548", "status_code": 500, "error": "endpoint answered 500", "response": "ok", "millis": 2 },
      { "at": "2026-10-10T07:49:22.827Z", "note": "redelivered by merchant you@acme.example" },
      { "at": "2026-10-10T07:49:23.934Z", "url": "https://acme.example/payitgo/ipn", "signed": "1791618563", "status_code": 200, "response": "ok", "millis": 1 }
    ]
  }
}
```

The top-level `url` is where the last attempt was posted; `history[].url`
keeps where each attempt went.

## Troubleshooting

| Symptom | Check |
|---|---|
| Nothing arrives | Is a URL set **for that mode**? `GET /v1/webhook` with the same key you create payments with (test and live are separate). Was the payment created with its own `webhook_url`? Look at the delivery history: no delivery means there was nowhere to send it |
| Deliveries fail with `endpoint answered 3xx` | A redirect. Set the final URL (exact scheme, host and trailing slash) |
| Deliveries fail with a timeout | Your handler takes over 10 seconds. Answer first, work after |
| Deliveries fail with a TLS or connection error | Your certificate must be valid for the host, and your firewall must accept connections from the internet |
| Signature never matches | Are you hashing the raw bytes, before any JSON parsing? Is it the secret of the right mode? Was the secret rolled? Did you include the `.` between `t` and the body? Are you comparing hex with hex? |
| "Timestamp too old" | Your server's clock is off: sync it with NTP |
| The same event twice | Expected now and then: deduplicate on `id` |
| `PUT /v1/webhook` answers `403` | You used a storefront key: use an account-wide key, or set `webhook_url` on the store's payments |
| `PUT /v1/webhook` answers `url must point at a public host` | payitgo does not post to `localhost` or private networks: use a public URL or a tunnel |

## Coming from another gateway

- **IPN:** payitgo's webhooks are the same idea, a signed `POST` on every
  payment change.
- **A callback URL per payment:** `webhook_url` on the payment, or one URL per
  mode with `PUT /v1/webhook`.
- **The IPN secret:** the webhook signing secret, one per mode
  (`GET /v1/webhook`).
- **The signature:** an HMAC-SHA256 of `t + "." + raw body`, exactly as
  received: no sorting of keys, no re-serializing. `t` is a timestamp: reject
  anything more than 5 minutes old.
- **The final status:** `confirmed` is final: fulfil. With direct settlement,
  `settlement.completed` tells you when the money reached your wallet.
- **A sandbox:** test mode, with the test key and the test webhook
  ([Test mode](/docs/testing)).

From Stripe: the `t=…,v1=…` signature works like `Stripe-Signature`, with the
event type also in `X-Webhook-Event`. From Coinbase Commerce: `X-CC-Webhook-Signature`
was an HMAC-SHA256 of the raw body; payitgo adds the timestamp to what is signed.

## Example notifications

One full notification per event type, as your server receives it (indented
here; the real body is one line). Payment events show a live `USDT_TRC20`
payment with direct settlement.

### payment.partially_paid

60 of 100 USDT arrived. The deposit is still confirming (`"status": "pending"`).

```json
{
  "created_at": "2026-10-10T10:04:11.208114937Z",
  "data": {
    "id": "5a9e3c7b1d4f2e8a6c0b9d17",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1002",
    "status": "partially_paid",
    "deposit_address": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": true,
    "crypto_amount": "100",
    "received_amount": "60",
    "usd_amount": "100",
    "amount_due_usd": "100",
    "fees_paid_by_customer": false,
    "network_fee": "6.019231",
    "network_fee_usd": "6.03",
    "settlement": "direct",
    "received_usd": "60",
    "rate_locked": "1",
    "tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0",
    "deposits": [
      { "tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0", "token": "USDT_TRC20", "chain": "tron", "amount": "60", "usd_value": "60", "counted": true, "status": "pending", "seen_at": "2026-10-10T10:04:11.207Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79413702, "made_at": "2026-10-10T10:04:06Z" }
    ],
    "expires_at": "2026-10-10T10:15:00.405Z",
    "created_at": "2026-10-10T10:00:00.405Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
    "checkout_url": "https://pay.example.com/pay/b28d4f61e9a03c75d2e8f1a6"
  },
  "id": "9e1a5c3f7b0d2e4a6c8f1b5d",
  "livemode": true,
  "type": "payment.partially_paid"
}
```

### payment.detecting

The full amount arrived; the deposit is confirming. Show "payment received",
but do not ship yet.

```json
{
  "created_at": "2026-10-10T09:03:02.419733021Z",
  "data": {
    "id": "8f3a2c1e9b7d4a6f05e1c2d3",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1001",
    "status": "detecting",
    "deposit_address": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": true,
    "crypto_amount": "49.9",
    "received_amount": "49.9",
    "usd_amount": "49.9",
    "amount_due_usd": "49.9",
    "fees_paid_by_customer": false,
    "network_fee": "6.019231",
    "network_fee_usd": "6.03",
    "settlement": "direct",
    "received_usd": "49.9",
    "rate_locked": "1",
    "tx_id": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd",
    "deposits": [
      { "tx_id": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd", "token": "USDT_TRC20", "chain": "tron", "amount": "49.9", "usd_value": "49.9", "counted": true, "status": "pending", "seen_at": "2026-10-10T09:03:02.418Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79412538, "made_at": "2026-10-10T09:02:57Z" }
    ],
    "expires_at": "2026-10-10T09:15:00.112Z",
    "created_at": "2026-10-10T09:00:00.112Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
    "checkout_url": "https://pay.example.com/pay/6f1e2a9c4d7b3e05a8c1f2d4",
    "description": "2 x Acme T-shirt",
    "metadata": { "customer_id": "cus_42" },
    "customer_email": "jane@example.com",
    "success_url": "https://acme.example/thanks",
    "cancel_url": "https://acme.example/cart"
  },
  "id": "7c3e9a1f5b2d8e4c6a0f1b3d",
  "livemode": true,
  "type": "payment.detecting"
}
```

### payment.confirmed

Final. Fulfil the order.

```json
{
  "created_at": "2026-10-10T09:04:03.771240512Z",
  "data": {
    "id": "8f3a2c1e9b7d4a6f05e1c2d3",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1001",
    "status": "confirmed",
    "deposit_address": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": true,
    "crypto_amount": "49.9",
    "received_amount": "49.9",
    "usd_amount": "49.9",
    "amount_due_usd": "49.9",
    "fees_paid_by_customer": false,
    "network_fee": "6.019231",
    "network_fee_usd": "6.03",
    "settlement": "direct",
    "received_usd": "49.9",
    "rate_locked": "1",
    "tx_id": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd",
    "deposits": [
      { "tx_id": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd", "token": "USDT_TRC20", "chain": "tron", "amount": "49.9", "usd_value": "49.9", "counted": true, "status": "confirmed", "seen_at": "2026-10-10T09:03:02.418Z", "confirmed_at": "2026-10-10T09:04:03.77Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79412538, "made_at": "2026-10-10T09:02:57Z" }
    ],
    "expires_at": "2026-10-10T09:15:00.112Z",
    "created_at": "2026-10-10T09:00:00.112Z",
    "confirmed_at": "2026-10-10T09:04:03.770981245Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TNVfZ8m3qR6xW2pL9kC4hJ7bD5sYt1uAeG",
    "checkout_url": "https://pay.example.com/pay/6f1e2a9c4d7b3e05a8c1f2d4",
    "description": "2 x Acme T-shirt",
    "metadata": { "customer_id": "cus_42" },
    "customer_email": "jane@example.com",
    "success_url": "https://acme.example/thanks",
    "cancel_url": "https://acme.example/cart"
  },
  "id": "2b8f4d6a0c1e3f5a7b9d2c4e",
  "livemode": true,
  "type": "payment.confirmed"
}
```

### payment.underpaid

The deadline passed with 60 of 100 USDT. The 60 are settled to you; refund the
customer or agree on the rest.

```json
{
  "created_at": "2026-10-10T10:15:04.551870233Z",
  "data": {
    "id": "5a9e3c7b1d4f2e8a6c0b9d17",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1002",
    "status": "underpaid",
    "deposit_address": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": true,
    "crypto_amount": "100",
    "received_amount": "60",
    "usd_amount": "100",
    "amount_due_usd": "100",
    "fees_paid_by_customer": false,
    "network_fee": "6.019231",
    "network_fee_usd": "6.03",
    "settlement": "direct",
    "received_usd": "60",
    "rate_locked": "1",
    "tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0",
    "deposits": [
      { "tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0", "token": "USDT_TRC20", "chain": "tron", "amount": "60", "usd_value": "60", "counted": true, "status": "confirmed", "seen_at": "2026-10-10T10:04:11.207Z", "confirmed_at": "2026-10-10T10:05:12.33Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79413702, "made_at": "2026-10-10T10:04:06Z" }
    ],
    "expires_at": "2026-10-10T10:15:00.405Z",
    "created_at": "2026-10-10T10:00:00.405Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
    "checkout_url": "https://pay.example.com/pay/b28d4f61e9a03c75d2e8f1a6"
  },
  "id": "4d6b8f0a2c5e7a9c1e3b5d7f",
  "livemode": true,
  "type": "payment.underpaid"
}
```

### payment.expired

Nothing arrived before the deadline. Captured from a test payment:

```json
{
  "created_at": "2026-10-10T08:03:13.937152328Z",
  "data": {
    "id": "6c4a2e8f0b1d3f5a7c9e2b4d",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1004",
    "status": "expired",
    "deposit_address": "TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": false,
    "crypto_amount": "49.9",
    "received_amount": "0",
    "usd_amount": "49.9",
    "amount_due_usd": "49.9",
    "fees_paid_by_customer": false,
    "network_fee": "3.212009",
    "network_fee_usd": "3.22",
    "settlement": "direct",
    "received_usd": "0",
    "rate_locked": "1",
    "deposits": [],
    "expires_at": "2026-10-10T08:02:43.72Z",
    "created_at": "2026-10-10T07:47:43.719Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TFn3Qw8kL5xR2vB7mH9sD4cJ6pY1tGzEaX",
    "description": "2 x Acme T-shirt",
    "metadata": { "customer_id": "cus_42" },
    "customer_email": "jane@example.com",
    "success_url": "https://acme.example/thanks",
    "cancel_url": "https://acme.example/cart"
  },
  "id": "9d4b2f7e1c6a3e08b5d2f1c4",
  "livemode": false,
  "type": "payment.expired"
}
```

### payment.failed

A deposit was seen but never confirmed: here the network did not confirm it
within 48 hours. The deposit shows `"status": "failed"` with `failed_at` and
`fail_reason`, and no longer counts in `received_amount` and `received_usd`.
Treat the order as unpaid.

```json
{
  "created_at": "2026-10-12T12:02:31.031662204Z",
  "data": {
    "id": "b7d9f1a3c5e2b4d6f8a0c1e3",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1006",
    "status": "failed",
    "deposit_address": "TXp2Rk7mW4vN9sC3hB6jD8fL1qY5tGzEaK",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": true,
    "crypto_amount": "25",
    "received_amount": "0",
    "usd_amount": "25",
    "amount_due_usd": "25",
    "fees_paid_by_customer": false,
    "network_fee": "6.019231",
    "network_fee_usd": "6.03",
    "settlement": "direct",
    "received_usd": "0",
    "rate_locked": "1",
    "tx_id": "3d363e97717f6fadd803b83f0f30cc49b7e39901b8f3d6c8cdd7dee6d16ba645",
    "deposits": [
      { "tx_id": "3d363e97717f6fadd803b83f0f30cc49b7e39901b8f3d6c8cdd7dee6d16ba645", "token": "USDT_TRC20", "chain": "tron", "amount": "25", "usd_value": "25", "counted": true, "status": "failed", "seen_at": "2026-10-10T12:02:30.115Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79415233, "made_at": "2026-10-10T12:02:25Z", "failed_at": "2026-10-12T12:02:31.02Z", "fail_reason": "confirmation timeout" }
    ],
    "expires_at": "2026-10-10T12:15:00.640Z",
    "created_at": "2026-10-10T12:00:00.640Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TXp2Rk7mW4vN9sC3hB6jD8fL1qY5tGzEaK",
    "checkout_url": "https://pay.example.com/pay/a3c5e7b9d1f4a6c8e0b2d4f6"
  },
  "id": "8a0c2e4b6d1f3a5c7e9b1d3f",
  "livemode": true,
  "type": "payment.failed"
}
```

### payment.wrong_asset

The customer sent 150 TRX to a USDT payment. With `accept_other_assets` off,
the deposit does not count (`"counted": false`) and the payment keeps waiting
for USDT; the TRX is still settled to you.

```json
{
  "created_at": "2026-10-10T11:03:20.612950871Z",
  "data": {
    "id": "e4c2a0b8d6f1e3a5c7b9d1f3",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1005",
    "status": "pending",
    "deposit_address": "TQm5Hc8vK2xN7rB4wS9jD6fG3pL1zYtEaV",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": true,
    "crypto_amount": "50",
    "received_amount": "0",
    "usd_amount": "50",
    "amount_due_usd": "50",
    "fees_paid_by_customer": false,
    "network_fee": "6.019231",
    "network_fee_usd": "6.03",
    "settlement": "direct",
    "received_usd": "0",
    "rate_locked": "1",
    "tx_id": "02d9e46278b55ce8148371de45cd76a0e845897466caa93e997428f21c6540b4",
    "deposits": [
      { "tx_id": "02d9e46278b55ce8148371de45cd76a0e845897466caa93e997428f21c6540b4", "token": "TRX", "chain": "tron", "amount": "150", "usd_value": "49.335", "counted": false, "status": "pending", "seen_at": "2026-10-10T11:03:20.611Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79414688, "made_at": "2026-10-10T11:03:15Z" }
    ],
    "expires_at": "2026-10-10T11:15:00.218Z",
    "created_at": "2026-10-10T11:00:00.218Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TQm5Hc8vK2xN7rB4wS9jD6fG3pL1zYtEaV",
    "checkout_url": "https://pay.example.com/pay/d7b5e3a1c9f2e4b6a8d0c2e4"
  },
  "id": "1c3e5a7f9b2d4f6a8c0e2b4d",
  "livemode": true,
  "type": "payment.wrong_asset"
}
```

### payment.late_deposit

The underpaid payment above got its missing 40 USDT 16 minutes after the
deadline. It moves on to `detecting` (a `payment.detecting` is sent too) and
will confirm with `"paid_late": true`.

```json
{
  "created_at": "2026-10-10T10:31:45.904377120Z",
  "data": {
    "id": "5a9e3c7b1d4f2e8a6c0b9d17",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "order_id": "ORD-1002",
    "status": "detecting",
    "deposit_address": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
    "token": "USDT_TRC20",
    "chain": "tron",
    "livemode": true,
    "crypto_amount": "100",
    "received_amount": "100",
    "usd_amount": "100",
    "amount_due_usd": "100",
    "fees_paid_by_customer": false,
    "network_fee": "6.019231",
    "network_fee_usd": "6.03",
    "settlement": "direct",
    "received_usd": "100",
    "rate_locked": "1",
    "tx_id": "7463696fcea213c9b884c0d0612f23db02aa413b6b24c59ac11614f899cba0f5",
    "paid_late": true,
    "deposits": [
      { "tx_id": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0", "token": "USDT_TRC20", "chain": "tron", "amount": "60", "usd_value": "60", "counted": true, "status": "confirmed", "seen_at": "2026-10-10T10:04:11.207Z", "confirmed_at": "2026-10-10T10:05:12.33Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79413702, "made_at": "2026-10-10T10:04:06Z" },
      { "tx_id": "7463696fcea213c9b884c0d0612f23db02aa413b6b24c59ac11614f899cba0f5", "token": "USDT_TRC20", "chain": "tron", "amount": "40", "usd_value": "40", "counted": true, "late": true, "status": "pending", "seen_at": "2026-10-10T10:31:45.902Z", "from": "TL8wHk3sD9pQ2vX6mB4nJ7cR5fY1tGzEaW", "block": 79414015, "made_at": "2026-10-10T10:31:40Z" }
    ],
    "expires_at": "2026-10-10T10:15:00.405Z",
    "created_at": "2026-10-10T10:00:00.405Z",
    "network_fee_note": "Final in about a minute.",
    "qr_payload": "TJr7Wq2nX5kP8sB3vH6mC9dF4gL1tYzRaU",
    "checkout_url": "https://pay.example.com/pay/b28d4f61e9a03c75d2e8f1a6"
  },
  "id": "6f0b2d4a8c1e3a5c7e9b0d2f",
  "livemode": true,
  "type": "payment.late_deposit"
}
```

### settlement.completed

Your share of the deposit of `payment.confirmed` above reached your wallet:
49.9 USDT less the 1% service fee and the network fee. A deposit converted on
its way to you also carries its `conversion`
([coins you have no payout address for](/docs/settlement#coins-you-have-no-payout-address-for)),
such as `{"via": "exchange", "from": "BTC", "amount_in": "0.0021", "to": "USDT_TRC20", "amount_out": "196.42", "status": "done"}`,
and its `tx_id` is the exchange's payout to your wallet.

```json
{
  "created_at": "2026-10-10T09:05:12.403118640Z",
  "data": {
    "id": "0441481512422d5c1e2ce3ab",
    "payment_id": "8f3a2c1e9b7d4a6f05e1c2d3",
    "deposit_tx": "c192401935e4ac25bb5f7cdba7443ada0e097cfdb537710236bf7d98c60851bd",
    "livemode": true,
    "mode": "direct",
    "token": "USDT_TRC20",
    "chain": "tron",
    "amount": "49.9",
    "service_fee": "0.499",
    "network_fee": "6.019231",
    "conversion_fee": "0",
    "net": "43.381769",
    "usd_value": "49.9",
    "usd_rate": "1",
    "service_fee_usd": "0.5",
    "network_fee_usd": "6.02",
    "conversion_fee_usd": "0",
    "net_usd": "43.38",
    "destination": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "tx_id": "16d7a564712e9079cddc178e2a80b8d2b6284574cc5bfeb8272580de9f6cae4e",
    "status": "completed",
    "created_at": "2026-10-10T09:04:03.81Z",
    "completed_at": "2026-10-10T09:05:12.402315877Z"
  },
  "id": "3e5a7c9f1b4d6a8c0e2f4b6d",
  "livemode": true,
  "type": "settlement.completed"
}
```

### settlement.failed

Your share could not be sent after repeated tries. payitgo's team retries it,
or returns it to your balance.

```json
{
  "created_at": "2026-10-10T16:41:09.877201394Z",
  "data": {
    "id": "7b9d1f3a5c8e0a2c4e6b8d0f",
    "payment_id": "5a9e3c7b1d4f2e8a6c0b9d17",
    "deposit_tx": "2c06ea8c765f5ad213262c8cd0233c51202ff1856b0751520af6a6976a232ff0",
    "livemode": true,
    "mode": "direct",
    "token": "USDT_TRC20",
    "chain": "tron",
    "amount": "60",
    "service_fee": "0.6",
    "network_fee": "6.019231",
    "conversion_fee": "0",
    "net": "53.380769",
    "usd_value": "60",
    "usd_rate": "1",
    "service_fee_usd": "0.6",
    "network_fee_usd": "6.02",
    "conversion_fee_usd": "0",
    "net_usd": "53.38",
    "destination": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "status": "failed",
    "created_at": "2026-10-10T10:05:12.34Z"
  },
  "id": "5a7c9e1b3d6f8a0c2e4b6d8f",
  "livemode": true,
  "type": "settlement.failed"
}
```

### payout.approved

```json
{
  "created_at": "2026-10-10T11:20:41.512937004Z",
  "data": {
    "id": "7e2c9a4f1b3d8e6a05c2f1d9",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "token": "USDT_TRC20",
    "chain": "tron",
    "amount": "250",
    "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
    "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "network_fee": "3.21",
    "network_fee_usd": "3.21",
    "send_amount": "246.79",
    "usd_rate": "1",
    "amount_usd": "250",
    "send_amount_usd": "246.79",
    "status": "approved",
    "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
    "approved_at": "2026-10-10T11:20:41.5Z",
    "created_at": "2026-10-10T10:00:00.31Z",
    "updated_at": "2026-10-10T11:20:41.5Z"
  },
  "id": "0d2f4b6a8c1e3a5c7e9f1b3d",
  "livemode": true,
  "type": "payout.approved"
}
```

### payout.rejected

```json
{
  "created_at": "2026-10-10T11:20:41.512937004Z",
  "data": {
    "id": "7e2c9a4f1b3d8e6a05c2f1d9",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "token": "USDT_TRC20",
    "chain": "tron",
    "amount": "250",
    "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
    "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "network_fee": "3.21",
    "network_fee_usd": "3.21",
    "send_amount": "246.79",
    "usd_rate": "1",
    "amount_usd": "250",
    "send_amount_usd": "246.79",
    "status": "rejected",
    "review_note": "the address could not be verified: please add it again",
    "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
    "rejected_at": "2026-10-10T11:20:41.5Z",
    "created_at": "2026-10-10T10:00:00.31Z",
    "updated_at": "2026-10-10T11:20:41.5Z"
  },
  "id": "4b6d8f0a2c5e7a9c1e3f5b7d",
  "livemode": true,
  "type": "payout.rejected"
}
```

### payout.sent

```json
{
  "created_at": "2026-10-10T11:42:03.894411702Z",
  "data": {
    "id": "7e2c9a4f1b3d8e6a05c2f1d9",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "token": "USDT_TRC20",
    "chain": "tron",
    "amount": "250",
    "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
    "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "network_fee": "3.21",
    "network_fee_usd": "3.21",
    "send_amount": "246.79",
    "usd_rate": "1",
    "amount_usd": "250",
    "send_amount_usd": "246.79",
    "status": "sent",
    "tx_id": "1f6b4e01f2d8debd54a3fc88a7d4494d81477828cbf2cd1e76dd2987ba34d71f",
    "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
    "approved_at": "2026-10-10T11:20:41.5Z",
    "created_at": "2026-10-10T10:00:00.31Z",
    "updated_at": "2026-10-10T11:42:03.88Z",
    "sent_at": "2026-10-10T11:42:03.88Z"
  },
  "id": "2f4b6d8a0c3e5a7c9e1b3d5f",
  "livemode": true,
  "type": "payout.sent"
}
```

### ping

Sent by [a test](#send-a-test). It goes to the account's URL of the mode.

```json
{
  "created_at": "2026-10-10T09:12:44.512837Z",
  "data": {
    "message": "pong"
  },
  "id": "5c0e1f9a2b7d4e3c8a6f1b2d",
  "livemode": false,
  "type": "ping"
}
```
