# Events index

All 38 core events: 34 domain events (published after the fact, observe only) and 4 pre-events (run before the action; 4 can be cancelled, 3 can be modified). On top of these, **every mutating API request** is a cancellable, modifiable event, see [Cancel and modify](cancellable.md). Rendered from `pelty_extensions::events::REGISTRY`; the test `crates/pelty-api/tests/event_registry_coverage.rs` fails when the source emits an event or payload key missing here. Machine-readable: [events.json](events.json).

## How events work

- **Domain events** (`invoice.paid`, …) are published after the action committed. Addons receive them through `on-event` when listed in `capabilities.events`, and as `after:<event>` / `finished:<event>` hooks. Webhook plugins receive them as signed HTTPS POSTs when listed in the manifest `hooks`. They cannot be cancelled or changed.
- **Pre-events** (`order.before_place`, …) run synchronously in the `before` phase, in priority order. Each listener may continue, modify the payload, or cancel. A cancel answers the API call with HTTP 409 `error.addon_hook_cancelled`.
- **Request events** `<METHOD> <route>` exist for every POST, PUT, PATCH and DELETE route, with all four phases. See [Cancel and modify](cancellable.md).
- **Custom events** `<addon-id>:<name>` are emitted by addons (`capabilities.emits`) and have the same phases; listeners can cancel or modify them in `before`.
- Listeners run lowest priority first (`lowest` 0, `low` 50, `normal` 100, `high` 150, `highest` 200, or any integer -1000..1000), ties by addon id. `monitor` (1000) runs last and only observes. See [Hooks and mixins](hooks.md).

## Categories

