Webhooks tell your server when something happens to an order, so you do not have to poll.
Events
| Event | Sent when | data holds |
|---|---|---|
order.created |
An order is placed | order |
order.shipped |
A package ships (one event per tracking number) | order, tracking_number, carrier |
order.cancelled |
An order is cancelled | order |
ping |
You send a test event | message |
Add an endpoint
In the portal: On Demand → Developers → Webhooks (test endpoints). Or with the API, using a key with webhooks:manage:
curl -X POST https://www.promogator.com/api/on-demand/v1/webhook-endpoints \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "url": "https://yourstore.com/hooks/promogator", "events": ["order.created", "order.shipped", "order.cancelled"] }'
The response includes the endpoint's signing secret (whsec_…), shown only now (and when you rotate it). An endpoint created with a test key receives test events; one created with a live key receives live events. You can have up to 5 endpoints in each mode.
Your URL must be https:// on a public host name (not an IP address or a local name).
What we send
A POST with a JSON body:
{
"id": "evt_01JB…",
"object": "event",
"type": "order.shipped",
"livemode": true,
"created_at": "2026-10-18T17:02:11Z",
"data": {
"order": { "id": "odo_01JB…", "external_id": "1203", "status": "shipped", "…": "…" },
"tracking_number": "1Z999AA10123456784",
"carrier": "ups"
}
}
And these headers (Standard Webhooks):
| Header | Value |
|---|---|
webhook-id |
The event id, the same on every retry |
webhook-timestamp |
Unix seconds when this attempt was signed |
webhook-signature |
v1, followed by the base64 signature |
content-type |
application/json |
user-agent |
Promogator-OnDemand-Webhooks/1 |
Verify the signature
Always verify the signature before you trust an event:
- Take your secret, drop the
whsec_prefix and base64-decode the rest: that is the signing key. - Build the signed content:
{webhook-id}.{webhook-timestamp}.{raw request body}. - Compute HMAC-SHA256 of it with the signing key, and base64-encode the result.
- Compare it with the part after
v1,inwebhook-signature, using a constant-time comparison. - Reject events whose
webhook-timestampis more than 5 minutes from your clock.
Use the raw body exactly as received: parsing and re-encoding the JSON changes it.
Node.js
const crypto = require('crypto');
function verify(rawBody, headers, secret) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const signed = `${headers['webhook-id']}.${headers['webhook-timestamp']}.${rawBody}`;
const expected = crypto.createHmac('sha256', key).update(signed).digest('base64');
const given = (headers['webhook-signature'] || '').split(' ')
.map(s => s.split(',')[1]).filter(Boolean);
const fresh = Math.abs(Date.now() / 1000 - Number(headers['webhook-timestamp'])) < 300;
return fresh && given.some(sig => sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}
PHP
function verify(string $rawBody, array $h, string $secret): bool {
$key = base64_decode(substr($secret, strlen('whsec_')));
$signed = $h['webhook-id'] . '.' . $h['webhook-timestamp'] . '.' . $rawBody;
$expected = base64_encode(hash_hmac('sha256', $signed, $key, true));
$fresh = abs(time() - (int) $h['webhook-timestamp']) < 300;
foreach (explode(' ', $h['webhook-signature'] ?? '') as $part) {
[, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($fresh && hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
Python
import base64, hashlib, hmac, time
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
fresh = abs(time.time() - int(headers["webhook-timestamp"])) < 300
sigs = [p.split(",", 1)[1] for p in headers.get("webhook-signature", "").split() if "," in p]
return fresh and any(hmac.compare_digest(expected, s) for s in sigs)
Respond fast
Reply with any 2xx status within 10 seconds, then do the work. We do not follow redirects, and only the first 8 KB of your reply is read.
Retries
If your server does not answer with a 2xx in time, we retry the same event (same webhook-id) after 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, then once a day for 3 days: about 3.7 days in all. An endpoint that has failed for 3 days in a row is disabled; fix it and switch it back on with PATCH /webhook-endpoints/{id} and {"status": "enabled"}.
Because of retries you can receive an event more than once: use webhook-id to ignore duplicates.
Test an endpoint
Send test event in the portal, or:
curl -X POST https://www.promogator.com/api/on-demand/v1/webhook-endpoints/we_01JB…/test \
-H "Authorization: Bearer $TOKEN"
We send a ping event right away and tell you how your server answered:
{ "object": "webhook_test", "event_id": "evt_…", "delivered": true, "response_code": 204, "error": null, "duration_ms": 182 }
Rotate the secret
POST /webhook-endpoints/{id}/rotate-secret returns a new whsec_… secret. The old one stops being used straight away, so update your server first.
Missed events
GET /events lists your recent events (newest first) and GET /events/{id} returns one, in the same shape we send them.