Webhooks
Event delivery from QuotaStack to your application, with HMAC-SHA256 signing, retry schedule, and event catalog.
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.
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:
| 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
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:
| 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.
{
"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:
| 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 for the two ID types.
Best practices
-
Respond quickly. Return a 2xx within 5 seconds. If processing takes longer, accept the webhook, queue the work, and process asynchronously.
-
Verify signatures. Always validate the
webhook-signatureheader before processing. Reject requests with invalid or missing signatures. -
Check timestamps. Reject events with a
webhook-timestampmore than 5 minutes old to prevent replay attacks. -
Deduplicate. Use
webhook-idto detect redeliveries. Store processed event IDs and skip duplicates. -
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.
-
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.
webhook-signature header before trusting the payload.