send0
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

  1. send0 signs each webhook payload with HMAC-SHA256 using your endpoint's signing secret
  2. The signature payload is {timestamp}.{body}
  3. The signature is sent in the X-Send0-Signature header as t={timestamp},v1={hex_signature}
  4. The timestamp is also sent separately in X-Send0-Timestamp

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 of express.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

ErrorCauseFix
Missing X-Send0-SignatureHeader not forwardedEnsure your server passes raw headers
Missing X-Send0-TimestampHeader not forwardedSame as above
Timestamp too oldClock skew or replayCheck server clock, increase tolerance
Signature mismatchWrong secret or modified bodyUse correct secret, ensure raw body (not parsed JSON)