Lettermint signs every webhook delivery so you can verify it came from us and the payload wasn’t tampered with.

Headers

We include these HTTP headers with each delivery:
HeaderDescription
x-lettermint-signatureThe computed signature using your webhook secret.
x-lettermint-timestampUnix timestamp (seconds) when we generated the signature.

Signature scheme

We compute an HMAC-SHA256 digest using your webhook’s secret and the exact raw request body: String to sign: ${timestamp}.${rawBody} signature format header value: sha256=<hex_digest> To verify, recompute the signature on your side using the same secret and compare it with timing-safe equality.

Replay protection

Reject requests with stale timestamps. We recommend a 5-minute window. Example logic:
  • Parse x-lettermint-timestamp as an integer.
  • If |now - timestamp| > 300 seconds, reject as stale.

Node.js examples

Express

import crypto from 'node:crypto'
import express from 'express'

const app = express()
const SECRET = process.env.LETTERMINT_WEBHOOK_SECRET

app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }))

app.post('/webhooks/lettermint', (req, res) => {
  const ts = req.header('x-lettermint-timestamp') || ''
  const sig = req.header('x-lettermint-signature') || ''
  const raw = req.rawBody

  const mac = crypto.createHmac('sha256', SECRET)
  mac.update(`${ts}.`)
  mac.update(raw)
  const expected = `sha256=${mac.digest('hex')}`

  if (expected.length !== sig.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    return res.status(401).send('invalid signature')
  }

  // OK: process event
  const event = req.body
  res.status(200).send('ok')
})

Next.js (Route Handler)

import { NextResponse } from 'next/server'
import crypto from 'node:crypto'

const SECRET = process.env.LETTERMINT_WEBHOOK_SECRET

export async function POST(req) {
  const ts = req.headers.get('x-lettermint-timestamp') || ''
  const sig = req.headers.get('x-lettermint-signature') || ''
  const raw = Buffer.from(await req.arrayBuffer())

  const h = crypto.createHmac('sha256', SECRET)
  h.update(`${ts}.`)
  h.update(raw)
  const expected = `sha256=${h.digest('hex')}`

  if (expected.length !== sig.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    return NextResponse.json({ error: 'invalid signature' }, { status: 401 })
  }

  const event = JSON.parse(raw.toString('utf8'))
  return NextResponse.json({ ok: true })
}

Next Steps