- [Affiliate](#cat-affiliate): 1
- [Chat](#cat-chat): 1
- [Client](#cat-client): 1
- [Customer](#cat-customer): 1
- [Invoice](#cat-invoice): 8
- [Login](#cat-login): 1
- [Order](#cat-order): 7
- [Payment](#cat-payment): 3
- [Payment method](#cat-payment_method): 1
- [Quote](#cat-quote): 1
- [Service](#cat-service): 7
- [Ticket](#cat-ticket): 3
- [User](#cat-user): 3

## All events

| Event | Category | Phases | Cancel | Modify | Payload |
|---|---|---|---|---|---|
| [`user.registered`](#user-registered) | User | after, finished | no | no | user_id, via |
| [`user.logged_in`](#user-logged-in) | User | after, finished | no | no | user_id |
| [`user.login_alert`](#user-login-alert) | User | after, finished | no | no | user_id, ip, user_agent |
| [`customer.closed`](#customer-closed) | Customer | after, finished | no | no | customer_id |
| [`order.created`](#order-created) | Order | after, finished | no | no | order_id, customer_id, total, currency |
| [`order.approved`](#order-approved) | Order | after, finished | no | no | order_id, services, auto |
| [`order.cancelled`](#order-cancelled) | Order | after, finished | no | no | order_id, fraud |
| [`order.refunded`](#order-refunded) | Order | after, finished | no | no | order_id, credit_note_id |
| [`order.fraud_review`](#order-fraud-review) | Order | after, finished | no | no | order_id, customer_id, score |
| [`order.fraud_rejected`](#order-fraud-rejected) | Order | after, finished | no | no | order_id, customer_id, score |
| [`invoice.created`](#invoice-created) | Invoice | after, finished | no | no | invoice_id, customer_id, service_id, renewal, on_demand, late_fee_for, debit_for, number, project_id, billable |
| [`invoice.paid`](#invoice-paid) | Invoice | after, finished | no | no | invoice_id, customer_id |
| [`invoice.voided`](#invoice-voided) | Invoice | after, finished | no | no | invoice_id |
| [`invoice.cancelled`](#invoice-cancelled) | Invoice | after, finished | no | no | invoice_id, reason |
| [`invoice.refunded`](#invoice-refunded) | Invoice | after, finished | no | no | invoice_id, credit_note_id, refund_id, amount |
| [`invoice.due_soon`](#invoice-due-soon) | Invoice | after, finished | no | no | invoice_id, customer_id, days_left |
| [`invoice.overdue`](#invoice-overdue) | Invoice | after, finished | no | no | invoice_id, customer_id, days_overdue |
| [`payment.succeeded`](#payment-succeeded) | Payment | after, finished | no | no | invoice_id, payment_id, gateway, provider_ref, autopay |
| [`payment.failed`](#payment-failed) | Payment | after, finished | no | no | invoice_id, payment_id, gateway, provider_ref, autopay, attempt |
| [`payment.disputed`](#payment-disputed) | Payment | after, finished | no | no | gateway, provider_ref, payment_id, event_type |
| [`payment_method.expiring`](#payment-method-expiring) | Payment method | after, finished | no | no | payment_method_id, customer_id |
| [`service.created`](#service-created) | Service | after, finished | no | no | service_id, order_id |
| [`service.provisioned`](#service-provisioned) | Service | after, finished | no | no | service_id, action, module |
| [`service.provision_failed`](#service-provision-failed) | Service | after, finished | no | no | service_id, action, error |
| [`service.suspended`](#service-suspended) | Service | after, finished | no | no | service_id, from, reason |
| [`service.unsuspended`](#service-unsuspended) | Service | after, finished | no | no | service_id, from, reason |
| [`service.terminated`](#service-terminated) | Service | after, finished | no | no | service_id, from, reason, provisioned |
| [`service.cancelled`](#service-cancelled) | Service | after, finished | no | no | service_id |
| [`ticket.opened`](#ticket-opened) | Ticket | after, finished | no | no | ticket_id, number |
| [`ticket.replied`](#ticket-replied) | Ticket | after, finished | no | no | ticket_id, by_staff |
| [`ticket.closed`](#ticket-closed) | Ticket | after, finished | no | no | ticket_id, reason |
| [`chat.started`](#chat-started) | Chat | after, finished | no | no | chat_id, name, email |
| [`quote.sent`](#quote-sent) | Quote | after, finished | no | no | quote_id |
| [`affiliate.payout_requested`](#affiliate-payout-requested) | Affiliate | after, finished | no | no | affiliate_id, payout_id, amount, currency |
| [`login.before`](#login-before) | Login | before | yes | no | email, ip, user_agent |
| [`client.before_register`](#client-before-register) | Client | before | yes | yes | email, display_name, ip |
| [`order.before_place`](#order-before-place) | Order | before | yes | yes | customer_id, currency, items, coupon |
| [`invoice.before_create`](#invoice-before-create) | Invoice | before | yes | yes | customer_id, currency, finalize, items |

## Affiliate

### affiliate.payout_requested

An affiliate asked for a payout.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `affiliate_id` | `uuid` | yes | Affiliate |
| `payout_id` | `uuid` | yes | Payout |
| `amount` | `decimal` | yes | Amount |
| `currency` | `string` | yes | ISO 4217 code |

**Emitted from:** `crates/pelty-api/src/routes/affiliates.rs:163`.

```json
{
  "affiliate_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "amount": "12.50",
  "currency": "EUR",
  "payout_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "affiliate.payout_requested"
    ],
    "hooks": [
      {
        "event": "finished:affiliate.payout_requested",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "affiliate.payout_requested" {
        let affiliate_id = payload["affiliate_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:affiliate.payout_requested", affiliate_id);
    }
}
```

## Chat

### chat.started

A visitor started a live chat.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `chat_id` | `uuid` | yes | Chat |
| `name` | `string` | no | Visitor name |
| `email` | `string` | no | Visitor email |

**Emitted from:** `crates/pelty-api/src/routes/live_chat.rs:60`.

```json
{
  "chat_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "email": "jane@example.com",
  "name": "…"
}
```

```json
{
  "capabilities": {
    "events": [
      "chat.started"
    ],
    "hooks": [
      {
        "event": "finished:chat.started",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "chat.started" {
        let chat_id = payload["chat_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:chat.started", chat_id);
    }
}
```

## Client

### client.before_register

Before a sign-up. Cancel to refuse it; `display_name` may be rewritten. The password never leaves the host.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| pre-event | `before` | yes | yes | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `email` | `string` | yes | Submitted email |
| `display_name` | `string` | yes | Display name (modifiable) |
| `ip` | `string` | no | Client IP |

**Modifiable keys** (everything else in a `modify` is ignored)

| Key | Rule |
|---|---|
| `display_name` | String of at most 200 bytes; anything else keeps the submitted name |

**Emitted from:** `crates/pelty-api/src/routes/auth.rs:113`.

```json
{
  "display_name": "…",
  "email": "jane@example.com",
  "ip": "203.0.113.7"
}
```

```json
{
  "capabilities": {
    "hooks": [
      {
        "event": "client.before_register",
        "fail_open": true,
        "priority": "normal",
        "timeout_ms": 500
      }
    ]
  }
}
```

```rust
// op `hook`, phase `before`: return proceed, modify or cancel.
fn hook(input: &Value) -> Value {
    if input["event"] != "client.before_register" || input["cancelled"] == true {
        return reply::proceed();
    }
    let mut p = input["payload"].clone();
    // Cancel: the caller gets HTTP 409 `error.addon_hook_cancelled`.
    if p["email"].as_str().is_some_and(|v| v.ends_with(".invalid")) {
        return reply::cancel("refused by my-addon");
    }
    // Modify: only the keys listed under "Modifiable keys" are read back.
    p["display_name"] = json!(p["display_name"].as_str().unwrap_or("").trim());
    reply::modify(p)
}
```

## Customer

### customer.closed

A customer account was closed (GDPR erasure or admin close).

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `customer_id` | `uuid` | yes | Customer |

**Emitted from:** `crates/pelty-api/src/security.rs:370`.

```json
{
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "customer.closed"
    ],
    "hooks": [
      {
        "event": "finished:customer.closed",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "customer.closed" {
        let customer_id = payload["customer_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:customer.closed", customer_id);
    }
}
```

## Invoice

### invoice.created

An invoice was issued (order, renewal, late fee, debit note, project or billable items).

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | yes | Invoice |
| `customer_id` | `uuid` | no | Customer |
| `service_id` | `uuid` | no | Renewed service |
| `renewal` | `bool` | no | Renewal invoice |
| `on_demand` | `bool` | no | Renewal requested early by the client |
| `late_fee_for` | `uuid` | no | Overdue invoice the fee is for |
| `debit_for` | `uuid` | no | Invoice the debit note corrects |
| `number` | `string` | no | Invoice number |
| `project_id` | `uuid` | no | Project |
| `billable` | `bool` | no | From billable items |

**Emitted from:** `crates/pelty-api/src/overdue.rs:38`, `crates/pelty-api/src/routes/billing.rs:214`, `crates/pelty-api/src/routes/billing.rs:671`, `crates/pelty-api/src/routes/billing.rs:700`, `crates/pelty-api/src/routes/billing.rs:772`, `crates/pelty-api/src/routes/credit.rs:147`, `crates/pelty-api/src/routes/projects.rs:126`, `crates/pelty-api/src/routes/renewals.rs:65`, `crates/pelty-api/src/routes/service_changes.rs:98`, `crates/pelty-api/src/worker.rs:40`, `crates/pelty-api/src/worker.rs:53`.

```json
{
  "billable": true,
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "debit_for": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "late_fee_for": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "number": "…",
  "on_demand": true,
  "project_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "renewal": true,
  "service_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "invoice.created"
    ],
    "hooks": [
      {
        "event": "finished:invoice.created",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "invoice.created" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:invoice.created", invoice_id);
    }
}
```

### invoice.paid

An invoice became fully paid (gateway, credit, autopay or admin).

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | yes | Invoice |
| `customer_id` | `uuid` | no | Customer (only on checkout auto-paid invoices) |

**Emitted from:** `crates/pelty-api/src/autopay.rs:115`, `crates/pelty-api/src/routes/billing.rs:219`, `crates/pelty-api/src/routes/billing.rs:830`, `crates/pelty-api/src/routes/bulk.rs:63`, `crates/pelty-api/src/routes/credit.rs:53`, `crates/pelty-api/src/routes/credit.rs:76`, `crates/pelty-api/src/routes/webhooks.rs:161`.

```json
{
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "invoice.paid"
    ],
    "hooks": [
      {
        "event": "finished:invoice.paid",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "invoice.paid" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:invoice.paid", invoice_id);
    }
}
```

### invoice.voided

An invoice was voided.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | yes | Invoice |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:723`, `crates/pelty-api/src/routes/bulk.rs:109`.

```json
{
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "invoice.voided"
    ],
    "hooks": [
      {
        "event": "finished:invoice.voided",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "invoice.voided" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:invoice.voided", invoice_id);
    }
}
```

### invoice.cancelled

An unpaid invoice was cancelled by automation.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | yes | Invoice |
| `reason` | `string` | yes | Why, e.g. `overdue_auto_cancel` |

**Emitted from:** `crates/pelty-api/src/overdue.rs:53`.

```json
{
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "reason": "…"
}
```

```json
{
  "capabilities": {
    "events": [
      "invoice.cancelled"
    ],
    "hooks": [
      {
        "event": "finished:invoice.cancelled",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "invoice.cancelled" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:invoice.cancelled", invoice_id);
    }
}
```

### invoice.refunded

A payment on the invoice was refunded.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | yes | Invoice |
| `credit_note_id` | `uuid` | no | Credit note |
| `refund_id` | `uuid` | no | Refund |
| `amount` | `decimal` | no | Refunded amount |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:746`, `crates/pelty-api/src/routes/refunds.rs:164`, `crates/pelty-api/src/routes/store.rs:489`.

```json
{
  "amount": "12.50",
  "credit_note_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "refund_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "invoice.refunded"
    ],
    "hooks": [
      {
        "event": "finished:invoice.refunded",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "invoice.refunded" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:invoice.refunded", invoice_id);
    }
}
```

### invoice.due_soon

Daily reminder: an invoice is due in a few days.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | yes | Invoice |
| `customer_id` | `uuid` | yes | Customer |
| `days_left` | `int` | yes | Days until due |

**Emitted from:** `crates/pelty-api/src/approval.rs:77`.

```json
{
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "days_left": 3,
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "invoice.due_soon"
    ],
    "hooks": [
      {
        "event": "finished:invoice.due_soon",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "invoice.due_soon" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:invoice.due_soon", invoice_id);
    }
}
```

### invoice.overdue

Daily reminder: an invoice is past due.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | yes | Invoice |
| `customer_id` | `uuid` | yes | Customer |
| `days_overdue` | `int` | yes | Days past due |

**Emitted from:** `crates/pelty-api/src/overdue.rs:35`.

```json
{
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "days_overdue": 3,
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "invoice.overdue"
    ],
    "hooks": [
      {
        "event": "finished:invoice.overdue",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "invoice.overdue" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:invoice.overdue", invoice_id);
    }
}
```

### invoice.before_create

Before an admin creates an invoice. Line descriptions may be rewritten; amounts only with `money.modify`.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| pre-event | `before` | yes | yes | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `customer_id` | `uuid` | yes | Customer |
| `currency` | `string` | yes | ISO 4217 code |
| `finalize` | `bool` | yes | Issue immediately |
| `items` | `array` | yes | `{description, quantity, unit_price}` lines (decimals as strings) |

**Modifiable keys** (everything else in a `modify` is ignored)

| Key | Rule |
|---|---|
| `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 |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:623`.

```json
{
  "currency": "EUR",
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "finalize": true,
  "items": []
}
```

```json
{
  "capabilities": {
    "hooks": [
      {
        "event": "invoice.before_create",
        "fail_open": true,
        "priority": "normal",
        "timeout_ms": 500
      }
    ]
  }
}
```

```rust
// op `hook`, phase `before`: return proceed, modify or cancel.
fn hook(input: &Value) -> Value {
    if input["event"] != "invoice.before_create" || input["cancelled"] == true {
        return reply::proceed();
    }
    let mut p = input["payload"].clone();
    // Cancel: the caller gets HTTP 409 `error.addon_hook_cancelled`.
    if p["customer_id"].as_str().is_some_and(|v| v.ends_with(".invalid")) {
        return reply::cancel("refused by my-addon");
    }
    // Modify: rewrite `description` on every `items` element.
    if let Some(lines) = p["items"].as_array_mut() {
        for l in lines {
            l["description"] = json!(l["description"].as_str().unwrap_or("").trim());
        }
    }
    reply::modify(p)
}
```

## Login

### login.before

Before a password sign-in is checked. Cancel to refuse the login (the client gets 409 `error.addon_hook_cancelled`). The payload is read-only: changes are ignored.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| pre-event | `before` | yes | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `email` | `string` | yes | Submitted email |
| `ip` | `string` | no | Client IP |
| `user_agent` | `string` | no | Browser user agent |

**Modifiable keys:** none. A `modify` reply is accepted but the host does not read anything back.

**Emitted from:** `crates/pelty-api/src/routes/auth.rs:151`.

```json
{
  "email": "jane@example.com",
  "ip": "203.0.113.7",
  "user_agent": "…"
}
```

```json
{
  "capabilities": {
    "hooks": [
      {
        "event": "login.before",
        "fail_open": true,
        "priority": "normal",
        "timeout_ms": 500
      }
    ]
  }
}
```

```rust
// op `hook`, phase `before`: return proceed, modify or cancel.
fn hook(input: &Value) -> Value {
    if input["event"] != "login.before" || input["cancelled"] == true {
        return reply::proceed();
    }
    let mut p = input["payload"].clone();
    // Cancel: the caller gets HTTP 409 `error.addon_hook_cancelled`.
    if p["email"].as_str().is_some_and(|v| v.ends_with(".invalid")) {
        return reply::cancel("refused by my-addon");
    }
    reply::proceed() // this event is read-only: a modify is ignored
}
```

## Order

### order.created

A customer placed an order.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `order_id` | `uuid` | yes | Order |
| `customer_id` | `uuid` | yes | Customer |
| `total` | `decimal` | yes | Order total |
| `currency` | `string` | yes | ISO 4217 code |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:212`.

```json
{
  "currency": "EUR",
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "order_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "total": "12.50"
}
```

```json
{
  "capabilities": {
    "events": [
      "order.created"
    ],
    "hooks": [
      {
        "event": "finished:order.created",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "order.created" {
        let order_id = payload["order_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:order.created", order_id);
    }
}
```

### order.approved

An order was accepted and its services created.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `order_id` | `uuid` | yes | Order |
| `services` | `array` | yes | Created service ids |
| `auto` | `bool` | no | Approved automatically on payment |

**Emitted from:** `crates/pelty-api/src/approval.rs:17`, `crates/pelty-api/src/routes/billing.rs:498`, `crates/pelty-api/src/routes/store.rs:441`.

```json
{
  "auto": true,
  "order_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "services": []
}
```

```json
{
  "capabilities": {
    "events": [
      "order.approved"
    ],
    "hooks": [
      {
        "event": "finished:order.approved",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "order.approved" {
        let order_id = payload["order_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:order.approved", order_id);
    }
}
```

### order.cancelled

An order was cancelled.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `order_id` | `uuid` | yes | Order |
| `fraud` | `bool` | no | Cancelled by the fraud check |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:535`, `crates/pelty-api/src/routes/bulk.rs:216`.

```json
{
  "fraud": true,
  "order_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "order.cancelled"
    ],
    "hooks": [
      {
        "event": "finished:order.cancelled",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "order.cancelled" {
        let order_id = payload["order_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:order.cancelled", order_id);
    }
}
```

### order.refunded

A paid order was refunded with a credit note.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `order_id` | `uuid` | yes | Order |
| `credit_note_id` | `uuid` | yes | Credit note |

**Emitted from:** `crates/pelty-api/src/routes/store.rs:490`.

```json
{
  "credit_note_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "order_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "order.refunded"
    ],
    "hooks": [
      {
        "event": "finished:order.refunded",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "order.refunded" {
        let order_id = payload["order_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:order.refunded", order_id);
    }
}
```

### order.fraud_review

The fraud check held an order for review.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `order_id` | `uuid` | yes | Order |
| `customer_id` | `uuid` | yes | Customer |
| `score` | `decimal` | yes | Fraud score |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:207`.

```json
{
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "order_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "score": "12.50"
}
```

```json
{
  "capabilities": {
    "events": [
      "order.fraud_review"
    ],
    "hooks": [
      {
        "event": "finished:order.fraud_review",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "order.fraud_review" {
        let order_id = payload["order_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:order.fraud_review", order_id);
    }
}
```

### order.fraud_rejected

The fraud check rejected an order.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `order_id` | `uuid` | yes | Order |
| `customer_id` | `uuid` | yes | Customer |
| `score` | `decimal` | yes | Fraud score |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:202`.

```json
{
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "order_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "score": "12.50"
}
```

```json
{
  "capabilities": {
    "events": [
      "order.fraud_rejected"
    ],
    "hooks": [
      {
        "event": "finished:order.fraud_rejected",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "order.fraud_rejected" {
        let order_id = payload["order_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:order.fraud_rejected", order_id);
    }
}
```

### order.before_place

Before an order is placed. Cancel to refuse; `coupon` may be set or cleared.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| pre-event | `before` | yes | yes | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `customer_id` | `uuid` | yes | Customer |
| `currency` | `string` | yes | ISO 4217 code |
| `items` | `array` | yes | Cart lines |
| `coupon` | `string` | no | Coupon code (modifiable) |

**Modifiable keys** (everything else in a `modify` is ignored)

| Key | Rule |
|---|---|
| `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 |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:171`.

```json
{
  "coupon": "…",
  "currency": "EUR",
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "items": []
}
```

```json
{
  "capabilities": {
    "hooks": [
      {
        "event": "order.before_place",
        "fail_open": true,
        "priority": "normal",
        "timeout_ms": 500
      }
    ]
  }
}
```

```rust
// op `hook`, phase `before`: return proceed, modify or cancel.
fn hook(input: &Value) -> Value {
    if input["event"] != "order.before_place" || input["cancelled"] == true {
        return reply::proceed();
    }
    let mut p = input["payload"].clone();
    // Cancel: the caller gets HTTP 409 `error.addon_hook_cancelled`.
    if p["customer_id"].as_str().is_some_and(|v| v.ends_with(".invalid")) {
        return reply::cancel("refused by my-addon");
    }
    // Modify: only the keys listed under "Modifiable keys" are read back.
    p["coupon"] = json!(p["coupon"].as_str().unwrap_or("").trim());
    reply::modify(p)
}
```

## Payment

### payment.succeeded

A payment was captured.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | no | Invoice |
| `payment_id` | `uuid` | no | Payment |
| `gateway` | `string` | no | Gateway id |
| `provider_ref` | `string` | no | Gateway reference |
| `autopay` | `bool` | no | Charged by autopay |

**Emitted from:** `crates/pelty-api/src/autopay.rs:113`, `crates/pelty-api/src/routes/billing.rs:825`, `crates/pelty-api/src/routes/bulk.rs:61`, `crates/pelty-api/src/routes/credit.rs:51`, `crates/pelty-api/src/routes/credit.rs:74`, `crates/pelty-api/src/routes/webhooks.rs:156`, `crates/pelty-api/src/routes/webhooks.rs:168`.

```json
{
  "autopay": true,
  "gateway": "stripe",
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "payment_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "provider_ref": "…"
}
```

```json
{
  "capabilities": {
    "events": [
      "payment.succeeded"
    ],
    "hooks": [
      {
        "event": "finished:payment.succeeded",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "payment.succeeded" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:payment.succeeded", invoice_id);
    }
}
```

### payment.failed

A payment attempt failed.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `invoice_id` | `uuid` | no | Invoice |
| `payment_id` | `uuid` | no | Payment |
| `gateway` | `string` | no | Gateway id |
| `provider_ref` | `string` | no | Gateway reference |
| `autopay` | `bool` | no | Autopay attempt |
| `attempt` | `int` | no | Autopay attempt number |

**Emitted from:** `crates/pelty-api/src/autopay.rs:128`, `crates/pelty-api/src/routes/webhooks.rs:175`.

```json
{
  "attempt": 3,
  "autopay": true,
  "gateway": "stripe",
  "invoice_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "payment_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "provider_ref": "…"
}
```

```json
{
  "capabilities": {
    "events": [
      "payment.failed"
    ],
    "hooks": [
      {
        "event": "finished:payment.failed",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "payment.failed" {
        let invoice_id = payload["invoice_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:payment.failed", invoice_id);
    }
}
```

### payment.disputed

A gateway reported a chargeback or dispute.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `gateway` | `string` | yes | Gateway id |
| `provider_ref` | `string` | yes | Gateway reference |
| `payment_id` | `uuid` | no | Payment |
| `event_type` | `string` | no | Gateway event type |

**Emitted from:** `crates/pelty-api/src/routes/webhooks.rs:124`.

```json
{
  "event_type": "…",
  "gateway": "stripe",
  "payment_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "provider_ref": "…"
}
```

```json
{
  "capabilities": {
    "events": [
      "payment.disputed"
    ],
    "hooks": [
      {
        "event": "finished:payment.disputed",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "payment.disputed" {
        let gateway = payload["gateway"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:payment.disputed", gateway);
    }
}
```

## Payment method

### payment_method.expiring

A saved card expires soon.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `payment_method_id` | `uuid` | yes | Payment method |
| `customer_id` | `uuid` | yes | Customer |

**Emitted from:** `crates/pelty-api/src/autopay.rs:64`.

```json
{
  "customer_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "payment_method_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "payment_method.expiring"
    ],
    "hooks": [
      {
        "event": "finished:payment_method.expiring",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "payment_method.expiring" {
        let payment_method_id = payload["payment_method_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:payment_method.expiring", payment_method_id);
    }
}
```

## Quote

### quote.sent

A quote was sent to a customer.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `quote_id` | `uuid` | yes | Quote |

**Emitted from:** `crates/pelty-api/src/routes/quotes.rs:158`.

```json
{
  "quote_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "quote.sent"
    ],
    "hooks": [
      {
        "event": "finished:quote.sent",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "quote.sent" {
        let quote_id = payload["quote_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:quote.sent", quote_id);
    }
}
```

## Service

### service.created

A service was created from an order.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `service_id` | `uuid` | yes | Service |
| `order_id` | `uuid` | yes | Order |

**Emitted from:** `crates/pelty-api/src/approval.rs:19`, `crates/pelty-api/src/routes/billing.rs:503`, `crates/pelty-api/src/routes/store.rs:443`.

```json
{
  "order_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "service_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "service.created"
    ],
    "hooks": [
      {
        "event": "finished:service.created",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "service.created" {
        let service_id = payload["service_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:service.created", service_id);
    }
}
```

### service.provisioned

A provisioning module action succeeded.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `service_id` | `uuid` | yes | Service |
| `action` | `string` | yes | `create`, `suspend`, … |
| `module` | `string` | yes | Module id |

**Emitted from:** `crates/pelty-api/src/provision.rs:120`.

```json
{
  "action": "…",
  "module": "…",
  "service_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "service.provisioned"
    ],
    "hooks": [
      {
        "event": "finished:service.provisioned",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "service.provisioned" {
        let service_id = payload["service_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:service.provisioned", service_id);
    }
}
```

### service.provision_failed

A provisioning module action failed.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `service_id` | `uuid` | yes | Service |
| `action` | `string` | yes | Module action |
| `error` | `string` | yes | Error message |

**Emitted from:** `crates/pelty-api/src/provision.rs:142`.

```json
{
  "action": "…",
  "error": "…",
  "service_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "service.provision_failed"
    ],
    "hooks": [
      {
        "event": "finished:service.provision_failed",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "service.provision_failed" {
        let service_id = payload["service_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:service.provision_failed", service_id);
    }
}
```

### service.suspended

A service was suspended.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `service_id` | `uuid` | yes | Service |
| `from` | `string` | no | Previous status |
| `reason` | `string` | no | Why |

**Emitted from:** `crates/pelty-api/src/overdue.rs:42`, `crates/pelty-api/src/routes/billing.rs:899`, `crates/pelty-api/src/routes/refunds.rs:151`.

```json
{
  "from": "…",
  "reason": "…",
  "service_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "service.suspended"
    ],
    "hooks": [
      {
        "event": "finished:service.suspended",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "service.suspended" {
        let service_id = payload["service_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:service.suspended", service_id);
    }
}
```

### service.unsuspended

A service was reactivated.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `service_id` | `uuid` | yes | Service |
| `from` | `string` | no | Previous status |
| `reason` | `string` | no | Why |

**Emitted from:** `crates/pelty-api/src/overdue.rs:65`, `crates/pelty-api/src/routes/billing.rs:900`.

```json
{
  "from": "…",
  "reason": "…",
  "service_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "service.unsuspended"
    ],
    "hooks": [
      {
        "event": "finished:service.unsuspended",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "service.unsuspended" {
        let service_id = payload["service_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:service.unsuspended", service_id);
    }
}
```

### service.terminated

A service was terminated.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `service_id` | `uuid` | yes | Service |
| `from` | `string` | no | Previous status |
| `reason` | `string` | no | Why |
| `provisioned` | `bool` | no | The module terminated it remotely |

**Emitted from:** `crates/pelty-api/src/overdue.rs:50`, `crates/pelty-api/src/routes/billable.rs:131`, `crates/pelty-api/src/routes/billing.rs:901`.

```json
{
  "from": "…",
  "provisioned": true,
  "reason": "…",
  "service_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "service.terminated"
    ],
    "hooks": [
      {
        "event": "finished:service.terminated",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "service.terminated" {
        let service_id = payload["service_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:service.terminated", service_id);
    }
}
```

### service.cancelled

A cancellation request was applied.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `service_id` | `uuid` | yes | Service |

**Emitted from:** `crates/pelty-api/src/routes/billing.rs:902`, `crates/pelty-api/src/routes/service_changes.rs:63`.

```json
{
  "service_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "service.cancelled"
    ],
    "hooks": [
      {
        "event": "finished:service.cancelled",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "service.cancelled" {
        let service_id = payload["service_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:service.cancelled", service_id);
    }
}
```

## Ticket

### ticket.opened

A support ticket was opened.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `ticket_id` | `uuid` | yes | Ticket |
| `number` | `string` | no | Ticket number |

**Emitted from:** `crates/pelty-api/src/routes/tickets.rs:308`.

```json
{
  "number": "…",
  "ticket_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "ticket.opened"
    ],
    "hooks": [
      {
        "event": "finished:ticket.opened",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "ticket.opened" {
        let ticket_id = payload["ticket_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:ticket.opened", ticket_id);
    }
}
```

### ticket.replied

A reply was posted on a ticket.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `ticket_id` | `uuid` | yes | Ticket |
| `by_staff` | `bool` | yes | Reply came from staff |

**Emitted from:** `crates/pelty-api/src/routes/tickets.rs:352`, `crates/pelty-api/src/routes/tickets.rs:588`.

```json
{
  "by_staff": true,
  "ticket_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "ticket.replied"
    ],
    "hooks": [
      {
        "event": "finished:ticket.replied",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "ticket.replied" {
        let ticket_id = payload["ticket_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:ticket.replied", ticket_id);
    }
}
```

### ticket.closed

A ticket was closed.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `ticket_id` | `uuid` | yes | Ticket |
| `reason` | `string` | no | Why, e.g. `inactive` |

**Emitted from:** `crates/pelty-api/src/routes/support.rs:418`.

```json
{
  "reason": "…",
  "ticket_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "ticket.closed"
    ],
    "hooks": [
      {
        "event": "finished:ticket.closed",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "ticket.closed" {
        let ticket_id = payload["ticket_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:ticket.closed", ticket_id);
    }
}
```

## User

### user.registered

A new account was created (password sign-up, SSO or OAuth).

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `user_id` | `uuid` | yes | New user |
| `via` | `string` | no | SSO/OAuth provider id when not a password sign-up |

**Emitted from:** `crates/pelty-api/src/oauth.rs:285`, `crates/pelty-api/src/routes/auth.rs:132`, `crates/pelty-api/src/saml.rs:354`.

```json
{
  "user_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e",
  "via": "…"
}
```

```json
{
  "capabilities": {
    "events": [
      "user.registered"
    ],
    "hooks": [
      {
        "event": "finished:user.registered",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "user.registered" {
        let user_id = payload["user_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:user.registered", user_id);
    }
}
```

### user.logged_in

A user signed in successfully.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `user_id` | `uuid` | yes | User |

**Emitted from:** `crates/pelty-api/src/routes/auth.rs:203`.

```json
{
  "user_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "user.logged_in"
    ],
    "hooks": [
      {
        "event": "finished:user.logged_in",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "user.logged_in" {
        let user_id = payload["user_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:user.logged_in", user_id);
    }
}
```

### user.login_alert

Sign-in from a new IP for an account with login alerts on.

| Kind | Phases | Cancellable | Modifiable | Since |
|---|---|---|---|---|
| domain event | `after`, `finished` | no | no | 0.1.0 |

**Payload**

| Key | Type | Always present | Description |
|---|---|---|---|
| `user_id` | `uuid` | yes | User |
| `ip` | `string` | yes | Client IP |
| `user_agent` | `string` | yes | Browser user agent |

**Emitted from:** `crates/pelty-api/src/routes/auth.rs:340`.

```json
{
  "ip": "203.0.113.7",
  "user_agent": "…",
  "user_id": "0190f7a2-6c1e-7b3a-9d2e-4f5a6b7c8d9e"
}
```

```json
{
  "capabilities": {
    "events": [
      "user.login_alert"
    ],
    "hooks": [
      {
        "event": "finished:user.login_alert",
        "priority": "monitor"
      }
    ]
  }
}
```

```rust
// on-event: delivered after the action was committed (cannot be cancelled).
fn on_event(name: &str, payload: &Value) {
    if name == "user.login_alert" {
        let user_id = payload["user_id"].as_str().unwrap_or_default();
        let _ = host::kv_set("last:user.login_alert", user_id);
    }
}
```

## Custom addon events

Named `<addon-id>:<event>`, declared in `capabilities.emits` and emitted with `pelty_addon_sdk::emit`. They are not in the registry because addons define them. Other addons listen with `before:<id>:<event>` (cancel or modify), `after:` or `finished:` in `hooks`. An emit inside a listener nests; the depth is limited to 4. The emitter gets `{cancelled, reason, payload}` back. See [Events and hooks](events-guide.md) for a full example.

## Lifecycle phases

Phases in order: `before`, `during`, `after`, `finished`. Source: `crates/pelty-api/src/addon_events.rs`.

- `before`: synchronous, in priority order; listeners may `modify` the payload or `cancel` it (`uncancel` too when `ignore_cancelled`). `monitor` listeners only observe. Money and secret rules apply.
- `during`: delivered while the action runs (concurrently, observe only).
- `after`: the action succeeded; the result is in the payload.
- `finished`: always last, with `status` = `ok` | `failed` | `cancelled`.

## Webhook plugins

A webhook plugin lists domain events in its manifest `hooks`. Each one is POSTed to `webhook_url`, signed, with the payload above. Delivery attempts are listed under Admin, Plugins, Deliveries.

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `String` | yes |  |
| `name` | `String` | yes |  |
| `version` | `String` | yes |  |
| `api_version` | `u32` | yes |  |
| `description` | `String` | no |  |
| `capabilities` | `Vec<String>` | no |  |
| `hooks` | `Vec<String>` | no |  |
| `webhook_url` | `Option<String>` | no | External plugins only: HTTPS endpoint receiving signed events. |
| `settings_schema` | `Option<serde_json::Value>` | no | Optional JSON-schema-like description of admin settings. |
| `ui` | `PluginUi` | no | UI contributions rendered by the frontend in named slots. |

Source: `crates/pelty-extensions/src/plugins.rs` (`PluginManifest`).

```json
{
  "api_version": 1,
  "hooks": [
    "invoice.paid",
    "order.created"
  ],
  "id": "my-plugin",
  "name": "My plugin",
  "version": "1.0.0",
  "webhook_url": "https://example.com/pelty"
}
```

