5. Webhooks
Webhooks
When events happen (an action completes, a campaign starts, a token pool runs low), we POST a JSON payload to the URL you registered. You verify the signature, return 2xx, and process asynchronously.
Registering a webhook
POST /v1/partner/webhooks (sk_ key required)
{
"url": "https://your-app.example.com/webhooks/sir",
"description": "Production webhook",
"eventTypes": ["action.completed", "action.failed", "transaction.completed"],
"receiveAllEvents": false,
"headers": { "X-My-Tenant": "prod" }
}
Response (the secret is shown once — store it):
{
"id": "...",
"url": "https://your-app.example.com/webhooks/sir",
"eventTypes": [...],
"secret": "whsec_<64hex>",
"isActive": true,
...
}
receiveAllEvents: true subscribes you to every event type. Otherwise, only the eventTypes[] you list.
We POST application/json with these headers:
Payload envelope
{
"id": "evt_abc123",
"type": "action.completed",
"createdAt": "2026-05-10T09:12:00Z",
"data": {
/* event-specific shape */
}
}
Signature verification
Algorithm:
signedPayload = `${X-SIR-Timestamp}.${rawRequestBody}`
expected = `sha256=` + HMAC-SHA256_hex(webhookSecret, signedPayload)
verify = constant-time-equal(X-SIR-Signature, expected)
The body is signed as the exact bytes we sent — do not re-serialize before hashing.
Node.js verifier (Express raw body)
import crypto from 'crypto';
import express from 'express';
const app = express();
app.use('/webhooks/sir', express.raw({ type: 'application/json' })); // raw body!
app.post('/webhooks/sir', (req, res) => {
const sig = req.header('X-SIR-Signature') ?? '';
const ts = req.header('X-SIR-Timestamp') ?? '';
const rawBody = req.body as Buffer; // Buffer because of express.raw
const signedPayload = `${ts}.${rawBody.toString()}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(signedPayload)
.digest('hex');
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.status(401).end();
// Optional: reject old events to prevent replay
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end();
const event = JSON.parse(rawBody.toString());
// … hand off to a queue and return quickly
res.status(200).end();
});
Python verifier (Flask)
import hmac, hashlib, time, os
from flask import request, abort
@app.post("/webhooks/sir")
def hook():
sig = request.headers.get("X-SIR-Signature", "")
ts = request.headers.get("X-SIR-Timestamp", "")
raw = request.get_data()
signed_payload = f"{ts}.{raw.decode()}"
expected = "sha256=" + hmac.new(
os.environ["WEBHOOK_SECRET"].encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
if abs(time.time() - int(ts)) > 300:
abort(401)
event = request.get_json()
# … enqueue, return 200
return "", 200
Retry & failure behavior
- We expect a 2xx within a reasonable timeout. Anything else counts as a failure.
- Failed deliveries are retried with exponential backoff. The delivery record's
status cycles through PENDING → RETRYING → DELIVERED or FAILED.
- After N consecutive failures across all deliveries, the webhook is auto-disabled and
disabledReason is set. You'll need to register a new endpoint or contact support to re-enable.
- You can manually re-queue a single failure:
POST /v1/partner/webhooks/:id/deliveries/:deliveryId/retry.
GET /v1/partner/webhooks/:id/deliveries returns a paginated, filterable history including request payload, response status/body (truncated to 10 KB), and timing.
Event types
Actions
| Type |
Fires when |
action.completed |
An action processed successfully and tokens were distributed |
action.failed |
Action processing failed |
action.reversed |
An action was fully or partially reversed |
Transactions
| Type |
|
transaction.completed |
Token distribution transaction succeeded |
transaction.reversed |
Token distribution transaction was reversed |
Redemptions
| Type |
|
redemption.approved |
Redemption request approved |
redemption.rejected |
Redemption request rejected |
redemption.completed |
Redemption fulfilled |
Token pools
| Type |
|
token_pool.low_balance |
Pool below threshold |
token_pool.depleted |
Pool empty |
token_pool.refilled |
Pool topped up |
token_pool_request.approved |
Allocation request approved |
token_pool_request.rejected |
Allocation request rejected |
Users
| Type |
|
user.created |
New partner user created |
user.upgraded |
Partner user upgraded to full SIR account |
Campaigns
| Type |
|
campaign.activated |
Campaign moved to ACTIVE |
campaign.paused |
Campaign paused |
campaign.completed |
Campaign ended |
campaign.budget_reached |
Campaign budget hit |
Bulk
| Type |
|
bulk_job.started |
Bulk processing started |
bulk_job.completed |
Bulk processing finished |
bulk_job.failed |
Bulk processing failed |
Partner / API key
| Type |
|
partner.status_changed |
Your partner status changed (also used for the test ping) |
api_key.expiring |
One of your keys is approaching its expiresAt |
Testing
POST /v1/partner/webhooks/:id/test dispatches a partner.status_changed ping with body { "type": "ping", "message": "Test webhook delivery from Partner API", "webhookId": "...", "timestamp": "..." }. Use this to verify your endpoint is reachable and your signature verification is correct.
The GET /v1/partner/dashboard/webhooks/health endpoint returns the last-24h success rate, average response time, and recent failures across all your webhooks.