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
- A shop admin opens Settings → Webhooks in the dashboard and adds your endpoint’s URL, choosing which events to send.
- Copy the signing secret (
whsec_…) into your receiver. An admin can reveal it again later. - Press Send test event: a
pingarrives within a minute, and the card’s delivery log shows your answer.
Events
| Type | Sent when |
|---|---|
order.files_ready | A Shopify order with a personalised line is paid, and its print files have been copied to permanent storage. |
design.files_ready | A shopper saves a design on your Kastomise Hosted page, and its print files have been copied to permanent storage. |
ping | An 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"
}
}lineslists the order’s personalised lines, numbered as on the order page and in the dashboard’s zip.file_nameis the file’s name there too:{order}-{line}-{zone}-qty{n}.{ext}.urlis the print file (a PDF, PNG, JPEG or SVG, as the product is set up);preview_urla small PNG to show.formatis its extension:pdf,png,jpgorsvg.printis the printed size and resolution, withlow_reswhen a photo is under 70 % of the print’s PPI: a job to check before printing. Older prints have noprint.print.formatis the file the product prints:cmyk-pdf,rgb-png,rgb-jpeg, a laser file,laser-svgorlaser-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, andprint.formatsaysrgb-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:8for greys on white,1for a black-and-white dither. Other files, and prints made before it, have noengraving. personalisationis what the customer typed and chose;model_coloursthe colours picked on a 3D product.addon_checkis our check of an order line’s add-on fee, when the shop charges for photo layouts:statusisok,short(underpaid: the fee line may have been removed, or the quantity changed in the cart),over, orunverifiedwith areason.expected_centsis owed for the whole line (the layouts’ prices ×quantity),paid_centswhat its fee lines paid, both incurrency, the order’s. It isnullwhen 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.missinglists 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_readyhasdata.design(id,code,config_id,customisation_type,dashboard_url,quantity,price,choices) in place ofdata.order, and one line, with files named{code}-{zone}-qty{n}.{ext}.config_idis the id of the product setup the design was made with.priceis what the hosted page showed when the customer saved, as the designs API gives it (null for an older design).choicesis what the customer chose on that page, priced or not, as the designs API gives it: each with itskind,labelandvalue([]for an older design). Apinghasdata: {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.
| Header | Value |
|---|---|
webhook-id | The event id (evt_…): the same on every retry. Use it to skip events you already have. |
webhook-timestamp | When this attempt was sent, in Unix seconds. |
webhook-signature | v1, + 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-agent | Kastomise-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.
Download links
url and preview_url are signed links that work for up to an hour (links_expire_at), and can stop a little sooner. Download right away. Each retry and each Resend carries new links. To fetch files later, use the orders and files API.
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
localhostor 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.