# Signed webhooks

Lettermint signs every webhook delivery so you can verify it came from us and the payload hasn't been tampered with. This prevents unauthorized parties from forging webhook events to your endpoint.

## Why verify signatures?

Without signature verification, anyone who discovers your webhook URL can send fake events to your endpoint. An attacker could:

- Trigger false payment confirmations to fulfill orders that were never paid
- Forge delivery events to mark messages as sent when they weren't
- Inject malicious data into your systems through crafted payloads
- Cause your application to take actions based on events that never occurred

Signature verification ensures that only Lettermint can trigger your webhook handlers. It takes just a few lines of code and should always be implemented in production.

## How it works

Each webhook delivery includes a cryptographic signature in the `X-Lettermint-Signature` header. The signature is computed using HMAC-SHA256 with your webhook's secret and contains:
- A timestamp to prevent replay attacks
- A hash of the exact payload we sent

You recompute the same signature on your side and compare. If they match, the webhook is authentic.

## Headers

Every webhook delivery includes these HTTP headers:

| Header                      | Description                                                         |
|-----------------------------|---------------------------------------------------------------------|
| **X-Lettermint-Signature**  | Signature in format `t={timestamp},v1={hmac_hex}`                   |
| **X-Lettermint-Event**      | Event type (e.g., `message.delivered`, `message.bounced`)           |
| **X-Lettermint-Delivery**   | Delivery timestamp (Unix seconds)                                   |
| **X-Lettermint-Attempt**    | Retry attempt number (1, 2, 3...)                                   |

## Signature scheme

The signature format follows the Stripe/Svix convention:

```
X-Lettermint-Signature: t=1704067200,v1=5d41402abc4b2a76b9719d911017c592
```

Where:
- `t` = Unix timestamp (seconds) when signature was generated
- `v1` = HMAC-SHA256 hex digest

### Signed payload format

We compute the signature over this exact string:

```
{timestamp}.{raw_json_body}
```

The JSON body is serialized with `JSON_UNESCAPED_SLASHES` and `JSON_UNESCAPED_UNICODE` flags. You must use the raw request body bytes, not a re-serialized version.

### Computing the signature

```javascript
const crypto = require('crypto')

const timestamp = '1704067200'
const rawBody = '{"id":"abc","event":"message.delivered",...}'
const secret = 'whsec_your_secret_here'

const signedPayload = `${timestamp}.${rawBody}`
const expectedSignature = crypto
  .createHmac('sha256', secret)
  .update(signedPayload)
  .digest('hex')

// expectedSignature should equal the v1 value from the header
```

:::warning
Use your webhook secret **as-is**, including the `whsec_` prefix. Do not strip or decode it.
:::

## Replay protection

Reject webhooks with stale timestamps to prevent replay attacks. We recommend a 5-minute tolerance window:

1. Parse the `t` value from `X-Lettermint-Signature`
2. Compare with current time: `|now - t| <= 300` seconds
3. Reject if outside the window

This prevents attackers from capturing and re-sending old webhook payloads.

## Implementation examples

<CodeTabs>
```javascript title="Express"
const express = require('express')
const crypto = require('crypto')

const app = express()
const SECRET = process.env.LETTERMINT_WEBHOOK_SECRET

// Important: capture raw body for signature verification
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf
  }
}))

app.post('/webhooks/lettermint', (req, res) => {
  const signature = req.header('X-Lettermint-Signature') || ''
  const rawBody = req.rawBody

  // Verify signature
  if (!verifySignature(rawBody, signature, SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' })
  }

  // Signature valid - process event
  const event = req.body
  console.log('Verified event:', event.event, event.id)

  res.status(200).json({ ok: true })
})

function verifySignature(rawBody, signature, secret, tolerance = 300) {
  // Parse signature header: t=123,v1=abc...
  const elements = {}
  signature.split(',').forEach(element => {
    const [key, value] = element.split('=', 2)
    if (key && value) elements[key] = value
  })

  if (!elements.t || !elements.v1) {
    return false
  }

  const timestamp = parseInt(elements.t, 10)
  const providedSignature = elements.v1

  // Check timestamp freshness (replay protection)
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - timestamp) > tolerance) {
    console.warn('Timestamp too old or too new')
    return false
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${rawBody.toString()}`
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex')

  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(providedSignature)
  )
}

