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", "...": "..." } }
}
- Respond with any 2xx within 10 seconds. Do slow work after responding.
- Anything else (or a timeout) is retried with backoff, starting at 30 seconds, doubling and capped at 1 hour, for about 24 hours. Redirects aren't followed.
- Deduplicate on
id. Retries andPOST /v1/events/{id}/resendreuse the same event ID. - Order isn't guaranteed. Use
created_atand the resource'sstatus, or re-read it withGET. livemodeisfalsein the sandbox.
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
POST /v1/webhook-endpoints/{id}/roll-secretreturns a newsecret. From then on we sign with both the new and the previous secret.- Deploy the new secret.
POST /v1/webhook-endpoints/{id}/roll-secretwith{"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.