Polling the API for transfer status is inefficient and unreliable. Webhooks push status changes to your endpoint the moment they happen. This step covers endpoint setup, signature verification, and idempotency.
https://yourapp.com/webhooks/payments)transfer.completed, transfer.returned, transfer.failedwhsec_) โ add it to your .envngrok http 3000 (or cloudflared tunnel) to expose your local server. Paste the ngrok HTTPS URL as your endpoint in the dashboard for sandbox testing.
Your endpoint must: (1) read the raw request body, (2) verify the HMAC-SHA256 signature before parsing, and (3) return HTTP 200 immediately. Heavy processing should happen asynchronously after the 200 response.
const express = require('express'); const crypto = require('crypto'); const app = express(); // IMPORTANT: parse as raw bytes โ must come before json() middleware app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-payments-signature']; const secret = process.env.PAYMENTS_WEBHOOK_SECRET; // Verify HMAC-SHA256 signature const expected = crypto .createHmac('sha256', secret) .update(req.body) .digest('hex'); const isValid = crypto.timingSafeEqual( Buffer.from(signature, 'utf8'), Buffer.from(expected, 'utf8') ); if (!isValid) { return res.status(401).end(); } const event = JSON.parse(req.body.toString()); // Respond 200 immediately, process asynchronously res.status(200).end(); switch (event.type) { case 'transfer.completed': handleTransferCompleted(event.data); break; case 'transfer.returned': handleTransferReturned(event.data); break; case 'transfer.failed': handleTransferFailed(event.data); break; } });
import hmac, hashlib, os from flask import Flask, request, abort import json app = Flask(__name__) @app.route("/webhooks/payments", methods=["POST"]) def webhook(): signature = request.headers.get("X-Payments-Signature", "") secret = os.environ["PAYMENTS_WEBHOOK_SECRET"].encode() body = request.get_data() # raw bytes โ critical expected = hmac.new(secret, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(signature, expected): abort(401) event = json.loads(body) # Return 200 immediately if event["type"] == "transfer.completed": handle_completed(event["data"]) elif event["type"] == "transfer.returned": handle_returned(event["data"]) return "", 200
express.json() before the signature check, JSON serialization differences will cause every signature to fail. Always read raw bytes first, verify signature, then parse JSON.
The Payments API retries unacknowledged webhooks up to 5 times over 24 hours. Your handler must be idempotent โ processing the same event twice should not double-credit an account or trigger two emails.
// Use Redis or your database to track processed event IDs const processedEvents = new Set(); // dev only โ use Redis in prod async function handleEvent(event) { if (processedEvents.has(event.id)) { console.log(`Duplicate event ${event.id} โ skipping`); return; } processedEvents.add(event.id); // Now safely process the event await process(event); }
# Use Redis or your database โ set() a key with TTL for 48h window processed_events = set() # dev only def handle_event(event): if event["id"] in processed_events: print(f"Duplicate event {event['id']} โ skipping") return processed_events.add(event["id"]) # Now safely process the event process(event)
whsec_ secret added to .envtimingSafeEqual / compare_digest