> For the complete documentation index, see [llms.txt](https://developers.share.inc/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developers.share.inc/webhooks/event-catalog.md).

# Event catalog

The canonical list of every event Share can deliver. Subscribe to a subset by listing event names in the `events[]` array when you call `POST /v1/webhooks` (see [Setup](/webhooks/setup.md)).

Every event is wrapped in the same [envelope](/webhooks/envelope.md); this page documents what `type`, `data.object`, and `data.current` carry per event family.

## Quick reference

| `type`                              | `data.object`   | When it fires                                                        |
| ----------------------------------- | --------------- | -------------------------------------------------------------------- |
| `payment.received`                  | `payment`       | A payment transitions into `COMPLETED`                               |
| `payment.failed`                    | `payment`       | A payment transitions into `FAILED`                                  |
| `payment.expired`                   | `payment`       | A pending payment was not completed in time                          |
| `refund.issued`                     | `refund`        | A refund was issued against a previous payment                       |
| `billing.cycle_started`             | `billing_cycle` | A billing cycle activated                                            |
| `billing.cycle_ending`              | `billing_cycle` | A billing cycle is approaching its expiry (renewal reminder window)  |
| `billing.overdue`                   | `billing_cycle` | A cycle passed its expiry without payment                            |
| `billing.suspended`                 | `billing_cycle` | Grace period elapsed; service is being suspended                     |
| `subscriber.created`                | `subscriber`    | A subscriber row was created                                         |
| `subscriber.updated`                | `subscriber`    | A field on the subscriber changed                                    |
| `subscriber.activated`              | `subscriber`    | A previously suspended subscriber returned to service                |
| `subscriber.suspended`              | `subscriber`    | Subscriber suspended (grace expired, manual hold, etc.)              |
| `subscriber.terminated`             | `subscriber`    | Subscriber was terminated — RADIUS credentials removed               |
| `subscriber.deleted`                | `subscriber`    | Subscriber record was deleted                                        |
| `subscriber.charge.created`         | `charge`        | A partner-initiated one-time charge was created against a subscriber |
| `subscription.created`              | `subscription`  | A new subscription attached to a subscriber                          |
| `subscription.updated`              | `subscription`  | A field on the subscription changed                                  |
| `subscription.plan_change_queued`   | `subscription`  | A plan change was queued for the end of the current cycle            |
| `subscription.plan_change_applied`  | `subscription`  | A queued plan change took effect at cycle boundary                   |
| `subscription.plan_change_rejected` | `subscription`  | Plan change rejected (Schedule D, disabled plan, unmapped tariff, …) |
| `subscription.plan_removed_queued`  | `subscription`  | Plan removal queued to end-of-cycle                                  |
| `subscription.plan_removed`         | `subscription`  | Plan removed immediately                                             |
| `subscription.deactivated`          | `subscription`  | Subscription transitioned to `INACTIVE`                              |
| `subscription.override_queued`      | `subscription`  | A pricing / speed / duration override was queued for next cycle      |
| `subscription.override_applied`     | `subscription`  | A queued override took effect                                        |
| `subscription.override_rejected`    | `subscription`  | An override was rejected by the validator                            |
| `installation.created`              | `installation`  | An installation was created for a subscription (paid or free)        |
| `installation.updated`              | `installation`  | An installation's status changed (fee cleared, installed, cancelled) |
| `network.session_started`           | `network`       | A RADIUS session opened (subscriber came online)                     |
| `network.session_ended`             | `network`       | A RADIUS session closed                                              |
| `network.throttled`                 | `network`       | A session was throttled (cap reached, fair-use, …)                   |
| `plan.created`                      | `plan`          | A new plan was created                                               |
| `plan.updated`                      | `plan`          | A plan's fields changed                                              |
| `plan.deleted`                      | `plan`          | A plan was deleted                                                   |

Every event also carries the standard envelope fields — `id`, `apiVersion`, `occurredAt`, `providerId`, `correlationId`, `livemode` — described in [Envelope](/webhooks/envelope.md).

## Payments

### `payment.received`

Fires when a payment transitions into `COMPLETED`, regardless of rail (STK push, paybill, manual reconciliation).

```json
{
  "id": "evt_...",
  "type": "payment.received",
  "data": {
    "object": "payment",
    "current": {
      "amount": 1500,
      "currency": "KES",
      "status": "COMPLETED",
      "reference": "PAY-AB12CD34",
      "completedAt": "2026-05-26T09:14:22.000Z"
    },
    "previous": { "status": "PROCESSING" }
  }
}
```

Use this event — not `subscription.updated` — to confirm money landed. Activation, cycle renewal, and split settlement all key off the same moment.

### `payment.failed`

A payment terminally failed (insufficient funds, timeout, partner rejection). `previous.status` is `PROCESSING`.

```json
{
  "type": "payment.failed",
  "data": {
    "object": "payment",
    "current": {
      "amount": 1500,
      "currency": "KES",
      "status": "FAILED",
      "reference": "PAY-AB12CD34",
      "failureReason": "insufficient_funds"
    },
    "previous": { "status": "PROCESSING" }
  }
}
```

### `payment.expired`

A pending payment was not completed in its TTL. `previous.status` is `PENDING`.

### `refund.issued`

A refund was issued against a previous payment. `data.object` is `refund`. No `previous` block — a refund is a new resource, not a state change.

## Billing

`data.object` is `billing_cycle`; `data.current` carries the affected cycle's fields:

```json
{
  "type": "billing.cycle_started",
  "data": {
    "object": "billing_cycle",
    "current": {
      "subscriptionId": "sbn_01H...",
      "planId": "pln_01H...",
      "status": "ACTIVE",
      "expiresAt": "2026-06-26T00:00:00.000Z"
    }
  }
}
```

| Event                   | `previous` block                                  | What it tells you                           |
| ----------------------- | ------------------------------------------------- | ------------------------------------------- |
| `billing.cycle_started` | `{ status: "SCHEDULED" }`                         | A new paid cycle is now live                |
| `billing.cycle_ending`  | absent                                            | Renewal window — collect or remind          |
| `billing.overdue`       | `{ status: "ACTIVE" }`                            | Cycle expired without payment; grace begins |
| `billing.suspended`     | `{ status: "OVERDUE", paymentStatus: "PENDING" }` | Grace expired; suspending service           |

`billing.suspended` and `subscriber.suspended` fire together at grace expiration — `billing.suspended` from the cycle's perspective, the subscriber event from the access perspective. Handle one or the other, not both, unless you really need both views.

## Subscribers

`data.object` is `subscriber`; `data.current` carries the subscriber's fields:

```json
{
  "type": "subscriber.created",
  "data": {
    "object": "subscriber",
    "current": {
      "externalUserId": "ACME-1234",
      "firstName": "Amina",
      "lastName": "Otieno",
      "email": "amina@example.co.ke",
      "phone": "+254712345678",
      "street": "12 River Rd",
      "city": "Nairobi",
      "region": "Nairobi",
      "postalCode": "00100",
      "country": "KE",
      "status": "active",
      "createdAt": "2026-05-26T09:14:22.000Z"
    }
  }
}
```

| Event                   | `previous` block          | Notes                                                                            |
| ----------------------- | ------------------------- | -------------------------------------------------------------------------------- |
| `subscriber.created`    | absent                    | Carries `createdAt`. Use it to mirror new subscribers into your CRM              |
| `subscriber.updated`    | changed fields only       | `data.changedFields[]` lists which fields moved; only those appear in `previous` |
| `subscriber.activated`  | `{ status: "suspended" }` | Subscriber returned to service                                                   |
| `subscriber.suspended`  | `{ status: "active" }`    | Service suspended (grace expired, manual hold, …)                                |
| `subscriber.terminated` | `{ status: "active" }`    | Service terminated — RADIUS credentials removed                                  |
| `subscriber.deleted`    | absent                    | Carries the `id` and a `deactivatedAt` timestamp                                 |

### `subscriber.charge.created`

Special case — `data.object` is `charge`, not `subscriber`. The full charge object is in `data.current`. The full field list is documented in the [API reference](/api-reference/api-reference.md) under the one-time charges resource. Useful when you bill installation fees, hardware, or ad-hoc adjustments out of band and need them mirrored into your ledger.

## Subscriptions

> **Splynx-initiated plan changes (`service.edit`).** When an ISP edits a customer's internet service in Splynx and picks a target Share plan from the **Migrate to Share Plan** dropdown, Share reads that `service.edit` and applies the change on the existing subscription — a free immediate switch, a pay-to-upgrade hold, or a switch scheduled for the current plan's end, depending on the **Free Immediate Upgrade** flag and the price direction (see [Handle a plan change](/guides/handle-a-plan-change.md)). Share does **not** use Splynx's `change-tariff` endpoint, and it does **not** consume the `edit_planned` / `planned_date` event — a deferred switch is applied at the end of the current billing cycle. A pay-to-upgrade hold puts the subscription in `AWAITING_PAYMENT_FOR_UPGRADE` while the current plan keeps running.
>
> If the edited internet service is one Share does **not** yet track (for example a brand-new Splynx service pointed at a Share plan), Share onboards it as a **new** subscription instead of changing an existing one — even when the customer already has other live Share subscriptions. A customer can therefore hold **several** Share subscriptions at once, one per migrated Splynx service; each is billed on its own cycle.

> **Splynx IP assignment (`service.create` / `service.edit`).** The IPv4 fields on a Splynx internet service are mirrored onto the Share RADIUS record for that service's CPE: a static `ipv4` becomes a fixed `Framed-IP-Address`, "taking IPv4 from a pool" maps to the named pool (Share reads the pool's title from your Splynx) or to the NAS default pool when no pool is picked, and clearing the fields removes the override again. Unchanged values are ignored, so re-saves and webhook re-deliveries have no effect.

