# 5. Webhooks

Webhooks let SIR Giving notify your backend when something changes. For example, you can receive an event when an action completes, an action fails, a token pool runs low, or a campaign changes status.

Use webhooks when your system needs a durable server-side record of reward outcomes.

## How webhook delivery works

1. You register an HTTPS endpoint.
2. SIR Giving returns a webhook signing secret once.
3. SIR Giving sends events to your endpoint.
4. Your server verifies the signature.
5. Your server stores or queues the event.
6. Your server returns a 2xx response quickly.

## Register a webhook

```http
POST /v1/partner/webhooks
X-Partner-Key: sk_test_...
X-Timestamp: <timestamp>
X-Signature: <signature>
Content-Type: application/json

{
  "url": "https://your-app.example.com/webhooks/sir",
  "description": "Sandbox webhook",
  "eventTypes": ["action.completed", "action.failed"],
  "receiveAllEvents": false
}
```

Response:

```json
{
  "id": "webhook-id",
  "url": "https://your-app.example.com/webhooks/sir",
  "eventTypes": ["action.completed", "action.failed"],
  "secret": "whsec_...",
  "isActive": true
}
```

Store `secret` immediately. It is shown once.

## Delivery headers

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `SIRGiving-Webhooks/1.0` |
| `X-SIR-Signature` | `sha256=<hex>` |
| `X-SIR-Timestamp` | Unix timestamp in seconds |

## Event envelope

```json
{
  "id": "evt_abc123",
  "type": "action.completed",
  "createdAt": "2026-05-10T09:12:00Z",
  "data": {
    "actionId": "...",
    "tokensDistributed": 50
  }
}
```

## Verify signatures

SIR Giving signs the timestamp and raw request body:

```text
signedPayload = X-SIR-Timestamp + "." + rawRequestBody
expected = "sha256=" + HMAC_SHA256_hex(webhookSecret, signedPayload)
```

Compare `expected` to `X-SIR-Signature` using constant-time comparison.

## Node.js Express example

```ts
import crypto from 'crypto';
import express from 'express';

const app = express();

app.use('/webhooks/sir', express.raw({ type: 'application/json' }));

app.post('/webhooks/sir', (req, res) => {
  const signature = req.header('X-SIR-Signature') ?? '';
  const timestamp = req.header('X-SIR-Timestamp') ?? '';
  const rawBody = req.body as Buffer;

  const signedPayload = `${timestamp}.${rawBody.toString()}`;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.SIR_WEBHOOK_SECRET!)
    .update(signedPayload)
    .digest('hex');

  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  if (!valid) return res.status(401).end();

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (ageSeconds > 300) return res.status(401).end();

  const event = JSON.parse(rawBody.toString());
  // Store or enqueue the event here.

  return res.status(200).end();
});
```

## Retry behavior

- Return a 2xx response when you accept the event.
- Anything else is treated as a failed delivery.
- Failed deliveries are retried with backoff.
- Slow handlers can cause duplicate deliveries, so return quickly and process asynchronously.
- You can inspect delivery history with `GET /v1/partner/webhooks/:id/deliveries`.
- You can manually retry a failed delivery with `POST /v1/partner/webhooks/:id/deliveries/:deliveryId/retry`.

## Test your webhook

```http
POST /v1/partner/webhooks/:id/test
X-Partner-Key: sk_test_...
X-Timestamp: <timestamp>
X-Signature: <signature>
```

Then check:

```http
GET /v1/partner/webhooks/:id/deliveries
```

## Common event types

| Event | When it fires |
|---|---|
| `action.completed` | An action processed successfully |
| `action.failed` | Action processing failed |
| `action.reversed` | An action was fully or partially reversed |
| `transaction.completed` | Token distribution transaction completed |
| `transaction.reversed` | Token distribution transaction reversed |
| `token_pool.low_balance` | Pool dropped below threshold |
| `token_pool.depleted` | Pool has no remaining balance |
| `token_pool.refilled` | Pool was topped up |
| `token_pool_request.approved` | Allocation request approved |
| `token_pool_request.rejected` | Allocation request rejected |
| `campaign.activated` | Campaign moved to active |
| `campaign.paused` | Campaign paused |
| `campaign.completed` | Campaign ended |
| `api_key.expiring` | One of your keys is nearing expiry |