---
title: Outbound Webhooks
description: Receive real-time HTTP event notifications for balance alerts, payment events, and product user spending caps.
---

Zorveus Outbound Webhooks send real-time HTTP event notifications to your server whenever state changes occur within your organization workspace.

Instead of polling Zorveus REST endpoints, your server registers a webhook URL to receive event payloads automatically.

---

## Architecture and Delivery Guarantees

Zorveus uses the Transactional Outbox Pattern to guarantee at-least-once event delivery without adding network latency to core API calls.

<Steps>
  <Step title="1. Atomic Event Persistence">
    Core API transactions write events directly to a PostgreSQL outbox table in the same database transaction.
  </Step>
  <Step title="2. Outbox Worker Processing">
    An asynchronous worker polls pending outbox events, checks active organization subscriptions, and computes an HMAC SHA-256 signature.
  </Step>
  <Step title="3. HTTP POST Delivery">
    Zorveus sends an HTTP POST request containing the event payload to your registered webhook URL.
  </Step>
  <Step title="4. Retry Policy">
    If your endpoint returns a 5xx status or times out, Zorveus retries delivery up to 10 times using exponential backoff.
  </Step>
</Steps>

---

## Webhook Event Catalog

Zorveus categorizes webhook events by product area:

| Feature Area | Event Type | Description |
| :--- | :--- | :--- |
| **Wallet** | `wallet.low_balance` | Available organization wallet balance drops below alert threshold. |
| **Wallet** | `wallet.payment_credited` | Wallet top-up purchase or credit succeeds. |
| **Wallet** | `wallet.payment_failed` | Wallet top-up transaction fails. |
| **Product User** | `product_user.cap_reached` | An attributed product user reaches a direct or connection cap for the configured period. |
| **Product User** | `product_user.credits_granted` | Promotional or admin credits are granted to a product user. |
| **App Connection** | `app_connection.cap_reached` | API key app connection reaches monthly spend cap. |
| **Provider Credential** | `provider_credential.quota_exhausted` | An upstream provider credential reports exhausted quota. |

---

## Retry Rules and Status Codes

Zorveus evaluates response HTTP status codes returned by your receiver endpoint:

* **`2xx` (200-299):** Success. Event marked as delivered.
* **`5xx` (Server Error):** Retryable. Zorveus schedules retries with exponential backoff up to 10 attempts.
* **`429` (Rate Limited):** Retryable. Zorveus backs off before retrying.
* **Timeouts and Connection Refused:** Retryable.
* **`4xx` (Client Error except 429):** Non-retryable. Requests returning `400`, `401`, or `404` are marked as failed immediately.
* **Disabled Webhooks (`enabled: false`):** Non-retryable. When an organization sets `"enabled": false`, the outbox worker skips HTTP delivery cleanly (`reason: "webhook_disabled_or_not_configured"`).

---

## Subscription Management

Manage webhook subscriptions through the Zorveus dashboard in your settings.

---

## Complete Event Payload Schemas

Zorveus sends event payloads in standard JSON format with an `X-Zorveus-Signature` header.

### 1. `wallet.low_balance`

```json
{
  "id": "outbox_evt_101",
  "event": "wallet.low_balance",
  "org_id": "org_7k9xq2fm",
  "timestamp": "2026-08-28T06:34:56.110229Z",
  "data": {
    "org_id": "org_7k9xq2fm",
    "available_balance": "4.50",
    "threshold": "10.00",
    "currency": "USD"
  }
}
```

### 2. `wallet.payment_credited`

```json
{
  "id": "outbox_evt_102",
  "event": "wallet.payment_credited",
  "org_id": "org_7k9xq2fm",
  "timestamp": "2026-08-28T06:34:56.110229Z",
  "data": {
    "org_id": "org_7k9xq2fm",
    "amount": "50.00",
    "new_balance": "54.50",
    "currency": "USD",
    "payment_provider": "stripe",
    "reference": "ch_3Mv123"
  }
}
```

### 3. `wallet.payment_failed`

```json
{
  "id": "outbox_evt_103",
  "event": "wallet.payment_failed",
  "org_id": "org_7k9xq2fm",
  "timestamp": "2026-08-28T06:34:56.110229Z",
  "data": {
    "org_id": "org_7k9xq2fm",
    "amount": "50.00",
    "currency": "USD",
    "payment_provider": "stripe",
    "reason": "card_declined"
  }
}
```

### 4. `product_user.cap_reached`

```json
{
  "id": "outbox_evt_104",
  "event": "product_user.cap_reached",
  "org_id": "org_7k9xq2fm",
  "timestamp": "2026-08-28T06:34:56.110229Z",
  "data": {
    "org_id": "org_7k9xq2fm",
    "app_id": "app_123",
    "product_end_user_id": "peu_987",
    "external_user_id": "customer_42",
    "app_connection_id": "conn_456",
    "target_type": "product_end_user",
    "target_id": "peu_987",
    "cap_target_type": "app_connection",
    "cap_target_id": "conn_456",
    "cap_rule_id": "cap_123",
    "model": "openai/gpt-4.1-mini",
    "cap_amount": "100.00",
    "spent_this_period": "99.99",
    "requested_amount": "0.02",
    "projected_spend": "100.01",
    "currency": "USD",
    "period_start": "2026-08-01T00:00:00Z",
    "period_end": "2026-09-01T00:00:00Z"
  }
}
```

### 5. `product_user.credits_granted`