> **Splynx service deletion (`service.delete`) is deferred, not immediate.** When an ISP removes a customer's internet service in Splynx, Share does **not** deactivate the subscriber on the spot. The subscription is scheduled for cancellation at the end of the current billing cycle — it stays `ACTIVE` and keeps internet until the cycle the subscriber has already paid for ends, then the subscription is archived and `subscription.deactivated` fires at that boundary. So a `service.delete` upstream surfaces here as a deferred `subscription.deactivated` at cycle end, not an immediate one.

`data.object` is `subscription`. The base snapshot in `data.current`:

```json
{
  "planId": "pln_01H...",
  "nextPlanId": null,
  "status": "ACTIVE",
  "isExternal": false,
  "startedAt": "2026-05-01T00:00:00.000Z",
  "expiresAt": "2026-06-01T00:00:00.000Z"
}
```

### `subscription.created`

A new subscription was attached. No `previous` block.

> **Echo suppression (Splynx integrations).** When Splynx re-broadcasts one of Share's own internet-service writes as an inbound `service.create` echo, Share drops it at source (DEV-1299) — so an echo produces **no** subscription and therefore **no** `subscription.created` webhook. You only ever receive one `subscription.created` per real subscription, never a duplicate for the echo.

### `subscription.updated`

A field on the subscription changed. `data.previous` carries only the fields that moved; `data.changedFields[]` lists their names.

