Agent skill

Lettermint Webhooks Skill

Receive and verify Lettermint webhooks. Use when setting up Lettermint webhook endpoints, debugging X-Lettermint-Signature verification, or handling email events like message.delivered, message.hard_bounced, message.spam_complaint, message.failed, and suppression.added.

Install this skill

npx skills add hookdeck/webhook-skills --skill lettermint-webhooks


When to Use This Skill

  • How do I receive Lettermint webhooks?
  • How do I verify the X-Lettermint-Signature header?
  • How do I handle message.delivered, message.hard_bounced, or message.spam_complaint events?
  • How do I react to suppression.added events from Lettermint?
  • Why is my Lettermint webhook signature verification failing?

Verification (core)

Lettermint sends an X-Lettermint-Signature header of the form t=<unix seconds>,v1=<hex>. v1 is the lowercase hex HMAC-SHA256 of "<t>.<raw body>", where <t> is the header's t value exactly as sent. The key is the webhook secret used as-is, including the whsec_ prefix, as UTF-8 bytes. During key rotation the header may carry more than one v1: accept the delivery if any of them matches, and ignore other keys such as v0.

Paste the whole secret, including the whsec_ prefix. Lettermint uses it as a plain HMAC key.

This is not Standard Webhooks, even though the secret starts with whsec_. Do not use the standardwebhooks or svix libraries, and do not strip the prefix or base64-decode the secret.

Lettermint's docs recommend rejecting timestamps more than 300 seconds from now, but retries (12 attempts over about 14 hours) and Hookdeck replays keep the original t, so the window is opt-in here (LETTERMINT_WEBHOOK_TOLERANCE_SECONDS, unset or 0 = off). Always sign over the raw body bytes, never a re-serialized object.

const crypto = require('crypto');

// rawBody is a Buffer (express.raw). toleranceSeconds = 0 skips the timestamp window.
function verifyLettermintWebhook(rawBody, header, secret, toleranceSeconds = 0) {
  if (!header || !secret) return false;
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = parts.find(([k]) => k === 't')?.[1];
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t) return false;
  if (toleranceSeconds > 0 && !(/^\d+$/.test(t) && Math.abs(Date.now() / 1000 - Number(t)) <= toleranceSeconds)) return false;
  const expected = crypto.createHmac('sha256', secret).update(Buffer.from(`${t}.`, 'latin1')).update(rawBody).digest();
  return sigs.some((sig) => /^[0-9a-fA-F]{64}$/.test(sig) && crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected));
}

Missing header, malformed value or mismatch: respond 401 (Lettermint does not retry 4xx other than 408 and 429).

Lettermint also ships SDKs with a Webhook.verify() helper. They additionally require X-Lettermint-Delivery to equal t and enforce a 300 second window by default. Hookdeck does not check X-Lettermint-Delivery, so these examples verify manually to behave the same way and keep replays working.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Common Event Types

Dispatch on the body's top-level event field (also sent in the X-Lettermint-Event header). The envelope is id, event, timestamp, context, data; outbound message events also carry sandbox and sandbox_result.

EventTriggered WhenCommon Use Cases
message.deliveredThe message was successfully deliveredUpdate delivery status
message.hard_bouncedPermanent delivery failure (e.g. user does not exist)Remove the address from your lists
message.soft_bouncedTemporary delivery failure (e.g. mailbox full)Monitor, retry logic
message.spam_complaintA recipient reported the message as spamStop mailing the recipient
message.failedTerminal processing or delivery failureAlert, investigate reason_code
suppression.addedA suppression entry was addedSync suppressions to another system
webhook.testYou clicked Test WebhookConfirm the endpoint works

The other documented events (message.created, message.sent, message.auto_replied, message.suppressed, message.policy_rejected, message.unsubscribed, message.opened, message.clicked, suppression.removed, message.inbound) are listed in references/overview.md. The handlers log any event they don't handle.

Environment Variables

LETTERMINT_WEBHOOK_SECRET=whsec_xxxxx          # The whole signing secret, including whsec_
LETTERMINT_WEBHOOK_TOLERANCE_SECONDS=0         # Optional replay window; a positive value opts in

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 lettermint --path /webhooks/lettermint

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 12, 2026

View on GitHub →