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.