```json
{
  "type": "subscription.updated",
  "data": {
    "object": "subscription",
    "current": { "expiresAt": "2026-06-01T00:00:00.000Z" },
    "previous": { "expiresAt": "2026-05-01T00:00:00.000Z" },
    "changedFields": ["expiresAt"]
  }
}
```

### `subscription.plan_change_queued`

A plan change has been recorded against the subscription but takes effect at the next cycle boundary.

```json
{
  "type": "subscription.plan_change_queued",
  "data": {
    "object": "subscription",
    "current": {
      "planId": "pln_CURRENT",
      "nextPlanId": "pln_NEW"
    },
    "previous": { "nextPlanId": null }
  }
}
```

### `subscription.plan_change_applied`

A previously-queued plan change took effect at the cycle boundary. `previous.nextPlanId` carries the value that has now been promoted to `current.planId`.

```json
{
  "type": "subscription.plan_change_applied",
  "data": {
    "object": "subscription",
    "current": { "planId": "pln_NEW", "nextPlanId": null },
    "previous": { "nextPlanId": "pln_NEW" }
  }
}
```

### `subscription.plan_change_rejected`

The change could not be applied. `data.rejection.reason` carries a machine-readable code such as `SCHEDULE_D_FLOOR`, `PLAN_DISABLED`, or `TARIFF_NOT_MAPPED`. No `previous` block — the rejection itself is the fact.

