---
title: Webhooks
description: Event delivery from QuotaStack to your application, with HMAC-SHA256 signing, retry schedule, and event catalog.
order: 8
---

# Webhooks

QuotaStack POSTs event payloads to a webhook URL you configure per tenant. Webhooks notify your application when things happen: credits granted, balance running low, subscription renewal due, contract ending.

> **Mental Model:** Webhooks are QuotaStack's **postal service** — they tell your app "something happened." Every delivery is signed (you know it's real), retried 7 times if you're offline, and ordered per-customer.

## Quick Take

- QuotaStack POSTs events to **one configured URL** per tenant
- Signed with **HMAC-SHA256** — always verify before processing
- **7 retry attempts** with exponential backoff; 5s timeout per attempt
- Events: credit granted/consumed/expired, subscription lifecycle (including pause/resume), contract end

## Diagram

Sequence diagram between QuotaStack and Your Server. QuotaStack POSTs an event; Your Server verifies the HMAC signature. If a delivery times out (server down), QuotaStack retries with exponential backoff until it gets a 200 OK.

```mermaid
sequenceDiagram
    participant Q as QuotaStack
    participant S as Your Server
    Q->>S: POST event
    Note over S: verify HMAC
    Q--xS: timeout (server down)
    Q->>S: retry (backoff)
    S->>Q: 200 OK
```

## Setup