app.listen(3000)
```

```typescript title="Next.js"
// app/api/webhooks/lettermint/route.ts
import { NextResponse } from 'next/server'
import crypto from 'crypto'

const SECRET = process.env.LETTERMINT_WEBHOOK_SECRET!

export async function POST(req: Request) {
  const signature = req.headers.get('X-Lettermint-Signature') || ''
  const rawBody = Buffer.from(await req.arrayBuffer())

  // Verify signature
  if (!verifySignature(rawBody, signature, SECRET)) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 401 }
    )
  }

  // Signature valid - process event
  const event = JSON.parse(rawBody.toString())
  console.log('Verified event:', event.event, event.id)

  return NextResponse.json({ ok: true })
}

function verifySignature(
  rawBody: Buffer,
  signature: string,
  secret: string,
  tolerance = 300
): boolean {
  // Parse signature header: t=123,v1=abc...
  const elements: Record<string, string> = {}
  signature.split(',').forEach(element => {
    const [key, value] = element.split('=', 2)
    if (key && value) elements[key] = value
  })

  if (!elements.t || !elements.v1) {
    return false
  }

  const timestamp = parseInt(elements.t, 10)
  const providedSignature = elements.v1

  // Check timestamp freshness (replay protection)
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - timestamp) > tolerance) {
    console.warn('Timestamp too old or too new')
    return false
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${rawBody.toString()}`
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex')

  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(providedSignature)
  )
}
```

```php title="Laravel"
// routes/web.php or routes/api.php
Route::post('/webhooks/lettermint', function (Request $request) {
    $signature = $request->header('X-Lettermint-Signature', '');
    $rawBody = $request->getContent();
    $secret = config('services.lettermint.webhook_secret');

    // Verify signature
    if (!verifySignature($rawBody, $signature, $secret)) {
        return response()->json(['error' => 'Invalid signature'], 401);
    }

    // Signature valid - process event
    $event = $request->json()->all();
    Log::info('Verified event', [
        'event_type' => $event['event'],
        'event_id' => $event['id']
    ]);

    return response()->json(['ok' => true]);
});

function verifySignature(string $rawBody, string $signature, string $secret, int $tolerance = 300): bool
{
    // Parse signature header: t=123,v1=abc...
    $elements = [];
    foreach (explode(',', $signature) as $element) {
        $parts = explode('=', $element, 2);
        if (count($parts) === 2) {
            [$key, $value] = $parts;
            $elements[$key] = $value;
        }
    }

    if (!isset($elements['t']) || !isset($elements['v1'])) {
        return false;
    }

    $timestamp = (int) $elements['t'];
    $providedSignature = $elements['v1'];

    // Check timestamp freshness (replay protection)
    if (abs(time() - $timestamp) > $tolerance) {
        Log::warning('Webhook timestamp too old or too new');
        return false;
    }

    // Compute expected signature
    $signedPayload = $timestamp . '.' . $rawBody;
    $expectedSignature = hash_hmac('sha256', $signedPayload, $secret);

    // Timing-safe comparison
    return hash_equals($expectedSignature, $providedSignature);
}
```

```python title="Flask"
# app.py
from flask import Flask, request, jsonify
import hmac
import hashlib
import time
import os

app = Flask(__name__)
SECRET = os.environ.get('LETTERMINT_WEBHOOK_SECRET')

@app.route('/webhooks/lettermint', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Lettermint-Signature', '')
    raw_body = request.get_data()

    # Verify signature
    if not verify_signature(raw_body, signature, SECRET):
        return jsonify({'error': 'Invalid signature'}), 401

    # Signature valid - process event
    event = request.get_json()
    print(f"Verified event: {event['event']} {event['id']}")

    return jsonify({'ok': True}), 200

def verify_signature(raw_body, signature, secret, tolerance=300):
    # Parse signature header: t=123,v1=abc...
    elements = {}
    for element in signature.split(','):
        parts = element.split('=', 1)
        if len(parts) == 2:
            key, value = parts
            elements[key] = value

    if 't' not in elements or 'v1' not in elements:
        return False

    timestamp = int(elements['t'])
    provided_signature = elements['v1']

    # Check timestamp freshness (replay protection)
    now = int(time.time())
    if abs(now - timestamp) > tolerance:
        print('Timestamp too old or too new')
        return False

    # Compute expected signature
    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    return hmac.compare_digest(expected_signature, provided_signature)

if __name__ == '__main__':
    app.run(port=3000)
```

```python title="FastAPI"
# main.py
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
import hmac
import hashlib
import time
import os

app = FastAPI()
SECRET = os.environ.get('LETTERMINT_WEBHOOK_SECRET')

@app.post('/webhooks/lettermint')
async def webhook(request: Request):
    signature = request.headers.get('X-Lettermint-Signature', '')
    raw_body = await request.body()

    # Verify signature
    if not verify_signature(raw_body, signature, SECRET):
        raise HTTPException(status_code=401, detail='Invalid signature')

    # Signature valid - process event
    event = await request.json()
    print(f"Verified event: {event['event']} {event['id']}")

    return JSONResponse({'ok': True})

def verify_signature(raw_body: bytes, signature: str, secret: str, tolerance: int = 300) -> bool:
    # Parse signature header: t=123,v1=abc...
    elements = {}
    for element in signature.split(','):
        parts = element.split('=', 1)
        if len(parts) == 2:
            key, value = parts
            elements[key] = value

    if 't' not in elements or 'v1' not in elements:
        return False

    timestamp = int(elements['t'])
    provided_signature = elements['v1']

    # Check timestamp freshness (replay protection)
    now = int(time.time())
    if abs(now - timestamp) > tolerance:
        print('Timestamp too old or too new')
        return False

    # Compute expected signature
    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}"
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    # Timing-safe comparison
    return hmac.compare_digest(expected_signature, provided_signature)
```

```ruby title="Sinatra"
# app.rb
require 'sinatra'
require 'openssl'
require 'json'

SECRET = ENV['LETTERMINT_WEBHOOK_SECRET']

post '/webhooks/lettermint' do
  signature = request.env['HTTP_X_LETTERMINT_SIGNATURE'] || ''
  raw_body = request.body.read

  # Verify signature
  unless verify_signature(raw_body, signature, SECRET)
    halt 401, { 'Content-Type' => 'application/json' }, { error: 'Invalid signature' }.to_json
  end

  # Signature valid - process event
  event = JSON.parse(raw_body)
  puts "Verified event: #{event['event']} #{event['id']}"

  content_type :json
  { ok: true }.to_json
end

def verify_signature(raw_body, signature, secret, tolerance = 300)
  # Parse signature header: t=123,v1=abc...
  elements = {}
  signature.split(',').each do |element|
    key, value = element.split('=', 2)
    elements[key] = value if key && value
  end

  return false unless elements['t'] && elements['v1']

  timestamp = elements['t'].to_i
  provided_signature = elements['v1']

  # Check timestamp freshness (replay protection)
  now = Time.now.to_i
  return false if (now - timestamp).abs > tolerance

  # Compute expected signature
  signed_payload = "#{timestamp}.#{raw_body}"
  expected_signature = OpenSSL::HMAC.hexdigest('sha256', secret, signed_payload)

  # Timing-safe comparison
  Rack::Utils.secure_compare(expected_signature, provided_signature)
rescue
  false
end
```

```bash title="cURL"
# Generate a valid signature for testing (replace with your secret and payload)
SECRET="whsec_your_secret_here"
TIMESTAMP=$(date +%s)
PAYLOAD='{"id":"test","event":"webhook.test","data":{}}'

# Compute HMAC-SHA256 signature
SIGNATURE=$(echo -n "${TIMESTAMP}.${PAYLOAD}" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)

# Send signed webhook request
curl -X POST https://your-endpoint.com/webhooks/lettermint \
  -H "Content-Type: application/json" \
  -H "X-Lettermint-Signature: t=${TIMESTAMP},v1=${SIGNATURE}" \
  -H "X-Lettermint-Event: webhook.test" \
  -d "$PAYLOAD"
```
</CodeTabs>

:::tip
Use the "Test Webhook" button in your dashboard to send a test event and verify your signature validation works correctly.
:::

## Verification steps

The signature verification process follows these steps:

1. **Extract** the `X-Lettermint-Signature` header and parse `t` (timestamp) and `v1` (signature hash)
2. **Validate timestamp** is within 5 minutes of current time to prevent replay attacks
3. **Get raw body** as the exact bytes received (not re-serialized JSON)
4. **Compute signature** using HMAC-SHA256 over `{timestamp}.{rawBody}` with your secret
5. **Compare** using timing-safe equality to prevent timing attacks

See the [implementation examples](#implementation-examples) above for complete, copy-paste code in your language.

## Finding your webhook secret

1. Navigate to Dashboard → Project → Routes → Select Route → Webhooks
2. Click on your webhook
3. Copy the secret (starts with `whsec_`)
4. Store it securely in your environment variables

<Frame>
  <img src="/docs/images/webhook-secret.png" alt="Webhook secret in dashboard" />
</Frame>

You can regenerate the secret at any time by clicking "Regenerate Secret". This invalidates the old secret immediately.

:::warning
Never commit webhook secrets to version control. Use environment variables or a secrets manager.
:::

## Testing signature verification

### Manual test with curl

```bash
# This will fail signature verification (as intended)
curl -X POST https://your-endpoint.com/webhooks/lettermint \
  -H "Content-Type: application/json" \
  -H "X-Lettermint-Signature: t=1704067200,v1=invalid" \
  -H "X-Lettermint-Event: webhook.test" \
  -d '{"id":"test","event":"webhook.test","data":{}}'
```

### Using the dashboard

The "Test Webhook" button sends a real, properly-signed webhook to your endpoint. This is the easiest way to verify your implementation.

## Production recommendations

### Security
- **Always verify signatures** in production environments
- **Use HTTPS endpoints** - Lettermint only delivers to HTTPS URLs
- **Rotate secrets** periodically (e.g., every 90 days)
- **Monitor failed deliveries** in the dashboard for potential attacks

### Performance
- **Verify then queue** - Return 200 immediately after verification, process async
- **Set reasonable timeout** - Your endpoint should respond within 30 seconds
- **Implement idempotency** - Use `event.id` to prevent processing duplicates

### Error handling
- Return **401** for signature verification failures
- Return **200-299** for successful processing
- Return **500-599** for temporary errors (we'll retry)

:::info
Failed deliveries are automatically retried with exponential backoff. See [Introduction](/platform/webhooks/introduction) for the retry schedule.
:::

## Troubleshooting

### "Invalid signature" errors

**Problem:** Signature verification always fails

**Solutions:**
- Verify you're using the raw request body, not re-serialized JSON
- Check that your secret includes the `whsec_` prefix
- Ensure body parsing middleware preserves raw body (see Express example)
- Confirm timestamp is being parsed as an integer, not string

### Timestamp too old warnings

**Problem:** "Timestamp too old or too new" errors

**Solutions:**
- Check your server's system clock is synchronized (use NTP)
- Increase tolerance to 600 seconds (10 minutes) if clock drift is unavoidable
- Verify you're parsing the `t` parameter correctly as Unix seconds

### Works in test but fails in production

**Problem:** Test webhook works, but real events fail

**Solutions:**
- Confirm production environment has correct `LETTERMINT_WEBHOOK_SECRET`
- Check production logging to see the actual signature format received
- Verify production isn't modifying request body (compression, proxies, etc.)

## Next steps

- **[Webhook events](./events):** See all available event types
- **[Introduction](./introduction):** Learn about webhook delivery and retries
- **[SDK documentation](/resources/sdks):** Use our official SDKs with built-in verification