```json
{
  "type": "subscription.plan_change_rejected",
  "data": {
    "object": "subscription",
    "current": { "planId": "pln_CURRENT" },
    "rejection": { "reason": "SCHEDULE_D_FLOOR" }
  }
}
```

### `subscription.plan_removed_queued` and `subscription.plan_removed`

The first fires when a plan removal is queued for end-of-cycle. The second fires when a plan is removed immediately (paths 2 and 3 of the plan-removal flow). The immediate form carries `current.inactiveReason: "PLAN_REMOVED"` and `current.status: "INACTIVE"`.

### `subscription.deactivated`

The subscription went `INACTIVE`. `previous.status` is `ACTIVE`. `current.inactiveReason` carries the cause (`GRACE_EXPIRED`, `PLAN_REMOVED`, …).

### Override events — `subscription.override_queued` / `_applied` / `_rejected`

Pricing, speed, or duration overrides follow the same queued / applied / rejected pattern as plan changes. `data.changedFields[]` lists which override fields the caller submitted (`priceOverride`, `speedOverride`, `durationOverride`). `subscription.override_rejected` includes `data.rejection.reason` (for example `CONTACT_SHARE`).

## Installations

`data.object` is `installation`. Fired when Share creates an installation for a subscription — both the paid path (the installation starts in `PENDING_PAYMENT`, awaiting the installation fee) and the free path (starts in `AWAITING_INSTALLATION`, no fee). This is a creation event, so no `previous` block is sent.

The `current` snapshot is sourced from the Share `Installation` record — the same source of truth Share stamps onto the Splynx internet service's `installation_required` / `installation_free` fields, so the two surfaces never diverge.

```json
{
  "type": "installation.created",
  "data": {
    "object": "installation",
    "current": {
      "installationId": "inst_01H...",
      "subscriptionId": "sub_01H...",
      "subscriberId": "subr_01H...",
      "required": true,
      "free": false,
      "status": "PENDING_PAYMENT",
      "kind": "INITIAL"
    }
  }
}
```

| `current` field | Meaning                                                                     |
| --------------- | --------------------------------------------------------------------------- |
| `required`      | Always `true` — the event only fires when an installation is created.       |
| `free`          | `true` when the installation fee is waived (or zero); `false` when payable. |
| `status`        | `PENDING_PAYMENT` (payable, fee unpaid) or `AWAITING_INSTALLATION` (free).  |
| `kind`          | `INITIAL`, `REPLACEMENT`, or `RELOCATION`.                                  |

### `installation.updated`

`data.object` is `installation`. Fired on each real status transition of an existing installation after creation:

* fee settled or waived → `AWAITING_INSTALLATION` (or straight to `ACTIVATED` when the install was already completed)
* installed / marked done → `ACTIVATED`
* cancelled → `CANCELLED`
* install window expired → `ABANDONED` (the stale-installation cleanup cron flips a non-terminal install that was never completed in time)
* subscription torn down → `ARCHIVED` (the subscription teardown cascade archives every non-terminal installation on the subscription)

No event is emitted when the status did not actually change (idempotent re-deliveries and no-op writes are suppressed). Because this is an update, the envelope carries a `previous` block alongside `current` — both use the same `{ required, free, status, kind }` snapshot shape as `installation.created`, so you can diff the transition directly. All fields are sourced from the Share `Installation` record (the same source of truth Share stamps onto the Splynx service fields), so the two surfaces never diverge.

```json
{
  "type": "installation.updated",
  "data": {
    "object": "installation",
    "current": {
      "installationId": "inst_01H...",
      "subscriptionId": "sub_01H...",
      "subscriberId": "subr_01H...",
      "required": true,
      "free": false,
      "status": "AWAITING_INSTALLATION",
      "kind": "INITIAL"
    },
    "previous": {
      "required": true,
      "free": false,
      "status": "PENDING_PAYMENT",
      "kind": "INITIAL"
    }
  }
}
```

