Webhook Best Practices
Return 200 Quickly
Your webhook endpoint should return a 200 status code as fast as possible. Do the heavy processing asynchronously.
app.post('/webhooks/inflow', express.json(), (req, res) => {
// Respond immediately
res.status(200).json({ received: true });
// Process the event asynchronously
processEvent(req.body).catch(console.error);
});If your endpoint doesn't respond with a 200 within 15 seconds (the hard timeout), the delivery is considered failed. As a best practice, aim to respond within 5 seconds by processing events asynchronously.
Handle Duplicate Events
Your endpoint may receive the same event more than once. Always implement idempotent processing.
Use the svix-id header as the unique delivery identifier — it is the same value across all retry attempts for the same event:
app.post('/webhooks/inflow', express.raw({ type: 'application/json' }), async (req, res) => {
const deliveryId = req.headers['svix-id'];
// Respond immediately
res.status(200).json({ received: true });
// Check if already processed
const existing = await db.webhookEvents.findOne({ deliveryId });
if (existing) return;
// Process the event
const { data } = JSON.parse(req.body);
for (const event of data) {
await handlePayment(event.payload);
}
// Record that we've processed this delivery
await db.webhookEvents.create({ deliveryId, processedAt: new Date() });
});Verifying Webhook Signature
Inflow signs every webhook using Svix. You must verify the signature before processing the payload. You can use the Svix SDK (npm install svix) to verify automatically, or implement verification manually as described below.
Webhook Headers
Each webhook request includes three headers:
| Header | Description |
|---|---|
| svix-id | Unique message identifier. The same when the same webhook is resent (e.g. after a previous failure). |
| svix-timestamp | Timestamp in seconds since the epoch. |
| svix-signature | Base64-encoded list of signatures (space-delimited). |
Constructing the Signed Content
The signed content is the concatenation of id, timestamp, and payload separated by a full stop (.). Do not modify the body before verifying — the signature is sensitive to any change.
// Use the raw request body (e.g. express.raw() in Express, not express.json())
const signedContent = `${headers['svix-id']}.${headers['svix-timestamp']}.${payload}`;Use the raw body as received; we recommend JSON.stringify(payload) only if you need to build the string from a parsed object that matches the exact bytes sent.
Determining the Expected Signature
Inflow uses HMAC with SHA-256. Use the base64 portion of your signing secret (the part after the whsec_ prefix) as the key.
Example: for secret whsec_MfKKr9g8GKYq7wJP0B1PLPZtOzLaLaSw, use MfKKr9g8GKYq7wJP0B1PLPZtOzLaLaSw (decoded from base64 as the HMAC key).
Node.js example:
const crypto = require('crypto');
const signedContent = `${headers['svix-id']}.${headers['svix-timestamp']}.${payload}`;
const secret = 'whsec_5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH';
// Decode the base64 portion of the secret
const secretBytes = Buffer.from(secret.split('_')[1], 'base64');
// Generate the HMAC signature
const expectedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');The generated signature should match one of the signatures in the svix-signature header.
Signature Format
The svix-signature header contains space-delimited entries. Each entry has a version prefix and a signature, e.g. v1,<base64>:
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo=
Remove the version prefix (e.g. v1,) before comparing: take the base64 part and check that your computed signature matches one of them.
Verifying the Timestamp
Compare svix-timestamp to your system time and reject the request if it is outside your tolerance (e.g. a few minutes). This prevents replay attacks.
Example (test vectors)
You can use this example to verify your implementation (note: verification may fail if the timestamp is too old).
| Field | Value |
|---|---|
| Secret | whsec_plJ3nmyCDGBKInavdOK15jsl |
| Payload | '{"event_type":"ping","data":{"success":true}}' |
| Message ID | msg_loFOjxBNrRLzqYUf |
| Timestamp | 1731705121 |
| Expected signature | v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0= |
Always reject requests with invalid or missing signatures. Never process unverified webhook payloads.
Handle All Event Types
Even if you only care about specific events, your endpoint should handle unexpected event types gracefully:
const { data } = req.body;
for (const event of data) {
switch (event.eventType) {
case 'payment_created':
case 'payment_status_updated':
handlePayment(event.payload);
break;
default:
console.log('Unhandled event type:', event.eventType);
break;
}
}Always return 200 even for event types you don't process — otherwise Inflow may consider the delivery failed.
Use HTTPS
Your webhook URL must use HTTPS. HTTP endpoints are not supported.
Monitor Webhook Health
Periodically check your webhook status:
curl https://api.inflowpay.xyz/api/webhook/{webhookId}/status \
-H "X-Inflow-Api-Key: inflow_priv_your_key"If the status shows disabled or a failureReason, investigate and fix the issue, then re-enable the webhook.
Recommended Architecture
Inflow Webhook
|
v
[Your Endpoint] --> [Message Queue / Job] --> [Business Logic]
|
v
Return 200
- Receive the webhook event.
- Acknowledge immediately with
200. - Queue the event for asynchronous processing.
- Process the event in a background worker.
This architecture ensures you never miss events due to processing delays.
Checklist
- Endpoint uses HTTPS
- Returns 200 within 5 seconds (hard limit: 15s)
- Handles duplicate events (idempotent)
- Processes events asynchronously
- Handles unknown event types gracefully
- Logs all received events for debugging
- Monitors webhook status regularly
Updated 17 days ago