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

# Continuous monitoring: SIM-swap and roaming subscriptions

> Enroll numbers into continuous SIM-swap and roaming monitoring, consume the operator's CloudEvent webhooks with a production receiver, and tear subscriptions down when the window closes.

# Continuous monitoring: SIM-swap and roaming subscriptions

The one-shot checks in [Network signals before you send](https://docs.orbit.devotel.io/guides/network-signals-open-gateway.md) answer "is this number suspicious right now?" at the moment you ask. A monitoring subscription answers the follow-up: "tell me the moment it changes." You enroll a number once, and the operator pushes a CloudEvent to your webhook when the SIM swaps, or when the device starts or stops roaming. No polling loop, no gap between checks.

Orbit exposes two subscription families under `/api/v1/numbers/network-apis`, both aligned with the CAMARA event-subscription contract:

| Family | Enroll with | Events delivered to your `notificationUrl` |
| - | - | - |
| SIM Swap | `POST /sim-swap/subscriptions` | `org.camaraproject.sim-swap-subscriptions.v0.swapped` |
| Device Roaming Status | `POST /device-roaming-status/subscriptions` | `org.camaraproject.device-roaming-status-subscriptions.v0.roaming-on` and `...roaming-off` |

Each family also has `GET` and `DELETE` routes on `/subscriptions/{subscriptionId}` to read state and tear down. Every request carries your `X-API-Key`.

## Check vs subscription: when to enroll

Polling the one-shot checks is the right shape for a batch screen: you already hold a recipient list, one request per recipient, and you cache the verdict for the send window. A subscription is the right shape when a number matters continuously, not only at the moment you send or log someone in:

* **High-value accounts.** Accounts whose takeover would be catastrophic (large wallets, payout rails, admin roles) deserve monitoring that reacts in seconds, not at the next poll.
* **Step-up windows.** After a password reset, a new-device login, or a suspicious `risk:score` verdict, enroll the number for the risk window instead of polling `sim-swap:check` every few minutes until the window closes.
* **Home-network fleets.** IoT devices and domestic-only customer bases where a device answering abroad is itself the alert. That is the roaming subscription's native use case.

The cost comparison is direct. Polling N numbers every M minutes is N × 1440/M requests per day, most of which return "nothing changed." A subscription is one enrollment request, then one event per actual change, and it sees the change when it happens rather than at the next poll boundary.

A subscription ends in one of three ways, and you choose the combination at enrollment:

1. **`subscriptionExpireTime`** — the subscription auto-expires at the RFC-3339 timestamp you set. Fit it to your risk window.
2. **`subscriptionMaxEvents`** — the operator stops after delivering that many events (1 to 1000). Useful as a safety cap.
3. **`DELETE`** — you tear the subscription down explicitly. Omit both limits and this is the only way it ends.

## Enroll a number

The enrollment body is the same shape for both families: the number, your webhook, and optional limits.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/numbers/network-apis/sim-swap/subscriptions" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: enroll-account-8412" \
  -d '{
    "phoneNumber": "+14155550100",
    "notificationUrl": "https://fraud.example.com/webhooks/network-apis/sim-swap",
    "notificationAuthToken": "bearer-token-you-generated",
    "subscriptionExpireTime": "2026-11-29T09:00:00Z"
  }'
```

| Field | Type | Required | Meaning |
| - | - | - | - |
| `phoneNumber` | string, E.164 | yes | The number to monitor. |
| `notificationUrl` | string, `https://` only | yes | The webhook the operator POSTs each CloudEvent to. |
| `notificationAuthToken` | string | no | A bearer token the operator must present on every callback so you can authenticate inbound notifications. Generate one per sink and treat it as a secret. |
| `subscriptionExpireTime` | string, RFC-3339 UTC | no | Auto-expiry. Omit for an open-ended subscription you tear down with `DELETE`. |
| `subscriptionMaxEvents` | integer, 1 to 1000 | no | Stop after this many delivered events. Omit for unbounded. |

A successful enrollment returns `201` with the operator-issued subscription id, its lifecycle status, and its expiry. Store the id; the read and teardown routes take it as a path parameter.

