On-demand fabrication
Webhooks
Be told when an order is paid, shipped, or cancelled, and verify the signature.
Register an HTTPS URL and we POST to it when something happens on your account. Every delivery is signed so you can verify it came from us. Failed deliveries are retried.
Events
| product.approved | A product was approved and priced. data is the product. |
|---|---|
| product.rejected | A product was rejected. data.review_note says why. |
| order.paid | An order was charged and its jobs created. data is the order. |
| order.payment_failed | A charge failed. data is the order with payment.error. |
| order.in_production | Glyph released the order to the shop. |
| order.shipped | Shipped; data.shipment has the tracking number. |
| order.cancelled | Cancelled by you or by Glyph; data.payment shows the refund. |
| ping | Sent by the test endpoint. |
Subscribe to specific events, or to * for all of them.
Register
/api/intake/webhooks{ "url": "https://shop.example.com/glyph/webhook", "events": ["order.paid", "order.shipped", "order.cancelled"], "description": "Store sync" }
201 returns the webhook and its secret, once:
{
"id": "686da967-…",
"url": "https://shop.example.com/glyph/webhook",
"events": ["order.paid", "order.shipped", "order.cancelled"],
"active": true,
"description": "Store sync",
"created_at": "2026-10-12T21:16:41.555+00:00",
"secret": "whsec_QLcgi8…",
"note": "Store the secret now; it is not shown again. Verify X-Glyph-Signature with it."
}
Up to 10 webhooks per key. GET /api/intake/webhooks lists them without secrets; DELETE /api/intake/webhooks/{id} removes one; POST /api/intake/webhooks/{id}/test sends a ping now and reports the HTTP status your server returned:
{ "delivered": true, "delivery": { "id": "2e23…", "attempts": 1, "last_status": 200, "last_error": null, "delivered_at": "2026-10-13T03:41:27.934+00:00", "next_attempt_at": null } }
The same body comes back with 502 and "delivered": false when your server did not answer 2xx.
What we send
POST https://shop.example.com/glyph/webhook
Content-Type: application/json
X-Glyph-Event: order.shipped
X-Glyph-Delivery: 2f0d…
X-Glyph-Signature: t=1760304000,v1=5d41402abc4b2a76b9719d911017c592…
{
"id": "2f0d…",
"event": "order.shipped",
"created_at": "2026-10-14T18:02:12.000Z",
"data": { "id": "d38f2acc-…", "status": "shipped", "shipment": { "carrier": "USPS", "tracking_number": "9334…" } }
}
Respond with any 2xx within 8 seconds. Do the work afterwards.
Verify the signature
The signature is HMAC-SHA256 over <t>.<raw body> with your secret. Reject it if t is more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
}
import hmac, hashlib, time, re
def verify(secret: str, header: str, raw_body: bytes) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not m:
return False
t, v1 = m.groups()
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Use the raw request body, not a re-serialized copy.
Retries
If your server does not answer 2xx, we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours, then stop. Deliveries can arrive more than once; use the id to ignore repeats. Events may arrive out of order; trust data.status, not the sequence.