# Cancel and modify

Everything an addon can stop or change before it happens, and exactly what it may change. There are three kinds: typed pre-events, request events on every mutating API route, and mixins.

## Rules

- Only the `before` phase can cancel or modify. `during`, `after` and `finished` only observe (the reply is ignored).
- Listeners run in priority order. Each sees the payload as changed by the ones before it, and `input.cancelled` tells it whether an earlier listener cancelled.
- After a cancel, later listeners are skipped unless their hook sets `ignore_cancelled: true`. Only those may reply `uncancel`, which clears the cancel.
- `monitor` listeners always run last and cannot change anything; a non-continue reply is logged and ignored.
- A cancel answers the API call with HTTP 409 and `error.addon_hook_cancelled`. The reason (at most 200 characters) is logged, not shown to the user.
- If a hook errors or times out (`timeout_ms`, default 500, capped by the sandbox), `fail_open: true` (default) continues and `fail_open: false` blocks the action with 409 `error.addon_hook_failed`.
- A `modify` must be a JSON object. The host reads back only the keys listed for the event; other keys are ignored.
- Money keys (`amount`, `price`, `unit_price`, `total`, `subtotal`, `tax`, `tax_total`, `discount`, `setup_fee`, `recurring_amount`, `credit`, `fee`, `balance`, `amount_paid`) change only for addons with `capabilities.money.modify`, must be exact decimal strings (at most 4 places), and every change is audited as `addon.money_modified`. Without the capability the money keys are reverted and the rest of the change is kept.
- Secrets never reach addons: `password`, `current_password`, `new_password`, `password_confirmation`, `secret`, `client_secret`, `token`, `api_key`, `totp`, `otp`, `mfa_code`, `recovery_code`, `card_number`, `cvc`, `cvv` and any key ending in `_password`, `_secret` or `_token` are replaced by `"[redacted]"` and restored afterwards, so they can be neither read nor changed.

## Reply format

```json
{"action":"continue"}
{"action":"modify","payload":{ "coupon": "SUMMER" }}
{"action":"cancel","reason":"not allowed"}
{"action":"uncancel"}
```

The Rust SDK builds these with `reply::proceed()`, `reply::modify(p)`, `reply::cancel(reason)` and `reply::uncancel()`.

## Typed pre-events

