Developers

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 lists every endpoint with an example request and response. The notification catalogue 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:

curl
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
iot.sims:read List SIMs, Get a SIM
iot.sims:write Update a SIM, Activate a SIM, Pause a SIM, Change a SIM's plan, Resume a SIM
iot.sims:cease Cease a SIM
iot.usage:read List a SIM's sessions, Get a SIM's usage, List fleet usage
iot.esim:read Get an eSIM activation code
platform.keys:read List API keys
platform.keys:manage Create an API key, Revoke an API key
platform.branding:read Get branding information for the authenticated user or extension
Walks up the organisation hierarchy to find custom branding
platform.events:read List events, Get an event, List notification types
platform.webhooks:read List webhooks, Get a webhook, List deliveries
platform.webhooks:manage Create a webhook, Update a webhook, Delete a webhook, Redeliver an event, Rotate a webhook's signing secret, Send a test event

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 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, choose which notification types 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, 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.

Node

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)

C#

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:

Help

Stuck, or need something the API doesn't do yet? Talk to us. Partners managing SIMs for their own customers use the same API with a partner key, which sees every customer below them.