Docs › Connect your store

Webhooks

Receive order.created, order.shipped and order.cancelled events and verify their signatures.

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:

bash
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:

json
{
  "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:

  1. Take your secret, drop the whsec_ prefix and base64-decode the rest: that is the signing key.
  2. Build the signed content: {webhook-id}.{webhook-timestamp}.{raw request body}.
  3. Compute HMAC-SHA256 of it with the signing key, and base64-encode the result.
  4. Compare it with the part after v1, in webhook-signature, using a constant-time comparison.
  5. Reject events whose webhook-timestamp is more than 5 minutes from your clock.

Use the raw body exactly as received: parsing and re-encoding the JSON changes it.

Node.js

javascript
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

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

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:

bash
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:

json
{ "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.

Need a hand? Partners: your account manager's details are on the Overview tab of your portal. Everyone else: call (877) 737-8191 or email support@promogator.com.
Compare /

Compare Products

Highlighted rows = products differ on this attribute