Verify webhook signatures before you trust an event

Check the Standard Webhooks signature on every event, handle retries without doing the same work twice, and rotate secrets without downtime.

Webhooks4 min read

Your webhook endpoint is a public URL, so anyone can post to it. The signature is how you know an event really came from iResendly and wasn't changed on the way. Check it on every request, before you act on anything inside.

Create an endpoint#

Add an endpoint under Webhooks in the console, or with POST /webhooks and a body of endpoint (a public HTTPS URL) and events (one or more event types). The response includes the endpoint's signing_secret, which starts with whsec_. Store it like a password, for example as IRESENDLY_WEBHOOK_SECRET.

How signing works#

Every request is signed in the Standard Webhooks format, so standard verification libraries work as well as the code below. Each request carries three headers:

HeaderContains
webhook-idThe message id.
webhook-timestampWhen it was sent, in Unix seconds.
webhook-signatureOne or more signatures separated by spaces, each v1, followed by base64.

To check it, take the secret after whsec_ and base64-decode it to get the key. Compute the HMAC-SHA256 of id.timestamp.body with that key, base64-encode it, and compare it with each v1 signature in constant time. Refuse timestamps more than five minutes from now, so a captured request can't be replayed later.

Verify it in your code#

Verify and accept a webhook
import crypto from "node:crypto"
import express from "express"

const app = express()
const SECRET = process.env.IRESENDLY_WEBHOOK_SECRET // "whsec_…"

function isValid(rawBody, headers) {
  const id = headers["webhook-id"]
  const timestamp = headers["webhook-timestamp"]
  const signatures = headers["webhook-signature"] ?? ""
  if (!id || !timestamp) return false

  // Refuse anything more than 5 minutes old (or ahead).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) return false

  const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64")
  const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest()

  return signatures.split(" ").some((part) => {
    const [version, value] = part.split(",")
    const given = Buffer.from(value ?? "", "base64")
    return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected)
  })
}

// express.raw keeps the body as the exact bytes we signed.
app.post("/webhooks/iresendly", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8")
  if (!isValid(raw, req.headers)) return res.status(400).send("Invalid signature")

  const event = JSON.parse(raw)
  res.status(200).end() // answer first…
  await queue.add(req.headers["webhook-id"], event) // …then do the slow work
})

Respond quickly, then do the work#

  • Answer with any 2xx within 15 seconds. Put slow work, such as database updates or emails of your own, on a queue and do it after responding.
  • If we don't get a 2xx, we retry after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours.
  • An endpoint that keeps failing for 5 days is turned off, and we email you. Turn it back on from its page in the console.

Handle the same event twice#

Because of retries, your endpoint can receive an event it has already processed, for example when your server did the work but timed out before answering. The webhook-id stays the same when we retry a delivery, so record the ids you've handled and skip any you've seen before. Each retry has a fresh timestamp and signature, so verify every attempt as normal.

Test, replay and rotate#

  • From an endpoint's page you can send a test event to check your code end to end.
  • Deliveries are kept for 30 days, and you can replay any of them, which is handy after fixing a bug in your handler.
  • Pick only the events you need when you create the endpoint. You can change them later with PATCH /webhooks/{id}.

You can rotate the secret from the endpoint's page. The old secret stops working at once, so update your server straight after. While you're switching over, your code can check against both the old and the new secret and accept either, then drop the old one.

Tip
Log the webhook-id and event type for every delivery you accept or reject. When something looks wrong, you can match it to the delivery log in the console.