Guides

Webhooks

Get an HTTP request the moment something changes on your affiliate account: a commission is earned or reversed, a referral or order is attributed, a withdrawal completes, or a brand changes your membership. Use it to update your own dashboard, post to Slack, or trigger a Zapier / Make workflow.

Overview

  • Each event is a JSON POST signed per the Standard Webhooks spec, so the open-source standardwebhooks libraries verify it.
  • You can add up to 5 endpoints. Each one subscribes to all events or a chosen list.
  • Delivery is at least once. Failed deliveries are retried for about 28 hours.
  • Payloads never contain customer names, emails or addresses. Customers appear as an opaque customer_ref that stays the same for you, so you can count repeat customers.
  • Only the account owner can manage endpoints. Team members can view them and the delivery log.

Quick Start

  1. Open Developers → Webhooks and click Add endpoint.
  2. Enter an https:// URL (port 443, publicly reachable) and pick the events, or keep All events.
  3. Copy the signing secret (whsec_…). You can reveal it again later from the endpoint page.
  4. Click Send test. You'll see the status code and the first 1 KB of your response right away, and the delivery appears in the log.
  5. Verify the signature in your handler (below) and return a 2xx within 10 seconds.

Payload & Headers

Every request looks like this:

POST /webhooks/oshi HTTP/1.1
content-type: application/json
user-agent: Oshi-Webhooks/1.0 (+https://affiliates.bitcoinrewards.app/docs/guides/webhooks)
webhook-id: evt_2b8f0c6e1d4a9b7c3e5f1a2d
webhook-timestamp: 1761217200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
HeaderMeaning
webhook-idThe event id (evt_…). Identical on every retry and resend: use it to dedupe.
webhook-timestampUnix seconds when this attempt was signed. Reject requests more than 5 minutes off your clock.
webhook-signaturev1,<base64 signature>. During a secret rotation there are two, separated by a space. Accept the request if any one matches.

The body is compact JSON. timestamp is when the event happened (ISO 8601, UTC); data depends on type. Amounts are integers in sats.

{
"id": "evt_2b8f0c6e1d4a9b7c3e5f1a2d",
"type": "commission.paid",
"timestamp": "2026-10-23T11:00:00Z",
"api_version": "2026-10-23",
"data": {
"commission": {
"id": "com_5179b350ece051a39f0d8c75",
"status": "paid",
"source": "lifetime",
"amount_sats": 4200,
"commission_percentage": 10,
"created_at": "2026-10-23T11:00:00Z",
"paid_at": "2026-10-23T11:05:00Z",
"available_at": null
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"order": {
"id": "5829104773185",
"number": "#1042",
"subtotal": "84.00",
"currency": "USD",
"created_at": "2026-10-23T10:58:12Z"
},
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e"
}
}

Verifying Signatures

The signature is an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_, then base64-encoded. Always use the raw request body: parsing and re-serialising the JSON changes the bytes.

Node.js (standardwebhooks)

// npm install standardwebhooks
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.OSHI_WEBHOOK_SECRET); // accepts the "whsec_" prefix
// rawBody: the exact request body string. Throws if the signature or
// timestamp (5 minute tolerance) is invalid.
const event = wh.verify(rawBody, {
"webhook-id": req.headers["webhook-id"],
"webhook-timestamp": req.headers["webhook-timestamp"],
"webhook-signature": req.headers["webhook-signature"],
});

Node.js (no dependencies)

import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.OSHI_WEBHOOK_SECRET; // "whsec_..."
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const TOLERANCE_SECONDS = 5 * 60;
// Use the RAW body: re-serialised JSON will not match the signature.
app.post("/webhooks/oshi", express.raw({ type: "application/json" }), (req, res) => {
const id = req.header("webhook-id");
const timestamp = req.header("webhook-timestamp");
const signatures = req.header("webhook-signature") ?? "";
const body = req.body.toString("utf8");
if (!id || !timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.status(400).send("Invalid timestamp");
}
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${body}`)
.digest();
// The header can hold several space-separated signatures (secret rotation).
const valid = signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) return false;
const given = Buffer.from(signature, "base64");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!valid) return res.status(401).send("Invalid signature");
const event = JSON.parse(body);
// Dedupe on event.id, queue the work, and answer quickly.
res.sendStatus(200);
});

Python

import base64, hashlib, hmac, time
TOLERANCE_SECONDS = 5 * 60
def verify(secret: str, headers: dict, body: bytes) -> bool:
"""secret is the "whsec_..." value; body is the raw request body."""
key = base64.b64decode(secret.removeprefix("whsec_"))
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in headers.get("webhook-signature", "").split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
# Or: pip install standardwebhooks
# from standardwebhooks.webhooks import Webhook
# event = Webhook(secret).verify(body, headers)

PHP

<?php
// Or: composer require standard-webhooks/standard-webhooks
$secret = getenv('OSHI_WEBHOOK_SECRET'); // "whsec_..."
$key = base64_decode(substr($secret, strlen('whsec_')));
$body = file_get_contents('php://input'); // raw body
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signatures = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
if ($id === '' || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('Invalid timestamp');
}
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
$valid = false;
foreach (explode(' ', $signatures) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) {
$valid = true;
break;
}
}
if (!$valid) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($body, true);
// Dedupe on $event['id'], queue the work, then:
http_response_code(200);

Event Catalog

Payloads below are examples of the data object. Fields marked nullable in the API reference can be null, for example order on a manual commission. New fields may be added within the same api_version; ignore ones you don't know.

Commissions

commission.created

A commission was recorded for an attributed order (lifetime, repeat purchase, or manual).

{
"commission": {
"id": "com_5179b350ece051a39f0d8c75",
"status": "pending",
"source": "lifetime",
"amount_sats": 4200,
"commission_percentage": 10,
"created_at": "2026-10-23T11:00:00Z",
"paid_at": null,
"available_at": null
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"order": {
"id": "5829104773185",
"number": "#1042",
"subtotal": "84.00",
"currency": "USD",
"created_at": "2026-10-23T10:58:12Z"
},
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e"
}

commission.paid

The commission was funded into your wallet. It stays locked until the brand's lockup period ends.

{
"commission": {
"id": "com_5179b350ece051a39f0d8c75",
"status": "paid",
"source": "lifetime",
"amount_sats": 4200,
"commission_percentage": 10,
"created_at": "2026-10-23T11:00:00Z",
"paid_at": "2026-10-23T11:05:00Z",
"available_at": null
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"order": {
"id": "5829104773185",
"number": "#1042",
"subtotal": "84.00",
"currency": "USD",
"created_at": "2026-10-23T10:58:12Z"
},
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e"
}

commission.available

The lockup period has passed and the commission can be withdrawn.

{
"commission": {
"id": "com_5179b350ece051a39f0d8c75",
"status": "available",
"source": "lifetime",
"amount_sats": 4200,
"commission_percentage": 10,
"created_at": "2026-10-23T11:00:00Z",
"paid_at": "2026-10-23T11:05:00Z",
"available_at": "2026-11-22T11:00:00Z"
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"order": {
"id": "5829104773185",
"number": "#1042",
"subtotal": "84.00",
"currency": "USD",
"created_at": "2026-10-23T10:58:12Z"
},
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e"
}

commission.reversed

All or part of a commission was clawed back (refund, cancellation, manual adjustment, or fraud).

{
"commission": {
"id": "com_5179b350ece051a39f0d8c75",
"status": "reversed",
"source": "lifetime",
"amount_sats": 4200,
"commission_percentage": 10,
"created_at": "2026-10-23T11:00:00Z",
"paid_at": "2026-10-23T11:05:00Z",
"available_at": null
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"order": {
"id": "5829104773185",
"number": "#1042",
"subtotal": "84.00",
"currency": "USD",
"created_at": "2026-10-23T10:58:12Z"
},
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e",
"reversal": {
"id": "rev_b5cbf5ab0401a0221d078f96",
"amount_sats": 2100,
"partial": true,
"reason": "refund"
}
}

Referrals

referral.created

A new customer was attributed to you for the first time at a brand.

{
"referral": {
"id": "rfl_10b2b5b1d2f9ad90bafd8810",
"created_at": "2026-10-23T10:58:30Z"
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e"
}

referral.reward_paid

The first-purchase referral reward for a customer you referred was paid to you.

{
"referral": {
"id": "rfl_10b2b5b1d2f9ad90bafd8810"
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"amount_sats": 1500,
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e"
}

Orders

order.attributed

An order was attributed to you, even if it earns no commission (commission_sats is null then).

{
"order": {
"id": "5829104773185",
"number": "#1042",
"subtotal": "84.00",
"currency": "USD",
"created_at": "2026-10-23T10:58:12Z"
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e",
"attribution": "first_purchase",
"commission_sats": 4200
}

order.refunded

An attributed order was refunded or cancelled, fully or partially.

{
"order": {
"id": "5829104773185",
"number": "#1042",
"subtotal": "84.00",
"currency": "USD",
"created_at": "2026-10-23T10:58:12Z"
},
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"customer_ref": "cus_3f9a1c0b7e2d4a6f8b1c9d0e",
"refund_type": "partial"
}

Withdrawals

withdrawal.created

A withdrawal was requested from the affiliate portal, the rewards app, or an auto-withdrawal.

{
"withdrawal": {
"id": "wd_ea00b51f15a2f184bff9b803",
"status": "pending",
"amount_sats": 25000,
"destination_type": "lightning_address",
"destination_masked": "s***@getalby.com",
"origin": "affiliate_portal",
"created_at": "2026-10-23T12:00:00Z",
"completed_at": null,
"failure": null
}
}

withdrawal.completed

The Lightning payment for a withdrawal was confirmed.

{
"withdrawal": {
"id": "wd_ea00b51f15a2f184bff9b803",
"status": "completed",
"amount_sats": 25000,
"destination_type": "lightning_address",
"destination_masked": "s***@getalby.com",
"origin": "affiliate_portal",
"created_at": "2026-10-23T12:00:00Z",
"completed_at": "2026-10-23T12:00:41Z",
"failure": null
}
}

withdrawal.failed

A withdrawal failed and the sats were returned to your wallet.

{
"withdrawal": {
"id": "wd_ea00b51f15a2f184bff9b803",
"status": "failed",
"amount_sats": 25000,
"destination_type": "lightning_address",
"destination_masked": "s***@getalby.com",
"origin": "affiliate_portal",
"created_at": "2026-10-23T12:00:00Z",
"completed_at": null,
"failure": {
"code": "NO_ROUTE",
"message": "Could not find a route to the destination."
}
}
}

Program

program.joined

You joined a brand's affiliate program (automatically, by approval, or by import).

{
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"membership": {
"id": "mem_7b1a1d043ee66d23f0967b28",
"code": "JANE10",
"link": "https://example-coffee.com/?ref=JANE10",
"discount_code": "JANE10",
"is_oshi_network": false,
"joined_via": "approved"
}
}

program.application_denied

A brand declined your application to its affiliate program.

{
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"application": {
"id": "app_cb5dc7d15281de961eb9bae8"
}
}

program.removed

A brand removed you from its affiliate program.

{
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"membership": {
"id": "mem_7b1a1d043ee66d23f0967b28",
"code": "JANE10"
}
}

discount_code.approved

A brand approved the discount code you requested.

{
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"membership_id": "mem_7b1a1d043ee66d23f0967b28",
"discount_code": "JANE15",
"link": "https://example-coffee.com/discount/JANE15"
}

discount_code.rejected

A brand rejected the discount code you requested.

{
"business": {
"id": "biz_30b71995a9ec2fcd10ca0275",
"name": "Example Coffee Co."
},
"membership_id": "mem_7b1a1d043ee66d23f0967b28",
"requested_discount_code": "JANE50"
}

Test

webhook.test

Sent only by Send test, only to that endpoint, whatever its event list. Not retried.

{
"message": "This is a test event from Oshi.",
"endpoint_id": "we_4c1d9e0f2a3b5c6d7e8f9a0b"
}

Notes

  • commission.status is one of pending, paid (funded, still locked), available (withdrawable), reversed.
  • withdrawal.* covers every withdrawal from your wallet, whichever app started it; origin tells you which. Destinations are masked.
  • commission.available is computed periodically and can arrive a few minutes after the lockup ends.
  • Object ids (biz_, com_, rfl_, wd_, mem_, app_, rev_) and customer_ref are opaque and stable for your account, so you can match events about the same object. order.id is the store's own order id.

Retries & Auto-disable

  • A delivery succeeds when your endpoint returns any 2xx within 10 seconds. Redirects are not followed and count as failures.
  • Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 10 hours: 8 attempts over about 28 hours. After that the delivery is marked failed.
  • Any delivery can be sent again with Resend in the delivery log. It goes out within about a minute, with the same webhook-id.
  • If an endpoint fails 50 times in a row and hasn't succeeded for 3 days, it is disabled and the reason is shown on the endpoint page. Fix your receiver, send a test, then click Re-enable.
  • Pausing an endpoint queues its events; they are sent when you resume.
  • The delivery log keeps 30 days of history.

Idempotency & Ordering

  • Delivery is at least once. Store the webhook-id (same as id in the body) and skip ones you have already processed.
  • Events are not guaranteed to arrive in order. A commission.paid can arrive before its commission.created. Each payload carries the object's current status, and timestamp is when the event happened, so keep the newest state per object id.

Secret Rotation

Rotate on the endpoint page creates a new secret immediately. For the next 24 hours each delivery carries two signatures, one per secret, so a receiver using either one keeps working. Deploy the new secret within that window; after 24 hours only the new secret signs. Rotate right away if a secret may have leaked.

Security

  • Always verify the signature and reject timestamps more than 5 minutes from now. This blocks forged and replayed requests.
  • Compare signatures with a constant-time function (timingSafeEqual, hmac.compare_digest, hash_equals).
  • Respond 2xx fast, then do slow work in a queue. Anything over 10 seconds is a failure and will be retried.
  • Endpoints must be public https:// URLs on port 443. Private, loopback and link-local addresses are rejected.
  • Keep the secret server-side, like an API key.

Testing

  • Zapier: create a Zap with the Webhooks by Zapier → Catch Hook trigger, paste its URL as an endpoint, then click Send test so Zapier learns the fields. Make's Custom webhook works the same way.
  • Request bins: point an endpoint at a webhook.site URL to see raw requests and headers. Delete it when you're done.
  • Local development: expose your local server through a tunnel (ngrok, Cloudflare Tunnel) to get a public https URL.
  • Use the delivery log to inspect each payload and your response, and Resend to replay one against a fixed handler.