Skip to main content

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.

Delivery format

We POST application/json with these headers:

Header Value
Content-Type application/json
User-Agent SIRGiving-Webhooks/1.0
X-SIR-Signature sha256=<hex>
X-SIR-Timestamp unix seconds
(any custom headers you set on the webhook)

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