Webhooks
Receive signed HTTP notifications when payments and invoices change state. Verify signatures, handle retries, deduplicate.
NoHoldPay sends a POST request to your endpoint when a payment changes state. Each request is signed. Your endpoint returns 2xx to acknowledge.
Setup
Webhook endpoints are configured per API key. The Webhooks page in the dashboard is a read-only inspector for delivery history. The create/edit surface lives on the API Keys page.
Enter your endpoint URL. Live-mode endpoints must use https (an http URL is rejected when
you save). Test-mode endpoints may use http, though localhost and private-network addresses
are always rejected. Optionally restrict by IP allowlist.
Pick the events to subscribe to. Payment events are selected by default, and invoice events can be added.
Save. Copy the webhook secret shown once after saving. Keep it server-side. Never expose it in browser code.
Each API key has its own webhook endpoint and its own secret. To rotate the secret later, click Rotate secret on the same panel. The old secret keeps signing deliveries alongside the new one for a 24-hour grace window (you will see two v1= values in the signature header), then stops verifying.
Event format
Every delivery is a JSON POST with the body:
{
"id": "evt_8f7c3a1d-4b2e-49ad-b1e7-3c19f0e0b9d2",
"event": "payment.confirmed",
"created_at": "2026-05-14T12:00:00Z",
"data": { ... }
}| Field | Description |
|---|---|
id | Unique delivery ID (evt_<uuid>). Use this to deduplicate retries. |
event | The event name. See the event list below. |
created_at | RFC 3339 timestamp in UTC. |
data | Event-specific payload. Contains the payment or invoice object. |
Headers sent on every delivery:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | NoHoldPay-Webhook/1.0 |
X-Webhook-Event | The event name, identical to the event field in the body. |
X-Webhook-Signature | t=<unix_ts>,v1=<hex> (signing details below). |
The platform's HTTP client uses a 30-second timeout per attempt. If your endpoint hasn't responded with 2xx headers in 30 seconds, the attempt fails and is queued for retry.
Verify the signature
X-Webhook-Signature: t=1715688000,v1=a3f9...| Part | Meaning |
|---|---|
t | Unix timestamp (seconds) when the request was sent. |
v1 | HMAC-SHA256 hex of "<t>.<raw_request_body>". |
During a secret rotation you may see multiple v1= values separated by commas. Accept the delivery if any one matches.
Verification steps
Read the X-Webhook-Signature header. Extract t and all v1 values.
Reject if |now - t| > 300 seconds. This limits replay attacks.
Compute HMAC-SHA256(secret, "<t>.<raw_body>"). Use the raw body bytes as received. Do not re-serialize through a JSON parser before verifying.
Hex-encode the result. Compare constant-time against each v1. Accept if any match.
Check your store for the id value. If already processed, return 200 without re-processing (idempotent ack). Keep IDs for at least 48 hours to cover all retries.
Example: Node.js
import { createHmac, timingSafeEqual } from "crypto";
function verify(secret: string, rawBody: Buffer, header: string): boolean {
const tMatch = header.match(/t=(\d+)/);
const v1Matches = header.match(/v1=([a-f0-9]+)/g) ?? [];
if (!tMatch || v1Matches.length === 0) return false;
const t = tMatch[1];
const v1s = v1Matches.map((s) => s.slice(3));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return v1s.some((v) => {
try {
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v, "hex"));
} catch {
return false;
}
});
}Retry schedule
If your endpoint returns a non-2xx status, times out (30 seconds), or rejects the TCP connection, delivery is retried on this schedule:
| Attempt | Delay after previous failure |
|---|---|
| 1 | 10 seconds |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 8 hours |
| 8 | 24 hours |
Each delay has +/-25% random jitter. After the 9th total attempt fails (the initial delivery plus 8 retries, about 35 hours of runway after the first failure), the delivery is marked exhausted and no further retries fire. The delivery log in the dashboard shows each delivery's latest attempt: the attempt counter, the most recent status code and response body (truncated to 1 KiB), and the next retry time.
Permanent client errors (4xx codes that signal the request is wrong) skip retries by default. 408, 425, and 429 are treated as transient and retry on the normal ladder.
Event reference
Payment events
| Event | When it fires |
|---|---|
payment.awaiting | A chain has been selected on the checkout (status flips from pending to awaiting_payment). Does not fire on initial POST /api/v1/payments. |
payment.detected | Transaction seen on-chain. Not yet final. |
payment.confirming | Waiting for the chain's required confirmations. |
payment.confirmed | Confirmed at the chain's required depth. Safe to release goods - see payment.expired_reorged below for the rare deep-reorg case. |
payment.overpaid | Confirmed, and the customer sent more than the invoice amount beyond the per-merchant threshold (default 1%). |
payment.underpaid | Cumulative received is below the invoice amount by more than the tolerance (default 0.5%). Window is still open. |
payment.refund_requested | Customer hit Request refund on the checkout. |
payment.refunded | Merchant marked the payment refunded after sending coins back. |
payment.cancelled | Cancelled before any on-chain transaction was detected. Payload includes cancelled_by: "customer" or "merchant". |
payment.expired | Window closed with no funds received. |
payment.expired_underpaid | Window closed with a partial amount received. Customer can still revive within the late-grace window. |
payment.expired_reorged | A confirming or confirmed transaction was removed from the chain by a reorg past the grace window. |
payment.failed | A gasless settlement failed and no funds will land for this authorization. Today the only firing site is the x402 (gasless) relay. The payload includes failure_reason carrying insufficient_balance or estimate_revert (aborted before broadcast) or settlement_reverted (the broadcast transaction reverted on-chain). |
Invoice events
| Event | When it fires |
|---|---|
invoice.sent | Merchant sent the invoice to the recipient. |
invoice.viewed | Recipient opened the invoice for the first time. |
invoice.paid | The linked payment confirmed. |
invoice.refunded | The linked payment was refunded. |
invoice.reminder | Reminder email sent. |
Tips
- Return
200as fast as possible. Do the work in a background queue. - Return
200for events you do not handle. The retry schedule treats any non-2xxas failure. - Always deduplicate on the
idfield. The retry ladder can re-deliver the same event for about 35 hours after the first attempt - keep IDs for 48 hours to be safe. - The delivery log in the dashboard shows the exact request body that was signed and the latest response body (truncated to 1 KiB) for debugging.