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.
HeaderMeaning
`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

text
secret = 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 = cb72807881cc4105b0b2f0d9277ac1f4b366bed9ee42f51ea0ac1fbf79b2742f

Signature verification (Node.js)

ts
import 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)

python
import 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)

go
package 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

http
HTTP/1.1 204 No Content

Event handling expectations

Event familyWhat it tells youWhat 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

StatusTerminalCredit customer?Notes
`pending`NoNoInvoice exists but no qualifying payment is confirmed.
`confirming`NoNoPayment was detected but still needs required confirmations.
`partially_paid`NoUsually noKeep waiting unless your product explicitly supports partial credit.
`paid`YesYesFetch invoice detail and credit once by `invoiceId`.
`overpaid`YesYesCredit the canonical invoice amount/paid amount and handle overpayment policy separately.
`expired`YesNoNo acceptable payment was completed before expiry.
`expired_partial`YesUsually noFunds may exist, but do not credit automatically without your own partial-payment policy.
`failed`YesNoTerminal failure state.
  1. Verify the webhook signature.
  2. Read `event` and `data.invoiceId`.
  3. Fetch the canonical invoice detail from PayChainHQ.
  4. Credit your internal ledger exactly once with a uniqueness constraint on `invoiceId`.
  5. 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.

Webhooks, signatures, retries, and replay safety | PayChainHQ