# Build on ThingSIM with the API.

> List and control your SIMs, change plans, read usage and receive signed webhooks. The same API the ThingSIM portal uses.

## What the API is

One HTTPS API at `https://partners.netavo.com/api/v1`, returning JSON. It is the API the ThingSIM portal itself uses, so anything you can do to a SIM in the portal you can automate: activate, pause, resume and cease SIMs, change their plan, read usage and sessions, fetch eSIM activation codes, and subscribe to notifications.

The [API reference](/developers/api) lists every endpoint with an example request and response. The [notification catalogue](/developers/notifications) lists every event we can send to your webhook. Both are generated from the API's own OpenAPI documents, which you can download from the reference to generate a client.

## Quick start

Create a key in the portal, put it in an environment variable, and list your SIMs:

```bash
export THINGSIM_API_KEY="your key"
curl "https://partners.netavo.com/api/v1/iot/sims?limit=5" \
  -H "Authorization: Bearer $THINGSIM_API_KEY"
```

## Getting a key

Sign in to the ThingSIM portal and open **API keys**. Name the key, choose its scopes, and copy it: it is shown once. A key belongs to your account, not to you, so it keeps working if you leave, and any admin on the account can see and revoke it. Revoking takes effect on the next request.

Give each integration its own key with only the scopes it needs. A dashboard that only reads usage needs `iot.sims:read` and `iot.usage:read`; it cannot pause a SIM by mistake. Ceasing a SIM cannot be undone, so it has its own scope, `iot.sims:cease`, that no other scope implies.

