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

# Guides

> Deeper, task-oriented walkthroughs for the most common integration scenarios.

These guides build on the **[Quickstart](/quickstart)** and assume you can already authenticate and make a basic call. Each one is a self-contained scenario you can implement independently.

## Handling async operations

Some operations don't complete instantly — provisioning an eSIM involves an operator round-trip that can take **up to \~45 minutes**; a port-in depends on the losing carrier's response time. Both of those, plus the asynchronous purchase variants and top-up, return a `transactionId` immediately instead of blocking:

1. **Call the endpoint.** You get back `202` (or `201` for top-up) with `{ transactionId, statusEndpoint }`.
2. **Poll `statusEndpoint`** (`GET /api/v3/transactions/{transactionId}`). No auth header needed — the transaction ID is itself the credential.
3. **Stop polling once `status` is terminal.** `COMPLETED`, `FAILED`, or `SCHEDULED` are terminal; `PENDING` and `PROCESSING` mean keep polling.

```js theme={null}
async function pollTransaction(statusEndpoint, { intervalMs = 5000, timeoutMs = 60 * 60 * 1000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(`https://api.spenza.com${statusEndpoint}`);
    const { data } = await res.json();
    if (data.status === "COMPLETED") return data.result;
    if (data.status === "FAILED") throw new Error(`Transaction failed: ${data.errorCode} — ${data.error}`);
    if (data.status === "SCHEDULED") return data; // scheduled for a future date, not an error
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error("Timed out waiting for transaction to complete");
}
```

<Note>
  Poll every \~5 seconds for most operations; eSIM provisioning and port-in are slower-moving, so a \~30 second interval is more appropriate for those to avoid hammering the endpoint for no benefit.
</Note>

## Provisioning an eSIM end-to-end

One endpoint provisions the eSIM, with or without a plan attached — include `planId` to assign one in the same operation instead of purchasing it separately afterward. **This endpoint is always asynchronous**, regardless of whether a plan is included:

**eSIM only** (SIM-only provisioning is currently supported for the SpenzaJ carrier family only — other carriers return `501 NOT_IMPLEMENTED` on this branch):

```bash theme={null}
curl -X POST "https://api.spenza.com/api/v1.1/esim" \
  -H "Authorization: Bearer $SPENZA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "imei": "356938035643809",
    "simId": "BIB009A",
    "email": "jane@example.com"
  }'
```

**eSIM + plan in one operation:**

```bash theme={null}
curl -X POST "https://api.spenza.com/api/v1.1/esim" \
  -H "Authorization: Bearer $SPENZA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "imei": "356938035643809",
    "simId": "BIB009A",
    "planId": "AT0001",
    "activateNow": true,
    "user": { "name": "Jane Doe", "email": "jane@example.com", "zipcode": "10011" }
  }'
```

Both return `202 Accepted` with `{ transactionId, statusEndpoint }`. Poll it with the pattern above; the completed `result` gives you the provisioned line:

```json theme={null}
{ "mdn": "+12792044584", "iccid": "89012345678901234567", "qrCode": "https://…/qrcode.png" }
```

Once provisioning completes, fetch the QR code / activation code again at any time — useful if the user needs to re-scan on a new device:

```bash theme={null}
curl "https://api.spenza.com/api/v1.1/esim/89012345678901234567/qr" \
  -H "Authorization: Bearer $SPENZA_TOKEN"