```json
{
  "id": "outbox_evt_105",
  "event": "product_user.credits_granted",
  "org_id": "org_7k9xq2fm",
  "timestamp": "2026-08-28T06:34:56.110229Z",
  "data": {
    "org_id": "org_7k9xq2fm",
    "app_id": "app_6b541d674ddc40df99485589985b22a9",
    "product_end_user_id": "peu_99e504f9565f454fbde4a8abec010cd1",
    "external_user_id": "sarah.jenkins@example.com",
    "credit_grant_id": "pucg_246b817030cb4a0fbf4ec2811736ed49",
    "granted_amount": "10.00",
    "currency": "USD",
    "reason": "Candidate Career Grant",
    "expires_at": null
  }
}
```

### 6. `app_connection.cap_reached`

```json
{
  "id": "outbox_evt_106",
  "event": "app_connection.cap_reached",
  "org_id": "org_7k9xq2fm",
  "timestamp": "2026-08-28T06:34:56.110229Z",
  "data": {
    "org_id": "org_7k9xq2fm",
    "app_connection_id": "conn_456",
    "target_type": "app_connection",
    "target_id": "conn_456",
    "cap_rule_id": "cap_456",
    "model": "openai/gpt-4.1-mini",
    "cap_amount": "500.00",
    "spent_this_period": "499.99",
    "requested_amount": "0.02",
    "projected_spend": "500.01",
    "currency": "USD",
    "period_start": "2026-08-01T00:00:00Z",
    "period_end": "2026-09-01T00:00:00Z"
  }
}
```

---

## Signature Verification

Reject malformed timestamps and signatures. Reject timestamps outside a five-minute tolerance before processing the body. Compute the signature over the original body bytes, not parsed or reserialized JSON. The digest is a 64-character SHA-256 hexadecimal string.

Every request includes an `X-Zorveus-Signature` header containing a UNIX timestamp (`t`) and an HMAC SHA-256 digest (`v1`):

```text
X-Zorveus-Signature: t=1756249200,v1=5d41402abc4b2a76b9719d911017c59205d41402abc4b2a76b9719d911017c5920
```

To verify the signature:

1. Extract `t` and `v1` from the header.
2. Concatenate the timestamp string, a dot, and the raw HTTP request body bytes (`timestamp_bytes + b"." + raw_http_body`).
3. Compute `HMAC-SHA256(webhook_secret, signed_payload)` as a hex string.
4. Compare expected and received signatures using a constant-time comparison function.

<CodeGroup titles={["Node.js (Express)", "Python (FastAPI / Flask)"]}>

```javascript
const crypto = require('crypto');

function verifyZorveusSignature(req, secret) {
  const sigHeader = req.headers['x-zorveus-signature'];
  if (!sigHeader) return false;

  const parts = {};
  for (const item of sigHeader.split(',')) {
    const idx = item.indexOf('=');
    if (idx !== -1) {
      parts[item.slice(0, idx).trim()] = item.slice(idx + 1).trim();
    }
  }

  const timestamp = parts.t;
  const signature = parts.v1;
  if (!timestamp || !signature) return false;
  if (!/^\d{1,12}$/.test(timestamp) || !/^[a-f0-9]{64}$/i.test(signature)) return false;
  const signedAt = Number(timestamp);
  if (!Number.isSafeInteger(signedAt) || Math.abs(Date.now() / 1000 - signedAt) > 300) return false;

  const rawBody = req.rawBody;
  if (!Buffer.isBuffer(rawBody)) return false;

  const signedPayload = Buffer.concat([
    Buffer.from(`${timestamp}.`, 'utf-8'),
    rawBody
  ]);

  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  const sigBuf = Buffer.from(signature.toLowerCase(), 'utf-8');
  const expBuf = Buffer.from(expectedSignature, 'utf-8');

  // Prevent length mismatch errors before timing-safe check
  if (sigBuf.length !== expBuf.length) {
    return false;
  }

  return crypto.timingSafeEqual(sigBuf, expBuf);
}
```

```python
import hmac
import hashlib
import re
import time

def verify_zorveus_signature(raw_body: bytes, sig_header: str, secret: str) -> bool:
    header_parts = {}
    for part in sig_header.split(","):
        if "=" in part:
            k, v = part.split("=", 1)
            header_parts[k.strip()] = v.strip()

    timestamp = header_parts.get("t")
    received_sig = header_parts.get("v1")

    if not timestamp or not received_sig:
        return False
    if not re.fullmatch(r"[0-9]{1,12}", timestamp) or not re.fullmatch(r"[a-fA-F0-9]{64}", received_sig):
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False

    signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
    computed_sig = hmac.new(
        secret.encode("utf-8"),
        signed_payload,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(received_sig.lower(), computed_sig)
```

</CodeGroup>

---

## Receiver Best Practices

For product-user events, resolve your customer through `data.external_user_id` and verify `data.app_id` and the envelope's organization. Do not use an internal Zorveus ID as your application's customer ID. An attributed request that reaches a connection cap can emit `product_user.cap_reached`; inspect `cap_target_type` to identify that cap.

Use one allowance service for webhook events and inference errors. Recheck [available allowance](/startups/check-allowance) when access may have recovered. A credit grant alone does not guarantee the next request will fit.

`provider_credential.quota_exhausted` contains `org_id`, `provider_credential_id`, `provider`, `credential_name`, and `exhausted_at` in `data`. Treat it as an upstream credential issue, not a customer allowance failure.

* **Acknowledge quickly.** Return HTTP `200` or `204` within 3 seconds. Queue long-running tasks asynchronously.
* **Prevent replay attacks.** Reject payloads with timestamps older than 5 minutes.
* **Deduplicate using event IDs.** Zorveus guarantees at-least-once delivery. Use the `id` property in the JSON payload to skip duplicate processing.
