Webhooks
PrintWisp POSTs signed JSON to your server when an order is placed and when a print file is ready or failed. Written now, sent later, retried for a day, and GET /v1/events catches up on anything missed.
Endpoints
An endpoint is a URL of yours plus the events it wants. You add it in the portal: under Organisation → API keys & webhooks it hears every store's events; on a PrintWisp Store's Webhooks tab, or on a connected shop, it hears only that store's. The portal shows the endpoint's whsec_… secret once; keep it with the key.
Addresses: https:// anywhere; plain http:// only on localhost, *.test or *.local, for trying it out; never a user:password in the URL. An event reaches the endpoints of the store it came from (a PrintWisp Store or a connected shop) and the organisation's endpoints: do not point both at one URL, or it arrives twice.
Events
| type | When |
|---|---|
order.created | A new order is placed. POST /v1/orders with a new order_ref, or a PrintWisp Store checkout. Once per order. data is everything GET /v1/orders answers, plus retrieve: the URL to fetch it again. A test order's events carry "livemode": false in data. |
design.print_ready | A print file is ready to download. A line's print file is ready. file is a signed link made when the message is sent, not when the event happened, so a retry hours later still gets a fresh 10-minute link. |
design.print_failed | A print file could not be made. The same data as design.print_ready, without file, plus error. |
ping | Send a test. You pressed Send a test on an endpoint in the portal. Goes to that one endpoint only, and is not listed by GET /v1/events. |
A test order's events carry "livemode": false in data, and GET /v1/events lists them only to a test key.
{
"id": "evt_8a0d3c7e1f5b2946",
"type": "order.created",
"created": "2026-10-05T04:31:02+00:00",
"data": {
"order": "ord_7d1e4b9a2c6f0583",
"order_ref": "WEB-10021",
"livemode": true,
"store": null,
"source": "api",
"status": null,
"placed_at": "2026-10-05T04:31:00+00:00",
"customer": { "name": "Jane Citizen", "email": "jane@example.com" },
"shipping": { "name": null, "address": null, "method": null, "tracking": null },
"totals": { "subtotal": null, "shipping": null, "tax": null, "total": null, "currency": null },
"lines": [
{
"line": "oln_41aa8c3e9d2b7f16",
"line_ref": "1",
"design": "dsn_5c2a9e01b7d4f638",
"sku": null,
"qty": 1,
"unit_price": null,
"line_amount": "89.00",
"currency": "AUD",
"print_status": "queued",
"components": [],
"fields": []
}
],
"retrieve": "https://printwisp.com/v1/orders?order=ord_7d1e4b9a2c6f0583"
}
}{
"id": "evt_9b1e4d8f2a6c3057",
"type": "design.print_ready",
"created": "2026-10-05T04:31:40+00:00",
"data": {
"order": "ord_7d1e4b9a2c6f0583",
"order_ref": "WEB-10021",
"line": "oln_41aa8c3e9d2b7f16",
"line_ref": "1",
"design": "dsn_5c2a9e01b7d4f638",
"sku": null,
"qty": 1,
"item_no": "VEL00001234",
"components": [],
"fields": [],
"file": {
"url": "https://printwisp.com/v1/file?l=oln_41aa8c3e9d2b7f16&e=1759640400&s=9f2c4e1a7b3d8f05c6e2a9d1b4f7c3e8a5d2b9f6c1e4a7d3b8f5c2e9a6d1b4f7",
"expires_at": "2026-10-05T04:51:00+00:00"
}
}
}{
"id": "evt_0c2f5e9a3b7d4168",
"type": "design.print_failed",
"created": "2026-10-05T04:31:40+00:00",
"data": {
"order": "ord_7d1e4b9a2c6f0583",
"order_ref": "WEB-10021",
"line": "oln_41aa8c3e9d2b7f16",
"line_ref": "1",
"design": "dsn_5c2a9e01b7d4f638",
"sku": null,
"qty": 1,
"components": [],
"fields": [],
"error": "why it could not be made"
}
}Delivery and headers
Written now, sent later: the event and one delivery per endpoint are recorded in the same step as the change they report, and a cron sends them. A server that is down for an hour misses nothing; a slow server never slows a render. Each delivery is a POST with a JSON body {id, type, created, data} and these headers. Answer any 2xx within 10 seconds (5 to connect); redirects are not followed, so answer at the URL you gave. Handle an event by its id: a retry of the same delivery carries the same id.
Verifying Studio-Signature
t is the unix time the message was sent; v1 is HMAC-SHA256(secret, t + "." + body) in hex, over the exact raw body. Recompute it with the endpoint's secret, compare in constant time, and refuse anything older than 5 minutes: a captured message cannot be replayed later.
Check your code with this message: secret whsec_test_docs_only, t 1759640400, the body below; the header must be t=1759640400,v1=c27782f34bbf98bccb8ca5839107b7cc82f620038ea22912d7464cc9c03faae5.
{"id":"evt_1d3a6f0b4c8e5279","type":"ping","created":"2026-10-05T04:30:00+00:00","data":{"message":"A test from Studio."}}<?php
function verify(string $secret, string $body, string $header, int $tolerance = 300): bool {
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k === 't' && ctype_digit((string)$v)) { $t = (int)$v; }
if ($k === 'v1') { $sigs[] = $v; }
}
if ($t === null || !$sigs || abs(time() - $t) > $tolerance) { return false; }
$want = hash_hmac('sha256', $t . '.' . $body, $secret);
foreach ($sigs as $sig) { if (hash_equals($want, (string)$sig)) { return true; } }
return false;
}
$body = file_get_contents('php://input');
if (!verify($secret, $body, $_SERVER['HTTP_STUDIO_SIGNATURE'] ?? '')) { http_response_code(400); exit; }
$event = json_decode($body, true); // { id, type, created, data }
// handle $event['type'] … then answer 2xx within 10 seconds
http_response_code(200);// Node: read the RAW body; a re-serialised body never matches.
const crypto = require('crypto');
function verify(secret, rawBody, header, tolerance = 300) {
let t = null; const sigs = [];
for (const part of String(header).split(',')) {
const i = part.indexOf('='); const k = part.slice(0, i).trim(); const v = part.slice(i + 1).trim();
if (k === 't' && /^\d+$/.test(v)) t = Number(v);
if (k === 'v1') sigs.push(v);
}
if (t === null || !sigs.length || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const want = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
return sigs.some(s => s.length === want.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(want)));
}
app.post('/hooks/printwisp', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(process.env.PRINTWISP_WEBHOOK_SECRET, req.body, req.get('Studio-Signature'))) return res.sendStatus(400);
const event = JSON.parse(req.body); // { id, type, created, data }
res.sendStatus(200); // answer first, work later
});import hmac, hashlib, time
def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
t, sigs = None, []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t" and v.isdigit():
t = int(v)
if k == "v1":
sigs.append(v)
if t is None or not sigs or abs(time.time() - t) > tolerance:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(want, s) for s in sigs)
# Flask
@app.post("/hooks/printwisp")
def hook():
if not verify(SECRET, request.get_data(), request.headers.get("Studio-Signature", "")):
return "", 400
event = request.get_json() # { id, type, created, data }
return "", 200# Recompute a signature by hand to check your code: printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" # must equal the v1= value when $T is the t= value and $BODY the exact raw body
Retries and catch-up
A delivery that is not answered 2xx is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours: 7 tries in all, then it is exhausted. 20 failures in a row switch the endpoint off (disabled_failing) until you turn it back on in the portal, which also forgives the failures. A delivery being sent is held for 10 minutes so two runs never send it twice.
The catch-up for anything missed is GET /v1/events?after=evt_…&limit=: every event of the organisation after the one you name, oldest first, with has_more. Keep the last id you handled.