```

## Managing the SIM lifecycle

A typical SIM's lifecycle through the API:

```text theme={null}
purchase/provision → assign to user → subscribe to a plan → monitor usage → (renew number | cancel plan) → unassign/deactivate
```

* **Assign** — `POST /api/v1.1/sims/{iccid}/assign`. Reassigning an already-assigned SIM is supported directly — the previous holder is replaced, not blocked.
* **Subscribe** — `POST /api/v1.1/plans/purchase` (synchronous) or `POST /api/v3/plans/purchase` (asynchronous, different request contract — see the API Reference).
* **Monitor** — `GET /api/v1.1/sims/{iccid}/usage` for the current cycle; pass `history=true` for prior cycles.
* **Renew the phone number** — `POST /api/v1.1/sims/{iccid}/renew-number` if the assigned number needs to change. This is rate-limited (20/min) — handle `429 RATE_LIMITED` gracefully rather than retrying immediately.
* **Toggle voice/SMS/data** — `PATCH /api/v1.1/sims/{iccid}/service-state` to enable or disable voice, SMS, and/or data on the line without cancelling the subscription itself. Each is independently optional (send just the ones you want to change), but toggling `voice`/`sms` still requires the active plan to include that capability, and `data` isn't controllable by any operator yet — see the API Reference for the exact `warnings` behavior on a mixed request.
* **Cancel a subscription** — `POST /api/v1.1/subscriptions/{iccid}/cancel`. Synchronous; cancels at end of the current billing period by default, so an `ACTIVE` subscription typically comes back `CANCEL_SCHEDULED` rather than `CANCELLED` immediately. Note this endpoint returns `201`, not `200`, on success.

## Organizing your fleet with groups

SIMs, devices, and users can each be organized into named groups (with an optional `spendLimit`, `dataQuota`, `currency`, and `dataUnit`) — separate resources under `/api/v1.1/sim-groups`, `/api/v1.1/device-groups`, and `/api/v1.1/user-groups`:

```bash theme={null}
curl -X POST "https://api.spenza.com/api/v1.1/sim-groups" \
  -H "Authorization: Bearer $SPENZA_TOKEN" -H "Content-Type: application/json" \
  -d '{ "name": "Sales Team", "spendLimit": 1000, "dataQuota": 100, "currency": "USD", "dataUnit": "GB" }'

curl -X POST "https://api.spenza.com/api/v1.1/sim-groups/Sales%20Team/sims" \
  -H "Authorization: Bearer $SPENZA_TOKEN" -H "Content-Type: application/json" \
  -d '{ "iccid": "8901260853182965429" }'
```

A group's `name` is its identifier in the URL — URL-encode it if it contains spaces or special characters, and note it **can't be renamed** once created (the update endpoints don't accept a new `name`). Removing a SIM from its group moves it back to the account's default sim-group (`movedTo: "default"`); removing a device or user from its group simply clears the assignment (`movedTo: null`) — there's no default group for those two.

## Porting in an existing number

Check eligibility first — it's a read-only call, no order is created:

```bash theme={null}
curl -X POST "https://api.spenza.com/api/v1.1/port-in/eligibility" \
  -H "Authorization: Bearer $SPENZA_TOKEN" -H "Content-Type: application/json" \
  -d '{ "portInNumber": "+15555551234", "plan": "AT0001" }'
```

<Note>
  Only the **SpenzaJ** carrier currently implements this check — every other carrier returns `501 NOT_IMPLEMENTED`, so don't treat that as a hard failure signal for carriers you know support port-in generally.
</Note>

Submitting the port-in itself is a documented exception to the rest of this API — there is no `POST /api/v1.1/port-ins`. The only submission endpoint is:

```bash theme={null}
curl -X POST "https://api.spenza.com/api/v3/port-in" \
  -H "Content-Type: application/json" \
  -d '{
    "plan": "AT0001",
    "portInNumber": "+15555551234",
    "currentCarrierName": "Verizon",
    "currentAccountNumber": "1234567890",
    "currentAccountPassword": "1234",
    "currentBillingAddress": { "street1": "123 Main St", "city": "New York", "state": "NY", "zip": "10011" },
    "employeeEmail": "jane@example.com",
    "employeeName": "Jane Doe"
  }'
```

This one requires your account's `Admin`/`Super Admin`/`Standard Admin` role rather than just a valid bearer token, and its success response is a **flat** shape — `{ success, transactionId, statusEndpoint, message, timestamp }`, no `data` wrapper — pointing at the legacy singular status path. See **[Environments & Versioning → One documented exception](/environments-versioning#one-documented-exception-port-in-submission)** before you build against it. `currentAccountPassword` is the losing carrier's account PIN required for FCC-compliant number portability (LNP) — handle it with the same care as any other credential; never log it.

Track status separately with:

```bash theme={null}
curl "https://api.spenza.com/api/v1.1/port-ins?status=IN_PROGRESS" \
  -H "Authorization: Bearer $SPENZA_TOKEN"
