---
title: Webhooks
description: Coinetic payment webhooks: setup, HMAC signature verification, deduplication, retries, delivery order, invoice and refund event types, and secret rotation.
url: https://coinetic.money/docs/webhooks/
lang: en
---

# Webhooks

Coinetic notifies your server of invoice changes with a POST request. A webhook is a signal to “go check the invoice,” not proof of payment.

## Setup

- Dashboard → “Keys & webhooks” → “Add.” The URL must be `https://` on a public address and port 443 (the sandbox also allows `http://localhost`).
- The endpoint secret is shown only once. You can have up to 5 active endpoints, and every event is sent to all of them.

## Request

```http
POST /webhooks/coinetic HTTP/1.1
Content-Type: application/json
User-Agent: cryptopay-webhooks/1
X-Webhook-Id: evt_3f9c…
X-Webhook-Timestamp: 1760000000
X-Webhook-Signature: v1=5d1e…

{"id":"evt_3f9c…","type":"invoice.paid","created_at":"2026-10-12T15:04:05Z","livemode":true,"data":{"invoice_id":"inv_x7k2…","order_id":"ORDER-1001","status":"paid","paid_amount":"49990000"}}
```

`X-Webhook-Id` stays the same across retries. During secret rotation, `X-Webhook-Signature` contains two comma-separated signatures. The technical names (`cryptopay-webhooks/1`) are legacy; you don't need to change anything on your side.

## Verifying the signature

```
expected = "v1=" + hex(HMAC-SHA256(webhook_secret, X-Webhook-Timestamp + "." + raw_body))
```

- Take the **raw body**: the bytes before any JSON parsing.
- Check that `X-Webhook-Timestamp` is within 5 minutes of the current time.
- Split `X-Webhook-Signature` on commas. The signature is valid if **any** part matches the expected value (use a constant-time comparison).
- No match? Respond with `401` and do nothing.

Test vector: secret `whsec-0123456789abcdef0123456789abcdef`, timestamp `1760000000`, body `{"id":"evt_1","type":"invoice.paid"}` → `v1=aceeb56215ba09e7a2923d48f89afb8331f422f2d0a8cf60f76fef69fe06a6a4`.

## Handling

```
verify signature → deduplicate by X-Webhook-Id → GET /v1/invoices/{invoice_id}
→ update the order from the API status (fulfill once) → respond 2xx
```

- **Deduplication.** The same event can arrive more than once. Store the `X-Webhook-Id` values you've processed, and set the “fulfilled” flag in the same transaction as the fulfillment itself.
- **Respond quickly**, within 10 seconds. Push long-running work to a queue.
- **Response codes:** `2xx` means accepted; `410 Gone` means don't retry this delivery; any other code (including `3xx`, since redirects aren't followed) or a timeout triggers a retry.
- Ignore unknown fields and event types: the list will grow.

Ready-made handlers in four languages are in the [quickstart](https://coinetic.money/docs/quickstart/#webhook).

## Retries

10 s → 30 s → 2 min → 10 min → 30 min → 1 h → 3 h ×7 → 1 h, with slight jitter. The final attempt comes roughly 24 hours later. You can resend a failed delivery from the dashboard: “Webhooks” → “Resend.”

## Ordering

Events for **the same invoice** are delivered to an endpoint in order: the next one isn't sent while the previous delivery is still awaiting a retry. Once that delivery finishes, successfully or not, the following events go out. So rely on the status from `GET /v1/invoices/{id}`, not on the sequence of events. Events for different invoices are independent.

## Event types

| Event | When |
| --- | --- |
| `invoice.detected` | Payment seen on the network |
| `invoice.confirming` | Payment is in a block, confirmations in progress |
| `invoice.paid` | Paid and confirmed; safe to fulfill (after re-checking). `data.late: true` means a stablecoin arrived after expiration and was credited automatically |
| `invoice.overpaid` | Paid with an excess |
| `invoice.underpaid` | Confirmed amount is short |
| `invoice.awaiting_payment` | The invoice is awaiting payment again (for example, after a reorg of its only payment) |
| `invoice.expired` | Expired with no payment |
| `invoice.late_payment` | Payment in a volatile asset after expiration |
| `invoice.manual_review` | Payment under AML review |
| `invoice.payment_held` | Payment held by AML (quarantine) |
| `invoice.reorged` | The block containing the payment was rolled back and the status recalculated (for example, `paid` → `confirming`) |
| `invoice.payment_after_close` | A payment arrived on an already closed invoice: it doesn't pay for the order, but it's available for refund |
| `invoice.cancelled` | The invoice was closed unpaid by an executed refund |
| `refund.sent` | Refund executed; `data.refund` contains the amount, address and `tx_hash` |

Billing events (`billing.*`) cover platform fee invoices: issued, paid, overdue, suspended and reinstated.

> **Digital goods and `invoice.reorged`.** A reorg after `paid` is rare, but it happens. If you deliver goods instantly, subscribe to this event and route these cases to support.

## Rotating the secret

Dashboard → “Rotate secret”: Coinetic starts signing with **both** secrets.

- Add the new secret to your server (accept both).
- Click “Complete rotation” to delete the old one.
- Remove the old secret from your server.
