Kastomise · Webhooks

“Files ready” webhooks

Get a signed HTTPS request the moment an order’s print files are ready, with links to download them, so they can go straight into a RIP queue, an MIS or a Zapier or Make flow without anyone opening the dashboard.

Getting started

  1. A shop admin opens Settings → Webhooks in the dashboard and adds your endpoint’s URL, choosing which events to send.
  2. Copy the signing secret (whsec_…) into your receiver. An admin can reveal it again later.
  3. Press Send test event: a ping arrives within a minute, and the card’s delivery log shows your answer.

Events

TypeSent when
order.files_readyA Shopify order with a personalised line is paid, and its print files have been copied to permanent storage.
design.files_readyA shopper saves a design on your Kastomise Hosted page, and its print files have been copied to permanent storage.
pingAn admin presses Send test event.

Each order and each design sends its event once, however many times Shopify delivers the order to us. Status changes, refunds and edits don’t send events (yet).

The payload

A POST with a JSON body (Content-Type: application/json), at most a few hundred kilobytes. Field names keep their meaning within an api_version; new fields may be added, so ignore the ones you don’t know.

{
  "id": "evt_01JB8Z3K6Q9T2V4X7Y0A1C3E5G",
  "type": "order.files_ready",
  "api_version": "2026-10-24",
  "created_at": "2026-10-24T09:12:03.000Z",
  "store_id": "4f1c…",
  "data": {
    "order": {
      "id": "8d2e…",
      "shopify_order_id": "5550123456789",
      "name": "#1042",
      "dashboard_url": "https://app.kastomise.com/dashboard/s/4f1c…/orders/8d2e…"
    },
    "lines": [
      {
        "line": 1,
        "shopify_line_item_id": 14123456789,
        "product_title": "Medal frame",
        "variant_title": "Black / 30 cm",
        "quantity": 2,
        "personalisation": { "v": 1, "…": "what the customer typed and chose" },
        "addon_check": {
          "status": "ok", "expected_cents": 1500, "paid_cents": 1500,
          "quantity": 2, "currency": "AUD"
        },
        "model_colours": [],
        "files": [
          {
            "zone_key": "background",
            "file_name": "1042-1-background-qty2.pdf",
            "format": "pdf",
            "url": "https://…/original.pdf?X-Amz-Expires=3600&…",
            "preview_url": "https://…/preview.png?X-Amz-Expires=3600&…",
            "print": {
              "width_cm": 20, "height_cm": 15, "bleed_cm": 0.3, "ppi": 300,
              "format": "cmyk-pdf", "mirrored": false,
              "min_photo_ppi": 127, "low_res": true
            }
          }
        ]
      }
    ],
    "missing": [{ "line": 2, "shopify_line_item_id": 14123456790, "zone_key": "front" }],
    "links_expire_at": "2026-10-24T10:12:04.000Z"
  }
}
  • lines lists the order’s personalised lines, numbered as on the order page and in the dashboard’s zip. file_name is the file’s name there too: {order}-{line}-{zone}-qty{n}.{ext}.
  • url is the print file (a PDF, PNG, JPEG or SVG, as the product is set up); preview_url a small PNG to show. format is its extension: pdf, png, jpg or svg. print is the printed size and resolution, with low_res when a photo is under 70 % of the print’s PPI: a job to check before printing. Older prints have no print.
  • print.format is the file the product prints: cmyk-pdf, rgb-png, rgb-jpeg, a laser file, laser-svg or laser-pdf: outlined vectors at true size in millimetres, each operation (engrave, score, cut) in its own colour, with no fonts; or a cutter file, cutter-svg: each vinyl colour as one filled shape in millimetres, bottom layer first, outlines laid under their letters, with no photos. A laser or cutter file that could not be made is sent as the PNG, and print.format says rgb-png. The DXF the dashboard makes from a cutter SVG isn’t sent: download it from the order or design page, or convert the SVG yourself.
  • An engraving PNG (an engraved product printed as a PNG) has print.engraving.bits: 8 for greys on white, 1 for a black-and-white dither. Other files, and prints made before it, have no engraving.
  • personalisation is what the customer typed and chose; model_colours the colours picked on a 3D product.
  • addon_check is our check of an order line’s add-on fee, when the shop charges for photo layouts: status is ok, short (underpaid: the fee line may have been removed, or the quantity changed in the cart), over, or unverified with a reason. expected_cents is owed for the whole line (the layouts’ prices × quantity), paid_cents what its fee lines paid, both in currency, the order’s. It is null when the line priced no add-on; a design’s line has none. A mismatch is only flagged: the order and its files go through as usual.
  • missing lists prints the order named whose file isn’t there: hold the job rather than print half of it. Check the order in the dashboard.
  • design.files_ready has data.design (id, code, config_id, customisation_type, dashboard_url, quantity, price, choices) in place of data.order, and one line, with files named {code}-{zone}-qty{n}.{ext}. config_id is the id of the product setup the design was made with. price is what the hosted page showed when the customer saved, as the designs API gives it (null for an older design). choices is what the customer chose on that page, priced or not, as the designs API gives it: each with its kind, label and value ([] for an older design). A ping has data: {endpoint_id, message}.
  • Never in a payload: the customer’s name, email, phone or address, or order notes. Your shop has those in Shopify or the dashboard.

