Docs / Concepts / Webhooks

Webhooks

Event delivery from QuotaStack to your application, with HMAC-SHA256 signing, retry schedule, and event catalog.

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
QuotaStack Your Server POST event verify HMAC timeout ✗ down retry (backoff) 200 OK ✓

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.

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 for details.

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 specification using HMAC-SHA256.

Each delivery includes three headers:

HeaderDescription
webhook-idUnique event ID. Use for deduplication.
webhook-signaturev1,{base64(HMAC-SHA256(secret, "{webhook-id}.{timestamp}.{body}"))}
webhook-timestampUnix 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

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 above).

Retry schedule

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

AttemptDelay after previous
1Immediate
230 seconds
35 minutes
430 minutes
52 hours
68 hours
724 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.

{
  "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.

{
  "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.

{
  "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.

{
  "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:

{
  "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.

{
  "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:

FieldDescription
event_idUnique event ID (also sent as the webhook-id header). Use for deduplication.
event_typeThe event name (e.g. credit.granted, subscription.renewed).
tenant_idYour tenant UUID.
environmentlive 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_atISO 8601 timestamp the event was generated.
idempotency_keyInternal key for the source operation.
dataEvent-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 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

The mistakes developers typically make with this concept — and what to do instead.

×
Don't skip HMAC signature verification
Why
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
Why
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
Why
Retries mean the same event may arrive multiple times. Use the event ID as an idempotency key in your handler.
🤖
Building with an AI agent?
Get this page as markdown: /docs/concepts/webhooks.md · Full index: /llms.txt