```

## Reconciling billing

Pull invoices on a schedule (for example, monthly) to reconcile against your own finance system. `from`/`to` here are **date-only** (`YYYY-MM-DD`) — unlike Orders/Transactions, which take full datetimes:

```bash theme={null}
curl "https://api.spenza.com/api/v1.1/invoices?from=2026-06-01&to=2026-06-30" \
  -H "Authorization: Bearer $SPENZA_TOKEN"
```

Each invoice includes `lineItems` and a `usageSummary`. To get the underlying PDF, follow the redirect from `GET /api/v1.1/invoices/{id}/pdf` — it 302s to a signed download URL (an S3-hosted PDF, or a Stripe receipt URL as a fallback when no stored PDF exists yet).

Check prepaid credit and top it up the same way:

```bash theme={null}
curl "https://api.spenza.com/api/v1.1/credit-balance" -H "Authorization: Bearer $SPENZA_TOKEN"

curl -X POST "https://api.spenza.com/api/v1.1/top-up" \
  -H "Authorization: Bearer $SPENZA_TOKEN" -H "Content-Type: application/json" \
  -d '{ "amount": 100, "currency": "usd" }'
```

`top-up` returns a `checkoutUrl` (hosted checkout) alongside a `transactionId`/`statusEndpoint` — redirect the user there to complete payment; poll the transaction to confirm it landed.

## Integrating webhooks

Register a webhook, then verify every delivery's signature before trusting it — see **[Webhooks](/webhooks)** for the full registration flow, the delivery-signing scheme, and how to inspect/redeliver past events.

## Testing call quality

Find out how good a real phone call from one of your lines is. Register a webhook, call a Spenza test number from a line on your account, and when the call ends Spenza sends a scored report — packet loss, jitter, round-trip time and a MOS score for each direction of the audio — to your webhook.

<Note>
  Spenza only **receives** test calls; it never calls your lines. A report is delivered only when the calling number is a line on **your** account — a call from any other number is measured but never sent to you.
</Note>

**1. Register a webhook.** Needs a read-write API key. The URL must be `https` on port 443 and publicly reachable; you can register up to 10.

```bash theme={null}
curl -s -X POST "https://api.spenza.com/api/v1.1/voice-quality/webhooks" \
  -H "Authorization: Bearer $SPENZA_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f29b6a1-4b8e-4f0a-9c2e-8e6b1d2a7c44" \
  -d '{ "url": "https://partner.example.com/hooks/voice-quality", "events": ["voice.test.completed"] }'
