Webhooks

Webhook signatures

Verify each webhook signature before you process its payload. This confirms that Lettermint created the request and that the payload did not change in transit.

Basic Auth and webhook signatures work together. Basic Auth passes an endpoint login check. Continue to verify each signature to confirm that Lettermint sent the delivery. See Basic authentication for setup.

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

Always verify signatures in production before a handler changes application state.

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.auto_replied)
X-Lettermint-DeliveryDelivery timestamp (Unix seconds)
X-Lettermint-AttemptRetry attempt number (1, 2, 3...)

Signature scheme

The signature format follows the Stripe/Svix convention:

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

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

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

Verify with the SDKs

Every official SDK verifies a delivery for you and behaves the same way:

  • It checks the HMAC-SHA256 signature in constant time, using the webhook's signing secret (whsec_...) as is.
  • It requires both the X-Lettermint-Signature and X-Lettermint-Delivery headers, and the delivery header must equal the signed timestamp.
  • It accepts deliveries up to 300 seconds old or ahead by default, and accepts any matching v1 signature.
  • It returns the parsed payload, or fails with a verification error whose reason is one of signature_header_missing, signature_header_malformed, delivery_header_missing, delivery_timestamp_mismatch, timestamp_out_of_tolerance, signature_mismatch, body_invalid or payload_invalid.

Always pass the raw request body, before any JSON parsing.

Node.js

The Node.js SDK 3.0 verifies a delivery for you. new Webhook(secret).verify(rawBody, headers) checks the HMAC-SHA256 signature in constant time, checks that X-Lettermint-Delivery matches the signed timestamp and is within the tolerance, and returns the parsed payload. It throws WebhookVerificationError with a reason otherwise.

Pass the webhook's signing secret (whsec_...), not an API token, and the raw request body. verify() is async and needs both the X-Lettermint-Signature and X-Lettermint-Delivery headers. It accepts the body as a string, Uint8Array (including Buffer), or ArrayBuffer, and the headers as a Fetch Headers object or a Node.js header object.

import express from 'express' import { Webhook, WebhookVerificationError } from 'lettermint' const app = express() const webhook = new Webhook(process.env.LETTERMINT_WEBHOOK_SECRET!) // express.raw keeps the body as a Buffer. Do not use express.json() on this route. app.post( '/webhooks/lettermint', express.raw({ type: 'application/json' }), async (req, res) => { try { const event = await webhook.verify(req.body, req.headers) console.log('Verified event:', event.event, event.id) res.sendStatus(204) } catch (error) { if (error instanceof WebhookVerificationError) { return res.sendStatus(400) } throw error } } )

The same Request handler works on Cloudflare Workers, Deno, Bun, and other runtimes with fetch and Web Crypto. The default tolerance is 300 seconds in either direction. Change it with new Webhook(secret, { tolerance: 60 }).

A valid signature does not stop a repeated delivery inside the tolerance window, so track event.id if you must not process an event twice.

Other languages

The other SDKs use the same rules, and the Laravel package verifies deliveries with the PHP SDK. See each language's guide for the full API.

<?php require __DIR__ . '/vendor/autoload.php'; use Lettermint\Exceptions\WebhookVerificationException; use Lettermint\Webhook; $webhook = new Webhook(getenv('LETTERMINT_WEBHOOK_SECRET')); // The signature covers the exact bytes, so read the raw body. // Do not decode and re-encode it before you verify. $rawBody = file_get_contents('php://input'); try { $payload = $webhook->verify($rawBody, getallheaders()); } catch (WebhookVerificationException $e) { error_log("Webhook rejected: {$e->reason}"); http_response_code(400); echo 'Invalid signature'; exit; } // $payload->id is unique per event. Store it to skip repeated deliveries. switch ($payload->event) { case 'message.delivered': // $payload->data holds the event data. break; case 'message.hard_bounced': break; } http_response_code(200);

Implementation examples

The following examples verify the signature by hand with the steps above. Use them in languages without an SDK helper, or when you want to see each step.

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.

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 above for complete, copy-paste code in your language.

Finding your webhook secret

  1. Go to Dashboard → Webhooks.
  2. Click on your webhook
  3. Copy the secret (starts with whsec_)
  4. Store it securely in your environment variables
Webhook secret in dashboard

You can change the secret at any time. This action makes the old secret invalid immediately. A target change does not change the secret.

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

Testing signature verification

Manual test with curl

TerminalCode
# 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)

Temporary delivery failures are retried with exponential backoff. HTTP 401 does not trigger automatic retries. 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