Verifying a webhook

Every request is signed the Standard Webhooks way, so their ready-made libraries work too. Check it before trusting a request.

HeaderValue
webhook-idThe event id (evt_…): the same on every retry. Use it to skip events you already have.
webhook-timestampWhen this attempt was sent, in Unix seconds.
webhook-signaturev1, + base64 of HMAC-SHA256 of {id}.{timestamp}.{body}, keyed with the secret’s bytes (base64 after whsec_). Two, space-separated, while a rotated secret still signs.
user-agentKastomise-Webhooks/1
// Node 18+, no dependencies. Read the RAW body: re-serialised JSON
// won't match the signature.
import {createHmac, timingSafeEqual} from 'node:crypto';

export function verifyKastomiseWebhook(rawBody, headers, secret) {
  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 (replays).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = 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 &&
      timingSafeEqual(given, expected);
  });
}
# Python 3.9+, standard library only. raw_body is the request body as bytes.
import base64, hashlib, hmac, time

def verify_kastomise_webhook(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature", "")
    if not msg_id or not timestamp:
        return False
    if abs(time.time() - int(timestamp)) > 300:  # refuse replays
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        part.startswith("v1,") and hmac.compare_digest(part[3:], expected)
        for part in signatures.split(" ")
    )

Responding

Answer with any 2xx within 10 seconds, then do the work (download the files, queue the job) after answering. Anything else is a failed attempt: another status, a redirect (we don’t follow them), a timeout, or a connection or TLS error. We keep the first 500 characters of your answer in the delivery log for your admins.

Retries and turning off

A failed delivery is tried again 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 26 hours after the first try (8 tries in all, each wait give or take 10 %), then marked failed. Events can arrive out of order, and the same event can arrive twice: use webhook-id.

When an event fails after every try and the endpoint hasn’t had a success for 72 hours, the endpoint is turned off and the card says why. Within the hour, the shop’s admins get an email naming the endpoint by its host. Turn it back on from the card, and use Resend on any delivery to send it again from the start with fresh links. Orders paid and designs saved while it is off aren’t queued for it, so they can’t be resent to it.

Endpoint rules

  • HTTPS on the standard port (443), with a valid certificate for the host name.
  • A public domain name: not an IP address, not localhost or a local or internal name, and not one that resolves to a private, loopback or reserved address. We check the name before every attempt and connect only to the address we checked.
  • No user name or password in the URL: check the signature instead.
  • Up to 3 endpoints per shop, each with its own secret.

Rotating the secret

Rotate secret makes a new secret that signs at once. The old one also signs for 24 hours, so both signatures are sent and your receiver can switch over; then only the new one does. Rotating again within those hours drops the oldest at once.