Operational guide
Webhooks, signatures, retries, and replay safety
Webhook handling is where most production mistakes happen. Your consumer must be signature-checked, idempotent, and resilient to retries and out-of-order arrival.
Minimum webhook contract
- PayChainHQ sends webhook events with `POST`; any `2xx` response marks delivery successful.
- Non-`2xx` responses, network errors, TLS errors, DNS / SSRF validation failures, redirects, and timeouts are delivery failures.
- Redirects are not followed. The delivery timeout is 30 seconds.
- PayChainHQ attempts delivery up to 5 times using this retry schedule: 1 minute, 5 minutes, 15 minutes, 1 hour, then 6 hours.
- Verify `X-Webhook-Signature` against the exact raw request body before parsing the JSON payload.
- Treat `event` + `data.invoiceId` / `data.withdrawalId` as lifecycle inputs, not as permission to re-run a payout or fulfillment blindly.
- Store webhook delivery IDs for audit, and enforce uniqueness by your own object IDs such as `invoiceId`.
- Design for duplicate delivery and delayed delivery. The retry system is intentionally replay-safe, not exactly-once.
| Header | Meaning |
|---|---|
| `Content-Type` | `application/json` |
| `X-Webhook-Signature` | Hex HMAC-SHA256 signature of the raw body. |
| `X-Webhook-Signature-Alg` | `HMAC-SHA256` |
| `X-Webhook-Timestamp` | Delivery timestamp. It is not part of the HMAC input today. |
| `X-Webhook-ID` | Webhook delivery/event row ID. |
| `X-Webhook-Attempt` | Current delivery attempt number. |
| `Authorization` | Optional `Bearer <merchant-configured-auth-token>` set in your dashboard. |
Webhook envelope
json{
"id": "evt_2b3f1fa0",
"event": "invoice.paid",
"timestamp": "2026-03-14T13:46:04.903Z",
"data": {
"invoiceId": "inv_123",
"businessId": "biz_123",
"status": "paid",
"amount": {
"raw": "150000000",
"decimals": 6,
"display": "150",
"symbol": "USDC",
"chain": "eth",
"networkId": "base-mainnet"
},
"paidAmount": {
"raw": "149750000",
"decimals": 6,
"display": "149.75",
"symbol": "USDC",
"chain": "eth",
"networkId": "base-mainnet"
},
"settledByTolerance": true,
"toleranceRaw": "500000",
"shortfallRaw": "250000",
"txHash": "0x9ee6..."
}
}Shared signature fixture
textsecret = whsec_test_0123456789abcdef0123456789abcdef
rawBody = {"id":"evt_test_123","event":"invoice.paid","data":{"invoiceId":"inv_123","status":"paid"},"timestamp":"2026-05-01T12:00:00.000Z"}
expected X-Webhook-Signature = cb72807881cc4105b0b2f0d9277ac1f4b366bed9ee42f51ea0ac1fbf79b2742fSignature verification (Node.js)
tsimport crypto from 'node:crypto';
export function verifyPayChainHQWebhook(rawBody, signatureHex, webhookSecret) {
const expected = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signatureHex, 'hex'), Buffer.from(expected, 'hex'));
}Signature verification (Python)
pythonimport hmac
import hashlib
def verify_paychain_webhook(raw_body: bytes, signature_hex: str, webhook_secret: str) -> bool:
expected = hmac.new(webhook_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_hex, expected)Signature verification (Go)
gopackage webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func VerifyPayChainWebhook(rawBody []byte, signatureHex string, webhookSecret string) bool {
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(rawBody)
expected := mac.Sum(nil)
provided, err := hex.DecodeString(signatureHex)
if err != nil {
return false
}
return hmac.Equal(provided, expected)
}Signature verification (PHP)
php<?php
function verifyPayChainWebhook(string $rawBody, string $signatureHex, string $webhookSecret): bool {
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);
return hash_equals($expected, $signatureHex);
}Use the webhook signing secret
The signing key is the `whsec_...` value shown once when you rotate the webhook signing secret. Include the `whsec_` prefix in the HMAC key. Do not use your API key, and do not parse then re-stringify JSON before verification.
Dashboard test event
- `webhook.test` validates endpoint reachability and signature verification only.
- It does not include `data.invoiceId` and must not be processed as a payment.
- Acknowledge it with a `2xx` response after signature verification.
webhook.test payload
json{
"id": "evt_test_delivery",
"event": "webhook.test",
"timestamp": "2026-05-01T12:00:00.000Z",
"data": {
"message": "Test webhook from PayChain",
"businessId": "biz_123",
"timestamp": "2026-05-01T12:00:00.000Z"
}
}Recommended handler response
httpHTTP/1.1 204 No ContentEvent handling expectations
| Event family | What it tells you | What you should store |
|---|---|---|
| `invoice.*` | Payment lifecycle changes for a specific invoice. | Invoice ID, status, paid amount, tx hash, shortfall / overpayment metadata. |
| `withdrawal.*` | Payout lifecycle changes for a specific withdrawal. | Withdrawal ID, payout amount, fee amount, status, tx hash, failure reason. |
| `billing.*` | Platform billing lifecycle changes. | Billing invoice ID, amount, status, tx hash if attached. |
Replay-safe rule
Always decide idempotency from your own business object state, not only from a webhook delivery row. Manual replay creates a new event row on purpose.
Invoice lifecycle and accounting
| Status | Terminal | Credit customer? | Notes |
|---|---|---|---|
| `pending` | No | No | Invoice exists but no qualifying payment is confirmed. |
| `confirming` | No | No | Payment was detected but still needs required confirmations. |
| `partially_paid` | No | Usually no | Keep waiting unless your product explicitly supports partial credit. |
| `paid` | Yes | Yes | Fetch invoice detail and credit once by `invoiceId`. |
| `overpaid` | Yes | Yes | Credit the canonical invoice amount/paid amount and handle overpayment policy separately. |
| `expired` | Yes | No | No acceptable payment was completed before expiry. |
| `expired_partial` | Yes | Usually no | Funds may exist, but do not credit automatically without your own partial-payment policy. |
| `failed` | Yes | No | Terminal failure state. |
- Verify the webhook signature.
- Read `event` and `data.invoiceId`.
- Fetch the canonical invoice detail from PayChainHQ.
- Credit your internal ledger exactly once with a uniqueness constraint on `invoiceId`.
- Store `X-Webhook-ID` separately for delivery audit and replay troubleshooting.
Do not credit from raw chain activity
Treat raw wallet/address activity as telemetry. Invoice detail is the accounting source of truth because it applies token support, network, confirmation, tolerance, and replay rules.