Set up a webhook endpoint, verify signatures, implement idempotency, and handle retry logic. Required reading before going to production.
Create an HTTP endpoint that accepts POST requests at a path like /webhooks/payments. Register this URL in your PaymentsAPI dashboard under Settings → Webhooks → Add Endpoint.
const express = require('express'); const app = express(); // Important: use raw body parser for webhook signature verification app.post('/webhooks/payments', express.raw({ type: 'application/json' }), async (req, res) => { // 1. Verify signature (see Step 2) // 2. Parse event // 3. Handle event type res.status(200).json({ received: true }); } ); app.listen(3000);
from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/webhooks/payments', methods=['POST']) def webhook(): payload = request.get_data() # raw bytes for signature # 1. Verify signature (see Step 2) # 2. Parse and handle event return jsonify({"received": True}), 200 if __name__ == '__main__': app.run(port=3000)
Return HTTP 200 as soon as you receive the webhook — before doing any database writes or external calls. The Payments API won't retry a webhook if it gets a 200, but it will if your endpoint times out. Move processing to a background job or queue.
Every webhook request includes a PaymentsAPI-Signature header containing an HMAC-SHA256 hash of the raw request body, signed with your webhook secret. Always verify this before processing — otherwise anyone can POST fake events to your endpoint.
const crypto = require('crypto'); function verifySignature(rawBody, signatureHeader, secret) { const expected = crypto .createHmac('sha256', secret) .update(rawBody) .digest('hex'); const actual = signatureHeader.replace('sha256=', ''); // Use timingSafeEqual to prevent timing attacks const expectedBuf = Buffer.from(expected, 'hex'); const actualBuf = Buffer.from(actual, 'hex'); if (expectedBuf.length !== actualBuf.length) return false; return crypto.timingSafeEqual(expectedBuf, actualBuf); } // In your route handler: const sig = req.headers['paymentsapi-signature']; if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); }
import hashlib, hmac, os def verify_signature(raw_body, signature_header): secret = os.environ["WEBHOOK_SECRET"].encode() expected = hmac.new( secret, raw_body, hashlib.sha256 ).hexdigest() actual = signature_header.removeprefix("sha256=") # compare_digest prevents timing attacks return hmac.compare_digest(expected, actual) # In your route: sig = request.headers.get("PaymentsAPI-Signature") if not verify_signature(payload, sig): return jsonify({"error": "Invalid signature"}), 401
The Payments API retries webhook delivery up to 5 times with exponential backoff if your endpoint returns a non-200 or times out. This means you may receive the same event multiple times. Your handler must be idempotent — processing the same event twice must produce the same outcome as processing it once.
// Simple in-memory dedup (use Redis or DB in production) const processedEvents = new Set(); async function handleWebhookEvent(event) { const eventId = event.id; if (processedEvents.has(eventId)) { console.log(`Skipping duplicate event: ${eventId}`); return; // already processed — do nothing } // Mark processing BEFORE doing work processedEvents.add(eventId); switch (event.type) { case 'transfer.completed': await handleTransferCompleted(event.data); break; case 'transfer.returned': await handleTransferReturned(event.data); break; default: console.log(`Unhandled event type: ${event.type}`); } }
# Simple in-memory dedup (use Redis or DB in production) processed_events = set() def handle_webhook_event(event): event_id = event["id"] if event_id in processed_events: print(f"Duplicate event: {event_id} — skipping") return processed_events.add(event_id) event_type = event["type"] if event_type == "transfer.completed": handle_transfer_completed(event["data"]) elif event_type == "transfer.returned": handle_transfer_returned(event["data"])
In production, store processed event IDs in a database or Redis with a TTL of 7 days (the maximum retry window). An in-memory set is fine for development but will reset on every server restart.