Skip to content
catchook

Guides

Verifying deliveries

Confirm a request to your receiver actually came from Catchook.

Your receiver_url is a public URL. Anything on the internet can POST to it, including a replay of a request someone captured. Every delivery therefore carries a signature computed with the destination’s own catchook_secret.

Check it before you trust the body.

Using @catchook/verify

A standalone, zero-dependency package — it does not pull in the CLI or anything else:

npm install @catchook/verify
import { verify } from "@catchook/verify"

app.post("/webhooks/catchook", async (req, res) => {
  const valid = await verify({
    body: req.rawBody,               // the raw bytes, not the parsed object
    signature: req.headers["catchook-signature"],
    secret: process.env.CATCHOOK_DESTINATION_SECRET,
  })

  if (!valid) return res.status(401).end()

  // Safe to parse and handle from here.
  res.status(200).end()
})

The raw body is not optional

The signature is computed over the exact bytes Catchook sent. JSON.parse followed by JSON.stringify will re-order keys, change whitespace, and produce a different digest — the signature will not match even though the request is genuine.

In Express, that means capturing the raw body before the JSON parser runs:

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

Frameworks differ; the requirement does not.

Answer quickly, work afterwards

Catchook waits for your response, and a delivery that times out is retried. If handling an event takes real time, acknowledge first and process after:

app.post("/webhooks/catchook", async (req, res) => {
  if (!(await verify({ ... }))) return res.status(401).end()

  await queue.push(req.body)   // fast
  res.status(200).end()
})

Any 2xx counts as accepted. Everything else is retried with backoff until the destination’s retry_max_attempts, then marked dead.

Be idempotent

Delivery is at-least-once. A receiver that returns 500 after committing its work will be sent the same event again, and providers themselves re-send. Key off catchook-event-id:

const eventId = req.headers["catchook-event-id"]
if (await alreadyHandled(eventId)) return res.status(200).end()

Rotating a destination secret

Rotation changes the secret immediately, and it can take up to 60 seconds to reach the delivery path — so deliveries signed with either the old or the new secret can arrive during that window. Accept both while rotating:

const valid =
  (await verify({ body, signature, secret: current })) ||
  (await verify({ body, signature, secret: previous }))

Drop the old one once the window has passed.