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. |
A set of subscription.* events (created, activated, renewed, upgraded, past_due, expired, reminder) exists for the beta subscriptions feature. They're not part of the standard checkout flow; see the note 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 (beta)
If you're in the subscriptions beta, seven more events fire: subscription.created, subscription.activated, subscription.renewed, subscription.upgraded, subscription.past_due, subscription.expired, and subscription.reminder. Their data carries subscriptionId, subscriberAccountId, storeId, tier, status, network, token, amount, buyerEmail, nextDueAt, renewUrl, and (on reminders) daysUntilDue. HashPay sends reminders and webhooks — it never auto-pulls funds.