Skip to main content

Webhook Authorization

Verify that webhook requests come from Lettr, not malicious actors.

Signature Verification

Every webhook request includes a signature header:
Lettr-Signature: t=1673789400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The signature contains:
  • t: Unix timestamp when the signature was created
  • v1: HMAC-SHA256 signature

Verify in Node.js

import crypto from 'crypto';

function verifyWebhookSignature(payload, signature, secret) {
  const elements = signature.split(',');
  const timestamp = elements.find(e => e.startsWith('t=')).slice(2);
  const expectedSignature = elements.find(e => e.startsWith('v1=')).slice(3);
  
  // Verify timestamp is recent (within 5 minutes)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    throw new Error('Timestamp too old');
  }
  
  // Compute expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const computedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');
  
  // Compare signatures
  if (!crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(computedSignature)
  )) {
    throw new Error('Invalid signature');
  }
  
  return JSON.parse(payload);
}

// Usage
app.post('/webhooks/lettr', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifyWebhookSignature(
      req.body.toString(),
      req.headers['lettr-signature'],
      process.env.LETTR_WEBHOOK_SECRET
    );
    
    // Process event
    res.sendStatus(200);
  } catch (err) {
    res.sendStatus(400);
  }
});

Using the SDK

The SDK handles verification for you:
import { verifyWebhook } from 'lettr';

app.post('/webhooks/lettr', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifyWebhook(
      req.body,
      req.headers['lettr-signature'],
      process.env.LETTR_WEBHOOK_SECRET
    );
    
    // Event is verified
    console.log(event.type, event.data);
    res.sendStatus(200);
  } catch (err) {
    console.error('Webhook verification failed');
    res.sendStatus(400);
  }
});

PHP Verification

function verifyWebhook($payload, $signature, $secret) {
    $elements = explode(',', $signature);
    $timestamp = null;
    $expectedSignature = null;
    
    foreach ($elements as $element) {
        if (str_starts_with($element, 't=')) {
            $timestamp = substr($element, 2);
        } elseif (str_starts_with($element, 'v1=')) {
            $expectedSignature = substr($element, 3);
        }
    }
    
    // Verify timestamp
    if (abs(time() - (int)$timestamp) > 300) {
        throw new Exception('Timestamp too old');
    }
    
    // Compute signature
    $signedPayload = "{$timestamp}.{$payload}";
    $computedSignature = hash_hmac('sha256', $signedPayload, $secret);
    
    if (!hash_equals($expectedSignature, $computedSignature)) {
        throw new Exception('Invalid signature');
    }
    
    return json_decode($payload, true);
}

Webhook Secret

Get your webhook secret from the dashboard or when creating a webhook:
const webhook = await lettr.webhooks.create({
  url: 'https://example.com/webhooks',
  events: ['email.delivered']
});

console.log(webhook.secret); // whsec_xxxxx
// Store this securely!

Rotate Webhook Secret

Rotate the secret if compromised:
const webhook = await lettr.webhooks.rotateSecret('wh_123');

console.log(webhook.secret); // New secret
After rotating the secret, update your webhook handler immediately to use the new secret.

IP Allowlisting

For additional security, allowlist Lettr’s IP addresses:
203.0.113.10
203.0.113.11
203.0.113.12
Contact support for the current list of webhook source IPs.