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

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/lettermint-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/lettermint-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/lettermint-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/lettermint-webhooks/references/overview.md). The handlers log any event they don't handle.

## Environment Variables

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

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

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/lettermint-webhooks/references/overview.md) - Lettermint webhook concepts, events, payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/lettermint-webhooks/references/setup.md) - Create a webhook in the dashboard and get the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/lettermint-webhooks/references/verification.md) - Signature verification details and gotchas