Developing Payment Callback/Webhook Notifications
A payment is confirmed on the blockchain — which means you need to process it in your system. The problem: the blockchain can't "call" your backend. There are two approaches: polling (you ask) and push (an external system notifies you). A webhook is a push notification from a payment processor or your own blockchain monitoring service.
Webhook System Architecture
Typical scheme for crypto payments:
Blockchain → Monitoring service → Webhook dispatcher → Your app endpoint
↓
Retry queue (Redis/DB)
The monitoring service is either a third-party provider (Alchemy Notify, Moralis Streams, QuickNode Streams) or your own process listening to the node.
Receiving Webhooks from Alchemy Notify
// Create webhook via Alchemy API
const response = await fetch('https://dashboard.alchemy.com/api/create-webhook', {
method: 'POST',
headers: {
'X-Alchemy-Token': process.env.ALCHEMY_AUTH_TOKEN!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
network: 'ETH_MAINNET',
webhook_type: 'ADDRESS_ACTIVITY',
webhook_url: 'https://yourapp.com/webhooks/crypto',
addresses: ['0xYourAddress'],
}),
})
Your Webhook Endpoint: Proper Handling
Main rule: webhook endpoint must respond 200 as quickly as possible. All heavy logic — in the queue:
// Express.js endpoint
app.post('/webhooks/crypto', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify signature — BEFORE any processing
const signature = req.headers['x-alchemy-signature'] as string
const isValid = verifyAlchemySignature(req.body, signature, process.env.WEBHOOK_SIGNING_KEY!)
if (!isValid) {
return res.status(401).send('Invalid signature')
}
// 2. Reply with 200 immediately
res.status(200).send('OK')
// 3. Put in queue for async processing
const payload = JSON.parse(req.body.toString())
await jobQueue.add('process-crypto-payment', payload, {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
})
})
function verifyAlchemySignature(body: Buffer, signature: string, signingKey: string): boolean {
const hmac = crypto.createHmac('sha256', signingKey)
hmac.update(body)
const digest = hmac.digest('hex')
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))
}
timingSafeEqual is mandatory — without it, timing attacks allow signature guessing in reasonable time.
Idempotency: Handle Retries Correctly
Webhook providers guarantee at-least-once delivery, not exactly-once. Your handler must be idempotent:
async function processPaymentWebhook(txHash: string, address: string, amountWei: bigint) {
// Use INSERT ... ON CONFLICT DO NOTHING
const result = await db.query(`
INSERT INTO processed_webhooks (tx_hash, processed_at)
VALUES ($1, NOW())
ON CONFLICT (tx_hash) DO NOTHING
RETURNING id
`, [txHash])
if (result.rowCount === 0) {
// Already processed — skip, not an error
return
}
// Main payment processing logic
await updatePaymentStatus(address, amountWei, txHash)
}
Retry Mechanism for Outgoing Webhooks (if your service notifies others)
If your service sends webhooks to client systems:
interface WebhookDelivery {
id: string
url: string
payload: object
attempt: number
nextRetryAt: Date
}
async function deliverWebhook(delivery: WebhookDelivery): Promise<void> {
try {
const res = await fetch(delivery.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signPayload(delivery.payload),
'X-Webhook-ID': delivery.id,
'X-Webhook-Attempt': String(delivery.attempt),
},
body: JSON.stringify(delivery.payload),
signal: AbortSignal.timeout(10_000), // 10 second timeout
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
await db.markDelivered(delivery.id)
} catch (err) {
const nextAttempt = delivery.attempt + 1
if (nextAttempt > 10) {
await db.markFailed(delivery.id, String(err))
return
}
// Exponential backoff: 30s, 1m, 2m, 5m, 10m, ...
const delayMs = Math.min(30_000 * Math.pow(2, nextAttempt - 1), 3_600_000)
await db.scheduleRetry(delivery.id, nextAttempt, new Date(Date.now() + delayMs))
}
}
Retry scheme: 10 attempts with exponential backoff is sufficient for most cases. Final failure — notify developers, save to dead letter queue.
Table for Storing Deliveries
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
target_url TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
attempt INTEGER DEFAULT 0,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
delivered_at TIMESTAMPTZ,
last_error TEXT
);
CREATE INDEX ON webhook_deliveries(status, next_retry_at)
WHERE status IN ('pending', 'retrying');
Partial index by status — worker when polling reads only active records, doesn't scan delivered ones.
Security: What's Required
- HMAC-SHA256 signature on each outgoing webhook with client secret
-
Timestamp in payload (
sent_atfield) and server-side check that webhook is not older than 5 minutes — protection from replay attacks - HTTPS only — don't deliver to HTTP endpoints
-
Idempotent key in header (
X-Webhook-ID) — receiver can deduplicate
Timeline for implementation: basic webhook endpoint with signature verification and queue — 1 day. Full system with retries, dead letter queue, delivery monitoring dashboard, subscription management — 2–3 days.







