Docs
Webhooks

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.

Where an event goesCopy
an order of a PrintWisp Store or a connected shop
  → that store's endpoints
  → the organisation's endpoints
  never another store's

an order of a Custom Website (API) store
  → the organisation's endpoints

Events

typeWhen
order.createdA 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_readyA 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_failedA print file could not be made. The same data as design.print_ready, without file, plus error.
pingSend 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.

HeadersCopy
POST /your/hook HTTP/1.1
Content-Type: application/json
User-Agent: Studio-Webhooks/1
Studio-Event: <type>
Studio-Delivery: whd_…
Studio-Signature: t=…,v1=…

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."}}
Verify Studio-SignatureCopy
<?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.

Retry scheduleCopy
try 1   at once
try 2   1 min after try 1
try 3   5 min after try 2
try 4   30 min after try 3
try 5   2 h after try 4
try 6   6 h after try 5
try 7   24 h after try 6

then exhausted; 20 failures in a row switch the endpoint off