DocsStart free
English
API docsWebhooks

API docsPayments

Webhooks

Receiving webhooks#

Unheld POSTs a JSON body to every enabled webhook URL you register, for every event. There is no per-event subscription — branch on event.

  • Payment events are always sent. Over your plan's hourly notification limit (Free: 500 an hour), events that carry no payment — invoice.created, invoice.heartbeat, pool.low, the subscription reminders, the customer address events, an expiry with nothing paid — are not sent and show as failed in the delivery log.
  • While your account is over capacity or deactivated, deliveries are held and sent once it is active again.

Respond 2xx quickly — within 30 seconds — and do the work afterwards. Every request carries:

Header Value
Content-Type application/json
X-Signature HMAC-SHA256 of <X-Timestamp>.<raw body>, hex
X-Timestamp Unix seconds
X-Request-Id A UUID, the same on every retry of one delivery — store it and skip repeats

A redirect (3xx) counts as a failed delivery: register the final URL.

Retries. A delivery that does not get a 2xx within 30 seconds is retried after 5 seconds, then after 10 more — three attempts in all. After the third failure it is marked failed in the delivery log, where you can send it again.

Verifying signatures#

Verify every delivery before acting on it. The signature covers <X-Timestamp>.<raw body> — the body exactly as received, before any JSON parsing. Reject a timestamp more than 5 minutes from your clock.

TypeScript
// TypeScript / Node.js
import crypto from 'crypto';

function verifyWebhook(
  rawBody: string,           // the raw request body — do NOT parse it first
  signingSecret: string,     // the webhook's signing secret
  xSignature: string,        // the X-Signature header
  xTimestamp: string,        // the X-Timestamp header
  toleranceSeconds = 300     // reject deliveries older than 5 minutes
): boolean {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(xTimestamp, 10)) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(`${xTimestamp}.${rawBody}`)
    .digest('hex');

  const expectedBytes = Buffer.from(expected, 'hex');
  const givenBytes = Buffer.from(xSignature, 'hex');
  if (expectedBytes.length !== givenBytes.length) return false;
  return crypto.timingSafeEqual(expectedBytes, givenBytes);
}
Python
# Python
import hmac
import hashlib
import time

def verify_webhook(raw_body: str, signing_secret: str,
                   x_signature: str, x_timestamp: str,
                   tolerance: int = 300) -> bool:
    if abs(int(time.time()) - int(x_timestamp)) > tolerance:
        return False
    expected = hmac.new(signing_secret.encode(),
                        f"{x_timestamp}.{raw_body}".encode(),
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, x_signature)
PHP
// PHP
function verify_webhook(string $rawBody, string $signingSecret,
                        string $xSignature, string $xTimestamp,
                        int $tolerance = 300): bool {
    if (abs(time() - (int)$xTimestamp) > $tolerance) return false;
    $expected = hash_hmac('sha256', "{$xTimestamp}.{$rawBody}", $signingSecret);
    return hash_equals($expected, $xSignature);
}

Event types#

Event Trigger
invoice.created An invoice was created
invoice.receiving The first payment was seen and the invoice is not yet paid — a payment still confirming, or a confirmed part-payment
invoice.confirming A further payment was seen and the invoice is still not paid
invoice.paid Paid: a payment reached the credit depth (minConfirmations) and covers the invoice — fulfil the order
invoice.overpaid Paid, with more than the invoice total
invoice.underpaid Not sent for a new invoice: a short payment never settles one. It stays open (invoice.receiving / invoice.confirming) so the payer can top up; if the clock runs out you receive invoice.expired with amountStatus: "UNDERPAID"
invoice.final The payment reached your ship-safe depth (finalConfirmations, set per network in Account → Payment currencies; default 20 blocks on EVM chains, 50 on Bitcoin). On Tron and the XRP Ledger it follows invoice.paid at once
invoice.expired The clock ran out before the invoice was covered — unpaid or part-paid: read paidAmountTotal and amountStatus (UNPAID or UNDERPAID). Also sent when you or the payer cancel an invoice. A covering payment inside the late-payment window still brings invoice.paid and status PAID_LATE; one that leaves it short sends invoice.receiving (or invoice.confirming if it already held money) with invoiceStatus: "EXPIRED" and the new paidAmountTotal
invoice.heartbeat A periodic pulse while an invoice is open — only when a heartbeat is configured on the webhook
customer.payment_detected A payment to one of your customers' permanent deposit addresses was confirmed. The invoice body below, plus customerId
customer.receive_method.changed You changed a customer's receive method in the dashboard
customer.static_wallet.provisioned A new permanent deposit address was created for a customer — with the customer, when you add a network, or when a subscription adds its plan's network. Sent once per address; asking again for one that exists sends nothing
pool.low Free receiving addresses on a network are running low, or ran out — add addresses or raise your pool limit before new invoices are refused
chain.paused We paused a network your customers hold deposit addresses on. A payment sent there is not watched until it resumes — tell those customers not to send on it
chain.resumed A network we had paused is watched again
subscription.renewed A subscription payment was evaluated, or a period was renewed automatically from the subscription's credit
subscription.credit_added A payment arrived for a period already paid — recorded as credit
subscription.expiring_soon The current period ends within the plan's lead time
subscription.expired The period ended unpaid
subscription.payment_misdirected The subscriber paid on a different network than the plan's
subscription.canceled A subscription was canceled — by you, or because you deleted its customer

There is no separate event for a cancellation or a confirmation: a cancelled invoice is announced as invoice.expired, a confirmed payment as invoice.paid.

Invoice event body#

Every invoice event carries the same flat body:

JSON
{
  "event": "invoice.paid",
  "invoiceId": "550e8400-...",
  "invoiceStatus": "PAID_CONFIRMED",
  "merchantId": "uuid",
  "chainId": "11155111",
  "assetType": "native",
  "assetContract": null,
  "amountRaw": "10000000000000000",
  "paidAmountTotal": "10000000000000000",
  "amountStatus": "PAID",
  "switchQuote": null,
  "sender": null,
  "receiver": "0xabc...def",
  "txHash": "0xabc...",
  "blockNumber": "19000000",
  "blockTimestamp": "2026-05-30T12:10:00.000Z",
  "confirmations": 12,
  "simulated": false,
  "confirmedAt": "2026-05-30T12:12:00.000Z",
  "finalConfirmations": 20,
  "finalizedAt": null,
  "externalRef": "order_8823",
  "metadata": { "external_ref": "order_8823", "cart_id": "c_88" },
  "customerId": null
}
Field Meaning
externalRef, metadata, customerId Match the event to your own order without fetching the invoice. metadata holds only the keys you sent ({} if none)
amountRaw The amount expected, in base units
paidAmountTotal What the invoice has been credited so far — on invoice.expired, what a part-paid invoice holds
amountStatus UNPAID, UNDERPAID, PAID or OVERPAID, against amountRaw
txHash, blockNumber, blockTimestamp, confirmations The payment behind the event; null when there is none (invoice.created, an expiry with nothing received)
simulated true when the payment was simulated on a test network — no transaction exists, and txHash starts with sim_
confirmedAt When the invoice was paid
finalConfirmations, finalizedAt The ship-safe depth for this invoice, and when it was reached (null until invoice.final)
switchQuote null unless the invoice was switched to another coin — then the conversion: { "method": "converted", "quote": { "sourceSymbol", "sourceUsd", "targetSymbol", "targetUsd", "quotedAt" }, "at" }
checkoutFields, checkoutFieldsVerified Only for invoices from a checkout button with declared fields. checkoutFieldsVerified is true only when the link was signed
donation On every event of an invoice created from a donation page: { donorName, message, usdCents, amountMode, presetUsed, hiddenAt } — see Get an invoice. null on every other invoice

Other event bodies#

Subscription, customer, pool and network events have no invoice behind them. Their body is { "event", "merchantId", … } with the fields below; subscription events also carry subscriptionId, customerId, customerExternalRef, planId and planName.

Event Fields
subscription.renewed After a payment: chainId, assetKey, txHash, amountStatus, paidAmountThisPayment, cumulativePaidThisCycle, planAmount, creditAddedFromOverpayment, creditBalance, startsNewPeriod, periodStart, periodEnd. A renewal from credit carries only reason: "credit_applied_automatically"
subscription.credit_added chainId, assetKey, txHash, creditAdded, creditBalance
subscription.expiring_soon periodEnd, leadSeconds
subscription.expired periodStart, periodEnd
subscription.payment_misdirected txHash, paidOnChainId, planChainId, amount
subscription.canceled reason (manual or customer_deleted), canceledAt
customer.receive_method.changed customerId, method
customer.static_wallet.provisioned customerId, chainId, address, destinationTag (XRP Ledger in tag mode; null elsewhere)
pool.low chainId, available (free addresses left; 0 when an invoice was just refused for lack of one), watermark (the low-water mark it fell below; absent when available is 0)
chain.paused / chain.resumed chainId, chainName, family, isTestnet, addressCount (your customer deposit addresses on it)

Amounts are base-unit strings here too.

Managing webhooks#

These routes take an API key with webhooks:write / webhooks:read, or a dashboard session.

Create:

POSTHTTP
/merchant/webhooks
Authorization: Bearer cv2_<mode>_<prefix>_<secret>
Content-Type: application/json
JSON
{ "url": "https://yourapp.com/webhooks/unheld" }

The URL must be https:// on a domain name — plain http://, a bare IP address and localhost are refused, as are names resolving to private networks or cloud metadata. The signing secret is generated by Unheld; you cannot supply your own. Your plan caps how many webhooks you can hold, and an account whose email is not yet confirmed cannot add one.

Response 201:

JSON
{
  "id": "uuid",
  "url": "https://yourapp.com/webhooks/unheld",
  "enabled": true,
  "signingSecret": "abc123...",
  "createdAt": "2026-05-30T00:00:00.000Z"
}

⚠️ signingSecret is shown once. Store it in your server's environment.

The other routes:

Route Does
GET /merchant/webhooks Lists your webhooks (webhooks[], count) — no secrets
PATCH /merchant/webhooks/:id Changes url, enabled, or the heartbeat (heartbeat_enabled, heartbeat_interval_seconds — one of the values GET /merchant/webhooks/heartbeat-options lists — and heartbeat_statuses: CREATED, PAID_UNCONFIRMED). At least one field; heartbeat_enabled: true needs heartbeat_interval_seconds in the same request. Answers { "id", "updated": true }
DELETE /merchant/webhooks/:id Deletes the webhook and its secret — irreversible. Answers { "id", "deleted": true }
POST /merchant/webhooks/:id/rotate-secret Issues a new secret (shown once) and answers { "id", "signingSecret", "rotatedAt" }. The old secret stops working at once — update your server first

Delivery log and manual retry#

HTTP
GET  /merchant/webhooks/:id/deliveries?limit=20&offset=0&status=failed&eventType=invoice.paid
POST /merchant/webhooks/:id/deliveries/:deliveryId/retry

The log answers { "deliveries": [ … ], "total", "limit", "offset" }. Each delivery carries id, invoiceId, eventType, status, attempts, responseStatus, responseBody, lastError, errorClassification, requestId, the payload sent, createdAt, lastAttemptAt and deliveredAt. Filter by status, eventType or invoiceId; limit is at most 100. A retry sends that delivery again and answers { "queued": true, "deliveryId": "uuid" }.