> ## Documentation Index
> Fetch the complete documentation index at: https://docs.scrip.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Real-time HTTP callbacks for balance changes, redemptions, and more

Webhooks push HTTP notifications to your server when key events happen: balance changes, redemptions, tier transitions, and more. Instead of polling the API for changes, you register a URL and Scrip delivers signed payloads the moment each event occurs.

## Creating an Endpoint

Register a URL and specify which events you want to receive:

```bash theme={null}
curl -X POST https://api.scrip.dev/v1/webhook-endpoints \
  -H "Authorization: Bearer $SCRIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/scrip",
    "description": "Production receiver",
    "enabled_events": ["balance.credited", "redemption.completed"]
  }'
```

The response includes a `secret` starting with `whsec_`. Store it immediately. It cannot be retrieved later. You'll use it to [verify signatures](#signature-verification).

```json theme={null}
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "url": "https://example.com/webhooks/scrip",
  "secret": "whsec_a1b2c3d4e5f6...",
  "enabled_events": ["balance.credited", "redemption.completed"],
  "status": "ACTIVE",
  "created_at": "2026-01-15T10:30:00Z",
  "updated_at": "2026-01-15T10:30:00Z"
}
```

Use `"enabled_events": ["*"]` to subscribe to all event types.

<Warning>
  URLs must use HTTPS with a publicly resolvable hostname. Private IPs, `localhost`, `.local`, and `.internal` domains are rejected.
</Warning>

## Event Types

| Event                      | Description                                              |
| -------------------------- | -------------------------------------------------------- |
| `balance.credited`         | Balance increased via rule action, API credit, or refund |
| `balance.debited`          | Balance decreased via rule action or API debit           |
| `balance.expired`          | Lots expired and auto-forfeited                          |
| `balance.held`             | Balance moved from AVAILABLE to HELD                     |
| `balance.released`         | Balance moved from HELD back to AVAILABLE                |
| `balance.voided`           | Provisionally issued HELD lots cancelled via void-hold   |
| `redemption.completed`     | Amount or catalog item redeemed                          |
| `redemption.reversed`      | Redemption fully or partially reversed                   |
| `transfer.completed`       | Transfer between participants or groups completed        |
| `event.completed`          | Event processing succeeded (all rules evaluated)         |
| `event.failed`             | Event processing or validation failed                    |
| `participant.created`      | New participant enrolled                                 |
| `participant.tier_changed` | Tier level upgraded, downgraded, or removed              |
| `program.funded`           | Program wallet funded                                    |
| `program.burned`           | Program wallet balance burned                            |

## Payload Format

Every delivery sends a JSON envelope:

```json theme={null}
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "type": "balance.credited",
  "api_version": "2026-03-01",
  "created_at": "2026-01-15T10:30:00Z",
  "organization_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "data": {
    "journal_entry_id": "...",
    "organization_id": "...",
    "program_id": "...",
    "participant_id": "...",
    "asset_id": "...",
    "amount": "100.00",
    "bucket": "AVAILABLE"
  }
}
```

| Field             | Description                                                              |
| ----------------- | ------------------------------------------------------------------------ |
| `id`              | Webhook event ID (UUID). Same across all endpoints receiving this event. |
| `type`            | The event type string.                                                   |
| `api_version`     | API version at time of emission. Currently `2026-03-01`.                 |
| `created_at`      | When the event was created (RFC 3339).                                   |
| `organization_id` | Organization that owns the event.                                        |
| `data`            | Event-specific payload.                                                  |

### Event Payloads

Balance and transfer payloads identify the target with exactly one of `participant_id` or `group_id`, depending on whether the target is a participant or a group. The examples below show the participant form; the group form is identical with `group_id` in place of `participant_id`. For `transfer.completed`, the source field is `source_participant_id` or `source_group_id`.

**`balance.credited` / `balance.debited`**

```json theme={null}
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "participant_id": "uuid",
  "asset_id": "uuid",
  "amount": "100.00",
  "bucket": "AVAILABLE",
  "reference_id": "auth_12345",
  "settle": {"held_amount": "80.00", "delta": "20.00"}
}
```

`reference_id` is present when the credit or settle was correlated to a hold. `settle` is present only for settle operations (credit with `reference_id` to AVAILABLE) and contains `held_amount` (total previously held) and `delta` (settle minus held; positive = over-capture, negative = under-capture).

**`balance.expired`**

```json theme={null}
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "participant_id": "uuid",
  "asset_id": "uuid",
  "lot_count": 3
}
```

**`balance.held` / `balance.released`**

```json theme={null}
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "participant_id": "uuid",
  "asset_id": "uuid",
  "amount": "100.00"
}
```