Configure your webhook endpoint and secret in your tenant config. Your tenant ID is visible in the admin dashboard (Settings → Tenant); see [API Conventions](/docs/concepts/conventions#finding-your-tenant-id) for details.

```bash
curl -X PATCH https://api.quotastack.io/v1/tenants/{id}/config \
  -H "X-API-Key: qs_live_..." \
  -H "Idempotency-Key: config-webhook:{tenantId}" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://your-app.com/webhooks/quotastack",
    "webhook_secret": "your-base64-encoded-secret"
  }'
```

The webhook secret must be a base64-encoded string. QuotaStack uses it as the HMAC key for signing payloads. Both the URL and the secret are required -- QuotaStack will not deliver webhooks without a signing secret configured.

## Signature verification

Webhooks are signed following the [Standard Webhooks](https://www.standardwebhooks.com/) specification using HMAC-SHA256.

Each delivery includes three headers:

| Header | Description |
|--------|-------------|
| `webhook-id` | Unique event ID. Use for deduplication. |
| `webhook-signature` | `v1,{base64(HMAC-SHA256(secret, "{webhook-id}.{timestamp}.{body}"))}` |
| `webhook-timestamp` | Unix timestamp (seconds) when the event was signed. |

The signature is computed over the concatenation of `{webhook-id}.{webhook-timestamp}.{raw-body}` using your decoded webhook secret as the HMAC-SHA256 key.

### Verification example

```python
import hmac
import hashlib
import base64
import time

def verify_webhook(payload_body, headers, secret):
    webhook_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signature = headers["webhook-signature"]

    # Reject stale events (optional, recommended: 5 min tolerance)
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp too old")

    # Compute expected signature
    secret_bytes = base64.b64decode(secret)
    message = f"{webhook_id}.{timestamp}.{payload_body}".encode()
    expected = hmac.new(secret_bytes, message, hashlib.sha256).digest()
    expected_sig = "v1," + base64.b64encode(expected).decode()

    # Constant-time comparison
    if not hmac.compare_digest(signature, expected_sig):
        raise ValueError("Invalid signature")
```

## Delivery guarantees

QuotaStack guarantees **at-least-once** delivery. An event may be delivered more than once if your endpoint returns a non-2xx response, the connection fails, or the request exceeds the delivery timeout.

**Delivery timeout:** 5 seconds per attempt. If your endpoint does not return a 2xx within 5 seconds, the attempt is treated as a failure and retried. Not configurable today.

**One webhook URL per tenant.** Multiple URLs and per-event routing are not supported. Configure the URL via the tenant config endpoint (see [Setup](#setup) above).

### Retry schedule

If delivery fails (non-2xx response, timeout, or network error), QuotaStack retries with exponential backoff:

| Attempt | Delay after previous |
|---------|---------------------|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 hours |

After 7 failed attempts, the event is moved to a dead letter queue. A public replay API is not yet available — contact support to replay dead-lettered events.

### Handling duplicates

Because delivery is at-least-once, your webhook handler should be idempotent. Use the `webhook-id` header for deduplication -- if you have already processed an event with that ID, return 200 and skip processing.

## Event catalog

All customer-scoped events carry both `customer_id` (QuotaStack UUID) and `external_customer_id` (your tenant's identifier) at the envelope level. If a customer was deleted before the event fires, `external_customer_id` is omitted but `customer_id` is always present.

### Credit events

**`credit.granted`** -- Credits added to a customer's wallet from any source: topup, plan grant, compensation, manual adjustment.

```json
{
  "event_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f64",
  "event_type": "credit.granted",
  "tenant_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "environment": "live",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "external_customer_id": "user_abc",
  "created_at": "2026-04-14T12:20:00Z",
  "idempotency_key": "topup:pay_abc123",
  "data": {
    "transaction_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f65",
    "credits": 50000,
    "source": "topup",
    "reason": "Payment pay_abc123",
    "balance_after": 75000
  }
}
```

**`credit.consumed`** -- Usage event debited credits from the wallet.

```json
{
  "event_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f66",
  "event_type": "credit.consumed",
  "tenant_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "environment": "live",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "external_customer_id": "user_abc",
  "created_at": "2026-04-14T12:21:00Z",
  "idempotency_key": "usage:msg_001",
  "data": {
    "transaction_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f67",
    "credits": -1500,
    "billable_metric_key": "chat_message",
    "balance_after": 73500
  }
}
```

**`credit.expired`** -- A credit block expired (reached its `expires_at` timestamp). Expired credits are no longer available for consumption.

```json
{
  "event_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f68",
  "event_type": "credit.expired",
  "tenant_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "environment": "live",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "external_customer_id": "user_abc",
  "created_at": "2026-04-14T12:22:00Z",
  "idempotency_key": "expiry:019d8a20-4ff5-7be0-81da-e1454b3d6f64",
  "data": {
    "block_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f64",
    "credits_expired": 12000,
    "balance_after": 61500
  }
}
```

### Subscription events

**`subscription.created`** -- New subscription created for a customer.

```json
{
  "event_id": "019d...",
  "event_type": "subscription.created",
  "tenant_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "environment": "live",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "external_customer_id": "user_abc",
  "created_at": "2026-04-14T12:00:00Z",
  "idempotency_key": "sub-create:user_abc:pv_monthly_pro",
  "data": {
    "subscription_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f70",
    "plan_variant_id": "pv_monthly_pro",
    "status": "active",
    "current_period_start": "2026-04-01T00:00:00Z",
    "current_period_end": "2026-05-01T00:00:00Z"
  }
}
```

**`subscription.renewed`** -- Billing period advanced and credits granted. For prepaid, this fires after the tenant calls the renew endpoint. For postpaid, this fires automatically at period end.

Postpaid renewals include a `usage_summary` with consumption totals for the prior period:

```json
{
  "event_id": "019d...",
  "event_type": "subscription.renewed",
  "tenant_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "environment": "live",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "external_customer_id": "user_abc",
  "created_at": "2026-05-01T00:00:00Z",
  "idempotency_key": "sub-renew:019d...:cycle-2",
  "data": {
    "subscription_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f70",
    "billing_mode": "postpaid",
    "prior_period": {
      "start": "2026-04-01T00:00:00Z",
      "end": "2026-05-01T00:00:00Z"
    },
    "new_period": {
      "start": "2026-05-01T00:00:00Z",
      "end": "2026-06-01T00:00:00Z"
    },
    "usage_summary": {
      "total_credits_consumed": 42000,
      "net_balance_at_cycle_start": 50000,
      "net_balance_at_cycle_end": 58000,
      "by_billable_metric": {
        "chat_message": 30000,
        "image_generation": 12000
      }
    }
  }
}
```

**`subscription.upgraded`** -- Subscription moved to a higher-tier plan variant.

**`subscription.downgraded`** -- Subscription moved to a lower-tier plan variant.

**`subscription.paused`** -- Subscription paused via `POST /v1/subscriptions/{id}/pause`.

**`subscription.resumed`** -- Subscription reinstated via `POST /v1/subscriptions/{id}/resume`.

**`subscription.canceled`** -- Subscription canceled immediately.

**`subscription.expired`** -- Subscription expired after grace period elapsed or cancelling subscription reached period end.

### Renewal and contract events

**`subscription.renewal_due`** -- Prepaid subscriptions only. Fires when the current period end is within `renewal_due_days`. This is your signal to charge the customer and call the renew endpoint.

```json
{
  "event_id": "019d...",
  "event_type": "subscription.renewal_due",
  "tenant_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "environment": "live",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "external_customer_id": "user_abc",
  "created_at": "2026-04-28T00:00:00Z",
  "idempotency_key": "sub-renewal-due:019d...:cycle-1",
  "data": {
    "subscription_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f70",
    "current_period_end": "2026-05-01T00:00:00Z",
    "plan_variant_id": "pv_monthly_pro"
  }
}
```

**`subscription.renewal_overdue`** -- Prepaid only. Period ended without the tenant calling renew. Grace period has started.

**`subscription.contract_ending_soon`** -- Contract end date is within `contract_ending_soon_days` (default 30). Fires once.

**`subscription.contract_ended`** -- Contract end date reached. Subscription transitioned to `contract_ended` status.

## Payload format

All webhook payloads are self-contained. They include enough data for your handler to act without making follow-up API calls. Every payload includes:

| Field | Description |
|---|---|
| `event_id` | Unique event ID (also sent as the `webhook-id` header). Use for deduplication. |
| `event_type` | The event name (e.g. `credit.granted`, `subscription.renewed`). |
| `tenant_id` | Your tenant UUID. |
| `environment` | `live` or `test` — matches the API key environment that produced the event. |
| `customer_id` | (customer-scoped events only) The QuotaStack customer UUID. |
| `external_customer_id` | (customer-scoped events only) Your tenant's identifier for the customer. Omitted if the customer was deleted before the event fired. |
| `created_at` | ISO 8601 timestamp the event was generated. |
| `idempotency_key` | Internal key for the source operation. |
| `data` | Event-specific payload with all relevant fields. |

Both customer identifiers live at the **envelope level**, not inside `data`. This means handlers can route by `external_customer_id` without parsing event-specific bodies. See [Customer identification](/docs/concepts/customer-identification) for the two ID types.

## Best practices

1. **Respond quickly.** Return a 2xx within 5 seconds. If processing takes longer, accept the webhook, queue the work, and process asynchronously.

2. **Verify signatures.** Always validate the `webhook-signature` header before processing. Reject requests with invalid or missing signatures.

3. **Check timestamps.** Reject events with a `webhook-timestamp` more than 5 minutes old to prevent replay attacks.

4. **Deduplicate.** Use `webhook-id` to detect redeliveries. Store processed event IDs and skip duplicates.

5. **Handle retries gracefully.** Your endpoint will receive the same event multiple times if it returns non-2xx. Make your handler idempotent -- use the event's idempotency key when calling QuotaStack APIs from within your handler.

6. **Use the payload directly.** Webhook payloads contain all the data you need. Avoid round-tripping back to the QuotaStack API to fetch event details.

## Common Mistakes

**✗ Don't skip HMAC signature verification**

Without it, anyone who learns your webhook URL can spoof events. Always verify the `webhook-signature` header before trusting the payload.

**✗ Don't do heavy work in the webhook handler**

Slow responses cause timeouts, which trigger retries, which duplicate work. Ack within 2 seconds and queue the real processing.

**✗ Don't assume once-only delivery**

Retries mean the same event may arrive multiple times. Use the event ID as an idempotency key in your handler.
