Quota
/ docs
Dashboard

Webhooks

Quota POSTs JSON to your endpoint when balances change, users connect, or usage completes. Every delivery is signed with HMAC-SHA256 over the raw body so you can verify authenticity.

POSThttps://api.usequota.ai/v1/webhooks
GEThttps://api.usequota.ai/v1/webhooks
PATCHhttps://api.usequota.ai/v1/webhooks/:id
DELETEhttps://api.usequota.ai/v1/webhooks/:id
Management endpoints use session auth
Webhook management is scoped to your developer account, not an API key. Authenticate with a session token (Authorization: Bearer sess_…) — the same credential the dashboard uses. API keys (sk-quota-…) are rejected with 401. The per-webhook whsec_… secret returned on creation only signs outgoing deliveries — never use it as a bearer token.

Event types#

user.connectedeventA user linked their Quota account to your app via OAuth.
user.disconnectedeventA user unlinked their account.
balance.updatedeventA user's credit balance changed after an API request.
balance.loweventA user's balance dropped below the configured low_balance_threshold.
usage.completedeventAn API request completed and credits were deducted.

Event payloads#

Every delivery sends a JSON body with id, type, created_at, and an event-specific data object.

balance.updated

{
  "id": "evt_abc123",
  "type": "balance.updated",
  "created_at": "2026-01-15T12:00:00.000Z",
  "data": {
    "user_id": "usr_123",
    "new_balance": 999950,
    "amount_spent": 50,
    "model": "gpt-4o",
    "endpoint": "/v1/chat/completions"
  }
}
Endpoint is a route template
The endpoint field on balance.updated emits a literal route template (e.g. /v1/chat/completions) — match on the template string, not on a concrete URL.

balance.low

{
  "id": "evt_def456",
  "type": "balance.low",
  "created_at": "2026-01-15T12:05:00.000Z",
  "data": {
    "user_id": "usr_123",
    "current_balance": 950000,
    "threshold": 1000000
  }
}

usage.completed

{
  "id": "evt_ghi789",
  "type": "usage.completed",
  "created_at": "2026-01-15T12:00:01.000Z",
  "data": {
    "user_id": "usr_123",
    "model": "gpt-4o",
    "prompt_tokens": 150,
    "completion_tokens": 80,
    "total_tokens": 230,
    "credits_used": 1175
  }
}

user.connected

{
  "id": "evt_jkl012",
  "type": "user.connected",
  "created_at": "2026-01-15T10:00:00.000Z",
  "data": {
    "user_id": "usr_123",
    "connected_at": "2026-01-15T10:00:00.000Z"
  }
}

user.disconnected

{
  "id": "evt_mno345",
  "type": "user.disconnected",
  "created_at": "2026-01-15T14:00:00.000Z",
  "data": {
    "user_id": "usr_123",
    "disconnected_at": "2026-01-15T14:00:00.000Z"
  }
}

Signature verification#

Every delivery includes an X-Quota-Signature header containing an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret.

verifyWebhookSignature is async — await it
The function returns a Promise<boolean>. Forgetting await makes if (isValid) branch on a Promise (always truthy) and silently bypasses verification. The SDK uses Web Crypto so it runs on Node, edge runtimes, and workers.
import { verifyWebhookSignature } from "@usequota/nextjs";

const rawBody = await request.text();
const signature = request.headers.get("X-Quota-Signature");

if (!signature) {
  return new Response("Missing signature", { status: 401 });
}

// Note the await — verifyWebhookSignature is async.
const isValid = await verifyWebhookSignature({
  payload: rawBody,
  signature,
  secret: process.env.QUOTA_WEBHOOK_SECRET!,
});

if (!isValid) {
  return new Response("Invalid signature", { status: 401 });
}

The Core SDK exposes verifyWebhookSignature and parseWebhook; the Next.js SDK adds createWebhookHandler, which verifies signatures and routes events for you.

Delivery headers#

X-Quota-SignatureheaderHMAC-SHA256 hex digest of the raw body, signed with the webhook secret.
X-Quota-EventheaderThe event type (e.g. balance.updated).
Content-TypeheaderAlways application/json.
User-AgentheaderAlways Quota-Webhooks/1.0.

Create a webhook#

curl -X POST https://api.usequota.ai/v1/webhooks \
  -H "Authorization: Bearer sess_your_session_token" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "your_client_id",
    "url": "https://yourapp.com/api/webhooks/quota",
    "events": ["balance.updated", "balance.low", "usage.completed"],
    "low_balance_threshold": 1000000
  }'
{
  "id": "wh_abc123",
  "url": "https://yourapp.com/api/webhooks/quota",
  "events": ["balance.updated", "balance.low", "usage.completed"],
  "secret": "whsec_abc123...",
  "low_balance_threshold": 1000000,
  "active": true,
  "created_at": "2026-01-15T12:00:00.000Z"
}
The secret is only returned on creation
Store secret securely the moment you create the webhook — Quota never returns it again. Lose it and you have to rotate the webhook to get a fresh one.

Create body

client_idstringrequiredOAuth client the webhook is scoped to. Must belong to the authenticated developer.
urlstringrequiredHTTPS endpoint Quota will POST to. http:// is allowed only for localhost during development.
eventsstring[]requiredNon-empty array of event types to subscribe to.
low_balance_thresholdnumberCredit threshold that fires balance.low. Optional; omit to disable the event.

List, get, update, delete#

List

curl "https://api.usequota.ai/v1/webhooks?client_id=your_client_id" \
  -H "Authorization: Bearer sess_your_session_token"

Get one

GET /v1/webhooks/:id returns one webhook's configuration (without the secret).

Update

PATCH /v1/webhooks/:id updates URL, events, threshold, or active status. Pass any subset.

curl -X PATCH https://api.usequota.ai/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer sess_your_session_token" \
  -H "Content-Type: application/json" \
  -d '{"active": false}'

Delete

DELETE /v1/webhooks/:id removes the subscription. No body. Returns { "success": true }.

Delivery history#

GET /v1/webhooks/:id/deliveries returns recent attempts (default 50, max 100 via ?limit=) including the response status and body returned by your endpoint.

{
  "deliveries": [
    {
      "id": "del_xyz",
      "event_type": "balance.updated",
      "payload": { "...": "..." },
      "response_status": 200,
      "response_body": "OK",
      "delivered_at": "2026-01-15T12:00:01.000Z",
      "created_at": "2026-01-15T12:00:00.000Z"
    }
  ]
}

Retries & URL rules#

Failed deliveries (non-2xx responses or network errors) are retried up to 3 times within 24 hours of creation.

  • Production webhooks must use https://.
  • http:// is allowed for localhost during development.
  • Private and link-local IPs are blocked at the SSRF layer (10.0.0.0/8, 192.168.0.0/16, etc.).

Error responses#

400invalid_requestMissing client_id, url, or events; events empty or contains an unknown type.
400invalid_urlURL must be HTTPS (or http://localhost for dev).
401unauthorizedMissing or invalid sess_… session token. API keys are not accepted on management endpoints.
403forbiddenOAuth client belongs to a different developer.
404not_foundOAuth client or webhook does not exist.