Setting Up Webhooks for Crypto Payment Confirmations
The webhook is where a crypto payment stops being a promise and becomes a paid order in your database. Get it right and you never think about it again; get it wrong and you'll be reconciling refunds by hand for months. Here's how to design a webhook endpoint that survives production.
What the webhook contains
When a payment confirms, Solpaygate POSTs a JSON body to your configured webhookUrl:
{
"paymentId": "pay_9x8y7z",
"orderId": "order_7c9f",
"status": "confirmed",
"amount": "29.00",
"currency": "USDC",
"network": "solana",
"confirmedAt": "2026-09-02T14:32:11Z"
}
status is one of confirmed, underpaid, expired, or failed. amount is a decimal string — never a float, so you can compare it exactly against what you expected. currency is one of SOL, USDC, USDT.
Two headers matter:
X-Solpaygate-Signature— hex HMAC-SHA256 of the raw body, keyed with your webhook secret.X-Solpaygate-Timestamp— ISO timestamp of when we sent this delivery. Reject anything older than five minutes.
Verifying the signature
Verify the signature before you do anything else. Read the raw request body — do not JSON-parse it first — and compute HMAC-SHA256 with your secret. Compare in constant time so an attacker can't measure how far a forged signature got:
const crypto = require('crypto');
function verify(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
If verification fails, return 401 and stop. Don't log the body of an unverified request — attackers can spam your logs with junk payloads.
Handling retries
Our retry policy: six attempts over 24 hours with exponential backoff (roughly 1m, 5m, 30m, 2h, 6h, 12h). We treat anything outside 2xx as a failure and retry. Long timeouts count as failures too — your handler must respond within ten seconds. If it can't, do the minimum work synchronously (verify, dedupe, enqueue) and finish the rest in a background job.
Because retries happen, your handler must handle duplicate deliveries safely. Which brings us to:
Idempotency
The same webhook can arrive twice. Two common causes:
- Your handler processed it, then crashed before returning 200.
- Your handler returned 200, but the response was lost in transit.
Idempotency is easy in practice: store the paymentId as a unique key in your payments table. On every receipt:
- Look up the paymentId. If it exists and is already marked paid, return 200 immediately.
- Otherwise, apply the update inside a single database transaction, then return 200.
- Never mutate state outside that transaction (email sends, API calls) without first checking the guard.
A tiny helper:
await db.transaction(async (tx) => {
const existing = await tx.payments.get(paymentId);
if (existing?.status === 'paid') return;
await tx.payments.upsert({ paymentId, orderId, status: 'paid', amount, currency });
await tx.orders.markPaid(orderId, paymentId);
});
Any downstream work — sending a receipt email, provisioning a subscription — should key off "did this transaction actually change the row?" so a duplicate delivery doesn't email the customer twice.
Testing your endpoint
Never wait for a real payment to test webhook handling. Two useful ways to test:
- The "Send test webhook" button in the dashboard. It signs a synthetic payload with your real secret and posts it to your configured URL, so signature verification is exercised end-to-end.
- A devnet payment round-trip. Slower but exercises the actual signing path plus the reconciliation logic, and confirms the shape of the body under real conditions.
Local development is easy with a tunnel — ngrok or Cloudflare Tunnel exposes localhost:3000 as an HTTPS URL. Set that URL as your dashboard webhook, click "Send test webhook", and watch the request land in your debugger. Test the underpayment and expired-payment paths explicitly — they're the ones that catch teams off guard when a real customer runs into them.
Once your handler is verified and idempotent, wire it into the full flow described in our API walkthrough. And for the pre-launch checklist of everything else you should have in place before flipping to mainnet, see our crypto payment security checklist.
Ready to accept crypto payments?
Solpaygate lets your business accept SOL, USDT, and USDC on Solana with a single API call. Non-custodial, no smart contract to deploy.
Start for free