Webhooks
Signed deliveries, retries, replay and the event catalog.
Register an endpoint with POST /webhook_endpoints (https only, public addresses only). The
signing secret is returned once; rotate it with POST /webhook_endpoints/{id}/secrets, after
which the previous secret verifies for 24 hours.
Each delivery is a POST with headers Orishare-Signature: t=<unix>,v1=<hex>,
Orishare-Delivery-Id, Orishare-Event-Id and Orishare-Event-Type. Verify with
HMAC-SHA256(secret, "<t>.<raw body>"), hex-encoded, compared in constant time, and reject
timestamps more than five minutes from now. Use the raw request body exactly as received, before
any JSON parsing. Dedupe on Orishare-Event-Id: delivery is at least once and replays reuse the
same id.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyOrishareSignature(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return (
expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
);
}verifyWebhookSignature in @orishare/api-client does the same; that client is a workspace
package of the Orishare repository and is not published to npm.
Failures retry with exponential backoff for about 24 hours; endpoints failing for a day are
disabled and can be re-enabled with PATCH. GET /webhook_deliveries shows attempts, status codes
and the captured response snippet; POST /webhook_deliveries/{id}/replay sends the same envelope
again; POST /webhook_deliveries with an endpoint_id sends a webhook.test ping.
Catalog
customer.created, customer.updated, points.awarded, points.deducted, points.expired,
points.reversed, tier.changed, reward.issued, reward.redeemed, challenge.progressed,
challenge.completed, rule.executed, webhook.endpoint.disabled, plus any custom name a
webhook.emit action sends. Subscribe with subscribed_types, or ["*"] for everything.
Points expiry
points.expired fires once for each lot of points that expires. A balance stops counting expired
points exactly at their expiry in every read, and the matching expire ledger entry and this event
follow when the expiry is settled: before the customer's next spend, or by the background job that
runs every ten minutes, whichever comes first. data holds:
| Field | Value |
|---|---|
customer_id | The customer, as the API addresses them (the surviving id after a merge) |
currency | Currency code |
amount | Points that expired, a positive number |
lot_id | The ledger entry (the earn) that expired |
entry_id | The expire ledger entry |
expired_at | When the lot expired (its expires_at), not when it was settled |
Orishare creates one event per lot. Delivery is at least once like every other event, so dedupe on
Orishare-Event-Id (or on lot_id).
A custom webhook's data holds type, rule, customer_id, event_id, execution_id and the
action's mapped payload (see Rules, webhook.emit). An action without a
payload sends the triggering event's properties instead.
Reward fulfilment requests
A catalogue offer whose delivery is webhook is fulfilled by your system: when a customer redeems
it, Orishare asks one of your webhook endpoints to deliver the reward. The destination is always an
endpoint you registered. An offer carries no URL, and nothing a customer sends can choose one.
To receive these requests, register an endpoint whose subscribed_types names
reward.fulfillment_requested. ["*"] does not opt in: a fulfilment request is an instruction to
hand out a reward, so an endpoint has to ask for it by name. If several active endpoints name the
type, the oldest one receives every request. While no endpoint does, redemptions wait as
pending_fulfillment and are sent once you register one.
The request is a POST signed like every other delivery (Orishare-Signature, verified as above
with that endpoint's signing secret), with these headers:
| Header | Value |
|---|---|
Idempotency-Key | Stable for the redemption. The same on every retry. |
Orishare-Event-Id | The same value as Idempotency-Key. |
Orishare-Event-Type | reward.fulfillment_requested |
{
"id": "commerce:rrd_...",
"type": "reward.fulfillment_requested",
"created_at": "2026-10-04T12:00:00.000Z",
"environment_id": "env_...",
"data": {
"redemption_id": "rrd_...",
"fulfillment_id": "rrd_...",
"idempotency_key": "commerce:rrd_...",
"provider_key": "gift_provider",
"redeemed_at": "2026-10-04T11:59:58.120Z",
"customer": { "id": "cust_...", "external_id": "user_42" },
"offer": { "version_id": "roff_...", "key": "gift_card", "version": 1, "title": "Gift card" },
"cost": { "currency_id": "cur_...", "amount": "500" }
}
}provider_key is the label from the offer's delivery settings, so one endpoint can route to
several fulfilment systems. created_at is the time of this attempt and changes between retries:
deduplicate on Idempotency-Key, never on the body. Receiving the same key twice must hand out the
reward once.
Your response decides what happens to the redemption:
| Your response | Result |
|---|---|
2xx with JSON body {"status":"fulfilled"} | The redemption is fulfilled. Other members of the object are ignored. The body must be at most 1024 bytes. |
Any other 2xx | Received but not confirmed. The redemption becomes reconciliation_required until you confirm it (see below). |
408, 425, 429, any 5xx, any 3xx | Not fulfilled. The same request is sent again on a later run. Redirects are never followed. |
Any other 4xx | Refused for good. The request is not sent again; the redemption stays pending_fulfillment until it is reconciled or cancelled. |
| No response within 10 seconds, or a dropped connection | Unknown. The redemption becomes reconciliation_required, because the reward may have been handed out. It is not sent again automatically. |
Answer {"status":"fulfilled"} only once the reward has really been delivered. If your system
works asynchronously, answer 202 and confirm afterwards with
POST /fulfillments/{id}/reconciliations (outcome: "fulfilled", the fulfilment's current
revision as expected_revision, a reason and a proof_digest). GET /fulfillments lists the
ones that are waiting. Orishare keeps the status code and the outcome of each attempt, never your
response body. Fulfilment requests are not listed under GET /webhook_deliveries and cannot be
replayed from there.