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

# Agency webhooks

> Subscribe to sub-account lifecycle events: DMLY posts a signed envelope to your endpoint as they happen.

Rather than polling, register an endpoint and DMLY will `POST` to it whenever one of your
sub-accounts changes: a workspace is created, suspended or restored, or a subscription or add-on
changes. These are **agency** webhooks, separate from the [workspace webhooks](/api-reference/webhooks)
that fire on activity *inside* a workspace.

## The events

There are six:

| Event                  | Fires when                                                                                      |
| ---------------------- | ----------------------------------------------------------------------------------------------- |
| `workspace.created`    | A customer signs up on your branded domain, or you create a sub-account with `POST /workspaces` |
| `workspace.suspended`  | A sub-account is suspended                                                                      |
| `workspace.restored`   | A suspended sub-account is restored                                                             |
| `subscription.changed` | A sub-account's base plan changes                                                               |
| `addon.granted`        | An add-on is granted to a sub-account                                                           |
| `addon.revoked`        | An add-on is revoked from a sub-account                                                         |

The suspend, restore, plan and add-on events fire whether the change was made in the agency
console or over the [Agency API](/api-reference/agency-introduction), so one subscription keeps you
in sync with both. `workspace.created` is the exception: the console has no create button, so it
fires only from a branded sign-up or from the API.

<Note>
  `workspace.created` fires only for a brand-new sub-account created by a branded sign-up or by
  `POST /workspaces`. Two other ways a workspace can join your agency do not fire it: an existing
  customer adding a second workspace from the workspace switcher, and your own workspace being moved
  under your agency when you took a reseller plan. Both appear in your sub-accounts list without a
  webhook, so if you must catch every workspace, reconcile periodically against `GET /workspaces`.
  [Client workspaces](/agency/client-workspaces) covers all three arrival paths.
</Note>

## Setting one up

You can manage webhook endpoints from the agency console, under **Reseller Dashboard → Webhooks**,
or over the API with the `/webhooks` endpoints in the sidebar. In the console each endpoint's row
carries **Test**, **Rotate secret**, **Edit** and **Remove**.

<Steps>
  <Step title="Register an endpoint">
    In the console, select **+ Add webhook**. Add your endpoint URL (it must be publicly reachable
    over HTTPS) and subscribe to the events you care about.
  </Step>

  <Step title="Store the signing secret">
    A signing secret is generated for you and returned **once**, on creation. Store it; you use it
    to verify every delivery. If you lose it, select **Rotate secret** on the endpoint's row to get
    a new one, which invalidates the old one.
  </Step>

  <Step title="Send a test">
    Use the test event to confirm your endpoint is reachable and your signature check works before
    relying on it. In the reseller dashboard, select **Test** on the endpoint's row. A test payload
    carries an extra `"test": true`.
  </Step>
</Steps>

## The payload

Every delivery has the same envelope. Only `data` varies by event:

```json theme={"dark"}
{
  "id": "9f1c4b6e-3a2d-4f8b-9c1e-2a7d5e8b0c31",
  "event": "workspace.created",
  "occurred_at": "2026-07-14T09:30:00+00:00",
  "agency_id": 7,
  "data": {
    "workspace": {
      "uuid": "11111111-1111-1111-1111-111111111111",
      "name": "Acme Fitness",
      "status": "active",
      "plan": { "uuid": "22222222-2222-2222-2222-222222222222", "name": "Starter" }
    }
  }
}
```

The `subscription.changed` payload adds the new `plan`; `addon.granted` and `addon.revoked` add an
`addon` object. Each event page in the sidebar shows its exact payload.

## Headers

| Header             | Value                                                            |
| ------------------ | ---------------------------------------------------------------- |
| `X-Dmly-Event`     | The event, e.g. `workspace.created`                              |
| `X-Dmly-Delivery`  | Id for this delivery: the envelope `id`, stable across retries   |
| `X-Dmly-Signature` | `sha256=<hmac>`, sent whenever the endpoint has a signing secret |
| `User-Agent`       | `Dmly-Agency-Webhooks/1.0`                                       |

## Verifying a delivery

The signature is an HMAC-SHA256 of the **raw request body**, keyed by your endpoint's secret.
Compute it over the bytes exactly as received; parsing and re-serialising the JSON first changes
the bytes, and the signature will never match.

```python theme={"dark"}
import hmac, hashlib

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

<Warning>
  Always compare with a constant-time function (`hmac.compare_digest`, `crypto.timingSafeEqual`). A
  plain `==` leaks timing information that can be used to forge a signature.
</Warning>

## Responding

Return any `2xx` to acknowledge. A non-`2xx` (or a timeout) is treated as a failure and retried
twice more, after roughly **10s** then **60s** (three attempts in total). After the third the
delivery is marked failed and not retried again.

Nothing is lost silently: every attempt is recorded. In the reseller dashboard, open **Webhooks**
and look under **Recent deliveries**, which lists the last 30 deliveries across all your endpoints
with columns **Webhook**, **Event**, **Result** and **When**. **Result** shows the outcome and the
HTTP status your endpoint returned, for example `error · 500`. The section only appears once there
has been at least one delivery. Each endpoint's row also shows its most recent outcome inline, as
`last: success` or `last: error`.

One case is never retried. DMLY re-checks your endpoint's host at delivery time, because DNS can
change between saving the endpoint and sending to it. If that check fails, nothing is sent: the
delivery shows in **Recent deliveries** as an error with no HTTP status, and no further attempt is
made.

<Tip>
  Deliveries are at-least-once: a timeout on your side can produce a retry for an event you already
  handled. Deduplicate on the envelope's `id` (the same value as the `X-Dmly-Delivery` header),
  which is stable across retries.
</Tip>

## Events

Each event is documented with its full payload in the sidebar under this section; the list is
generated from the API itself, so it is never out of date.
