Webhooks
Webhook Verification
Verify webhook signatures to ensure authenticity.
Why verify?
Webhook verification ensures that the payload was sent by send0 and hasn't been tampered with. Always verify signatures before processing webhook events.
How it works
- send0 signs each webhook payload with HMAC-SHA256 using your endpoint's signing secret
- The signature payload is
{timestamp}.{body} - The signature is sent in the
X-Send0-Signatureheader ast={timestamp},v1={hex_signature} - The timestamp is also sent separately in
X-Send0-Timestamp
Using the SDK (recommended)
import { Send0 } from 'send0';
const event = Send0.webhooks.verify(
rawBody, // raw request body (string or Buffer)
headers, // request headers object
webhookSecret, // your endpoint's signing secret
);
// event is now typed as Send0WebhookEvent
console.log(event.type); // e.g. 'email.delivered'Express.js
import express from 'express';
import { Send0 } from 'send0';
const app = express();
// IMPORTANT: Use express.raw() to get the raw body
app.post(
'/webhooks/send0',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = Send0.webhooks.verify(
req.body,
req.headers,
process.env.SEND0_WEBHOOK_SECRET!,
);
// Process event...
res.json({ received: true });
} catch (err) {
console.error('Webhook verification failed:', err);
res.status(400).json({ error: 'Invalid signature' });
}
},
);Important: You must use
express.raw({ type: 'application/json' })instead ofexpress.json(). The raw body is required for signature verification — parsed JSON will not match the signature.
Next.js (App Router)
import { Send0 } from 'send0';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.text();
const headers: Record<string, string> = {};
req.headers.forEach((value, key) => {
headers[key] = value;
});
try {
const event = Send0.webhooks.verify(
body,
headers,
process.env.SEND0_WEBHOOK_SECRET!,
);
switch (event.type) {
case 'email.delivered':
// handle delivery
break;
case 'email.bounced':
// handle bounce
break;
}
return NextResponse.json({ received: true });
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
}Manual verification (without SDK)
If you can't use the SDK, verify manually:
import crypto from 'node:crypto';
function verifyWebhook(
body: string,
signature: string,
timestamp: string,
secret: string,
) {
const payload = `${timestamp}.${body}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Parse v1 signature from header
const parts = signature.split(',');
const sigValue = parts.find(p => p.startsWith('v1='))?.slice(3);
if (!sigValue) throw new Error('Invalid signature format');
// Timing-safe comparison
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(sigValue, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('Invalid signature');
}
return JSON.parse(body);
}Timestamp tolerance
By default, the SDK rejects webhooks older than 5 minutes to prevent replay attacks. You can customize this with a fourth parameter (in seconds):
const event = Send0.webhooks.verify(
rawBody,
headers,
secret,
600, // 10 minutes tolerance
);Common errors
| Error | Cause | Fix |
|---|---|---|
| Missing X-Send0-Signature | Header not forwarded | Ensure your server passes raw headers |
| Missing X-Send0-Timestamp | Header not forwarded | Same as above |
| Timestamp too old | Clock skew or replay | Check server clock, increase tolerance |
| Signature mismatch | Wrong secret or modified body | Use correct secret, ensure raw body (not parsed JSON) |