# Webhooks

Receive payment updates, check signatures and handle retries.

Source: https://docs.invoise.me/integration/webhooks/

A webhook is an HTTP request Invoise sends to your server when a payment changes. You can start with [GET status checks](https://docs.invoise.me/payments/status/) and add webhooks when you need automatic updates.

## 1. Register your endpoint

```bash
curl -X POST 'https://platform.invoise.me/api/v1/shops/{shop_id}/webhooks' \
  -H 'Authorization: Bearer ivk_...' \
  -H 'Idempotency-Key: <saved-unique-key>' \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/invoise","filters":["transfer","invoice","payout"]}'
```

Use your own public HTTPS URL. Local and private addresses are rejected. Save the `secret` returned when the endpoint is created: you need it to verify requests.

A shop can have up to 10 endpoints. An endpoint's URL can be changed, so reuse one instead of adding another. See [Limits](https://docs.invoise.me/integration/idempotency-and-errors/#limits).

## 2. Choose the events you need

| Event | Meaning |
| --- | --- |
| `transfer.observed` | A transfer was seen; it is not confirmed yet. |
| `transfer.confirmed` | An incoming transfer has enough confirmations. It may be only a partial invoice payment. |
| `transfer.reverted` | A previous transfer event was reversed. |
| `invoice.closed` | The invoice closed on chain. |
| `invoice.cancelled` | The checkout was cancelled or the invoice expired; this is not a refund. |
| `payout.sent` | The payout was broadcast or its outgoing transfer was seen. |
| `payout.confirmed` | Complete settlement accounting is available. |
| `payout.reverted` | A previous payout confirmation was reversed. |
| `payout.failed` | A terminal payout failure needs intervention. |

For `invoice.cancelled`, `data.reason` says why: `expired` when the [invoice lifetime](https://docs.invoise.me/payments/deposits-and-invoices/#invoice-lifetime) ran out, `merchant_blocked` when Invoise staff blocked the merchant. A cancellation by the merchant has no `reason`.

Subscribe by group: `transfer`, `invoice`, `payout`. Incoming transfers and outgoing payouts are different events. A temporary RPC or gas delay is not `payout.failed`.

`gas.wait` and `sweep.failed` are accepted as filters, but Invoise does not send them to your endpoint. They are internal signals: `gas.wait` means a payout is waiting for network fee funds and continues on its own; `sweep.failed` means an attempt to move the funds to your recipient failed. You need no action for either. A payout that cannot complete reaches you as `payout.failed`.

In a sandbox shop you can simulate both through `POST /shops/{shop_id}/sandbox/simulate` to check that a delayed payout does not break your flow. They change the payout's state only and send no webhook.

## 3. Verify before processing

`Invoise-Signature` has the form `t=<timestamp>,v1=<hex>`. The signature is HMAC-SHA256 of `timestamp + "." + raw_body`, using your webhook secret.

The following Node.js example also rejects timestamps more than five minutes from the server clock. Keep that clock synchronised and choose your tolerance deliberately.

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

export function verify(rawBody, header, secret) {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? '');
  if (!match) return false;
  const [, timestamp, signature] = match;
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(rawBody)
    .digest();
  return timingSafeEqual(Buffer.from(signature, 'hex'), expected);
}
```

Pass the original request bytes as `rawBody`. Do not parse JSON and stringify it before checking the signature. Reject invalid signatures before storing or acting on their contents.

## 4. Process once

A shortened payload looks like this:

```json
{
  "id": "<event-id>",
  "type": "transfer.confirmed",
  "created_at": "2026-09-19T10:00:00Z",
  "data": {
    "issuance_id": "<issuance-id>",
    "shop_id": "<shop-id>",
    "external_id": "order-123",
    "type": "invoice",
    "invoice_cancelled": false
  }
}
```

| Case | Handling |
| --- | --- |
| Duplicates | Deduplicate using `Invoise-Event-ID`, which matches the envelope's `id`. Save the ID and the business update atomically. |
| Accepting an event | Save accepted events durably, return 2xx promptly, and process queued work afterwards. |
| Retries and replays | Retries and manual replays repeat the event ID. `Invoise-Attempt-ID` changes and is not a deduplication key. |
| Event order | Events can arrive out of order. Use their contents and reconcile against [current status](https://docs.invoise.me/payments/status/) when needed. |

For reversals, `chain_event.reference_event_id` points to the earlier chain event. Payout payloads include a stable `payout_id`, transaction hash, amounts and fee details; unknown amounts are `null`. Their incoming-transfer ID list is capped at 100 and flags truncation.

Older stored events may use `project_id`; read `shop_id` first and fall back to it if needed. Replays keep the original bytes.

## Inspect failed delivery

List `GET /api/v1/shops/{shop_id}/deliveries`. Replay a delivery with `POST /api/v1/shops/{shop_id}/deliveries/{id}/replay` and a saved idempotency key. Replaying does not create a new business event.
