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, or register an endpoint with createWebhookEndpoint below. 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.

A franchise location receives order.created, order.updated, order.shipped, and order.cancelled for orders routed to that location, including orders placed on the network storefront. The payload is the order id. Call getOrder for line items, mockup URLs, production file URLs, and payout (the per-order Stripe fee, Riverr fee, and amount paid to the location). payout is null until the card payment is split, which can be after order.created — read the order again if it is null. See Location-routed orders.

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).

Prepaid-label orders still emit the same events. order.created fires when createOrderFromGtin succeeds with a shippingLabel. order.shipped fires when fulfillment prints your label — Shipment.trackingCode is the number you sent, and shipment cost is 0. See Prepaid shipping labels.

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
}
}