NoHoldPay

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.

Go to API Keys in the dashboard.
Expand a key (or create one). Scroll to the Webhook settings panel.

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": { ... }
}
FieldDescription
idUnique delivery ID (evt_<uuid>). Use this to deduplicate retries.
eventThe event name. See the event list below.
created_atRFC 3339 timestamp in UTC.
dataEvent-specific payload. Contains the payment or invoice object.

Headers sent on every delivery:

HeaderValue
Content-Typeapplication/json
User-AgentNoHoldPay-Webhook/1.0
X-Webhook-EventThe event name, identical to the event field in the body.
X-Webhook-Signaturet=<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...
PartMeaning
tUnix timestamp (seconds) when the request was sent.
v1HMAC-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:

AttemptDelay after previous failure
110 seconds
230 seconds
32 minutes
410 minutes
530 minutes
62 hours
78 hours
824 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

EventWhen it fires
payment.awaitingA chain has been selected on the checkout (status flips from pending to awaiting_payment). Does not fire on initial POST /api/v1/payments.
payment.detectedTransaction seen on-chain. Not yet final.
payment.confirmingWaiting for the chain's required confirmations.
payment.confirmedConfirmed at the chain's required depth. Safe to release goods - see payment.expired_reorged below for the rare deep-reorg case.
payment.overpaidConfirmed, and the customer sent more than the invoice amount beyond the per-merchant threshold (default 1%).
payment.underpaidCumulative received is below the invoice amount by more than the tolerance (default 0.5%). Window is still open.
payment.refund_requestedCustomer hit Request refund on the checkout.
payment.refundedMerchant marked the payment refunded after sending coins back.
payment.cancelledCancelled before any on-chain transaction was detected. Payload includes cancelled_by: "customer" or "merchant".
payment.expiredWindow closed with no funds received.
payment.expired_underpaidWindow closed with a partial amount received. Customer can still revive within the late-grace window.
payment.expired_reorgedA confirming or confirmed transaction was removed from the chain by a reorg past the grace window.
payment.failedA 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

EventWhen it fires
invoice.sentMerchant sent the invoice to the recipient.
invoice.viewedRecipient opened the invoice for the first time.
invoice.paidThe linked payment confirmed.
invoice.refundedThe linked payment was refunded.
invoice.reminderReminder email sent.

Tips

  • Return 200 as fast as possible. Do the work in a background queue.
  • Return 200 for events you do not handle. The retry schedule treats any non-2xx as failure.
  • Always deduplicate on the id field. 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.

On this page