# Webhooks

> Receive order updates from Mintage Pay, verify their signature and handle retries.

Source: https://mintagepay.com/docs/webhooks/ (last updated 2026-10-25)

A webhook is a `POST` that Mintage Pay sends to your server when something happens to an invoice, such as a payment
arriving. Use it to mark orders paid.

## Register an endpoint

`POST /v1/webhook-endpoints` (scope `webhooks:manage`)

```json
{ "url": "https://example.com/mintagepay/webhook", "description": "My store" }
```

It returns `{ "id", "url", "secret", "event_types" }`. The `secret` starts with `whsec_` and is shown only once: store
it. You can also add an endpoint in the dashboard under **Developers > Webhooks**.

| Call | Does |
|---|---|
| `GET /v1/webhook-endpoints` | Lists your endpoints, each with its `event_types` |
| `PATCH /v1/webhook-endpoints/{id}` | Changes what it receives: `{ "events": [...] }`, and or `"description"` |
| `DELETE /v1/webhook-endpoints/{id}` | Disables it |
| `POST /v1/webhook-endpoints/{id}/test` | Sends one `webhook.test` event now and tells you whether your endpoint answered `2xx` |

### Choosing events

`events` is optional. It lists exact event names (`"invoice.paid"`) or a whole family (`"invoice.*"`). An endpoint
created without `events` receives every invoice event in the table below. Unknown names are refused with `400`.

## The event

Each event is a `POST` with this JSON body:

```json
{
  "id": "evt_...",
  "type": "invoice.paid",
  "created_at": "2026-10-01T18:04:11.000Z",
  "data": { "invoice": { "id": "Kp3...", "status": "paid", "order_id": "1042" } }
}
```

`data.invoice` is the full [invoice object](https://mintagepay.com/docs/api/#the-invoice-object).

| Event | Sent when |
|---|---|
| `invoice.payment_detected` | A transfer is seen on the network, not final yet |
| `invoice.partially_paid` | Some money is confirmed, but not enough |
| `invoice.paid` | The invoice is fully paid. `paid_late` is `true` if that happened after it expired or was cancelled |
| `invoice.expired` | Time ran out before full payment. `amount_paid` is above zero if part was paid |
| `invoice.cancelled` | You cancelled it |
| `invoice.refunded` | A refund you recorded was verified on the network. The status does not change |
| `invoice.overpaid` | More money arrived on an invoice that was already `paid` |
| `invoice.awaiting_bank_transfer` | The customer chose bank transfer. Hold the order; do not cancel it |
| `invoice.held_for_review` | A payment came from a sanctioned address. See [Sanctions holds](#sanctions-holds) |

`invoice.partially_paid` is also sent for a late part payment on an `expired` or `cancelled` invoice. The status is
then unchanged and `paid_late` is `true`.

## Verify the signature

Every event carries two headers:

```
MintagePay-Signature: t=1759341234,v1=5f1c...
MintagePay-Event-Id: evt_...
```

`v1` is the hex HMAC-SHA256 of `t`, a dot and the raw request body, keyed with your endpoint's secret. To verify:

1. Compute `HMAC_SHA256(secret, t + "." + rawBody)` on the body exactly as received, before any JSON parsing.
2. Compare it with `v1` in constant time.
3. Reject the event if `t` is more than 300 seconds away from now.

Node.js:

```js
import crypto from 'node:crypto';

export function verifyMintagePay(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.trim().split('=')));
  if (!/^\d+$/.test(parts.t ?? '') || !/^[0-9a-f]{64}$/.test(parts.v1 ?? '')) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

PHP:

```php
function verify_mintagepay(string $rawBody, string $signatureHeader, string $secret): bool {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $p) {
        $kv = explode('=', trim($p), 2);
        if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; }
    if (abs(time() - (int) $parts['t']) > 300) { return false; }
    return hash_equals(hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret), $parts['v1']);
}
```

Python:

```python
import hashlib, hmac, time

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

## Answering and retries

Answer `2xx` within 10 seconds. Anything else is retried with growing pauses: 30 seconds, 1 minute, 2 minutes and so
on up to 6 hours, for 12 attempts in all. Redirects are not followed, so register the final address.

Events can arrive more than once or out of order. Before acting:

- re-read `data.invoice.status`, or call `GET /v1/invoices/{id}`;
- make your handler safe to run twice for the same order. `MintagePay-Event-Id` is the event's `id`, so a repeat of
  the same event carries the same value.

Answer `2xx` to event types your integration does not handle. An error is retried.

## Test events

"Send test event" in the dashboard, or `POST /v1/webhook-endpoints/{id}/test`, sends one event with
`"type": "webhook.test"`. It is signed like a real event and carries `data.webhook_endpoint` (`id`, `url`) and a
`message`, but no invoice. It is not retried.

## Sanctions holds

Every paying address is checked against the US sanctions list (OFAC SDN). On a match:

- the invoice gets `held_for_review: true` and you receive `invoice.held_for_review`;
- `invoice.paid` is **not** sent while the hold lasts, and a fully paid held invoice reports
  `status: "held_for_review"`, never `paid`;
- do not ship and do not refund. `invoice.paid` is sent if the hold is released.

Treat `held_for_review: true` as "not paid".
