Skip to main content

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.createdA new order is created
order.updatedAn order's fields change
order.shippedAn order ships (tracking available)
order.cancelledAn order is cancelled
batch.updatedA production batch changes
batch.completedA batch finishes production
item.printedA batch item is printed (or reprinted)
item.qc_passedA batch item passes QC (or is marked fulfilled)
invoice.createdAn invoice is generated
invoice.paidAn 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:

HeaderDescription
Riverr-EventThe event type, e.g. order.shipped
Riverr-Delivery-IdUnique id for this delivery (use for idempotency)
Riverr-Signaturet=<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
}
}