Webhooks
Webhooks let Riverr push events to your server in real time instead of you
polling the API on a timer. Register an HTTPS endpoint, subscribe it to the
events you care about, and Riverr will POST a signed JSON payload whenever one
of those events happens for your account.
Configure this in Settings → Developer → Webhooks. Sellers and account admins each get their own endpoint. A seller URL only receives that seller's shops and orders. An admin URL with no seller scope still receives the whole tenant.
Why use webhooks instead of polling? Polling
getOrder/ batch / invoice endpoints on a short interval is slow, wasteful, and rate-limited. A webhook delivers the change the moment it happens, with less load on both sides.
Event types
Event (type in payload) | Fires when |
|---|---|
order.created | A new order is created |
order.updated | An order's fields change |
order.shipped | An order ships (tracking available) |
order.cancelled | An order is cancelled |
batch.updated | A production batch changes |
batch.completed | A batch finishes production |
item.printed | A batch item is printed (or reprinted) |
item.qc_passed | A batch item passes QC (or is marked fulfilled) |
invoice.created | An invoice is generated |
invoice.paid | An invoice is paid |
When subscribing via the API, use the enum form (ORDER_SHIPPED); the delivered
payload's type uses the dotted form (order.shipped).
Register an endpoint
mutation {
createWebhookEndpoint(
input: {
url: "https://your-server.example.com/riverr/webhooks"
enabledEvents: [ORDER_SHIPPED, ORDER_CANCELLED, INVOICE_PAID]
description: "Production order sync"
}
) {
id
url
enabledEvents
active
secret # returned ONLY on create — store it now, it is never shown again
}
}
The secret in the response is used to verify signatures (below). It is
returned only on creation and when you rotate it — store it securely.
Manage endpoints with webhookEndpoints, webhookEndpoint(id),
updateWebhookEndpoint, deleteWebhookEndpoint, and
rotateWebhookEndpointSecret.
Delivery payload
Each delivery is a POST with a JSON body and these headers:
| Header | Description |
|---|---|
Riverr-Event | The event type, e.g. order.shipped |
Riverr-Delivery-Id | Unique id for this delivery (use for idempotency) |
Riverr-Signature | t=<unix-seconds>,v1=<hmac-sha256-hex> (see below) |
{
"id": "evt_1a2b3c",
"type": "order.shipped",
"createdAt": "2026-09-01T14:03:22.000Z",
"data": { "id": "8PrWoLNZHpAxQBWdSS3D" }
}
Most events carry a thin reference (data.id); fetch full detail from the API.
The item events (item.printed, item.qc_passed) instead carry the production
fields inline, so a fulfillment integration can react without an extra read:
{
"id": "evt_9f8e7d",
"type": "item.qc_passed",
"createdAt": "2026-09-02T12:03:22.000Z",
"data": {
"itemId": "8PrWoLNZHpAxQBWdSS3D-1-1",
"batchId": "b_7Qk2",
"orderId": "8PrWoLNZHpAxQBWdSS3D",
"orderItemId": "oi_44",
"printed": true,
"printedAt": "2026-09-02T11:59:10.000Z",
"qcPassedAt": "2026-09-02T12:03:20.000Z",
"fulfilled": false
}
}
Respond with a 2xx status quickly (within 10s). Any non-2xx or timeout is
retried with exponential backoff. Because retries can cause the same event to
arrive more than once, treat Riverr-Delivery-Id (or the event id) as an
idempotency key.
Verifying signatures
The Riverr-Signature header is t=<timestamp>,v1=<signature>, where the
signature is the hex HMAC-SHA256 of the string "<timestamp>.<raw request body>"
using your endpoint's secret as the key. Always compute the HMAC over the
raw request body (before JSON parsing).
Reject the request if the signature doesn't match, or if the timestamp is too old (e.g. more than 5 minutes), to guard against replay.
PHP
<?php
// $secret is the endpoint secret returned on createWebhookEndpoint.
$payload = file_get_contents('php://input'); // RAW body — do not json_decode first
$header = $_SERVER['HTTP_RIVERR_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
$parts[$k] = $v;
}
$timestamp = $parts['t'] ?? '';
$provided = $parts['v1'] ?? '';
// Reject stale timestamps (replay protection).
if (!$timestamp || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('stale timestamp');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
if (!hash_equals($expected, $provided)) {
http_response_code(400);
exit('invalid signature');
}
// Signature OK — process the event.
$event = json_decode($payload, true);
http_response_code(200);
Node.js
const crypto = require("crypto");
function verifyRiverrWebhook(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const timestamp = parts.t;
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false; // stale / replay
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1 || ""),
);
}
Testing your endpoint
Send a signed webhook.test event to confirm your server is reachable and your
signature check works:
mutation {
sendTestWebhookEvent(id: "your-endpoint-id") {
success
statusCode
error
}
}
Review recent attempts (including the test) with:
query {
webhookDeliveries(endpointId: "your-endpoint-id", limit: 20) {
event
success
statusCode
attempts
error
createdAt {
_seconds
}
}
}
Rotating the secret
If a secret is exposed, rotate it. The response includes the new secret; update your server, then the old secret stops working.
mutation {
rotateWebhookEndpointSecret(id: "your-endpoint-id") {
id
secret
}
}