| Scope | Needed to |
|---|---|
| `iot.plans:read` | [List plans](/developers/api#op-listPlans) |
| `iot.sims:read` | [List SIMs](/developers/api#op-listSims), [Get a SIM](/developers/api#op-getSim) |
| `iot.sims:write` | [Update a SIM](/developers/api#op-updateSim), [Activate a SIM](/developers/api#op-activateSim), [Pause a SIM](/developers/api#op-pauseSim), [Change a SIM's plan](/developers/api#op-changeSimPlan), [Resume a SIM](/developers/api#op-resumeSim) |
| `iot.sims:cease` | [Cease a SIM](/developers/api#op-ceaseSim) |
| `iot.usage:read` | [List a SIM's sessions](/developers/api#op-listSimSessions), [Get a SIM's usage](/developers/api#op-getSimUsage), [List fleet usage](/developers/api#op-listFleetUsage) |
| `iot.esim:read` | [Get an eSIM activation code](/developers/api#op-getSimEsim) |
| `platform.keys:read` | [List API keys](/developers/api#op-listApiKeys) |
| `platform.keys:manage` | [Create an API key](/developers/api#op-createApiKey), [Revoke an API key](/developers/api#op-revokeApiKey) |
| `platform.branding:read` | [Get branding information for the authenticated user or extension
Walks up the organisation hierarchy to find custom branding](/developers/api#op-getBranding) |
| `platform.events:read` | [List events](/developers/api#op-listEvents), [Get an event](/developers/api#op-getEvent), [List notification types](/developers/api#op-listNotificationTypes) |
| `platform.webhooks:read` | [List webhooks](/developers/api#op-listWebhooks), [Get a webhook](/developers/api#op-getWebhook), [List deliveries](/developers/api#op-listWebhookDeliveries) |
| `platform.webhooks:manage` | [Create a webhook](/developers/api#op-createWebhook), [Update a webhook](/developers/api#op-updateWebhook), [Delete a webhook](/developers/api#op-deleteWebhook), [Redeliver an event](/developers/api#op-redeliverWebhookDelivery), [Rotate a webhook's signing secret](/developers/api#op-rotateWebhookSecret), [Send a test event](/developers/api#op-sendTestEvent) |

## Authentication

Send the key as a bearer token on every request:

```http
GET /api/v1/iot/sims HTTP/1.1
Host: partners.netavo.com
Authorization: Bearer your-key
```

A missing, expired or revoked key gets `401`. A valid key without the scope an endpoint needs gets `403`, and the error names the scope. Keys are secrets: keep them on your servers, never in a device's firmware or a web page.

## Conventions

**JSON in camelCase.** Every request and response body is JSON with camelCase field names. Timestamps are ISO 8601 in UTC (`2026-09-25T09:30:00Z`). Money is an object with the amount as a decimal string, so it never loses a penny: `{ "amount": "2.40", "currency": "GBP", "includesVat": false }`.

**SIMs are identified by ICCID**, the number printed on the card. Everything else has an opaque id with a prefix (`wh_…` for a webhook, `evt_…` for an event).

**Pagination.** Lists return a page at a time, newest or most recently changed first:

```json
{
  "data": [{ "iccid": "8944110068212345678", "status": "active" }],
  "hasMore": true,
  "nextCursor": "c2ltXzg5NDQxMTAwNjgyMTIzNDU2Nzg"
}
```

Ask for up to 100 items with `limit`. To get the next page, pass `nextCursor` back as `startingAfter`, until `hasMore` is `false`.

**Errors** use [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem documents with the media type `application/problem+json`. Branch on `code`, which never changes; `title` and `detail` are written for people and may.

```json
{
  "type": "https://partners.netavo.com/api/problems/invalid-state",
  "title": "Invalid state",
  "status": 409,
  "detail": "Only inactive SIMs can be activated; this SIM is active.",
  "code": "invalid-state",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
```

Anything outside your account is `404`, never `403`, so an id you do not own looks exactly like one that does not exist. Validation errors (`400`, code `validation-failed`) list what was wrong with each field in `errors`.

**Idempotency.** Every `POST` that changes something accepts an `Idempotency-Key` header. Send a new unique value (a UUID) with each action, and the same value when you retry it: if the first attempt got through, the retry returns the original result instead of acting twice. Keys are remembered for 24 hours. Reusing a key with a different body gets `422`.

**Asynchronous changes.** Activating, pausing, resuming and ceasing a SIM happen on the mobile network, which takes a moment. These endpoints answer `202 Accepted` with the SIM in a pending state (`pendingActivation`, for example) and send a notification when the change takes effect. Poll the SIM, or better, listen for the webhook.

**Rate limits.** Each key has its own limits, and every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers. Changes and bulk reads have lower limits than single reads. Go over and you get `429` with a `Retry-After` header saying how many seconds to wait.

**Versioning.** Version 1 only ever grows: we add endpoints, fields and enum values, but never remove or rename them. Ignore fields you don't recognise and treat enums as open. A change that would break an integration would be a new version, `/v2`, alongside this one.

## Webhooks

Webhooks tell your systems when something happens rather than making them ask: a SIM activates, reaches 80% of its allowance, runs out of data, or changes plan. Create one in the portal or with [the webhooks API](/developers/api#tag-platform-webhooks), choose which [notification types](/developers/notifications) it receives, and store the signing secret it returns.

Each notification is an HTTPS `POST` of a JSON event to your URL. Answer with any `2xx` within 10 seconds; do the work afterwards. Anything else is retried with increasing gaps for about 24 hours, after which the webhook is switched off and you are told. The same event can arrive more than once, and not always in order, so use its `id` to ignore duplicates and its `occurredAt` to order them. The last 30 days of events are also available from [the events API](/developers/api#op-listEvents), to catch up after downtime.

### Verifying signatures

Every request carries a `Webhook-Signature` header:

```http
Webhook-Signature: t=1790328764,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is when we signed it, in Unix seconds. `v1` is the hex HMAC-SHA256 of the timestamp, a full stop and the raw request body, keyed with your signing secret. While a secret is being rotated there are two `v1` values, one per secret; accept the request if either matches. Reject requests more than five minutes old, so a captured request cannot be replayed.

Compute the signature over the body exactly as received, before parsing it: re-serialised JSON will not match.

```javascript
import crypto from "node:crypto";

// rawBody: the request body as a string or Buffer, before JSON parsing.
export function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = header.split(",").map((p) => p.trim().split("="));
  const timestamp = parts.find(([key]) => key === "t")?.[1];
  const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value);
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return signatures.some(
    (s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))
  );
}
```

```python
import hashlib
import hmac
import time

# raw_body: the request body as bytes, before JSON parsing.
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
    timestamps = [value for key, value in parts if key == "t"]
    signatures = [value for key, value in parts if key == "v1"]
    if not timestamps or abs(time.time() - int(timestamps[0])) > tolerance:
        return False

    signed = timestamps[0].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)
```

```csharp
using System.Security.Cryptography;
using System.Text;

// rawBody: the request body as a string, before JSON parsing.
static bool VerifyWebhook(string rawBody, string header, string secret, int toleranceSeconds = 300)
{
    var parts = header.Split(',').Select(p => p.Trim().Split('=', 2)).Where(p => p.Length == 2).ToList();
    var t = parts.FirstOrDefault(p => p[0] == "t")?[1];
    if (!long.TryParse(t, out var timestamp)
        || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > toleranceSeconds)
        return false;

    var expected = HMACSHA256.HashData(
        Encoding.UTF8.GetBytes(secret),
        Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
    return parts.Where(p => p[0] == "v1").Any(p =>
    {
        try { return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(p[1]), expected); }
        catch (FormatException) { return false; }
    });
}
```

Webhook URLs must use HTTPS and resolve to a public address. We don't follow redirects.

## OpenAPI documents

The reference is generated from these, and they are the contract. Download them to generate a client in your language or import them into Postman or Insomnia:

- [IoT API](/developers/api/v1-iot.openapi.json): SIMs, usage, plans and eSIM.
- [Platform API](/developers/api/v1-platform.openapi.json): webhooks, notification types and events.
- [Notification catalogue](/developers/api/notification-types.json): every notification type with an example payload.

## Help

Stuck, or need something the API doesn't do yet? [Talk to us](/contact). Partners managing SIMs for their own customers use the same API with a partner key, which sees every customer below them.

Source: https://thingsim.com/developers