**`balance.voided`**

```json theme={null}
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "participant_id": "uuid",
  "asset_id": "uuid",
  "amount": "100.00",
  "reference_id": "auth_12345"
}
```

**`transfer.completed`**

```json theme={null}
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "source_participant_id": "uuid",
  "asset_id": "uuid",
  "recipients": [
    {
      "participant_id": "uuid",
      "amount": "50.00"
    }
  ]
}
```

<Note>
  The source uses exactly one of `source_participant_id` or `source_group_id`. Each entry in `recipients` contains exactly one of `participant_id` or `group_id`, along with the `amount` transferred to that recipient. Source and recipient types may differ (e.g., a participant can transfer to a group).
</Note>

**`redemption.completed`** (amount redemption)

```json theme={null}
{
  "redemption_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "participant_id": "uuid",
  "asset_id": "uuid",
  "amount": "50.00"
}
```

**`redemption.completed`** (catalog item redemption)

```json theme={null}
{
  "redemption_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "participant_id": "uuid",
  "reward_catalog_item_id": "uuid"
}
```

**`redemption.reversed`**

```json theme={null}
{
  "reversal_id": "uuid",
  "redemption_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "participant_id": "uuid",
  "asset_id": "uuid",
  "amount": "50.00"
}
```

**`participant.created`**

```json theme={null}
{
  "participant_id": "uuid",
  "organization_id": "uuid",
  "external_user_id": "user_123"
}
```

**`participant.tier_changed`**

When the target is a participant:

```json theme={null}
{
  "participant_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "tier": "loyalty",
  "level": "gold"
}
```

When the target is a group:

```json theme={null}
{
  "group_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "tier": "loyalty",
  "level": "gold"
}
```

When the target is a program:

```json theme={null}
{
  "program_id": "uuid",
  "organization_id": "uuid",
  "tier": "loyalty",
  "level": "gold"
}
```

`level` is `null` when a tier is removed (downgrade to base level).

<Note>
  Exactly one of `participant_id`, `group_id`, or `program_id` is present, depending on the target of the tier change. The `SET_TIER` rule action supports cross-targeting via the `target` field, so consumers should not assume this is always a participant.
</Note>

**`program.funded` / `program.burned`**

```json theme={null}
{
  "journal_entry_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "asset_id": "uuid",
  "amount": "1000.00"
}
```

**`event.completed`**

```json theme={null}
{
  "event_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid"
}
```

**`event.failed`**

```json theme={null}
{
  "event_id": "uuid",
  "organization_id": "uuid",
  "program_id": "uuid",
  "error": "description of what went wrong",
  "error_code": "participant_suspended"
}
```

`error_code` is included when the failure has a classified machine-readable code (e.g. `participant_suspended`, `program_inactive`); it is omitted for unclassified failures. The same code is persisted on the event resource, so you can branch on failure type without string-matching `error`. Some early validation failures (e.g. an unparseable payload) omit `program_id`.

## Signature Verification

Every delivery includes a `Scrip-Signature` header so you can verify it came from Scrip and wasn't tampered with.

### Header Format

```
Scrip-Signature: t=1706090400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
```

| Component | Description                                               |
| --------- | --------------------------------------------------------- |
| `t`       | Unix timestamp (seconds) when the signature was generated |
| `v1`      | Hex-encoded HMAC-SHA256 signature                         |

### Verification Steps

<Steps>
  <Step title="Extract components">
    Parse the `t` and `v1` values from the `Scrip-Signature` header.
  </Step>

  <Step title="Construct signed payload">
    Concatenate the timestamp, a literal dot, and the raw request body: `{t}.{raw_body}`
  </Step>

  <Step title="Compute expected signature">
    Calculate `HMAC-SHA256(your_endpoint_secret, signed_payload)` and hex-encode the result.
  </Step>

  <Step title="Compare signatures">
    Use constant-time comparison. Reject the request if they don't match.
  </Step>

  <Step title="Check timestamp">
    Reject if `abs(now - t)` exceeds your tolerance. We recommend 5 minutes.
  </Step>
</Steps>

### Example (Python)

```python theme={null}
import hmac, hashlib, time

def verify_webhook(header, secret, body):
    # Parse header
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = parts["t"]
    signature = parts["v1"]

    # Construct signed payload
    signed_payload = f"{timestamp}.{body}"

    # Compute expected signature
    expected = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    # Constant-time compare
    if not hmac.compare_digest(signature, expected):
        raise ValueError("Invalid signature")

    # Replay protection
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp too old")
```

### Example (Node.js)

