> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ryzeapi.cloud/llms.txt
> Use this file to discover all available pages before exploring further.

# Enterprise webhooks

> Account webhook: configure it once and receive the trial and billing events from all your instances

The **account webhook** is the recommended way to receive Enterprise events. You configure it **once on your account** and it starts receiving events from **all** your instances, with no per-instance setup.

The per-instance webhook still works (backward compatible with what already exists), but for trial and billing events the account webhook is preferred: a single endpoint, a single signing secret, and it follows the instances you create and delete with no extra work.

## Configure the account webhook

There are two ways to configure it.

### In the panel

In the panel, at **`/settings`**, you set the URL that receives the events, pick which events you want, view and rotate the signing secret, and send a test event to confirm your endpoint is receiving and verifying the signature.

### Via the API

Every route accepts the account **TokenAccount**, or the global token passing `?account=<account-name>`.

**`GET /api/account/webhook`**

Returns the current configuration. The secret is never returned in cleartext on a read, only masked.

```json 200 OK theme={null}
{
  "url": "https://your-server.com/webhooks/ryze",
  "events": [
    "enterprise.trial.started",
    "enterprise.trial.ending",
    "enterprise.instance.billable",
    "enterprise.cycle.renewed"
  ],
  "enabled": true,
  "secretMasked": "whsec_••••a1b2"
}
```

**`PATCH /api/account/webhook`**

Saves the URL, the event list, and turns it on or off. On the **first** configuration, when the account still has no secret, the response includes the `secret` in cleartext **once**. Store that value, it is not shown again. To get a new one, rotate the secret.

<ParamField body="url" type="string">
  Endpoint that receives the deliveries. Use HTTPS. An unsafe or disallowed URL is rejected with `400`.
</ParamField>

<ParamField body="events" type="array">
  The event names you want to receive. If you omit the field, the account uses the four events by default. An empty list (`[]`) means "receive none".
</ParamField>

<ParamField body="enabled" type="boolean">
  Turns delivery on or off. With `false`, the configuration is preserved but nothing is delivered.
</ParamField>