| Field              | Meaning                                                                                        |
| ------------------ | ---------------------------------------------------------------------------------------------- |
| `current.required` | Always `true` — the installation row exists.                                                   |
| `current.free`     | `true` when the installation fee is waived (or zero); `false` when payable.                    |
| `current.status`   | The new status: `AWAITING_INSTALLATION`, `ACTIVATED`, `CANCELLED`, `ABANDONED`, or `ARCHIVED`. |
| `current.kind`     | `INITIAL`, `REPLACEMENT`, or `RELOCATION`.                                                     |
| `previous`         | The `{ required, free, status, kind }` snapshot before the transition.                         |

## Network

`data.object` is `network`. These are ephemeral session signals — no `previous` block on any of them.

```json
{
  "type": "network.session_started",
  "data": {
    "object": "network",
    "current": {
      "sessionId": "sess_01H...",
      "ipAddress": "100.64.12.18"
    }
  }
}
```

| Event                     | `current` fields         | Use it for                           |
| ------------------------- | ------------------------ | ------------------------------------ |
| `network.session_started` | `sessionId`, `ipAddress` | Online-now status, accounting start  |
| `network.session_ended`   | `sessionId`, `reason`    | Online-now status, accounting stop   |
| `network.throttled`       | `sessionId`, `reason`    | Cap-reached / fair-use notifications |

These events are higher-volume than the rest of the catalog. Make sure your receiver scales accordingly, and consider whether you really need them — most billing-and-CRM use cases do not.

## Plans

`data.object` is `plan`. `data.current` carries the plan's fields:

```json
{
  "type": "plan.updated",
  "data": {
    "object": "plan",
    "current": {
      "planName": "Home 50 Mbps",
      "planType": "RESIDENTIAL",
      "downloadSpeedMbps": 50,
      "uploadSpeedMbps": 20,
      "price": 3500,
      "priceCurrency": "KES",
      "billingCycleDays": 30,
      "dataCapMb": null,
      "graceDays": 3,
      "isActive": true
    },
    "previous": { "price": 3000 },
    "changedFields": ["price"]
  }
}
```

| Event          | `previous` block     |
| -------------- | -------------------- |
| `plan.created` | absent               |
| `plan.updated` | changed fields only  |
| `plan.deleted` | `{ isActive: true }` |

## Legacy `user.*` event names

Older integrations registered against `user.created`, `user.updated`, `user.suspended`, and `user.activated`. These names are still **accepted on input** to `POST /v1/webhooks` and `PUT /v1/webhooks/:id`, but they are silently rewritten to the canonical `subscriber.*` form on persist. You will never receive an event named `user.*` — the body always carries the `subscriber.*` form. Migrate any new subscriptions to the `subscriber.*` names; the `user.*` aliases will be removed in a future `apiVersion`.

## Choosing what to subscribe to

A few common shapes:

* **Mirror billing state into your CRM.** Subscribe to `subscriber.created`, `subscriber.updated`, `subscription.created`, `subscription.updated`, `subscription.plan_change_applied`, `subscription.deactivated`. Skip the `_queued` events unless you surface them in your UI.
* **Reconcile money.** Subscribe to `payment.received`, `payment.failed`, `payment.expired`, `refund.issued`. Match on `data.current.reference`.
* **Alert on lapses.** Subscribe to `billing.overdue` and `billing.suspended`. You can ignore `subscriber.suspended` if you already handle `billing.suspended` — they fire together.
* **Track online presence.** Subscribe to `network.session_started`, `network.session_ended`. Be mindful of volume.

Subscribing to fewer events is almost always the right move. You can always add more later via `PUT /v1/webhooks/:id` — the `events[]` field fully replaces the previous list.

## Next steps

* [Setup](/webhooks/setup.md) — register a subscription against these events
* [Envelope](/webhooks/envelope.md) — the wrapping every event carries
* [Signature verification](/webhooks/verification.md) — the HMAC contract