```javascript theme={null}
const crypto = require("crypto");

function verifyWebhook(header, secret, body) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=", 2))
  );
  const { t: timestamp, v1: signature } = parts;

  const signedPayload = `${timestamp}.${body}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  const valid = crypto.timingSafeEqual(
    Buffer.from(signature, "hex"),
    Buffer.from(expected, "hex")
  );

  if (!valid) throw new Error("Invalid signature");
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    throw new Error("Timestamp too old");
}
```

## Retry Policy

If your endpoint has a retryable failure such as a 5xx response, network error, or timeout, Scrip retries with exponential backoff:

| Attempt | Delay     | Cumulative |
| ------- | --------- | ---------- |
| 1       | Immediate | 0          |
| 2       | 5 min     | 5 min      |
| 3       | 10 min    | 15 min     |
| 4       | 20 min    | 35 min     |
| 5       | 40 min    | 1h 15m     |
| 6       | 80 min    | 2h 35m     |
| 7       | 160 min   | 5h 15m     |
| 8       | 320 min   | 10h 35m    |

After 8 attempts (\~10.5 hours), the delivery is marked `FAILED`. You can manually [resend](#resend-a-delivery) any terminal delivery to send the event again.

### Response Handling

| Your Response               | Scrip's Behavior                                                                                                                                                                                                                                                                      |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **2xx**                     | Marked `DELIVERED`                                                                                                                                                                                                                                                                    |
| **429** (rate limited)      | Retried at the `Retry-After` time when provided, otherwise after 30 seconds; parsed `Retry-After` values are capped at 1 hour. Does not count toward the endpoint's failure rate. The endpoint is paused until `rate_limited_until`, while new matching events can continue to queue. |
| **4xx** (except 429)        | Marked `FAILED` immediately. No retry.                                                                                                                                                                                                                                                |
| **5xx**                     | Retried on the backoff schedule                                                                                                                                                                                                                                                       |
| **Network error / timeout** | Retried on the backoff schedule                                                                                                                                                                                                                                                       |

<Note>
  Return a 2xx quickly (within 30 seconds). Process the payload asynchronously if your handler needs more time. The worker enforces a 30-second timeout per delivery attempt.
</Note>

### Delivery Error Codes

Failed attempts include a stable `error_code` alongside the human-readable `last_error`. Use `error_code` for alerts and dashboards.

| Error Code                  | Meaning                                                                           |
| --------------------------- | --------------------------------------------------------------------------------- |
| `consumer_response_timeout` | Your endpoint did not respond before the timeout                                  |
| `connection_error`          | Scrip could not connect to your endpoint                                          |
| `tls_error`                 | TLS handshake or certificate validation failed                                    |
| `consumer_4xx`              | Your endpoint returned a non-429 4xx response                                     |
| `consumer_5xx`              | Your endpoint returned a 5xx response                                             |
| `rate_limited`              | Your endpoint returned 429 and is paused for backoff                              |
| `endpoint_disabled`         | The endpoint was disabled and queued deliveries were failed out                   |
| `endpoint_archived`         | The endpoint was archived and queued deliveries were failed out                   |
| `endpoint_auto_disabled`    | The endpoint was auto-disabled because of sustained failures                      |
| `endpoint_backlog_exceeded` | The endpoint was auto-disabled because its backlog was sustained and non-draining |
| `endpoint_no_longer_active` | The endpoint was not active at delivery time                                      |
| `marshal_error`             | Scrip could not serialize the webhook payload                                     |

## Managing Endpoints

### Disable and Re-enable

Temporarily stop deliveries without deleting the endpoint:

```bash theme={null}
curl -X PATCH https://api.scrip.dev/v1/webhook-endpoints/{id} \
  -H "Authorization: Bearer $SCRIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "DISABLED"}'
```

Disabling an endpoint immediately fails queued or in-flight deliveries for that endpoint with `error_code: "endpoint_disabled"`. Set `status` back to `ACTIVE` to receive future matching events. Events that occurred while the endpoint was disabled are not retroactively delivered.

If you disabled an endpoint temporarily and still want to deliver specific failed events, re-enable the endpoint and [resend](#resend-a-delivery) the terminal delivery records.

### Rotate Secret

If a secret is compromised, rotate it immediately:

```bash theme={null}
curl -X POST https://api.scrip.dev/v1/webhook-endpoints/{id}/rotate-secret \
  -H "Authorization: Bearer $SCRIP_API_KEY"
```

The old secret is invalidated immediately. Update your verification code with the new secret before any in-flight deliveries arrive.

### Delete

Deleting an endpoint archives it. It stops receiving deliveries and is removed from list results:

```bash theme={null}
curl -X DELETE https://api.scrip.dev/v1/webhook-endpoints/{id} \
  -H "Authorization: Bearer $SCRIP_API_KEY"
