Webhooks
Event delivery from QuotaStack to your application, with HMAC-SHA256 signing, retry schedule, and event catalog.
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, low balance, exhausted, and expiring soon, subscription lifecycle (including pause/resume), contract end
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
Setup has two parts: a URL and a signing secret. Both are required. QuotaStack does not deliver webhooks without both.
1. Set your webhook URL. Use the admin dashboard (Settings → Webhooks) or the admin API (owner or admin role). Product API keys (X-API-Key) cannot change 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/admin/tenants/{tenant_id}/config \
-H "Cookie: qs_admin_session=..." \
-H "Idempotency-Key: config-webhook:{tenantId}" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://your-app.com/webhooks/quotastack"
}'
2. Create your signing secret. You do not choose the secret — QuotaStack generates it. Click Rotate secret in the dashboard (Settings → Webhooks), or call the rotate endpoint:
curl -X POST https://api.quotastack.io/v1/admin/tenants/{tenant_id}/webhook-secret/rotate \
-H "Cookie: qs_admin_session=..." \
-H "Idempotency-Key: rotate-secret:{tenantId}:1"
The response contains the new base64 secret one time. Save it now. No endpoint returns it again — config reads only tell you whether a secret exists (webhook_secret_set).
3. Test your endpoint. Send a test event before you rely on webhooks. Use the dashboard (Settings → Webhooks → Send test event) or the API:
curl -X POST https://api.quotastack.io/v1/webhooks/test \
-H "Cookie: qs_admin_session=..." \
-H "Idempotency-Key: webhook-test:{tenantId}:1"
QuotaStack sends a test.ping event to your URL. The event goes through the real pipeline: it is signed with your secret and retried on failure, exactly like a real event. Check the result in the delivery log: GET /v1/webhooks/events?event_type=test.ping. If your signature verification passes on test.ping, it passes on every event.
Rotating the secret
Rotation is the same call. Know these three facts before you rotate:
- Rotation is immediate. The old secret stops signing the moment the call returns. There is no grace window.
- Update your verifier with the new secret right away. Until you do, deliveries fail verification at your endpoint and QuotaStack retries them.
- Retried deliveries sign with the new secret. QuotaStack computes a fresh signature on every delivery attempt, so a delivery that failed during the switchover succeeds on its next retry.
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.
Always verify against the exact raw request bytes, not a re-parsed body. In Express, mount the webhook route with express.raw({ type: "application/json" }) — a re-serialized JSON.parsed body will not match byte-for-byte.
Verifying with a library
QuotaStack follows the Standard Webhooks spec exactly, so the official standardwebhooks libraries (npm, PyPI, Go, Ruby, and more) verify deliveries out of the box. Prefer them over hand-rolling:
import { Webhook } from "standardwebhooks";
const wh = new Webhook(secret); // your base64-encoded webhook secret
wh.verify(rawBody, headers); // throws if the signature is invalid
Verifying by hand — Node/TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(rawBody: string, headers: Record<string, string>, secret: string): void {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signature = headers["webhook-signature"] ?? "";
// Reject stale events (recommended: 5 min tolerance)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error("Timestamp outside tolerance");
}
// Compute the expected signature
const secretBytes = Buffer.from(secret, "base64");
const signedContent = `${id}.${timestamp}.${rawBody}`;
const expected =
"v1," + createHmac("sha256", secretBytes).update(signedContent).digest("base64");
// Constant-time comparison; timingSafeEqual throws on length
// mismatch (e.g. a missing header), so guard the length first
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new Error("Invalid signature");
}
}
Verifying by hand — 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")
Verifying by hand — Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strconv"
"time"
)
func verifyWebhook(rawBody []byte, headers map[string]string, secret string) error {
id := headers["webhook-id"]
timestamp := headers["webhook-timestamp"]
signature := headers["webhook-signature"]
// Reject stale events (recommended: 5 min tolerance)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return errors.New("bad webhook-timestamp")
}
if age := time.Since(time.Unix(ts, 0)); age > 5*time.Minute || age < -5*time.Minute {
return errors.New("timestamp outside tolerance")
}
// Compute the expected signature
secretBytes, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
return errors.New("bad secret")
}
mac := hmac.New(sha256.New, secretBytes)
fmt.Fprintf(mac, "%s.%s.%s", id, timestamp, rawBody)
expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if !hmac.Equal([]byte(expected), []byte(signature)) {
return errors.New("invalid signature")
}
return nil
}
Pass the raw request bytes, not a re-encoded struct. Read them with
io.ReadAll(r.Body) before any JSON decoding, and hand the same slice to both
verifyWebhook and json.Unmarshal.
Test vector
Pin your implementation against this worked example before going live. The backend’s own test suite asserts the same vector against the signing code, so it cannot drift:
secret (base64): d2hzZWNfZXhhbXBsZV9zaWduaW5nX2tleQ==
webhook-id: evt_2q9vD4pM8xZk3nQeT7wYbR
webhook-timestamp: 1735689600
raw body: {"event_id":"evt_2q9vD4pM8xZk3nQeT7wYbR","event_type":"credit.granted","data":{"credits":5000}}
expected header: v1,r/3zdnogTqoRLD4vB8+9MxoKXhIjKcauQjSeH+gGRzQ=
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. Dead-lettered events are not lost — you can requeue them yourself, from the dashboard (Activity → Webhooks → Redeliver) or the API:
curl -X POST https://api.quotastack.io/v1/webhooks/events/{event_id}/redeliver \
-H "X-API-Key: $QS_KEY" \
-H "Idempotency-Key: redeliver:{event_id}"
Redelivery resets the event to pending with a fresh retry schedule (7 new attempts). The next attempt signs with your current secret — useful when the event dead-lettered because of a secret rotation or an endpoint outage you have since fixed. Only dead_letter events can be redelivered; the call returns 409 for events in any other status.
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.
Debugging deliveries
When your endpoint is not receiving events, the delivery log tells you two things: whether QuotaStack sent the event, and what your server said back. Start there before changing any code.
The delivery log
GET /v1/webhooks/events lists the events QuotaStack has emitted to your configured URL, newest first.
curl https://api.quotastack.io/v1/webhooks/events \
-H "X-API-Key: qs_live_..."
You can filter the list:
| Parameter | Values |
|---|---|
status | pending, delivered, failed, dead_letter |
event_type | Any event name, such as credit.granted |
limit | How many to return |
cursor | The pagination.next_cursor from the previous page |
To see only what has given up retrying:
curl "https://api.quotastack.io/v1/webhooks/events?status=dead_letter&limit=20" \
-H "X-API-Key: qs_live_..."
What each status means
| Status | Meaning | What to do |
|---|---|---|
pending | Queued. Either not tried yet, or waiting for the next retry. | Wait. |
delivered | Your endpoint answered with a 2xx code. | Nothing. |
failed | An attempt failed, and there are retries left. | Fix your endpoint. The next retry can still succeed. |
dead_letter | Every retry was used up. QuotaStack will not try again on its own. | Fix your endpoint, then redeliver it yourself. |
Reading a failed delivery
Each event carries its delivery attempts. Two fields answer most questions:
attempts— how many times QuotaStack has tried. Compare it to the retry schedule above to see where in the ladder you are.last_error— what went wrong on the most recent try.
Three common shapes of last_error:
- A timeout. Your endpoint took too long to answer. Return 200 first and do the slow work afterwards.
- A 4xx from your server. Your handler rejected the event. A 401 or 403 here usually means your signature check failed — confirm with the test vector above.
- A 5xx from your server. Your handler crashed. The event is fine; your code raised an error.
Redelivering a dead-lettered event
Once you have fixed the cause, send the event again:
curl -X POST https://api.quotastack.io/v1/webhooks/events/{event_id}/redeliver \
-H "X-API-Key: qs_live_..." \
-H "Idempotency-Key: $(uuidgen)"
This sets the event back to pending and resets attempts to 0, so it gets a full retry ladder again. Only dead_letter events can be redelivered; anything else returns 409.
The retry signs with your current secret. If you rotated the secret after the event first failed, verify the redelivered copy against the new secret, not the old one.
Still nothing arriving?
Send a test event before debugging further. POST /v1/webhooks/test puts a test.ping through the same pipeline — same signing, same retries — and the Setup section shows the call. If test.ping verifies, your signature handling is correct and the problem is with a specific event, not your endpoint.
Event catalog
Every event has its own page. Each one says what the payload holds, when the event fires, when it does not, and which API calls raise it.
- All events — the full catalogue, 21 of them
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.
Configuring the alerts
credit.low_balance and credit.expiring_soon are off until you switch them
on. Everything else fires on its own.
Configuring the low-balance threshold
The tenant-wide default lives on tenant config, so it needs an admin session — a product API key cannot set it. The optional per-customer override does use your normal API key.
# Tenant default (admin session — owner or admin role)
curl -X PATCH https://api.quotastack.io/v1/admin/tenants/{tenant_id}/config \
-H "Cookie: qs_admin_session=..." \
-H "Idempotency-Key: config-low-balance:{tenantId}" \
-H "Content-Type: application/json" \
-d '{"low_balance_threshold_mc": 5000}'
# Per-customer override (tenant API key)
curl -X PATCH https://api.quotastack.io/v1/customers/{customer_id} \
-H "X-API-Key: qs_live_..." \
-H "Idempotency-Key: customer-low-balance:{customerId}" \
-H "Content-Type: application/json" \
-d '{"low_balance_threshold_mc": 20000}'
On a customer, low_balance_threshold_mc set to null (or omitted) inherits the tenant default, and 0 disables credit.low_balance for that customer specifically. Both values are millicredits, as everywhere else.
Enabling proactive expiry warnings
The lead window is also tenant config, so it takes the same admin session. There is no per-customer equivalent.
# Lead time in hours (admin session — owner or admin role)
curl -X PATCH https://api.quotastack.io/v1/admin/tenants/{tenant_id}/config \
-H "Cookie: qs_admin_session=..." \
-H "Idempotency-Key: config-expiring-soon:{tenantId}" \
-H "Content-Type: application/json" \
-d '{"credit_expiring_soon_hours": 72}'
0 (the default) disables the event, and 8760 — one year — is the maximum accepted value.
How the expiry events sequence
For a pack that will zero the customer out when it lapses:
credit.expiring_soon, once the block enters the lead window — your window to notify or top up. Only if you have configured lead hours.credit.expiredwhen the block actually reaches itsexpires_at.credit.exhaustedif that drop takes effective balance to zero or below.
With credit_expiring_soon_hours left at 0 you simply start at step 2, which is the behaviour that existed before this event shipped.
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
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.
Loading…