| Event | Cancel | Modifiable keys and rules | Payload |
|---|---|---|---|
| [`login.before`](events.md#login-before) | yes | none (read-only) | `email`, `ip`, `user_agent` |
| [`client.before_register`](events.md#client-before-register) | yes | `display_name`: String of at most 200 bytes; anything else keeps the submitted name | `email`, `display_name`, `ip` |
| [`order.before_place`](events.md#order-before-place) | yes | `coupon`: Non-empty string of at most 64 bytes sets the code, `null` removes it, any other value keeps the submitted code. The code is then validated like a typed one | `customer_id`, `currency`, `items`, `coupon` |
| [`invoice.before_create`](events.md#invoice-before-create) | yes | `items[].description`: Non-blank string of at most 500 bytes, matched by position; otherwise the original line text; `items[].unit_price`: Needs `capabilities.money.modify`; an exact decimal string (at most 4 places), audited as `addon.money_modified`. Without the capability the change is reverted | `customer_id`, `currency`, `finalize`, `items` |

### Example: cancel, modify and uncancel

_extensions/examples/addon-guide/src/lib.rs · snippet hooks_

```rust
/// Pre-events and lifecycle phases listed in `capabilities.hooks` call op
/// `hook`. Return `proceed`, `modify(payload)` or `cancel(reason)`.
fn hook(input: &Value) -> Value {
    let event = input["event"].as_str().unwrap_or("");
    if input["phase"].as_str().unwrap_or("before") != "before" {
        return reply::proceed(); // during/after/finished only observe
    }
    let mut p = input["payload"].clone();
    bump(&format!("hook:{event}"));
    match event {
        // Cancel: refuse sign-in for one domain.
        "login.before" if p["email"].as_str().is_some_and(|e| e.ends_with("@denied.test")) => reply::cancel("domain not allowed"),
        // Modify: tidy up the display name before the account is created.
        "client.before_register" => {
            p["display_name"] = json!(p["display_name"].as_str().unwrap_or("").trim().to_string());
            reply::modify(p)
        }
        // Own custom event (declared as `before:integration-guide:greeted`,
        // delivered with `event` = the bare name and `phase` = "before").
        "integration-guide:greeted" if p["name"] == "nobody" => reply::cancel("no greeting for nobody"),
        "order.before_place" => order_hook(input, p),
        _ => reply::proceed(),
    }
}
```

_extensions/examples/addon-guide/src/lib.rs · snippet uncancel_

```rust
/// `order.before_place` with `ignore_cancelled: true` and priority `highest`:
/// this runs even after an earlier addon cancelled the order. `uncancel` is
/// only honoured for such hooks. Here: always allow orders using the
/// `GUIDE-VIP` coupon, and normalise coupon codes to upper case (`coupon` is
/// the only key the host reads back from this event).
fn order_hook(input: &Value, mut p: Value) -> Value {
    let coupon = p["coupon"].as_str().unwrap_or("").trim().to_uppercase();
    if input["cancelled"] == true {
        return if coupon == "GUIDE-VIP" { reply::uncancel() } else { reply::proceed() };
    }
    if coupon.is_empty() {
        return reply::proceed();
    }
    p["coupon"] = json!(coupon);
    reply::modify(p)
}
```

## Request events (every mutating route)

Every POST, PUT, PATCH and DELETE under `/api/v1` (except addon routes) is an event named `<METHOD> <route template>`. A hook `before:<METHOD> <route>` gets `{method, path, route, query, body}` and may cancel the request or rewrite `body` (the JSON request body, at most 1 MiB, money and secret rules apply). Patterns: `before:*` (every request), `before:POST /api/v1/admin/*` (prefix), or an exact template. Because they see every request, these hooks are a dangerous capability (`hooks.any`) the admin must approve at install. `during`, `after` (with the JSON response) and `finished` (with `status` and `http_status`) observe.

```json
{
  "capabilities": {
    "hooks": [
      {
        "event": "before:POST /api/v1/client/tickets",
        "priority": "high"
      },
      {
        "event": "after:PUT /api/v1/client/profile",
        "priority": "monitor"
      }
    ]
  }
}
```

459 cancellable request events:

| Hook event | Action |
|---|---|
| `before:DELETE /api/v1/account/identities/{id}` | unlink |
| `before:DELETE /api/v1/account/oauth-apps/{id}` | my revoke |
| `before:DELETE /api/v1/account/privacy/erasure` |  |
| `before:POST /api/v1/account/privacy/erasure` |  |
| `before:DELETE /api/v1/account/security-question` |  |
| `before:PUT /api/v1/account/security-question` |  |
| `before:POST /api/v1/account/tos/accept` | accept tos |
| `before:PUT /api/v1/admin/addons/registry/settings` | put registry settings |
| `before:POST /api/v1/admin/addons/registry/{id}/install` | registry install |
| `before:POST /api/v1/admin/addons/upload` | upload |
| `before:DELETE /api/v1/admin/addons/{id}` | uninstall |
| `before:POST /api/v1/admin/addons/{id}/disable` | disable |
| `before:POST /api/v1/admin/addons/{id}/enable` | enable |
| `before:POST /api/v1/admin/addons/{id}/install` | install |
| `before:POST /api/v1/admin/addons/{id}/mixins` | mixins toggle |
| `before:PUT /api/v1/admin/addons/{id}/settings` | put settings |
| `before:POST /api/v1/admin/affiliates/materials` |  |
| `before:DELETE /api/v1/admin/affiliates/materials/{id}` |  |
| `before:PUT /api/v1/admin/affiliates/materials/{id}` |  |
| `before:POST /api/v1/admin/affiliates/payouts/{id}` |  |
| `before:PUT /api/v1/admin/affiliates/product-rates` |  |
| `before:DELETE /api/v1/admin/affiliates/product-rates/{product_id}` |  |
| `before:POST /api/v1/admin/affiliates/tiers` |  |
| `before:DELETE /api/v1/admin/affiliates/tiers/{id}` |  |
| `before:PUT /api/v1/admin/affiliates/{id}` |  |
| `before:POST /api/v1/admin/announcements` |  |
| `before:DELETE /api/v1/admin/announcements/{id}` |  |
| `before:PUT /api/v1/admin/announcements/{id}` |  |
| `before:DELETE /api/v1/admin/api-keys/{id}` |  |
| `before:POST /api/v1/admin/automation/{kind}/run` |  |
| `before:PUT /api/v1/admin/automation/{kind}/settings` | put setting |
| `before:POST /api/v1/admin/autopay/run` | Run auto-pay now (idempotent with the daily job). |
| `before:POST /api/v1/admin/backups` |  |
| `before:DELETE /api/v1/admin/backups/{id}` |  |
| `before:POST /api/v1/admin/banned-emails` | add |
| `before:DELETE /api/v1/admin/banned-emails/{domain}` | remove |
| `before:POST /api/v1/admin/billable-items` |  |
| `before:DELETE /api/v1/admin/billable-items/{id}` |  |
| `before:POST /api/v1/admin/bundles` |  |
| `before:DELETE /api/v1/admin/bundles/{id}` |  |
| `before:PUT /api/v1/admin/bundles/{id}` |  |
| `before:POST /api/v1/admin/campaigns` |  |
| `before:POST /api/v1/admin/campaigns/preview` |  |
| `before:DELETE /api/v1/admin/campaigns/{id}` |  |
| `before:PUT /api/v1/admin/campaigns/{id}` |  |
| `before:POST /api/v1/admin/campaigns/{id}/cancel` |  |
| `before:DELETE /api/v1/admin/campaigns/{id}/schedule` |  |
| `before:POST /api/v1/admin/campaigns/{id}/schedule` | Hold a draft until `send_at`; the worker snapshots recipients then. |
| `before:POST /api/v1/admin/campaigns/{id}/send` |  |
| `before:POST /api/v1/admin/cancellations/{id}/decide` |  |
| `before:POST /api/v1/admin/catalog/addons` |  |
| `before:PUT /api/v1/admin/catalog/addons/{id}` |  |
| `before:POST /api/v1/admin/catalog/groups` |  |
| `before:POST /api/v1/admin/catalog/products` |  |
| `before:PATCH /api/v1/admin/catalog/products/{id}` |  |
| `before:PUT /api/v1/admin/catalog/products/{id}/billing` |  |
| `before:POST /api/v1/admin/catalog/products/{id}/fields` |  |
| `before:DELETE /api/v1/admin/catalog/products/{id}/fields/{field}` |  |
| `before:PUT /api/v1/admin/catalog/products/{id}/fields/{field}` |  |
| `before:PUT /api/v1/admin/catalog/products/{id}/fixed-term` |  |
| `before:POST /api/v1/admin/catalog/products/{id}/options` |  |
| `before:DELETE /api/v1/admin/catalog/products/{id}/options/{option}` |  |
| `before:PUT /api/v1/admin/catalog/products/{id}/prices` |  |
| `before:PUT /api/v1/admin/catalog/products/{id}/recommendations` |  |
| `before:PUT /api/v1/admin/catalog/products/{id}/stock` |  |
| `before:POST /api/v1/admin/chats/{id}/close` |  |
| `before:POST /api/v1/admin/chats/{id}/messages` |  |
| `before:POST /api/v1/admin/client-fields` |  |
| `before:DELETE /api/v1/admin/client-fields/{id}` |  |
| `before:PUT /api/v1/admin/client-fields/{id}` |  |
| `before:POST /api/v1/admin/client-group-rules` | create |
| `before:POST /api/v1/admin/client-group-rules/apply` | apply |
| `before:DELETE /api/v1/admin/client-group-rules/{id}` | remove |
| `before:PUT /api/v1/admin/client-group-rules/{id}` | update |
| `before:POST /api/v1/admin/client-groups` |  |
| `before:DELETE /api/v1/admin/client-groups/{id}` |  |
| `before:PUT /api/v1/admin/client-groups/{id}` |  |
| `before:POST /api/v1/admin/client-groups/{id}/members` |  |
| `before:DELETE /api/v1/admin/client-groups/{id}/members/{customer_id}` |  |
| `before:DELETE /api/v1/admin/contact-messages/{id}` | contact del |
| `before:PUT /api/v1/admin/contact-messages/{id}` | contact status |
| `before:POST /api/v1/admin/contact-messages/{id}/ticket` | contact to ticket |
| `before:POST /api/v1/admin/coupons` |  |
| `before:DELETE /api/v1/admin/coupons/{id}` |  |
| `before:PUT /api/v1/admin/coupons/{id}` |  |
| `before:POST /api/v1/admin/currencies` |  |
| `before:PUT /api/v1/admin/currencies/rates/settings` |  |
| `before:POST /api/v1/admin/currencies/rates/update` |  |
| `before:POST /api/v1/admin/currencies/sync-prices` |  |
| `before:PUT /api/v1/admin/currencies/{code}` |  |
| `before:POST /api/v1/admin/currencies/{code}/default` |  |
| `before:PUT /api/v1/admin/custom-code` |  |
| `before:POST /api/v1/admin/customers` |  |
| `before:PATCH /api/v1/admin/customers/{id}` |  |
| `before:PUT /api/v1/admin/customers/{id}/auto-pay` |  |
| `before:POST /api/v1/admin/customers/{id}/billable-items/invoice` |  |
| `before:POST /api/v1/admin/customers/{id}/close` | close customer |
| `before:POST /api/v1/admin/customers/{id}/credit` |  |
| `before:PUT /api/v1/admin/customers/{id}/fields` |  |
| `before:POST /api/v1/admin/customers/{id}/group` |  |
| `before:PUT /api/v1/admin/customers/{id}/kyc` |  |
| `before:PUT /api/v1/admin/customers/{id}/legal-entity` |  |
| `before:POST /api/v1/admin/customers/{id}/login-as` | login as customer |
| `before:POST /api/v1/admin/customers/{id}/merge` | merge customer |
| `before:POST /api/v1/admin/customers/{id}/notes` |  |
| `before:DELETE /api/v1/admin/customers/{id}/notes/{note}` |  |
| `before:PUT /api/v1/admin/customers/{id}/notes/{note}` |  |
| `before:POST /api/v1/admin/customers/{id}/security-question/verify` | Check a caller's answer. Rate limited per customer and audited. |
| `before:POST /api/v1/admin/customers/{id}/vat/verify` |  |
| `before:POST /api/v1/admin/domains/bulk` | admin bulk |
| `before:PUT /api/v1/admin/domains/margins` |  |
| `before:PUT /api/v1/admin/domains/premium-settings` |  |
| `before:POST /api/v1/admin/domains/run` |  |
| `before:POST /api/v1/admin/domains/sync-prices` |  |
| `before:PUT /api/v1/admin/domains/tld-fields/{tld}` |  |
| `before:PATCH /api/v1/admin/domains/{id}` |  |
| `before:PUT /api/v1/admin/domains/{id}/dns` | admin dns put |
| `before:POST /api/v1/admin/domains/{id}/registrant-verification` |  |
| `before:POST /api/v1/admin/domains/{id}/ssl-check` |  |
| `before:POST /api/v1/admin/domains/{id}/sync` |  |
| `before:POST /api/v1/admin/downloads` |  |
| `before:DELETE /api/v1/admin/downloads/{id}` |  |
| `before:POST /api/v1/admin/email-log/{id}/resend` | admin resend |
| `before:PUT /api/v1/admin/email-templates/{name}/enabled` | Switch a template on or off; disabled templates are never queued. |
| `before:DELETE /api/v1/admin/email-templates/{name}/{locale}` |  |
| `before:PUT /api/v1/admin/email-templates/{name}/{locale}` |  |
| `before:POST /api/v1/admin/email-templates/{name}/{locale}/preview` |  |
| `before:POST /api/v1/admin/email-templates/{name}/{locale}/test` | Queue the saved template, with sample data, to the signed-in admin. |
| `before:POST /api/v1/admin/emails/{job}/resend` |  |
| `before:POST /api/v1/admin/gateways/custom` |  |
| `before:DELETE /api/v1/admin/gateways/{id}` |  |
| `before:PUT /api/v1/admin/gateways/{id}` |  |
| `before:PUT /api/v1/admin/gateways/{id}/rules` |  |
| `before:POST /api/v1/admin/gateways/{id}/test-webhook` | Verify + parse a pasted webhook without applying it (logged as a test). |
| `before:POST /api/v1/admin/gdpr/requests/{id}/approve` |  |
| `before:POST /api/v1/admin/gdpr/requests/{id}/reject` |  |
| `before:PUT /api/v1/admin/general-settings` |  |
| `before:POST /api/v1/admin/gift-cards` |  |
| `before:POST /api/v1/admin/gift-cards/{id}/disable` |  |
| `before:POST /api/v1/admin/import/csv/analyze` | Headers, a 5-row preview, the target fields and a suggested mapping. |
| `before:POST /api/v1/admin/import/csv/run` | Validate every row; unless `dry_run`, import them all-or-nothing. |
| `before:POST /api/v1/admin/inactive-clients/run` | Run the erasure now with the saved settings (no-op when disabled). |
| `before:POST /api/v1/admin/invoices` |  |
| `before:POST /api/v1/admin/invoices/bulk` | Apply one action to many invoices; returns a per-invoice result. |
| `before:POST /api/v1/admin/invoices/merge` |  |
| `before:POST /api/v1/admin/invoices/{id}/apply-credit` |  |
| `before:POST /api/v1/admin/invoices/{id}/credit-note` |  |
| `before:POST /api/v1/admin/invoices/{id}/debit-note` | Issue a debit note (extra charge) against an unpaid or paid invoice. |
| `before:POST /api/v1/admin/invoices/{id}/duplicate` |  |
| `before:POST /api/v1/admin/invoices/{id}/finalize` |  |
| `before:POST /api/v1/admin/invoices/{id}/items` |  |
| `before:PUT /api/v1/admin/invoices/{id}/notes` |  |
| `before:POST /api/v1/admin/invoices/{id}/payments` | Record a manual payment (bank transfer…). Idempotent. |
| `before:POST /api/v1/admin/invoices/{id}/split` |  |
| `before:POST /api/v1/admin/invoices/{id}/void` |  |
| `before:POST /api/v1/admin/jobs/recurring` | Enqueue a recurring-billing run now (deduplicated per minute). |
| `before:POST /api/v1/admin/jobs/{id}/retry` |  |
| `before:POST /api/v1/admin/kb/articles` |  |
| `before:DELETE /api/v1/admin/kb/articles/{id}` |  |
| `before:PUT /api/v1/admin/kb/articles/{id}` |  |
| `before:PUT /api/v1/admin/kb/articles/{id}/tags` | put tags |
| `before:POST /api/v1/admin/kb/categories` |  |
| `before:DELETE /api/v1/admin/kb/categories/{id}` |  |
| `before:PUT /api/v1/admin/kb/categories/{id}` |  |
| `before:POST /api/v1/admin/kyc/documents/{id}/review` |  |
| `before:PUT /api/v1/admin/kyc/settings` |  |
| `before:POST /api/v1/admin/legal-entities` |  |
| `before:DELETE /api/v1/admin/legal-entities/default` |  |
| `before:DELETE /api/v1/admin/legal-entities/{id}` |  |
| `before:PUT /api/v1/admin/legal-entities/{id}` |  |
| `before:POST /api/v1/admin/legal-entities/{id}/default` |  |
| `before:POST /api/v1/admin/mass/customers` |  |
| `before:POST /api/v1/admin/mass/domains` |  |
| `before:POST /api/v1/admin/mass/services` |  |
| `before:POST /api/v1/admin/mass/tickets` |  |
| `before:POST /api/v1/admin/module-queue/{id}/retry` |  |
| `before:PUT /api/v1/admin/my-notes` |  |
| `before:POST /api/v1/admin/network-issues` |  |
| `before:DELETE /api/v1/admin/network-issues/{id}` |  |
| `before:PUT /api/v1/admin/network-issues/{id}` |  |
| `before:POST /api/v1/admin/network-issues/{id}/updates` |  |
| `before:POST /api/v1/admin/noticeboard` |  |
| `before:DELETE /api/v1/admin/noticeboard/{id}` | Authors delete their own notices; staff with settings access can delete any. |
| `before:POST /api/v1/admin/notification-rules` |  |
| `before:POST /api/v1/admin/notification-rules/preview` | Payload that would be POSTed for an event (no network call). |
| `before:DELETE /api/v1/admin/notification-rules/{id}` |  |
| `before:PUT /api/v1/admin/notification-rules/{id}` |  |
| `before:DELETE /api/v1/admin/notification-rules/{id}/secret` | Stop signing a webhook. |
| `before:POST /api/v1/admin/notification-rules/{id}/secret` | Rotate the HMAC signing secret of a generic webhook; returned once. |
| `before:POST /api/v1/admin/notification-rules/{id}/test` | Queue a test delivery through the real channel. |
| `before:POST /api/v1/admin/oauth-clients` | admin create |
| `before:DELETE /api/v1/admin/oauth-clients/{id}` | admin delete |
| `before:PATCH /api/v1/admin/oauth-clients/{id}` | admin update |
| `before:POST /api/v1/admin/oauth-clients/{id}/secret` | admin rotate |
| `before:POST /api/v1/admin/order-forms` | admin create |
| `before:DELETE /api/v1/admin/order-forms/{id}` | admin delete |
| `before:PUT /api/v1/admin/order-forms/{id}` | admin update |
| `before:POST /api/v1/admin/orders` | Manual order for a customer, priced from the catalog like a client order. |
| `before:POST /api/v1/admin/orders/bulk` |  |
| `before:DELETE /api/v1/admin/orders/{id}` |  |
| `before:POST /api/v1/admin/orders/{id}/approve` |  |
| `before:POST /api/v1/admin/orders/{id}/cancel` |  |
| `before:POST /api/v1/admin/orders/{id}/notes` |  |
| `before:POST /api/v1/admin/orders/{id}/pending` |  |
| `before:POST /api/v1/admin/orders/{id}/refund` | Refund an order: credit the paid invoice in full (credit note) and close |
| `before:POST /api/v1/admin/overdue/run` | Run today's overdue automation now (idempotent with the daily job). |
| `before:POST /api/v1/admin/payments/{id}/refunds` |  |
| `before:PUT /api/v1/admin/plugins/{id}` |  |
| `before:POST /api/v1/admin/plugins/{id}/deliveries/{did}/redeliver` |  |
| `before:PUT /api/v1/admin/plugins/{id}/settings` |  |
| `before:PUT /api/v1/admin/plugins/{id}/webhook` |  |
| `before:POST /api/v1/admin/plugins/{id}/webhook/secret` | Generate a new signing secret and return it once. |
| `before:PUT /api/v1/admin/prefs` |  |
| `before:PUT /api/v1/admin/product-groups/{id}/order-form` | admin set group |
| `before:PUT /api/v1/admin/products/{id}/free-domain` | admin free put |
| `before:PUT /api/v1/admin/products/{id}/module` |  |
| `before:PUT /api/v1/admin/products/{id}/server-group` | product group set |
| `before:POST /api/v1/admin/projects` |  |
| `before:DELETE /api/v1/admin/projects/{id}` |  |
| `before:PUT /api/v1/admin/projects/{id}` |  |
| `before:POST /api/v1/admin/projects/{id}/invoice-time` | Bill all unbilled billable time on one invoice. |
| `before:DELETE /api/v1/admin/projects/{id}/links` |  |
| `before:POST /api/v1/admin/projects/{id}/links` |  |
| `before:POST /api/v1/admin/projects/{id}/tasks` |  |
| `before:DELETE /api/v1/admin/projects/{id}/tasks/{task}` |  |
| `before:PUT /api/v1/admin/projects/{id}/tasks/{task}` |  |
| `before:POST /api/v1/admin/projects/{id}/time` |  |
| `before:DELETE /api/v1/admin/projects/{id}/time/{entry}` |  |
| `before:POST /api/v1/admin/quotes` |  |
| `before:DELETE /api/v1/admin/quotes/{id}` |  |
| `before:PUT /api/v1/admin/quotes/{id}` |  |
| `before:POST /api/v1/admin/quotes/{id}/send` |  |
| `before:POST /api/v1/admin/roles` |  |
| `before:DELETE /api/v1/admin/roles/{id}` |  |
| `before:PUT /api/v1/admin/roles/{id}` |  |
| `before:POST /api/v1/admin/saml/idps` | admin create |
| `before:DELETE /api/v1/admin/saml/idps/{id}` | admin delete |
| `before:PUT /api/v1/admin/saml/idps/{id}` | admin update |
| `before:POST /api/v1/admin/saml/idps/{id}/rotate-key` | admin rotate key |
| `before:POST /api/v1/admin/saml/idps/{id}/validate` | admin validate |
| `before:POST /api/v1/admin/saml/metadata` | admin parse metadata |
| `before:POST /api/v1/admin/security-questions` |  |
| `before:DELETE /api/v1/admin/security-questions/{id}` |  |
| `before:PUT /api/v1/admin/security/captcha` | put policy |
| `before:PUT /api/v1/admin/security/duo` |  |
| `before:POST /api/v1/admin/security/duo/test` |  |
| `before:POST /api/v1/admin/security/ip-rules` | add rule |
| `before:DELETE /api/v1/admin/security/ip-rules/{id}` | delete rule |
| `before:PUT /api/v1/admin/security/policy` | put policy |
| `before:PUT /api/v1/admin/security/staff-2fa` | Turning the policy on requires the actor to have 2FA (no self lock-out). |
| `before:POST /api/v1/admin/security/tos` | publish tos |
| `before:PUT /api/v1/admin/security/trusted-proxies` |  |
| `before:POST /api/v1/admin/server-groups` | groups create |
| `before:DELETE /api/v1/admin/server-groups/{id}` | groups delete |
| `before:PUT /api/v1/admin/server-groups/{id}` | groups update |
| `before:PUT /api/v1/admin/server-groups/{id}/servers` | groups set members |
| `before:POST /api/v1/admin/servers` |  |
| `before:POST /api/v1/admin/servers/health/check` |  |
| `before:DELETE /api/v1/admin/servers/{id}` |  |
| `before:PUT /api/v1/admin/servers/{id}` |  |
| `before:POST /api/v1/admin/servers/{id}/import` | Create services for panel accounts (no provisioning call; status `ok`). |
| `before:POST /api/v1/admin/servers/{id}/test` | Check the stored credentials against the panel. Returns `{ok, error?}` |
| `before:POST /api/v1/admin/servers/{id}/vendor-catalog/sync` | Pull the vendor catalog through the server's module. |
| `before:POST /api/v1/admin/services/{id}/action` |  |
| `before:DELETE /api/v1/admin/services/{id}/change` |  |
| `before:POST /api/v1/admin/services/{id}/change` |  |
| `before:POST /api/v1/admin/services/{id}/module` | Manually (re)run a module action. Does not change the billing status. |
| `before:POST /api/v1/admin/services/{id}/module/custom` |  |
| `before:POST /api/v1/admin/services/{id}/renew-now` |  |
| `before:POST /api/v1/admin/services/{id}/usage` |  |
| `before:PUT /api/v1/admin/settings/add-funds` |  |
| `before:PUT /api/v1/admin/settings/autopay` |  |
| `before:PUT /api/v1/admin/settings/bank-transfer` |  |
| `before:PUT /api/v1/admin/settings/domain-defaults` |  |
| `before:PUT /api/v1/admin/settings/due-reminders` |  |
| `before:PUT /api/v1/admin/settings/einvoice` |  |
| `before:PUT /api/v1/admin/settings/fraud` |  |
| `before:PUT /api/v1/admin/settings/housekeeping` |  |
| `before:PUT /api/v1/admin/settings/inactive-clients` |  |
| `before:PUT /api/v1/admin/settings/invoice-branding` |  |
| `before:PUT /api/v1/admin/settings/on-demand-renewals` |  |
| `before:PUT /api/v1/admin/settings/order-approval` |  |
| `before:PUT /api/v1/admin/settings/overdue` |  |
| `before:PUT /api/v1/admin/settings/proforma` |  |
| `before:PUT /api/v1/admin/settings/registrant-verification` |  |
| `before:PUT /api/v1/admin/settings/reversal` |  |
| `before:PUT /api/v1/admin/settings/smtp` |  |
| `before:POST /api/v1/admin/settings/smtp/test` | Send a test mail synchronously with the *saved* settings. |
| `before:PUT /api/v1/admin/settings/tax` |  |
| `before:PUT /api/v1/admin/settings/ticket-auto-close` | auto close put |
| `before:PUT /api/v1/admin/setup-wizard/dismissed` |  |
| `before:PUT /api/v1/admin/site` | admin put |
| `before:POST /api/v1/admin/site/pages` | admin create |
| `before:DELETE /api/v1/admin/site/pages/{id}` | admin delete |
| `before:PUT /api/v1/admin/site/pages/{id}` | admin update |
| `before:PUT /api/v1/admin/support/departments/{id}` | update department |
| `before:POST /api/v1/admin/support/departments/{id}/pipe` | pipe now |
| `before:POST /api/v1/admin/support/spam-rules` | add spam |
| `before:DELETE /api/v1/admin/support/spam-rules/{id}` | del spam |
| `before:PUT /api/v1/admin/system/updates` |  |
| `before:POST /api/v1/admin/system/updates/check` |  |
| `before:POST /api/v1/admin/tax-rules` |  |
| `before:DELETE /api/v1/admin/tax-rules/{id}` |  |
| `before:PUT /api/v1/admin/tax-rules/{id}` |  |
| `before:PUT /api/v1/admin/themes/active` |  |
| `before:POST /api/v1/admin/ticket-canned` |  |
| `before:DELETE /api/v1/admin/ticket-canned/{id}` |  |
| `before:PUT /api/v1/admin/ticket-canned/{id}` |  |
| `before:POST /api/v1/admin/ticket-departments` |  |
| `before:PUT /api/v1/admin/ticket-departments/{id}` |  |
| `before:PUT /api/v1/admin/ticket-departments/{id}/sla` |  |
| `before:POST /api/v1/admin/ticket-escalations` |  |
| `before:POST /api/v1/admin/ticket-escalations/run` | Enqueue an escalation pass now (durable job; the worker also schedules one |
| `before:DELETE /api/v1/admin/ticket-escalations/{id}` |  |
| `before:PUT /api/v1/admin/ticket-escalations/{id}` |  |
| `before:POST /api/v1/admin/ticket-statuses` |  |
| `before:DELETE /api/v1/admin/ticket-statuses/{slug}` |  |
| `before:PUT /api/v1/admin/ticket-statuses/{slug}` |  |
| `before:POST /api/v1/admin/tickets` |  |
| `before:PATCH /api/v1/admin/tickets/{id}` |  |
| `before:POST /api/v1/admin/tickets/{id}/merge` |  |
| `before:DELETE /api/v1/admin/tickets/{id}/messages/{mid}` |  |
| `before:PUT /api/v1/admin/tickets/{id}/messages/{mid}` |  |
| `before:POST /api/v1/admin/tickets/{id}/reply` |  |
| `before:POST /api/v1/admin/tickets/{id}/split` |  |
| `before:PUT /api/v1/admin/tickets/{id}/tags` |  |
| `before:POST /api/v1/admin/tickets/{id}/time` | time add |
| `before:DELETE /api/v1/admin/tickets/{id}/time/{entry}` | time del |
| `before:PUT /api/v1/admin/tickets/{id}/watchers` | put watchers |
| `before:PUT /api/v1/admin/tlds` |  |
| `before:POST /api/v1/admin/tlds/bulk-adjust` | admin tld adjust |
| `before:POST /api/v1/admin/tlds/copy-currency` | admin tld copy |
| `before:DELETE /api/v1/admin/tlds/{tld}/{currency}` |  |
| `before:POST /api/v1/admin/todos` |  |
| `before:DELETE /api/v1/admin/todos/{id}` |  |
| `before:PUT /api/v1/admin/todos/{id}` |  |
| `before:POST /api/v1/admin/tracked-links` |  |
| `before:DELETE /api/v1/admin/tracked-links/{id}` |  |
| `before:PATCH /api/v1/admin/transactions/{id}` |  |
| `before:DELETE /api/v1/admin/translations` |  |
| `before:PUT /api/v1/admin/translations` |  |
| `before:POST /api/v1/admin/usage-metrics` |  |
| `before:DELETE /api/v1/admin/usage-metrics/{id}` |  |
| `before:PUT /api/v1/admin/usage-metrics/{id}` |  |
| `before:POST /api/v1/admin/usage/bill` |  |
| `before:POST /api/v1/admin/usage/sample` |  |
| `before:POST /api/v1/admin/users/{id}/api-keys/revoke-all` |  |
| `before:POST /api/v1/admin/users/{id}/login-as` | login as user |
| `before:DELETE /api/v1/admin/users/{id}/passkeys` | Revoke every passkey of a user (lost device / compromised account). |
| `before:PUT /api/v1/admin/users/{id}/roles` |  |
| `before:POST /api/v1/admin/users/{id}/verify-email` | Mark a user's email address as confirmed (staff override). Idempotent. |
| `before:POST /api/v1/admin/vendor-catalog/{id}/import` |  |
| `before:PUT /api/v1/admin/vendor-catalog/{id}/markup` |  |
| `before:POST /api/v1/affiliates/track` | SPA landing (`/r/:code`): validates the code and sets the tracking cookie. |
| `before:POST /api/v1/api-keys` |  |
| `before:DELETE /api/v1/api-keys/{id}` |  |
| `before:DELETE /api/v1/auth/avatar` |  |
| `before:PUT /api/v1/auth/avatar` |  |
| `before:POST /api/v1/auth/duo/finish` |  |
| `before:POST /api/v1/auth/duo/start` | Swap the password-step challenge for a Duo authorize URL. Health-checks |
| `before:POST /api/v1/auth/email/resend` |  |
| `before:POST /api/v1/auth/email/verify` |  |
| `before:POST /api/v1/auth/impersonation/stop` | stop impersonation |
| `before:PUT /api/v1/auth/locale` |  |
| `before:POST /api/v1/auth/login` |  |
| `before:POST /api/v1/auth/logout` |  |
| `before:POST /api/v1/auth/mfa/backup-codes` | Replace all backup codes; returned once. |
| `before:POST /api/v1/auth/mfa/disable` | Turn 2FA off; needs the password and a current code (or a backup code). |
| `before:POST /api/v1/auth/mfa/enable` |  |
| `before:POST /api/v1/auth/mfa/setup` | Start enrolment: a fresh pending secret, replaced on each call until enabled. |
| `before:POST /api/v1/auth/mfa/verify` | Second login step. Max 5 attempts per challenge, 5 minute lifetime. |
| `before:POST /api/v1/auth/passkeys/discover/finish` |  |
| `before:POST /api/v1/auth/passkeys/discover/start` | Usernameless sign-in: empty `allowCredentials`, the authenticator picks a |
| `before:POST /api/v1/auth/passkeys/login/finish` | Passwordless sign-in. A user-verified passkey counts as both factors. |
| `before:POST /api/v1/auth/passkeys/login/start` |  |
| `before:POST /api/v1/auth/passkeys/reauth/finish` | Step-up with a passkey instead of password + code. |
| `before:POST /api/v1/auth/passkeys/reauth/start` |  |
| `before:POST /api/v1/auth/passkeys/register/finish` |  |
| `before:POST /api/v1/auth/passkeys/register/start` | Enrolment needs the password (and 2FA code when enabled) again. |
| `before:DELETE /api/v1/auth/passkeys/{id}` |  |
| `before:POST /api/v1/auth/password/change` | change password |
| `before:POST /api/v1/auth/password/forgot` |  |
| `before:POST /api/v1/auth/password/reset` |  |
| `before:POST /api/v1/auth/reauth` | reauth |
| `before:POST /api/v1/auth/register` |  |
| `before:POST /api/v1/auth/saml/logout` | logout |
| `before:DELETE /api/v1/auth/sessions` | Sign out every other device; the calling session stays valid. |
| `before:DELETE /api/v1/auth/sessions/{id}` |  |
| `before:POST /api/v1/chat/sessions` |  |
| `before:POST /api/v1/chat/sessions/{id}/close` |  |
| `before:POST /api/v1/chat/sessions/{id}/messages` |  |
| `before:POST /api/v1/client/affiliate` |  |
| `before:POST /api/v1/client/affiliate/payouts` |  |
| `before:PUT /api/v1/client/billing/auto-pay` |  |
| `before:POST /api/v1/client/billing/payment-methods` |  |
| `before:DELETE /api/v1/client/billing/payment-methods/{id}` |  |
| `before:POST /api/v1/client/billing/payment-methods/{id}/default` |  |
| `before:POST /api/v1/client/checkout` | Turn a cart into an order + invoice for the signed-in customer. The |
| `before:POST /api/v1/client/contacts` |  |
| `before:DELETE /api/v1/client/contacts/{id}` |  |
| `before:POST /api/v1/client/coupons/check` | Validate a code for the order form. Returns only what the customer needs. |
| `before:POST /api/v1/client/credit/add-funds` | Create a deposit invoice; once paid (any gateway) the amount is credited. |
| `before:POST /api/v1/client/domains` |  |
| `before:POST /api/v1/client/domains/bulk` | client bulk |
| `before:PUT /api/v1/client/domains/{id}/addons` | client addons |
| `before:PUT /api/v1/client/domains/{id}/auto-renew` |  |
| `before:PUT /api/v1/client/domains/{id}/contact` |  |
| `before:PUT /api/v1/client/domains/{id}/dns` | client dns put |
| `before:POST /api/v1/client/domains/{id}/epp` | Reveal the transfer-out (EPP) code. Audited; owner only, active domains. |
| `before:PUT /api/v1/client/domains/{id}/nameservers` |  |
| `before:POST /api/v1/client/domains/{id}/registrant-verification/resend` |  |
| `before:POST /api/v1/client/domains/{id}/renew` |  |
| `before:PUT /api/v1/client/email-preferences` |  |
| `before:PUT /api/v1/client/fields` |  |
| `before:POST /api/v1/client/gift-cards/redeem` |  |
| `before:POST /api/v1/client/invoices/mass-pay` | Pay several of my unpaid invoices from account credit, oldest due first. |
| `before:POST /api/v1/client/invoices/merge` | Client-side merge (WHMCS "mass payment"): combine own unpaid invoices into |
| `before:POST /api/v1/client/invoices/{id}/apply-credit` |  |
| `before:POST /api/v1/client/invoices/{id}/pay` | Start paying an invoice with a gateway: redirect (Stripe) or manual |
| `before:POST /api/v1/client/kyc/documents` | Raw body upload. PNG/JPEG/WebP/PDF only. |
| `before:DELETE /api/v1/client/kyc/documents/{id}` |  |
| `before:PUT /api/v1/client/marketing` |  |
| `before:POST /api/v1/client/orders` |  |
| `before:POST /api/v1/client/payments/{id}/complete` | Payer returned from the provider: capture/check and apply (idempotent). |
| `before:PUT /api/v1/client/profile` |  |
| `before:POST /api/v1/client/quotes/{id}/accept` |  |
| `before:POST /api/v1/client/quotes/{id}/decline` |  |
| `before:DELETE /api/v1/client/services/{id}/cancellation` |  |
| `before:POST /api/v1/client/services/{id}/cancellation` |  |
| `before:DELETE /api/v1/client/services/{id}/change` |  |
| `before:POST /api/v1/client/services/{id}/change` |  |
| `before:POST /api/v1/client/services/{id}/module/{action}` |  |
| `before:POST /api/v1/client/services/{id}/renew-now` |  |
| `before:POST /api/v1/client/services/{id}/sso` |  |
| `before:POST /api/v1/client/subaccounts` | Create a sub-account that signs in with its own email and password and |
| `before:POST /api/v1/client/subaccounts/invite` | Invite a contact by email: the account is created with an unusable random |
| `before:DELETE /api/v1/client/subaccounts/{id}` | Remove a contact: its access ends immediately (sessions revoked) and |
| `before:PUT /api/v1/client/subaccounts/{id}` |  |
| `before:POST /api/v1/client/tickets` |  |
| `before:POST /api/v1/client/tickets/{id}/close` |  |
| `before:POST /api/v1/client/tickets/{id}/rate` |  |
| `before:POST /api/v1/client/tickets/{id}/reply` |  |
| `before:POST /api/v1/client/vat/verify` |  |
| `before:POST /api/v1/compat/whmcs` | handle |
| `before:POST /api/v1/headless/carts` | Create a server-side cart owned by the key's customer; the response holds |
| `before:PUT /api/v1/headless/carts/{id}` |  |
| `before:POST /api/v1/headless/carts/{id}/checkout` | Turn the cart into an order and a finalized invoice (fraud screening, |
| `before:POST /api/v1/headless/invoices/{id}/pay` | Start a payment for an invoice; returns the gateway redirect/action. |
| `before:POST /api/v1/licensing/verify` | Public. Bind on first use, then verify; the response is HMAC-signed with |
| `before:POST /api/v1/oauth/authorize` | authorize decide |
| `before:POST /api/v1/public/contact` | contact submit |
| `before:POST /api/v1/public/domains/verify-registrant` | E-mail link target: confirms the registrant address (all its pending domains). |
| `before:POST /api/v1/public/kb/articles/{slug}/vote` |  |
| `before:POST /api/v1/sso/consume` | Called by the panel. 200 with the claims the first time; 401 otherwise. |
| `before:POST /api/v1/sso/saml/{id}/acs` | acs |
| `before:PUT /api/v1/store/carts/{id}` | Create or replace a cart. The id is an unguessable client-generated |
| `before:POST /api/v1/tickets/attachments` | Upload a file (raw body). Returns an id to pass as `attachment_ids` when |
| `before:POST /api/v1/unsubscribe/{token}` | No CSRF/session: the unguessable token is the credential. |
| `before:POST /api/v1/webhooks/{gateway}` |  |

## Mixins

A mixin wraps one core route under `/api/v1` (any method, `GET` too; not `/api/v1/auth/*` or addon routes): `before` rewrites the request body, `after` rewrites the JSON response (only values of keys it already has), `around` does both with shared `state`, and `return` replaces the response with one of the same shape. Mixins need admin approval (`mixins`, `mixins.return`) and can be switched off per addon. See [Hooks and mixins](hooks.md).

_extensions/examples/addon-guide/src/lib.rs · snippet mixin_

```rust
/// Mixins wrap a core route (`capabilities.mixins`). An `after` mixin may
/// change values of keys the response already has; new keys are dropped.
fn mixin(input: &Value) -> Value {
    let mut resp = input["response"].clone();
    // Opt-in per request so this example never changes other callers.
    let wanted = input["request"]["query"].as_str().unwrap_or("").contains("guide=1");
    if input["at"] == "after" && wanted {
        if let Some(c) = resp["city"].as_str() {
            resp["city"] = json!(c.to_uppercase());
        }
        return json!({ "response": resp });
    }
    json!({})
}
```

