Webhooks

Phyzi POSTs a JSON event to your endpoint every time something happens to a release or an order. Register endpoints with POST /v1/webhook-endpoints. Every event is also stored and readable through GET /v1/events, so nothing is lost while your endpoint is down.

The request

POST /webhooks/phyzi HTTP/1.1
Content-Type: application/json
User-Agent: Phyzi-Webhooks/1.0
Phyzi-Signature: t=1793652859,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Phyzi-Event-Id: evt_3k9w2m7q8r1t5v6x0z4a
Phyzi-Event-Type: master_order.shipped

{
  "id": "evt_3k9w2m7q8r1t5v6x0z4a",
  "type": "master_order.shipped",
  "created_at": "2026-11-02T18:04:13.950Z",
  "livemode": false,
  "data": { "master_order": { "id": "mo_…", "status": "shipped", "...": "..." } }
}

Verifying signatures

Phyzi-Signature is t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of "<t>.<raw request body>" keyed with your endpoint's secret (whsec_…). While you rotate secrets there can be two v1= values; accept the request if either matches.

Always verify against the raw body bytes. Re-serialised JSON won't match.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyPhyzi(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return parts.some(([k, v]) => {
    const got = Buffer.from(v ?? "", "hex");
    return k === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
  });
}

// Express: app.post("/webhooks/phyzi", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyPhyzi(req.body.toString("utf8"), req.get("Phyzi-Signature"), process.env.PHYZI_WEBHOOK_SECRET)) return res.sendStatus(400);
//   res.sendStatus(200); handle(JSON.parse(req.body));
// });

Python

import hashlib, hmac, time

def verify_phyzi(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [p.strip().split("=", 1) for p in header.split(",")]
    try:
        t = int(next(v for k, v in parts if k == "t"))
    except (StopIteration, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in parts)

Reject requests older than 5 minutes (the tolerance above) to block replays.

Rotating the secret

  1. POST /v1/webhook-endpoints/{id}/roll-secret returns a new secret. From then on we sign with both the new and the previous secret.
  2. Deploy the new secret.
  3. POST /v1/webhook-endpoints/{id}/roll-secret with {"expire_previous": true} stops signing with the old one.

Event types

Every data object contains the full current resource, as it would come back from a GET.

Type When data
ping You called POST /v1/webhook-endpoints/{id}/test message, webhook_endpoint_id
release.received We saw BatchComplete_*.xml and started checking delivery
release.validated The batch passed. The release is created or updated. delivery, release
release.rejected The batch failed. Nothing changed. delivery (with errors)
release.taken_down A PurgeReleaseMessage was processed delivery, release
master_order.created You created a master order master_order
master_order.proof_ready A print proof is ready for the artist master_order (the newest entry in proofs has image URLs)
master_order.proof_approved You approved the proof master_order, proof_id, note
master_order.proof_rejected You rejected it. A new proof round follows. master_order, proof_id, note
master_order.master_built The master drive is built and checked master_order
master_order.units_loaded All units are duplicated and sample-checked master_order
master_order.shipped Units shipped to the artist and/or added to fan stock master_order (shipment has tracking when units went to the artist)
master_order.cancelled Phyzi cancelled the order (rare, and we'll talk to you first) master_order
fan_order.accepted A fan order was accepted (status accepted = reserved from stock, queued = made to order) fan_order
fan_order.shipped It shipped fan_order (shipment.carrier, tracking_number, tracking_url)
fan_order.cancelled It was cancelled. Reserved stock goes back. fan_order
stock.updated A release's fan stock changed release_id, stock_on_hand, delta, reason, availability

A master order's status goes created → proof_ready → (proof_approved | back to created on rejection) → master_built → units_loaded → shipped. Its timeline records when each step happened.