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-Signatureheader? - How do I handle
message.delivered,message.hard_bounced, ormessage.spam_complaintevents? - How do I react to
suppression.addedevents 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_, 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.
| Event | Triggered When | Common Use Cases |
|---|---|---|
message.delivered | The message was successfully delivered | Update delivery status |
message.hard_bounced | Permanent delivery failure (e.g. user does not exist) | Remove the address from your lists |
message.soft_bounced | Temporary delivery failure (e.g. mailbox full) | Monitor, retry logic |
message.spam_complaint | A recipient reported the message as spam | Stop mailing the recipient |
message.failed | Terminal processing or delivery failure | Alert, investigate reason_code |
suppression.added | A suppression entry was added | Sync suppressions to another system |
webhook.test | You clicked Test Webhook | Confirm 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
- references/overview.md - Lettermint webhook concepts, events, payload shape
- references/setup.md - Create a webhook in the dashboard and get the secret
- references/verification.md - Signature verification details and gotchas