```

The response carries `signingSecret` **once** — store it to verify deliveries.

**2. Call the test number.** Call **+1 279 204 4328** from a line on your account and stay on the line for about 20 seconds; a call too short to measure comes back `INCONCLUSIVE`.

**3. Receive `voice.test.completed`.** Shortly after you hang up:

```json theme={null}
{
  "id": "evt_3f2a9c1e5b7d4e8a9c217d4e5f6a8b90",
  "type": "voice.test.completed",
  "api_version": "2026-10-01",
  "created_at": "2026-10-07T12:24:40.000Z",
  "data": {
    "test_id": "vqt_3f2a9c1e5b7d4e8a9c217d4e5f6a8b90",
    "source_number": "+12792043120",
    "test_destination": "+12792044328",
    "verdict": "PASS",
    "headline": "PASS — Good voice quality",
    "quality_class": "Excellent",
    "mos_cqe": 4.4,
    "score": 93,
    "confidence": "high",
    "report": {
      "quality": [
        { "path": "node_to_caller", "packetLossPercent": 0, "jitterMsMax": 0, "jitterMsMean": 0,
          "rttMsMean": 44.22, "reportCount": 4,
          "rating": { "rFactor": 92.67, "mosCqe": 4.4, "category": "best", "delayMs": 22.1 } }
      ]
    }
  }
}
```

Deliveries are signed exactly like operator webhooks — verify `X-Spenza-Signature` as described in **[Verifying delivery signatures](/webhooks#verifying-delivery-signatures)**, using this webhook's `signingSecret`. Success is any `2xx` within 10 seconds; a failed delivery is retried after 30 seconds, 2 minutes and 10 minutes (4 attempts), and `410 Gone` stops retries. Dedupe on `id`.

**Reading the report.** `node` is Spenza's voice server and `caller` is the phone that dialled, so `node_to_caller` is the audio Spenza sent towards your line and `caller_to_node` is the audio your line sent to Spenza.

| Field | What it means | Good |
| - | - | - |
| `verdict` | `PASS`, `WARNING`, `FAIL`, or `INCONCLUSIVE` when there wasn't enough media to judge. | `PASS` |
| `packetLossPercent` | Share of audio packets that never arrived — each one is a tiny gap in the sound. | 0%; 1–3% sounds choppy; above 5% is bad |
| `jitterMsMax` / `jitterMsMean` | How unevenly packets arrived (worst / average), in ms. | Under 30 ms |
| `rttMsMean` | Round-trip time to the far end and back, in ms. | Under 150 ms; above \~300 ms people talk over each other |
| `reportCount` | Quality reports received during the call, about one every 5 seconds. | 3 or more |
| `rating.rFactor` | ITU-T G.107 transmission rating, 0–100. | 90+ best, 80–90 high, 70–80 medium |
| `rating.mosCqe` | The same rating on the 1–5 call-quality scale; **4.4 is the maximum** for standard phone audio. | Above 4.0 |
| `confidence` | `high` when enough of the call was measured, `low` when it was too short. | `high` |

The score is calculated from how the audio packets travelled, not from listening to the call, so a faulty microphone or speaker isn't detected. It measures as far as the carrier equipment on your line's side. A jitter of exactly `0` usually means your carrier recalculates these reports rather than passing raw figures through — that's normal. Place test calls one after another rather than all at once.

## Testing and validation guidance

There's no separate sandbox documented for this API (see **[Environments & Versioning](/environments-versioning)**), so validate your integration carefully against production:

* Start with **read-only** calls (`GET /api/v1.1/sims`, `GET /api/v1.1/plans`) to confirm authentication and pagination before writing any code that mutates state.
* Use a small number of real-but-low-value test resources (a single spare SIM, a test user) rather than scripting bulk writes against production inventory.
* Use an `Idempotency-Key` on write calls that support it — see **[Core Concepts → Idempotency](/core-concepts#idempotency)** — so a client-side retry after a timeout can't duplicate the action.
* Validate request bodies client-side against the field tables in the API Reference — a `400 VALIDATION_ERROR`/`MISSING_FIELDS` on a write call is cheaper to catch locally than after Spenza has already tried to place an order.
* For destructive or billable actions (plan purchases, top-ups, port-ins), gate them behind an explicit confirmation step in your own tooling until you're confident in the integration.

## Production-readiness checklist

* [ ] Credentials are stored in a secrets manager, not in code or `.env` files committed to git.
* [ ] Your client re-authenticates automatically on `401 UNAUTHORIZED` rather than crashing.
* [ ] Every write call has retry logic for `429`/`5xx` with backoff (see **[Rate Limits](/rate-limits)**), and does **not** blindly retry `4xx` validation errors.
* [ ] Write calls that support it send an `Idempotency-Key`, so a retry after a timeout can't duplicate a purchase.
* [ ] Async operations are polled with a bounded timeout, not an infinite loop.
* [ ] Webhook endpoints (if used) verify `X-Spenza-Signature` and are idempotent against redelivery — see **[Webhooks](/webhooks)**.
* [ ] You have a support contact path for issues — see **[Support](/support)**.


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