Why Duplicates Happen
Paychainly retries webhook delivery if your endpoint returns a non-2xx status or times out. Network blips mean the same deposit_detected event may arrive 2–3 times. Your handler must be safe to call multiple times.
The txHash is Your Idempotency Key
Every payment event carries a unique txHash — the on-chain transaction hash. Use it as a natural idempotency key:
CREATE TABLE processed_payments (
tx_hash VARCHAR(66) PRIMARY KEY,
user_id INTEGER NOT NULL,
amount DECIMAL(18,6) NOT NULL,
processed_at TIMESTAMP DEFAULT NOW()
);
Node.js Handler Pattern
app.post('/webhooks/paychainly', async (req, res) => {
// 1. Verify signature first
if (!verifySignature(req.rawBody, req.headers['x-paychainly-signature'], WEBHOOK_SECRET)) {
return res.status(401).end();
}
const { txHash, amount, userId } = req.body;
// 2. Idempotency check
const [record, created] = await ProcessedPayment.findOrCreate({
where: { txHash },
defaults: { userId, amount },
});
if (!created) {
// Already processed — safe to acknowledge
return res.status(200).json({ status: 'already_processed' });
}
// 3. Business logic — runs exactly once per txHash
await creditUserAccount(userId, amount);
await sendConfirmationEmail(userId);
res.status(200).json({ status: 'ok' });
});
Database-Level Guarantee
The PRIMARY KEY on tx_hash gives you a database-level guarantee. Even under concurrent requests, only one insert succeeds — the other gets a unique constraint error which you can catch and handle as "already processed".
Timeout Considerations
Paychainly's webhook timeout is 25 seconds (WEBHOOK_FETCH_TIMEOUT_MS). If your handler takes longer, queue the work and acknowledge immediately:
// Acknowledge first, process async
res.status(200).json({ status: 'queued' });
await jobQueue.add('process-payment', { txHash, amount, userId });