Skip to main content
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.

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:
HeaderDescription
X-Lettermint-SignatureSignature in format t={timestamp},v1={hmac_hex}
X-Lettermint-EventEvent type (e.g., message.delivered, message.bounced)
X-Lettermint-DeliveryDelivery timestamp (Unix seconds)
X-Lettermint-AttemptRetry 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

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
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

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)
Use the “Test Webhook” button in your dashboard to send a test event and verify your signature validation works correctly.

Step-by-step verification

  1. Extract the signature header
    const signature = req.headers['x-lettermint-signature']
    // Example: "t=1704067200,v1=5d41402abc4b2a76b9719d911017c592"
    
  2. Parse timestamp and hash
    const [tPart, v1Part] = signature.split(',')
    const timestamp = tPart.split('=')[1]  // "1704067200"
    const providedHash = v1Part.split('=')[1]  // "5d41402a..."
    
  3. Check timestamp freshness
    const now = Math.floor(Date.now() / 1000)
    if (Math.abs(now - parseInt(timestamp)) > 300) {
      throw new Error('Webhook timestamp too old')
    }
    
  4. Get raw request body
    // Must be the exact bytes received, not re-serialized JSON
    const rawBody = req.rawBody  // Buffer or string
    
  5. Compute expected signature
    const signedPayload = `${timestamp}.${rawBody}`
    const expectedHash = crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex')
    
  6. Compare with timing-safe equality
    const isValid = crypto.timingSafeEqual(
      Buffer.from(expectedHash),
      Buffer.from(providedHash)
    )
    

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
You can regenerate the secret at any time by clicking “Regenerate Secret” - this will invalidate the old secret immediately.
Never commit webhook secrets to version control. Use environment variables or a secrets manager.

Testing signature verification

Manual test with curl

# 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)
Failed deliveries are automatically retried with exponential backoff. See 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