Webhooks
When a payment changes state, HashPay POSTs a signed JSON event to your store's webhook URL. This page is the catalogue and contract; for a build-it-together walkthrough see Step 6.
Event catalogue
The events you'll build on, all in the buyer-driven payment flow:
| Event | Fires when |
|---|---|
payment.detected | A matching transfer is first seen on-chain. |
payment.confirmed | The transfer reaches the chain's confirmation depth. Fulfil on this. |
payment.underpaid | Less than the amount due was received. |
payment.overpaid | More than the amount due was received. |
payment.expired | The payment window closed with nothing received. |
payment.canceled | The buyer canceled the payment. |
Recurring Prices additionally fire subscription.* events (created, activated, renewed, past_due, expired, canceled, reminder) over the subscription's lifecycle — see the section at the bottom.
Payload
Every event is an envelope wrapping an event-specific data object:
{
"id": "pay_66f2a19b03c84d2ea1b7c4de:payment.confirmed",
"type": "payment.confirmed",
"createdAt": "2026-06-28T12:15:30Z",
"apiVersion": "2026-07-02",
"data": {
"paymentId": "pay_66f2a19b03c84d2ea1b7c4de",
"accountId": "acct_5b3e8c1fa2d9407be6135f9c",
"storeId": "store_0c47d9e2ab35f18c6e24ba70",
"mode": "live",
"network": "base",
"token": "usdc",
"address": "0xA1b2…",
"amount": "29.00",
"amountBaseUnits": "29000000",
"decimals": 6,
"txHash": "0x9f8e…",
"blockNumber": 19283746,
"status": "CONFIRMED",
"confirmations": 12,
"metadata": { "orderId": "order_123" }
}
}
Data fields
| Field | Notes |
|---|---|
id | Stable event id (<paymentId>:<type>). Same on retries — your idempotency key. |
type | The event name. |
createdAt | When the event was generated (ISO 8601). |
apiVersion | Payload schema version, currently "2026-07-02". Bumped only on a breaking payload change. |
data.paymentId | The payment's pay_… id. |
data.accountId · data.storeId | Your account and the originating store. |
data.mode | "test" or "live". |
data.network · token · address | Chain, stablecoin, and your receiving address. |
data.amount · amountBaseUnits · decimals | Display and exact amounts. |
data.txHash · blockNumber | The settling transaction. Absent until detected. |
data.status | The payment status name (e.g. "CONFIRMED"). |
data.confirmations | Present once detected/confirmed. |
data.originHost | Host of the page that opened the checkout — present when captured. |
data.metadata | Whatever you attached at checkout — present when set. |
data.expectedAmount | underpaid / overpaid only |
data.receivedAmount · receivedAmountBaseUnits | underpaid / overpaid only |
Delivery headers
| Header | Value |
|---|---|
X-HashPay-Event | The event type, e.g. payment.confirmed. |
X-HashPay-Delivery | The event id — stable across retries, and authenticated (it's also inside the signed body). |
X-HashPay-Signature | t=<unix seconds>,v1=<hex>[,v1=<hex>…] — delivery timestamp + one signature per active secret. |
Signing
Each v1 is an HMAC-SHA256 (hex) over the string <t> + "." + <raw request body>, keyed with your store's whsec_ secret. Recompute it and compare in constant time against every v1 entry — accept if any matches (there's one per active secret, so a rotation drops nothing). Reject deliveries whose t is more than 5 minutes from your clock; retries are re-signed with a fresh t, so a legitimate redelivery never ages out. The one rule that matters: hash the raw bytes before any JSON parsing reformats them.
const parts = signatureHeader.split(',')
const t = (parts.find((p) => p.startsWith('t=')) || '').slice(2)
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.`).update(rawBody).digest('hex')
const ok = Math.abs(Date.now() / 1000 - Number(t)) <= 300 && // 5-min replay window
parts.filter((p) => p.startsWith('v1=')).some((p) => // any active secret
crypto.timingSafeEqual(Buffer.from(p.slice(3)), Buffer.from(expected)))
Full verification snippets in Node, Python, PHP and Ruby are on the Step 6 page. The secret is per store, shown once when you set the webhook URL on the Integration page.
Delivery & retries
- Timeout: each attempt waits up to 15 seconds for your response.
- Success: any
2xx. Anything else (or a timeout) is a failure. - Retries: delivery is asynchronous with exponential backoff, retried for up to 72 hours; after that the delivery is marked dead. Acknowledge quickly and do slow work afterward, or one payment becomes several deliveries.
- Idempotency: retries reuse the same
id/X-HashPay-Delivery. Record processed ids and skip duplicates so fulfilment runs exactly once. - Delivery log: every delivery (attempts, status, your endpoint's response) is inspectable in the dashboard under Integration → Delivery log, or via
GET /v1/stores/{storeId}/webhook/deliverieswith yoursk_key.
Subscription events
A checkout against a recurring Price opens a subscription and fires subscription.created, then over its lifecycle: subscription.activated (first payment confirmed), subscription.reminder (before each anniversary; carries daysUntilDue), subscription.renewed, subscription.past_due (anniversary passed unpaid), subscription.expired (grace period ran out), and subscription.canceled (buyer or seller canceled). Their data carries subscriptionId, storeId, mode, priceId, status, network, token, amount, interval, buyerEmail, nextDueAt, renewUrl, and the checkout's metadata — key your fulfilment (e.g. which of your users subscribed) off metadata, and gate access on activated/renewed vs expired. HashPay emails the buyer the reminders and the hosted renew link — it never auto-pulls funds.