Webhooks
Receive real-time HTTP notifications when events occur in your Scoperly account.
Overview
Instead of polling the API for changes, register webhook endpoints to receive push notifications. Scoperly will send an HTTP POST request to your URL whenever a subscribed event occurs.
Reliable
Automatic retries with exponential backoff (5 attempts total, spread over roughly half a day).
Secure
HMAC-SHA256 signatures on every payload so you can verify authenticity.
Filterable
Subscribe to specific event types. Only receive what you need.
Managing Endpoints
Webhook endpoints are managed from the supplier portal (Connect API page), not with API keys. The management endpoints below live under /api/v1/connect/webhooks and use your portal session with the CONNECT_MANAGE permission. Deliveries themselves are pushed to the URLs you register — your integration only needs to receive and verify them.
/api/v1/connect/webhooksList your registered webhook endpoints. Portal session auth (CONNECT_MANAGE permission).
/api/v1/connect/webhooksRegister a new webhook endpoint. Portal session auth (CONNECT_MANAGE permission). The signing secret is generated by the server and returned once in the creation response — it cannot be chosen by the caller.
Request Body
| Parameter | Type | Description |
|---|---|---|
urlrequired | string | HTTPS endpoint URL to receive events. Validated against the URL requirements below. |
eventsrequired | string[] | List of event types to subscribe to (e.g. ["order.placed", "order.cancelled"]). |
side | string | SUPPLIER or BUYER. Defaults to SUPPLIER. Order events are currently delivered to SUPPLIER endpoints only. |
The creation response includes the server-generated secret (format whsec_...) exactly once — store it immediately; it is never returned again.
/api/v1/connect/webhooks/:idDeactivate a webhook endpoint. Portal session auth (CONNECT_MANAGE permission). Returns 204 No Content.
/api/v1/connect/webhooks/:id/testSend a signed test.ping delivery to the endpoint (see Testing Webhooks below). Portal session auth (CONNECT_MANAGE permission).
/api/v1/connect/webhooks/:id/deliveriesDelivery history for an endpoint (last 50 deliveries) — status, HTTP response code, and response excerpt, for debugging. Portal session auth (CONNECT_MANAGE permission).
Event Types
| Event | Trigger |
|---|---|
order.placed | A buyer places a new order with your products. |
order.accepted | An order is accepted by the supplier. |
order.rejected | An order is rejected by the supplier. |
order.dispatched | An order is marked as dispatched. |
order.delivered | Buyer confirms delivery of an order. |
order.cancelled | An order is cancelled (by buyer or system). |
order.amended | Order lines changed and the buyer confirmed the change. |
payment.confirmed | A payment against an order invoice is confirmed. |
order.payment_status_changed | The payment status of an order changed (UNPAID / PARTIAL / PAID). Payload additionally carries paymentStatus. |
Payload Format
Every webhook delivers a JSON envelope with three top-level fields:event (the type), data (a minimal pointer to the affected order), and timestamp (server-side ISO 8601 at dispatch). The event type is also sent as the X-Scoperly-Event header so you can dispatch without parsing the body first.
{
"event": "order.placed",
"timestamp": "2025-03-20T09:15:00Z",
"data": {
"orderId": 42,
"orderNumber": "SCP-2025-000042",
"status": "PLACED",
"updatedAt": "2025-03-20T09:15:00Z"
}
}data is deliberately a pointer, not a snapshot: it carries only orderId, orderNumber, status and updatedAt (plus paymentStatus for order.payment_status_changed, carrying the value the event was emitted for). On receipt, re-read the order via GET /connect/v1/supplier/orders/:id to get the full detail. This is deliberate: the full order stays behind the authenticated API, so a receiver endpoint never holds order detail or address data it did not fetch itself.
Webhook Security
Every webhook request includes two headers you must check:
X-Scoperly-Signature: formatt=<epoch-seconds>,v1=<hex>. The hex isHMAC-SHA256(secret, "<timestamp>.<raw-body>").X-Scoperly-Event: the event type (e.g.order.placed). Useful for quick dispatch before JSON parsing.
Binding the timestamp into the signature makes each payload unique in time, so a replayed request is detectable by a stale timestamp. Always enforce a ±5-minute window on the timestamp in addition to verifying the HMAC — otherwise a signed request sniffed once could be replayed forever.
import crypto from 'crypto';
const REPLAY_WINDOW_SECONDS = 300; // 5 minutes
function verifyScoperlyWebhook(
rawBody: string | Buffer,
signatureHeader: string,
secret: string,
): boolean {
// Parse "t=<epoch>,v1=<hex>"
const parts: Record<string, string> = {};
for (const kv of signatureHeader.split(',')) {
const [k, v] = kv.split('=');
if (k && v !== undefined) parts[k.trim()] = v.trim();
}
const timestamp = Number.parseInt(parts.t ?? '', 10);
const providedSig = parts.v1;
if (!Number.isFinite(timestamp) || !providedSig) return false;
// Replay protection: reject if timestamp is outside the 5-minute window
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > REPLAY_WINDOW_SECONDS) return false;
// Recompute HMAC over "<timestamp>.<raw-body>"
const bodyString = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8');
const signedPayload = `${timestamp}.${bodyString}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// Timing-safe comparison
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(providedSig, 'hex'),
);
} catch {
return false; // length mismatch
}
}
// In your Express handler (must use raw body, NOT parsed JSON):
app.post(
'/webhooks/scoperly',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-scoperly-signature'] as string;
const eventType = req.headers['x-scoperly-event'] as string;
if (!signature || !verifyScoperlyWebhook(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString('utf8'));
switch (eventType) {
case 'order.placed':
handleNewOrder(event.data); // data.orderId — re-read the order via the API
break;
case 'order.payment_status_changed':
handlePaymentStatus(event.data); // data.paymentStatus: UNPAID | PARTIAL | PAID
break;
}
res.status(200).json({ received: true });
},
);Important: Verify the signature over the raw request bytes — not the re-serialized JSON. Parse JSON only after HMAC passes. Always enforce the 5-minute timestamp window and use constant-time comparison.
Secrets: Scoperly generates a cryptographically random webhook secret and returns it once, when you create the endpoint in the supplier portal. Store it in environment variables; never log it or ship it in client-side code.
URL Requirements
Webhook URLs are validated at registration and re-checked at every delivery attempt (DNS-rebinding guard). Scoperly refuses to deliver to any host that resolves to one of the following:
- Non-HTTPS URLs (
http://is rejected) - Loopback (
127.0.0.0/8,::1) - RFC 1918 private ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) - Link-local (
169.254.0.0/16,fe80::/10) - IPv4 CGNAT (
100.64.0.0/10) and reserved (240.0.0.0/4) - IPv6 Unique Local Addresses (
fc00::/7) - IPv4-mapped IPv6 forms of any of the above (
::ffff:10.0.0.1, etc.) - Cloud metadata endpoints (
169.254.169.254,metadata.google.internal)
If your endpoint temporarily resolves to a private IP (e.g. during DNS propagation), the delivery fails with a non-retryable error and is marked FAILED. Fix the DNS before registering the endpoint; failed deliveries are visible in the Deliveries log in the supplier portal.
Retry Policy
If your endpoint returns a non-2xx status code or times out (10 s connect / 30 s read), Scoperly retries with exponential backoff — 5 attempts total, with the delay growing as 5attempt minutes between attempts:
| Attempt | Delay after failure |
|---|---|
| 1st retry | 5 minutes |
| 2nd retry | 25 minutes |
| 3rd retry | ~2 hours |
| 4th retry (final) | ~10 hours |
After 5 failed attempts (the initial delivery plus 4 retries), the delivery is marked FAILED. There is no manual re-send — treat webhooks as a hint and use the resumable order change feed as your source of truth for anything you must not miss.
Endpoint health: deliveries are sent only to the supplier tenant's endpoints. A failing endpoint is marked FAILING, and after 5 consecutive failed deliveries it is automatically DISABLED and receives nothing further. A successful delivery resets the counter; once disabled, re-enable by recreating the endpoint or contacting support.
Testing Webhooks
Send a test delivery from the supplier portal (or the management endpoint below) to verify connectivity and your signature verification.
/api/v1/connect/webhooks/:id/testSend a signed test.ping delivery to the endpoint. Portal session auth (CONNECT_MANAGE permission).
The test delivery is signed exactly like a real delivery — same X-Scoperly-Signature format, with X-Scoperly-Event: test.ping — so a receiver that verifies signatures per the section above accepts it unchanged. The payload:
{
"event": "test.ping",
"timestamp": "2025-03-20T09:15:00Z",
"data": {
"message": "This is a test webhook from Scoperly",
"test": true
}
}The "test": true field lets your handler distinguish it from real events. The response reports success, statusCode and responseTime; the probe is not recorded in the delivery log.