<ParamField query="account" type="string">
  Account name. Required only when you use the global token. With the TokenAccount, the account comes from the token itself.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PATCH "https://ryzeapi.cloud/api/account/webhook" \
    -H "token: $Token_Account" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-server.com/webhooks/ryze",
      "events": [
        "enterprise.trial.started",
        "enterprise.trial.ending",
        "enterprise.instance.billable",
        "enterprise.cycle.renewed"
      ],
      "enabled": true
    }'
  ```

  ```javascript JavaScript theme={null}
  await fetch("https://ryzeapi.cloud/api/account/webhook", {
    method: "PATCH",
    headers: {
      "token": process.env.Token_Account,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      url: "https://your-server.com/webhooks/ryze",
      events: [
        "enterprise.trial.started",
        "enterprise.trial.ending",
        "enterprise.instance.billable",
        "enterprise.cycle.renewed"
      ],
      enabled: true
    })
  });
  ```

  ```python Python theme={null}
  import os, requests

  requests.patch(
      "https://ryzeapi.cloud/api/account/webhook",
      headers={
          "token": os.environ["Token_Account"],
          "Content-Type": "application/json",
      },
      json={
          "url": "https://your-server.com/webhooks/ryze",
          "events": [
              "enterprise.trial.started",
              "enterprise.trial.ending",
              "enterprise.instance.billable",
              "enterprise.cycle.renewed",
          ],
          "enabled": True,
      },
  )
  ```
</CodeGroup>

```json 200 OK (first configuration) theme={null}
{
  "url": "https://your-server.com/webhooks/ryze",
  "events": [
    "enterprise.trial.started",
    "enterprise.trial.ending",
    "enterprise.instance.billable",
    "enterprise.cycle.renewed"
  ],
  "enabled": true,
  "secret": "whsec_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
  "secretMasked": "whsec_••••e8f9"
}
```

<Warning>
  The `secret` only appears in this first response (and when you rotate it). After that, a read returns just the `secretMasked`. If you lose the value, rotate to generate a new one.
</Warning>

**`POST /api/account/webhook/rotate-secret`**

Generates a new secret and returns it **once**. The previous secret stops being used on subsequent deliveries.

```json 200 OK theme={null}
{
  "secret": "whsec_112233445566778899aabbccddeeff00",
  "secretMasked": "whsec_••••ff00"
}
```

**`POST /api/account/webhook/test`**

Sends an `enterprise.webhook.test` event to the configured URL, so you can validate reception and the signature. Responds `400` if there is no URL configured. The test event is delivered even when the configuration is disabled.

```json test event delivered theme={null}
{
  "event": "enterprise.webhook.test",
  "account": "my-account",
  "description": "This is a test event from RyzeAPI. Your account webhook is configured correctly.",
  "data": {},
  "timestamp": "2026-08-19T12:00:00Z"
}
```

## The four events

| Event                          | When it fires                                                                         |
| ------------------------------ | ------------------------------------------------------------------------------------- |
| `enterprise.trial.started`     | An instance's trial started.                                                          |
| `enterprise.trial.ending`      | An instance's trial ends in about 24 hours.                                           |
| `enterprise.instance.billable` | The instance started being billed.                                                    |
| `enterprise.cycle.renewed`     | The account's monthly renewal was charged (amount charged, base, overage and period). |

## Envelope

Every delivery has the same JSON body:

<ResponseField name="event" type="string">
  Event name in the form `enterprise.<kind>`, one of the four above (or `enterprise.webhook.test`).
</ResponseField>

<ResponseField name="account" type="string">
  Your account name.
</ResponseField>

<ResponseField name="description" type="string">
  A human-readable English summary of what happened. Handy for logs and for showing to your team.
</ResponseField>

<ResponseField name="data" type="object">
  The event fields. The content varies per event kind, see below.
</ResponseField>

<ResponseField name="timestamp" type="string">
  Time of the delivery, in RFC3339 (UTC).
</ResponseField>

### `enterprise.instance.billable`

```json theme={null}
{
  "event": "enterprise.instance.billable",
  "account": "my-account",
  "description": "Instance 'client-acme' is now billable. It will be charged from Aug 25, 2026 until the end of the current cycle.",
  "data": {
    "instance": "client-acme",
    "numberJid": "5511999998888@s.whatsapp.net",
    "billableSince": "2026-08-25T12:00:00Z"
  },
  "timestamp": "2026-08-25T12:00:05Z"
}
```

### `enterprise.cycle.renewed`

```json theme={null}
{
  "event": "enterprise.cycle.renewed",
  "account": "my-account",
  "description": "Your monthly cycle renewed. You were charged R$ 500.00 (base R$ 500.00 + usage R$ 0.00) for the period Aug 1, 2026 to Sep 1, 2026.",
  "data": {
    "amountChargedCents": 50000,
    "baseCents": 50000,
    "overageCents": 0,
    "periodStart": "2026-08-01T00:00:00Z",
    "periodEnd": "2026-09-01T00:00:00Z"
  },
  "timestamp": "2026-09-01T00:00:03Z"
}
```

## `data` fields

The `data` object carries only the fields relevant to each event.

### Trial events (`trial.started`, `trial.ending`)

<ResponseField name="instance" type="string">
  Instance name.
</ResponseField>

<ResponseField name="numberJid" type="string">
  WhatsApp number connected to the instance. Populated when a number is already associated.
</ResponseField>

<ResponseField name="trialEndsAt" type="string">
  Date and time when the trial ends, in RFC3339.
</ResponseField>

### Switch to billing (`instance.billable`)

<ResponseField name="instance" type="string">
  Instance name.
</ResponseField>

<ResponseField name="numberJid" type="string">
  WhatsApp number connected to the instance. Populated when a number is already associated.
</ResponseField>

<ResponseField name="billableSince" type="string">
  Date and time from which the instance is billed, in RFC3339.
</ResponseField>

### Monthly renewal (`cycle.renewed`)

<ResponseField name="amountChargedCents" type="integer">
  Amount actually charged on the renewal, in cents.
</ResponseField>

<ResponseField name="baseCents" type="integer">
  The base for the period (the floor), in cents.
</ResponseField>

<ResponseField name="overageCents" type="integer">
  How much usage went over the base, in cents. It is `0` when usage fits within the base.
</ResponseField>

<ResponseField name="periodStart" type="string">
  Start of the charged period, in RFC3339.
</ResponseField>

<ResponseField name="periodEnd" type="string">
  End of the charged period, in RFC3339.
</ResponseField>

## Validate the signature

Every delivery carries the header **`X-Ryze-Signature: sha256=<hex>`**, an HMAC-SHA256 of the **raw** request body using your account signing secret. Compute the same HMAC on your server and compare, to be sure the delivery came from RyzeAPI and was not tampered with.

<Warning>
  Compare in **constant time** (`timingSafeEqual` / `compare_digest`), never with a string `==`. And use the body exactly as received (the raw bytes), not the reserialized JSON, otherwise the signature will not match.
</Warning>

<CodeGroup>
  ```javascript JavaScript theme={null}
  import crypto from "crypto";
  import express from "express";

  const app = express();

  function verify(rawBody, header, secret) {
    const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
    const received = (header || "").replace(/^sha256=/, "");
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(received, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // express.raw preserves the raw bytes, needed for the HMAC to match.
  app.post("/webhooks/ryze", express.raw({ type: "application/json" }), (req, res) => {
    const ok = verify(req.body, req.get("X-Ryze-Signature"), process.env.RYZE_WEBHOOK_SECRET);
    if (!ok) return res.status(401).send("bad signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // ... handle the event (event.event, event.data) ...
    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  import hmac, hashlib, os
  from flask import Flask, request, abort

  app = Flask(__name__)

  def verify(raw_body: bytes, header: str, secret: str) -> bool:
      expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
      received = (header or "").removeprefix("sha256=")
      return hmac.compare_digest(expected, received)

  @app.post("/webhooks/ryze")
  def ryze_webhook():
      raw = request.get_data()  # raw bytes, needed for the HMAC to match
      if not verify(raw, request.headers.get("X-Ryze-Signature", ""), os.environ["RYZE_WEBHOOK_SECRET"]):
          abort(401)

      event = request.get_json()
      # ... handle the event (event["event"], event["data"]) ...
      return "", 200
  ```
</CodeGroup>

<Note>
  When you **rotate** the secret, accept both the new and the previous secret for a short window. That way, deliveries already in flight during the switch keep validating.
</Note>

## Per-instance webhook (still supported)

Before the account webhook, Enterprise events were delivered to the instance's own webhook. That still holds: if an instance's webhook (or WebSocket) `events` list includes the `enterprise.*` names, that instance's events also arrive there, in the same envelope as the API's other events. See [Configure webhook](/en/api/events/webhook-configure).

For billing and lifecycle, prefer the account webhook: a single endpoint receives everything, with no per-instance setup, and the deliveries are signed.

## Next

<CardGroup cols={2}>
  <Card title="Billing control" icon="sliders" href="/en/enterprise/billing-control">
    Force billing on an instance.
  </Card>

  <Card title="FAQ" icon="circle-question" href="/en/enterprise/faq">
    Common questions about trial, billing and events.
  </Card>
</CardGroup>
