Webhooks
Webhooks push fulfilment events to your server as they happen, so you don't have to keep polling /orders or /units. We sign each delivery with HMAC-SHA256 using a secret you pick, so you can check it really came from us.
Registering an endpoint
POST /webhooks
Content-Type: application/json
{
"url": "https://your-domain.com/webhooks/glycanage",
"events": ["order.shipped", "unit.registered", "result.ready"],
"secret": "a-secret-of-at-least-32-characters-long"
}
urlhas to be anhttps://URL.eventsis one or more oforder.shipped,unit.registered,result.ready.secretis 32 to 128 characters and signs every delivery. Generate it randomly and keep it wherever you keep your API key.
GET /webhooks
DELETE /webhooks/{id}
Delivery format
Every event arrives as an HTTP POST to your URL, with this body:
{
"event": "order.shipped",
"timestamp": "2026-07-06T09:21:00.000Z",
"data": {
"order_id": "4f3c2a4e-1d2b-4f6a-9c8e-2b1a3c4d5e6f",
"reference": "SHOP-10231",
"kits": [
{
"kit_code": "GA-AB-123456",
"qr_link": "https://qr.glycanage.com/9f8e7d6c5b4a3210",
"tracking_code": "TRK123456"
}
]
}
}
Two headers come with it:
| Header | Contents |
|---|---|
X-Webhook-Event | The event type, mirroring event in the body. |
X-Webhook-Signature | HMAC SHA-256 of the raw request body, keyed with your secret, lowercase hex. |
Verifying the signature
Compute the HMAC over the exact bytes you received, before you parse the JSON. If you re-serialise the parsed object you won't get the same digest back.
const crypto = require("crypto");
// Capture the raw body: express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })
function verifySignature(rawBody, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(signature ?? "", "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hmac, hashlib
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature or "", expected)
Reject anything that fails verification with a 401. Never act on a payload you haven't verified.
Events
order.shipped
The warehouse has sent the order out. This is where you find out the kit codes.
| Field | Type |
|---|---|
order_id | string |
reference | string | null |
kits | array of { kit_code, qr_link, tracking_code } |
qr_link is the page your customer uses to register the kit. It's printed on the kit itself, but having it here means you can drop it into your own shipping confirmation email too.
unit.registered
The customer has finished the QR page for one kit, so analysis can go ahead.
| Field | Type |
|---|---|
order_id | string |
reference | string | null |
kit_code | string |
result.ready
A report has been released. This is a status signal only, and carries no result data.
| Field | Type |
|---|---|
order_id | string |
reference | string | null |
kit_code | string |
result_id | string |
Fetch the report itself from GET /reports/{result_id} or GET /reports/kit/{kitCode}, as JSON or as a PDF with Accept: application/pdf. This is also the moment an AI interpretation becomes available for the kit.
Sandbox simulation
In the sandbox the whole lifecycle is simulated, and one action sets it off: placing an order. Over the next few seconds you'll get
order.shipped → unit.registered → result.ready
with generated kit codes, QR links, tracking and result values that are fake but structurally valid. An order reaches result.ready in roughly half a minute, so you can put all three handlers through their paces in one run.
Unlike the Partner API, there's no second action needed to move the lab stages along. There's no assignment step here, because your customers register their own kits.
None of this happens outside the sandbox. In production these events reflect real warehouse, laboratory and analysis activity.
Handling deliveries well
Respond quickly. Acknowledge with 200 within 10 seconds, then do the real work asynchronously.
Be idempotent. An event may be delivered more than once. Key on something stable (result_id for result.ready, kit_code for unit.registered, order_id for order.shipped) and make reprocessing a no-op.
app.post("/webhooks/glycanage", (req, res) => {
if (!verifySignature(req.rawBody, req.get("x-webhook-signature"), SECRET)) {
return res.status(401).send("Unauthorized");
}
const { event, data } = req.body;
const key = `${event}:${data.result_id ?? data.kit_code ?? data.order_id}`;
if (alreadyHandled(key)) return res.status(200).send("OK");
enqueue(event, data); // process out of band
markHandled(key);
res.status(200).send("OK");
});
Don't rely on ordering. Events for different kits on the same order can turn up in any order.
Keep a fallback. Webhook delivery is best-effort. For anything you can't afford to miss, reconcile every so often against GET /units or GET /reports. The results_ready flag on a kit tells you whether a report exists, whether or not its webhook ever arrived.
Testing
Expose a local endpoint with ngrok, then register the public URL against the sandbox:
ngrok http 3000
{
"url": "https://abc123.ngrok.io/webhooks/glycanage",
"events": ["order.shipped", "unit.registered", "result.ready"],
"secret": "test-secret-key-at-least-32-characters"
}
Now place a sandbox order and watch all three events land.
Troubleshooting
Nothing arrives. Check the webhook is registered in the same environment you're ordering in, since sandbox registrations don't receive production events. Then check your URL is https://, reachable from the internet, and returning 200.
Signatures never match. You're almost certainly hashing a re-serialised body. Capture and hash the raw bytes instead.
Events stop mid-flow. In production, a kit the customer never registers will never produce result.ready. Check registered on GET /units/kit/{kitCode}.
For anything else, email support@glycanage.com with your reseller name, the affected order reference and any error logs.