Webhooks
Receive signed callbacks for invoice status changes and verify them safely.
CoinPay sends an HTTP POST to your webhook URL when an invoice changes state. Configure one endpoint (and secret) per environment — test and live are separate.
Configure
Configure webhook URL and signing secret in the merchant dashboard (Webhooks).
API keys cannot create, update, or inspect webhook configuration.
| Environment | URL rules |
|---|---|
test | http or https; localhost / tunnels allowed |
live | https only; private and metadata hosts blocked |
Secret is required on first create (16–255 characters). Omit secret on later updates to keep the existing one.
Optional customHeaders are appended to every delivery. You cannot override Content-Type or X-Coinpay-Signature.
Webhook configure/list/resend endpoints are not part of the public merchant API reference — use the dashboard.
Events
| Event | When | Merchant action |
|---|---|---|
invoice.created | Invoice persisted | Optional logging |
invoice.payment_detected | Transfer seen, not final | Wait |
invoice.underpaid | Partial payment | Wait for top-up (30m) |
invoice.paid | Finality within 1h window | Fulfill / ship |
invoice.late_paid | Finality after 1h window | Funds received — do not auto-fulfill |
invoice.overpaid | Amount above required | Ops / support (no auto-sweep in Phase 1) |
invoice.expired | No payment in 1h | Close order |
invoice.expired_underpaid | Top-up window ended short | Close / contact customer |
invoice.settled | Sweep complete | Accounting confirmation |
invoice.payment_invalidated | Reorg removed a payment | Reconcile carefully |
Fulfillment rule: treat only invoice.paid as safe to ship. Use invoice.settled for balance/accounting. Never treat invoice.late_paid as a normal fulfillment signal.
Payload
{
"id": "<invoiceId>-<statusSequence>",
"type": "invoice.paid",
"createdAt": "2026-07-04T12:00:00Z",
"environment": "test",
"data": {
"invoiceId": "uuid",
"orderId": "uuid",
"status": "PAID",
"merchantId": "uuid",
"referenceId": "merchant-ref-optional",
"metadata": {},
"amount": "10",
"amountReceived": "10",
"token": "USDC",
"chainId": "sepolia",
"sequence": 3
}
}invoiceId and orderId are the same UUID (orderId kept for older integrations).
amount and amountReceived are decimal strings in major units ("10" = 10.00 USDC/USDT).
Idempotency: process on envelope id. Resends keep the same id and create a new delivery row.
Verify signatures
Header: X-Coinpay-Signature: t=<unix_seconds>,v1=<hmac_hex>
HMAC-SHA256 over {timestamp}.{rawBody} with your webhook secret.
- Parse
tandv1 - Reject if
|now - t| > 300seconds - Recompute HMAC and compare with constant-time equality
- Always use the raw request body (not a re-serialized JSON object)
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyCoinpaySignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=")),
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return false;
const ageSec = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSec) || ageSec > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
} catch {
return false;
}
}Respond with 2xx quickly after verifying. Heavy work should be async on your side.
Delivery and retries
- Success: HTTP 2xx within ~10 seconds (redirects are not followed)
- Backoff: 1m → 5m → 30m → 2h → 24h (6 attempts)
- Inspect and resend deliveries from the merchant dashboard (Webhooks)
Test vs live
Test API keys only create and read test invoices; deliveries go to the test webhook. Live is a separate URL + secret after go-live approval.
See also: Invoice lifecycle · Errors