Skip to content
Docs menu · Webhooks and signatures
Guides / Webhooks and signatures

Webhooks and signatures

CatzPrint calls your server when jobs print or fail, and when stations and printers change. Every call is signed so you can prove it came from us.

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

EventWhendata
job.createdA job was accepted.{ job }
job.printedThe station confirmed the job printed.{ job }
job.failedThe job failed. job.status_detail says why.{ job }
job.cancelledThe job was cancelled.{ job }
job.retriedA failed job was sent again.{ job }
station.onlineA station connected.{ station_id }
station.offlineA station disconnected.{ station_id }
printer.statusA 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.
Example request
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
});
ImportantOnly trust the event after the signature checks out. Each delivery is signed when it is sent, so after you rotate the secret every delivery — including retries of older events — uses the new one. Update your server as soon as you rotate.

Responses and retries

  • Answer with any 2xx status 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 status and 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:

Signature
v1 = hex( HMAC_SHA256( secret, t + "." + raw_body ) )
  1. Read the raw body bytes — before any JSON parsing.
  2. Split the header on , and take t and v1.
  3. Reject the request if t is more than 5 minutes from your clock, to stop replays.
  4. Compute the HMAC and compare it to v1 with 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
});