```json theme={null}
{
  "data": {
    "id": "sub-7f3a9c21",
    "status": "ACTIVE",
    "startsAt": "2026-10-30T09:12:00Z",
    "expiresAt": "2026-11-29T09:00:00Z"
  },
  "request_id": "req_01J9ZK",
  "timestamp": "2026-10-30T09:12:01Z"
}
```

Notes on the enrollment path:

* **Send an `Idempotency-Key`.** If your enroll call retries on a network error, the key prevents a second live subscription for the same number.
* **`notificationUrl` must be HTTPS.** Plain-HTTP and internal addresses are rejected with a `422`.
* **The roaming variant is the same call with a different path.** `POST /device-roaming-status/subscriptions` takes the identical body; the operator then delivers `roaming-on` / `roaming-off` events instead of `swapped`.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/numbers/network-apis/device-roaming-status/subscriptions" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+14155550100",
    "notificationUrl": "https://fraud.example.com/webhooks/network-apis/roaming",
    "notificationAuthToken": "bearer-token-you-generated"
  }'
```

## Read state and tear down

Read a subscription's current state by id:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/numbers/network-apis/sim-swap/subscriptions/sub-7f3a9c21" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

The response carries the same `id` / `status` / `startsAt` / `expiresAt` shape, so a reconciliation job can compare what the operator reports against what you enrolled.

Tear down with `DELETE`; a `204` means the operator accepted the teardown and stops delivering notifications.

```bash theme={null}
curl -X DELETE "https://api.orbit.devotel.io/api/v1/numbers/network-apis/sim-swap/subscriptions/sub-7f3a9c21" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

Plan for a short tail of in-flight events: notifications already inside the operator's delivery pipeline when you tear down can still arrive moments after the `204`. Your consumer must ack and discard events for subscriptions it no longer tracks, not error on them. The same tolerance covers subscriptions that hit `subscriptionExpireTime` or `subscriptionMaxEvents` a beat before your bookkeeping does.

## Consume the CloudEvents

The operator POSTs each event to your `notificationUrl` as a CloudEvent. Three rules make the receiver production-grade; they are the same rules as the [production webhook consumer checklist](https://docs.orbit.devotel.io/guides/webhook-consumer-production-checklist.md), adapted to this sink:

1. **Authenticate the callback.** On this sink the operator presents your `notificationAuthToken` as an `Authorization: Bearer` header on every delivery. Reject anything else with a `401`. (Orbit-dispatched webhooks elsewhere in the platform authenticate differently, with the `X-Orbit-Signature` HMAC; the pattern below swaps only the authenticity check.)
2. **Dedupe on the event `id`.** Delivery is at-least-once; the same event can arrive more than once. Persist seen ids in your database, not in memory, so dedupe survives restarts.
3. **Ack fast.** Verify, dedupe, enqueue, return `200`. The decision logic runs in a queue worker, never inline in the handler.

```typescript theme={null}
import express from "express";

const expectedAuth = `Bearer ${process.env.SIM_SWAP_SINK_TOKEN}`;

const app = express();

app.post("/webhooks/network-apis/sim-swap", express.raw({ type: "*/*" }), (req, res) => {
  // 1. Authenticate: the operator echoes your notificationAuthToken as a bearer.
  if ((req.headers.authorization ?? "") !== expectedAuth) {
    return res.status(401).json({ error: "Invalid bearer" });
  }
  const event = JSON.parse(req.body.toString());

  // 2. Dedupe on the CloudEvent id; persist seen ids in your database.
  if (seenRecently(event.id)) return res.status(200).json({ duplicate: true });
  remember(event.id);

  // 3. Enqueue and ack immediately; the worker below does the real work.
  queue.enqueue(event);
  return res.status(200).json({ ok: true });
});
```

The worker is where a swap becomes a decision. Route on the CloudEvent `type`, resolve the number from the event's `data` block (its exact shape follows your operator's CAMARA implementation; the `device.phoneNumber` layout shown here matches what Orbit enrolls), and apply your own policy:

