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.
API-key caller restrictions do not affect outgoing webhook delivery. The webhook IP allowlist, if configured, restricts destination addresses. See API caller IPs and Cloudflare.
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 on port 443. Test-mode endpoints
may also use http on port 80. In both modes the host must be a public domain name, not an IP
address. See Webhook URL rules for everything that is checked when you
save. 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.
Limits for new accounts
Until one of the businesses on your login has received a confirmed live payment or completed a confirmed live top-up, the login can hold at most 3 active webhook endpoints across all its businesses, in live and test mode together, and can use Send test at most 10 times in a rolling 24 hours. The platform operator can change both numbers. Once any business on the login has live activity, neither limit applies, and existing endpoints are never removed.
| Code | HTTP | When |
|---|---|---|
WEBHOOK_ENDPOINT_LIMIT_REACHED | 422 | Saving another webhook endpoint (on a new or existing API key) would exceed the endpoint limit. Remove an endpoint you do not use. |
WEBHOOK_TEST_LIMIT_REACHED | 429 | The daily test allowance is used up. The Retry-After header gives the seconds to wait. |
Suspended and closed accounts
No webhooks are delivered for a business that is suspended or closed. New events create no delivery, and a delivery that was already waiting for a retry is held instead of sent. A held delivery is marked exhausted once it is more than 48 hours overdue. If the suspension is lifted before then, held deliveries resume on their retry schedule.
Webhook URL rules
NoHoldPay checks every webhook URL when you save it: when you create an API key with a webhook, when you add a webhook to a key, and when you edit a webhook's URL. A URL that breaks a rule is refused with a 422 VALIDATION_ERROR whose message starts with Webhook URL invalid:, and nothing is saved.
Scheme and port
| Key mode | Allowed | Not allowed |
|---|---|---|
Live (pk_live_... keys) | https:// on port 443 | http://, any other port |
Test (pk_test_... keys) | https:// on port 443, or http:// on port 80 | Any other port |
You can leave the port out or write the default explicitly (https://hooks.example-shop.com:443/nhp is fine). Ports such as 3000, 8080 or 8443 are refused, and so are padded spellings such as :0443. If your receiver runs on another port, put it behind a reverse proxy or load balancer that listens on 443.
These scheme and port rules also apply to every delivery. If your endpoint answers with a redirect (307 or 308), the new location must follow the same scheme and port rules or the delivery fails. An endpoint saved before these rules existed on another port stops receiving deliveries until you change its URL to use the default port. Its failed deliveries show Destination is blocked by outbound network policy.
The host must be a domain name
Use a public domain name that you control, in both live and test mode:
- No IP addresses.
https://203.0.113.10/webhookandhttps://[2001:db8::1]/webhookare refused. Shorthand or numeric forms of an address (for examplehttp://2130706433/) are refused too. Point a DNS record at your server and use that name. - No reserved or private names. Names under
.test,.example,.invalid,.localhost,.local,.onion,.alt,.internaland.arpa(includinghome.arpa) are refused, as areexample.com,example.netandexample.organd their subdomains. These names are reserved for documentation, testing or private networks and cannot receive webhooks from the internet. - A public top-level domain. The name must end in a top-level domain that is delegated on the public internet, such as
.com,.ioor.co.uk. Private suffixes such as.corp,.lanor.homeare refused. - The name must exist in DNS. A name that does not exist, or that has no address records, is refused when you save it. If DNS is temporarily unavailable, the URL is accepted.
- The name must resolve to a public address. A name whose address is private, loopback or link-local is refused.
Services that are not accepted
Some public services are refused because webhook payloads sent to them would be exposed to others, or because they exist to point a public name at a private address:
- Wildcard DNS and DNS rebinding services (for example nip.io, sslip.io, lvh.me and localtest.me) and out-of-band security testing services are refused in both live and test mode.
- Public request inspection services (for example webhook.site, Pipedream RequestBin, Beeceptor, Request Catcher, PostBin and smee.io) are accepted for test-mode keys only. They are useful while you build an integration, but anyone who has the URL can read what is sent to it, so they are refused for live webhooks. Before you go live, point your live key's webhook at a receiver you control.
The platform operator can also refuse additional domains. When a URL is refused for any of these reasons, the message is URL host is not allowed for webhooks.
If saving fails with 503
If the response is 503 SERVICE_UNAVAILABLE with the message Webhook URL could not be checked right now. Please try again shortly., nothing was saved. Retry the request after a short wait.
Error messages
Message (after Webhook URL invalid:) | What to change |
|---|---|
URL is malformed | Check the whole URL for typos, spaces or stray characters. |
URL must include a host | Write the full URL, starting with https:// and your domain name. |
URL must use https | Live keys need https://. |
URL must use http or https | Test keys accept only https:// or http://. |
URL must use the default HTTPS port (443) | Remove the port or use 443. |
URL must use the default port for its scheme (443 for https, 80 for http) | Test keys: 443 for https, 80 for http. |
URL host must be a domain name, not an IP address | Use a DNS name instead of an address. |
URL host uses a reserved domain name | Use your own registered domain. |
loopback hostnames are not allowed | Do not use localhost or .local names. |
URL host must be a registered domain under a public top-level domain | Use a domain under a public top-level domain. |
URL host is not a valid hostname | Check the spelling of the host. |
URL host was not found in DNS | Create the DNS record first, then save the URL. |
host "..." resolves to a blocked IP range | Point the name at a public address. |
URL host is not allowed for webhooks | Use a receiver you control. Request inspection services work with test-mode keys only. |
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.