```

## Endpoint Health

Scrip monitors delivery health per endpoint. A bad destination can be paused temporarily, then auto-disabled if it keeps failing or stops draining its backlog.

| Mechanism            | Effect                                                                                        |
| -------------------- | --------------------------------------------------------------------------------------------- |
| Circuit breaker      | Sustained failures pause delivery until `circuit_broken_until`                                |
| Rate-limit backoff   | A 429 response pauses delivery until `rate_limited_until`                                     |
| Failure auto-disable | Sustained failures after cooldown set the endpoint to `DISABLED` and fail queued deliveries   |
| Backlog auto-disable | A sustained, non-draining backlog sets the endpoint to `DISABLED` and fails queued deliveries |

Use delivery stats to see whether an endpoint is healthy, paused, disabled, or building a backlog:

```bash theme={null}
curl https://api.scrip.dev/v1/webhook-endpoints/delivery-stats \
  -H "Authorization: Bearer $SCRIP_API_KEY"
```

Each row includes the 24-hour delivery rollup (`success_24h`, `fail_24h`, `success_rate`, `degraded`) plus current backlog and block fields: `pending_count`, `oldest_pending_at`, `circuit_broken_until`, `rate_limited_until`, `blocked_reason`, and `blocked_until`.

To re-enable an endpoint after resolving the underlying issue:

```bash theme={null}
curl -X PATCH https://api.scrip.dev/v1/webhook-endpoints/{id} \
  -H "Authorization: Bearer $SCRIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "ACTIVE"}'
```

HTTP `429` responses do not count toward the failure-rate breaker. However, if rate limiting creates a sustained, non-draining backlog, Scrip can auto-disable the endpoint with `error_code: "endpoint_backlog_exceeded"`. Re-enable the endpoint and resend any failed deliveries you still need.

## Debugging Deliveries

### List Deliveries for an Endpoint

```bash theme={null}
curl https://api.scrip.dev/v1/webhook-endpoints/{id}/deliveries?status=FAILED \
  -H "Authorization: Bearer $SCRIP_API_KEY"
```

### Inspect a Delivery

The detail endpoint includes `last_response_status`, `last_response_body` (truncated to 4 KB), `last_error`, `error_code`, and `resend_seq`:

```bash theme={null}
curl https://api.scrip.dev/v1/webhook-deliveries/{id} \
  -H "Authorization: Bearer $SCRIP_API_KEY"
```

### Resend a Delivery

Resend issues a **new** delivery of the same event to the same endpoint: a fresh delivery with its own `id` and an incremented `resend_seq`. The source delivery is left untouched, so you keep a complete history of every attempt.

```bash theme={null}
curl -X POST https://api.scrip.dev/v1/webhook-deliveries/{id}/resend \
  -H "Authorization: Bearer $SCRIP_API_KEY"
```

The response is the newly created delivery (status `PENDING`), which you can poll like any other. Use resend to replay a webhook after fixing a bug in your handler, or to confirm your endpoint deduplicates correctly.

Only **terminal** deliveries can be resent, and the endpoint must be active:

| Condition                                  | Response                                    |
| ------------------------------------------ | ------------------------------------------- |
| Source delivery is `DELIVERED` or `FAILED` | New delivery created, returned as `PENDING` |
| Source delivery is `PENDING` or `SENDING`  | `409 Conflict` (`not_resendable`)           |
| Endpoint is disabled or archived           | `409 Conflict` (`endpoint_not_active`)      |

## Best Practices

| Practice                           | Rationale                                                                           |
| ---------------------------------- | ----------------------------------------------------------------------------------- |
| Verify signatures on every request | Prevents spoofed deliveries                                                         |
| Return 2xx quickly, process async  | Avoids timeouts and unnecessary retries                                             |
| Use `*` sparingly                  | Subscribe only to events you need to reduce noise                                   |
| Handle duplicates idempotently     | Network retries can deliver the same event more than once. Use `id` to deduplicate. |
| Monitor failed deliveries          | Check delivery status periodically or alert on consecutive failures                 |

## Delivery Guarantees

Webhook events are created atomically with their domain operations. If the underlying transaction rolls back, no webhook is emitted. Retried domain operations (like event reprocessing) do not produce duplicate webhooks.

Delivery is **at-least-once**: a single event may be delivered more than once if your endpoint returns a 2xx but the acknowledgment is lost in transit. Design your handler to be idempotent using the envelope's `id` field to detect duplicates.
