Webhooks and signatures
Add an endpoint
In the console, open Webhooks, enter your URL and choose the events you want. The URL must use https. You'll see the endpoint's signing secret (it starts with whsec_) once — store it with your other secrets. You can rotate it any time; the new secret is used from the next delivery.
An organization can have up to 20 endpoints. Use Send test to deliver a ping event, and the delivery log to see every attempt and resend one.
Events
| Event | When | data |
|---|---|---|
job.created | A job was accepted. | { job } |
job.printed | The station confirmed the job printed. | { job } |
job.failed | The job failed. job.status_detail says why. | { job } |
job.cancelled | The job was cancelled. | { job } |
job.retried | A failed job was sent again. | { job } |
station.online | A station connected. | { station_id } |
station.offline | A station disconnected. | { station_id } |
printer.status | A printer's status changed, such as out of paper. | { printer_id, status, detail } |
job has the same shape as the job you get from the API. Test deliveries have the type ping.
What we send
A POST with a JSON body of type, created_at, org_id and data, plus these headers:
CatzPrint-Signature—t=<unix seconds>,v1=<hex signature>CatzPrint-Delivery— a unique ID for this delivery. Retries reuse it, so you can ignore duplicates.
POST /hooks/catzprint HTTP/1.1
Content-Type: application/json
User-Agent: CatzPrint-Webhooks/2
CatzPrint-Delivery: 0b8f6c1e-5a77-4b0e-8c1d-7f3e2a9d4c10
CatzPrint-Signature: t=1791796443,v1=5f2b0c3e8d…a91e
{"created_at":"2026-10-12T09:14:03Z","data":{"job":{"id":"6f1c2a7e-…","status":"printed","reference":"order-1042",…}},"org_id":"9b2f…","type":"job.printed"}Verify the signature
Use your SDK's webhooks.verify (Webhook::verify in PHP, VerifyWebhook in Go). Pass the raw request body, the CatzPrint-Signature header and your signing secret. It checks the signature in constant time, rejects timestamps more than 5 minutes off, and returns the parsed event — or raises a signature error, which you answer with 400.
import express from "express";
import { webhooks, SignatureVerificationError } from "@catzprint/sdk";
const app = express();
// Use the raw body: re-serialising the JSON would change the bytes.
app.post("/hooks/catzprint", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhooks.verify(req.body, req.get("CatzPrint-Signature"), process.env.CATZPRINT_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof SignatureVerificationError) return res.status(400).send("bad signature");
throw err;
}
if (event.type === "job.printed") markOrderPrinted(event.data.job.reference); // your code
res.sendStatus(204); // answer quickly; do slow work in the background
});Responses and retries
- Answer with any
2xxstatus within 15 seconds. Do slow work in the background. - Any other status, a timeout or a connection error counts as a failure. We retry up to 8 attempts in total, waiting 30 seconds, then 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours and 12 hours — about a day overall.
- We don't follow redirects. Point the endpoint at its final URL.
- Events can arrive out of order or more than once. Use the job's
statusand the delivery ID rather than arrival order.
Verify without an SDK
The signature is an HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with your signing secret:
v1 = hex( HMAC_SHA256( secret, t + "." + raw_body ) )- Read the raw body bytes — before any JSON parsing.
- Split the header on
,and taketandv1. - Reject the request if
tis more than 5 minutes from your clock, to stop replays. - Compute the HMAC and compare it to
v1with a constant-time comparison.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.CATZPRINT_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
// Returns true when the CatzPrint-Signature header matches the raw body.
export function verifyCatzPrint(rawBody, header, secret, now = Date.now() / 1000) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + ".")
.update(rawBody)
.digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const app = express();
// Use the raw body: re-serialising the JSON would change the bytes.
app.post("/hooks/catzprint", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyCatzPrint(req.body, req.get("CatzPrint-Signature"), SECRET)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "job.printed") {
markOrderPrinted(event.data.job.reference); // your code
}
res.sendStatus(204); // answer quickly; do slow work in the background
});