Orishare
Concepts

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:

FieldValue
customer_idThe customer, as the API addresses them (the surviving id after a merge)
currencyCurrency code
amountPoints that expired, a positive number
lot_idThe ledger entry (the earn) that expired
entry_idThe expire ledger entry
expired_atWhen 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:

HeaderValue
Idempotency-KeyStable for the redemption. The same on every retry.
Orishare-Event-IdThe same value as Idempotency-Key.
Orishare-Event-Typereward.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 responseResult
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 2xxReceived but not confirmed. The redemption becomes reconciliation_required until you confirm it (see below).
408, 425, 429, any 5xx, any 3xxNot fulfilled. The same request is sent again on a later run. Redirects are never followed.
Any other 4xxRefused 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 connectionUnknown. 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.

On this page