```typescript theme={null}
async function onSimSwapEvent(event: { type: string; data?: { device?: { phoneNumber?: string } } }) {
  if (event.type !== "org.camaraproject.sim-swap-subscriptions.v0.swapped") return;
  const phoneNumber = event.data?.device?.phoneNumber;
  if (!phoneNumber) return;

  // Your policy, your thresholds: a swap on a monitored number is a
  // strong account-takeover signal.
  await sessions.revokeForNumber(phoneNumber, { reason: "sim_swap_detected" });
  await auth.requireStepUpOnNextLogin(phoneNumber, { factor: "existing_method" });
}
```

Revoking active sessions and forcing step-up on the next login is the canonical mapping: the swapped verdict never blocks anything by itself, it feeds your session policy. Keep the one-shot `sim-swap:check` in your login flow as well; the subscription catches swaps between logins, the check catches them at the door.

## Roaming on and off: pair before you act

The roaming family delivers two event types, `roaming-on` when the device starts roaming and `roaming-off` when it stops. Treat them as two edges of one state, not as two independent alarms.

Devices near a border, or under roaming agreements that re-anchor periodically, can flap: `roaming-on`, `roaming-off`, `roaming-on` inside an hour. Acting on a single `roaming-on` (freezing an account, holding a payout) turns border towns into false positives. Debounce instead:

* Persist the latest event type and timestamp per number when the worker processes an event; do not act inline.
* A scheduled pass evaluates only numbers whose state has been unchanged for your quiet window (30 to 60 minutes is a workable default for a domestic-only customer base).
* A `roaming-off` that follows a `roaming-on` inside the quiet window resolves it: the device came home, and the pair is a log line, not an alert.
* A `roaming-on` that survives the quiet window is your corroborating signal for the fraud review queue, alongside SIM-swap and device-swap verdicts.

## Coverage and fail-soft

Subscriptions exist only when a GSMA/CAMARA operator is configured for the deployment. Probe `GET /api/v1/numbers/network-apis/status` before you build on this surface:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/numbers/network-apis/status" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

On `{ "enabled": false }` every enrollment returns `503 SERVICE_UNAVAILABLE`, and your integration must route around the whole family: keep the polling path from [Network signals before you send](https://docs.orbit.devotel.io/guides/network-signals-open-gateway.md) (`sim-swap:check`, `device-roaming-status:retrieve`) as your standing screen. It is also the right fallback inside an enabled deployment for the highest-value numbers, because absence of events is not evidence of absence of change: a delivery gap means nothing the operator could see happened, nothing more. Periodic one-shot checks complement subscriptions; they do not compete with them.

## Tenant controls

> [!WARNING]
>
> Continuous monitoring consults the operator's record about a subscriber over time. That is a processing act your privacy notice and consent capture must cover, and the decision to act on a verdict is yours. Orbit is the conduit that delivers the operator's event to your decision point; it does not own your regulatory posture.

Concretely: pair enrollment with your existing consent flows (see [Consent management](https://docs.orbit.devotel.io/compliance/consent-management.md)), and keep the act decisions, revoke, step-up, hold, refuse, in your own policy code with your own thresholds. Enrollment never sends, routes, or blocks a message; outbound messaging stays on your normal send path. See [Country compliance requirements](https://docs.orbit.devotel.io/compliance/country-requirements.md) for the tenant-owned controls these signals feed into.

## In the console

The same subscriptions are operable from the console under **Developer → Network APIs**. The SIM Swap monitoring card enrolls a number, shows the subscriptions created from the console with their operator status (Active, Inactive, or Pending), re-reads their state, and stops them behind a confirmation dialog; the matching card below it does the same for roaming monitoring. Use the console for manual operations and debugging; your production automation enrolls and tears down through the API above.

## What's next

* [Network signals before you send](https://docs.orbit.devotel.io/guides/network-signals-open-gateway.md) — the one-shot checks these subscriptions complement.
* [Production webhook consumer checklist](https://docs.orbit.devotel.io/guides/webhook-consumer-production-checklist.md) — the full receiver pattern: retry ladder, dead-letter replay, and the 7-day dedupe window.
* [Network APIs reference](https://docs.orbit.devotel.io/api-reference/network-apis.md) — every endpoint's parameters, response schema, and error contract.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.