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 / 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
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
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:
{
"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:
/merchant/webhooks
Authorization: Bearer cv2_<mode>_<prefix>_<secret>
Content-Type: application/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:
{
"id": "uuid",
"url": "https://yourapp.com/webhooks/unheld",
"enabled": true,
"signingSecret": "abc123...",
"createdAt": "2026-05-30T00:00:00.000Z"
}⚠️
signingSecretis 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#
GET /merchant/webhooks/:id/deliveries?limit=20&offset=0&status=failed&eventType=invoice.paid
POST /merchant/webhooks/:id/deliveries/:deliveryId/retryThe 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" }.