Customer API
With the customer API, your own software orders and manages mobile subscriptions with eSIM on your VoiceOne account. A new employee automatically gets a business mobile number, a lost phone is blocked right away and a leaver is cancelled. Webhooks tell you how things are going.
The API is enabled per account. To use it, email [email protected]. You then create keys yourself in One, under My account → API. That is also where you manage your webhooks and see the log of every request.
On this page
Quick start
- Email [email protected] to have the sandbox enabled for your account.
- Create a sandbox key in One (
vo_test_…). It is shown once, so store it safely straight away. - Fetch the products you are allowed to order:
curl https://api.voiceone.nl/v1/products \
-H "Authorization: Bearer vo_test_…"- Order a subscription. Send a unique
Idempotency-Keywith every POST:
curl https://api.voiceone.nl/v1/mobile-subscriptions \
-H "Authorization: Bearer vo_test_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: new-advisor-42" \
-d '{
"product": "your_product_code",
"user": { "first_name": "Jan", "last_name": "Jansen", "email": "[email protected]" },
"external_reference": "advisor-42"
}'- In the sandbox, the subscription becomes active after about a minute. Follow it with a webhook, or fetch it with
GET /v1/mobile-subscriptions/{id}. - Everything working? Then ask for live access. A live key (
vo_live_…) only works from the IP addresses you set on the key.
Basics
Address, key and format
- Address:
https://api.voiceone.nl/v1. HTTPS only; plain http returns 403. - Key: send it as
Authorization: Bearer <key>. A key starts withvo_live_(live) orvo_test_(sandbox), followed by 40 letters and digits. The key decides the environment. VoiceOne only stores a hash: a lost key means creating a new one. - Format: JSON in UTF-8 with
Content-Type: application/json, at most 64 kB per request. Amounts are strings with two decimals, in euros, excluding VAT. Times are ISO 8601 with a time zone (Europe/Amsterdam), dates areYYYY-MM-DDand phone numbers are E.164 (+31612345678). - Request ID: every response has an
X-Request-Idheader. Include it when you have a question about a request. - Server to server: the API has no CORS. Call it from your server, never from a browser or app, where your key would be readable by anyone.
- New fields and events may be added. Make your system ignore fields it does not know.
Permissions per key
You choose the permissions of a key when you create it. Give a key only what your system needs.
- subscriptions:read
- Read subscriptions and products.
- subscriptions:write
- Order, change, block, unblock, cancel, and request a new eSIM or a SIP channel. These actions cost money.
- esim:read
- The activation code and confirmation code of an eSIM in the response. Without this permission those fields are
null. - sip:read
- Fetch the credentials of a SIP channel.
- calls:read
- Not in use yet (calls, in a future version).
Webhooks never contain the eSIM codes, not even with esim:read. Fetch them with GET /v1/mobile-subscriptions/{id} once esim.ready is true.
Limits
- 120 requests per minute per key. Above that, 429
rate_limitedwith aRetry-Afterheader (seconds). - Many requests with an invalid key from one IP address: 429 for ten minutes.
- Live only from fixed IP addresses: at most 20 addresses or ranges per key, IPv4 or IPv6, ranges no larger than /16 (IPv4) or /32 (IPv6). The sandbox works without.
- Daily limit on new subscriptions per account (10 by default, agreed with VoiceOne). Above it, the order is still accepted, with status
awaiting_approval. VoiceOne releases it after a check. - Expiry date: a key can have an end date. After it, the key returns 401.
Idempotency: never twice
- Every POST needs an
Idempotency-Keyheader: 1 to 200 characters, letters, digits and. _ : -. Use a new one per action, such as the ID from your own system. - The same Idempotency-Key with the same body within 7 days returns exactly the same response, with the header
Idempotent-Replayed: true. Nothing is executed again, so you can safely retry after a timeout. - The same Idempotency-Key with a different body, or from a different API key: 409
idempotency_conflict. Is the first request still running: 409request_in_progresswithRetry-After. - A 5xx, a 409
busyand a 429 are not stored: feel free to retry with the same Idempotency-Key. - Once an order has been placed, that request never gets a 5xx again, but a 202.
Lists
GET /v1/mobile-subscriptions returns the newest first, 50 at a time by default. Use ?limit= (1–100) and, for the next page, ?cursor= with the value of next_cursor. You can filter on ?status= and ?external_reference=. The list contains every mobile subscription on your account, including the ones not ordered through the API.
{
"data": [ { "object": "mobile_subscription", "id": "ms_1234", … } ],
"has_more": true,
"next_cursor": "ms_1234"
}Errors
An error always has this shape. details is only present when there is something to report, such as the field that is wrong.
{
"error": {
"code": "validation_error",
"message": "user.last_name is required (1-60 characters).",
"details": { "field": "user.last_name" },
"request_id": "HrRbWgZ9J6Erbn70"
}
}Make your system act on code, not on message: the text may change.
- 400 invalid_
request - Invalid JSON, not a JSON object, or a body larger than 64 kB.
- 401 unauthorized
- No key, or an unknown, revoked or expired key.
- 403 api_
disabled - The API, or this environment, is disabled for your account.
- 403 ip_
not_ allowed - The IP address is not allowed for the key.
- 403 insufficient_
scope - The key lacks the permission (
details.required). - 403 account_
blocked - Your account is blocked, for example because of overdue payments.
- 403 forbidden
- This request is not possible with this key, such as
sandbox/simulatewith a live key. - 403 webrtc_
disabled - Calling in the browser is not enabled for your account.
- 404 not_
found - Unknown endpoint, or a subscription that does not exist or belongs to another account.
- 405 method_
not_ allowed - Wrong method. The
Allowheader names the right one. - 409 busy
- An action is already running on this SIM or account. Try again shortly.
- 409 invalid_
state - The action does not fit the current state, such as unblocking a SIM that is not blocked.
detailsoften gives the currentstatus. - 409 not_
supported - Not possible for this product (a SIP channel is only available for Mobiel Compleet).
- 409 idempotency_
conflict - This Idempotency-Key was already used for a different request.
- 409 request_
in_ progress - A request with this Idempotency-Key is still running.
- 422 validation_
error - Invalid input.
details.fieldnames the field. - 422 product_
not_ allowed - This product is not enabled for your account (see
GET /v1/products). - 422 idempotency_
key_ required - POST without an
Idempotency-Key. - 429 rate_
limited - Too many requests. Wait the number of seconds in
Retry-After. - 500 internal
- Unexpected error. Email the
request_idto [email protected]. - 503 unavailable
- A component is temporarily unavailable. Try again later.
Endpoints
Every POST needs an Idempotency-Key. The {id} of a subscription starts with ms_ (ms_test_ in the sandbox).
esim:read).subscriptions:readProducts
{
"data": [
{
"object": "product",
"code": "your_product_code",
"name": "Mobiel Compleet",
"type": "mobile_subscription",
"price_excl": "x.xx",
"period": "month",
"setup_fee_excl": "x.xx",
"currency": "EUR"
}
],
"has_more": false,
"next_cursor": null
}price_excl is the price agreed for your account, otherwise the list price. setup_fee_excl is the one-off fee per new subscription (null if there is none).
Codes, names and amounts in the examples on this page are made up. The real ones for your account are in the response of GET /v1/products.
The mobile subscription
Every response about a subscription, and every webhook, contains this object.
{
"object": "mobile_subscription",
"id": "ms_1234",
"environment": "live",
"status": "pending",
"product": { "code": "your_product_code", "name": "Mobiel Compleet" },
"user": { "first_name": "Jan", "last_name": "Jansen", "email": "[email protected]" },
"external_reference": "advisor-42",
"phone_number": null,
"number": { "type": "new", "porting": null },
"sim": { "type": "esim", "status": "to_order" },
"esim": { "ready": false, "activation_code": null, "confirmation_code": null },
"esim_swap": null,
"sip_channel": null,
"order": { "id": "ord_567", "number": "ORD20261187" },
"cancellation": null,
"created_at": "2026-11-03T14:02:11+01:00",
"activated_at": null
}- status
awaiting_approval(above the daily limit, waiting for approval) ·pending(being delivered) ·needs_attention(needs manual work and takes longer, but is not rejected) ·active·blocked·ended·cancelled(order cancelled before delivery).- phone_
number - The mobile number once it is known (event
subscription.number_assigned). - number
typeisneworporting. When keeping a number,portingholdscurrent_number,status(requested,planned,completedorrejected) andplanned_date.- sim
typeisesim, orphysicalfor a physical SIM that was already on your account.status:to_order·ordered·shipped·active·blocked·ended.- esim
readybecomes true once the eSIM is available.activation_codeis the LPA code (LPA:1$…) andconfirmation_codethe confirmation code; both only withesim:readand only in a response, never in a webhook.- esim_
swap - After a new eSIM was requested:
status(ready,completed,revokedorfailed),requested_at,completed_atand, withesim:readand only forGET …/{id}, the codes of the new eSIM. Aftercompleted, the new eSIM is inesim. - sip_
channel - After a request:
status(pending,activeorremoved),username,registeredandregistered_at. The username and registration only whenactive; the password is never in here. - webrtc_
login - With a browser login:
username,status(active) andonline(as reported by the gateway, ornull). Otherwisenull. - cancellation
- After a cancellation:
ends_at(the last day the subscription works) andstops_on(the day it stops). - external_
reference - Your own reference, such as the employee ID in your system. Returned in every response and webhook.
- order
- The order the subscription came from, with
idandnumber.
Ordering
Order a new subscription with a new number:
POST /v1/mobile-subscriptions
{
"product": "your_product_code",
"user": { "first_name": "Jan", "last_name": "Jansen", "email": "[email protected]" },
"external_reference": "advisor-42",
"number": { "type": "new" },
"sim": { "type": "esim" }
}Or keep an existing Dutch mobile number (number porting):
{
"product": "your_product_code",
"user": { "first_name": "Jan", "last_name": "Jansen" },
"number": {
"type": "porting",
"current_number": "+31612345678",
"provider": "Example Mobile",
"customer_number": "12345678",
"timing": "contract_end"
}
}- product
- Required. A code from
GET /v1/products. - user.
first_ name, user. last_ name - Required, 1–60 characters. The name appears on the invoice line and in the usage statement.
- user.
email - Optional. The address the eSIM is sent to; empty means the main address of your account. Your system can also install the eSIM itself using the activation code (
esim:read). - external_
reference - Optional, at most 100 characters.
- number.
type new(default) orporting. Withporting,current_number(a Dutch mobile number starting with 06) andprovider(the current provider) are required. Optional arecustomer_numberandsim_numberat that provider, andtiming:contract_end(default) orasap. Withasapyou accept any early termination fee at the current provider.- sim.
type - Only
esimin this version.
- Response: 202 with the subscription, status
pendingorawaiting_approval. - For orders through the API, VoiceOne sends no order confirmation and no delivery email. Your system follows the state through webhooks.
- You request a SIP channel separately after ordering (see SIP channel).
Managing
Changing details
PATCH /v1/mobile-subscriptions/{id}
{
"user": { "first_name": "Anna", "last_name": "de Vries", "email": "[email protected]" },
"external_reference": "advisor-57"
}Only user.first_name, user.last_name, user.email and external_reference can change. Send only what changes. Response 200 with the subscription.
Blocking and unblocking
POST /v1/mobile-subscriptions/{id}/block
POST /v1/mobile-subscriptions/{id}/unblock- Blocks calls, SMS and data right away, for example after a loss or when someone leaves. No body. It can take up to about 90 seconds; response 200 with the subscription.
- Already (un)blocked, or not an active SIM: 409
invalid_state. Another action running: 409busy. Unblocking is only possible if you blocked it yourself, not after a block by VoiceOne.
Cancelling
POST /v1/mobile-subscriptions/{id}/cancel
{ "reason": "Employee left the company" }- Optionally a
reason(at most 500 characters). The subscription stops after a notice period of one month, or at the end of a running contract if that is later. A mobile subscription always stops on the 1st of a month. - Response 200 with the subscription and
cancellation, for example{ "ends_at": "2026-12-31", "stops_on": "2027-01-01" }. Only foractiveorblocked. To cancel an order that is still being delivered, email [email protected]. - Already cancelled: 409
invalid_state. Account blocked: 403account_blocked. VoiceOne does not email the user. You receive the webhooksubscription.cancellation_scheduled, andsubscription.endedon the end date.
New eSIM
POST /v1/mobile-subscriptions/{id}/esim-swap
{
"user": { "first_name": "Anna", "last_name": "de Vries", "email": "[email protected]" }
}- A new eSIM on the same number, for example for a new phone or when the number moves to another employee. In that case, optionally send the new
user. The current eSIM keeps working until the new one is installed. - It can take up to about 90 seconds. Response 200 with
esim_swap.status: "ready"and, withesim:read, the codes of the new eSIM. If the request was sent but not confirmed, the response is 202 withesim_swap.status: "failed": do not retry, VoiceOne will look into it. - Only for an active, unblocked eSIM. A new one already waiting: 409. At most a few times per 24 hours per SIM, above that 429. Webhooks:
subscription.esim_swap_readyand, after installation,subscription.esim_swap_completed. - Number moving to another employee? Remove the previous employee’s access in One (My account → Users), otherwise they can see the codes of the new eSIM.
SIP channel
With Mobiel Compleet, every user can get a SIP channel at no extra cost. It lets you register a SIP client for that user, such as the dialer or softphone in your own system.
Requesting
POST /v1/mobile-subscriptions/{id}/sip-channel, without a body and with anIdempotency-Key. Response 202 with the subscription andsip_channel.status: "pending".- VoiceOne creates the channel within a few minutes once the SIM is active. The webhook
subscription.sip_channel_readyfollows. - If a channel already exists or the request is still running, the response is 200 with the subscription as it is: there is never a second channel.
- Not Mobiel Compleet: 409
not_supported. Ended: 409invalid_state. Account blocked: 403account_blocked. A channel still belongs to a previous owner of the SIM: 409invalid_state. VoiceOne cleans it up; request it again afterwards.
Credentials
{
"object": "sip_credentials",
"subscription_id": "ms_1234",
"environment": "live",
"server": "mv.voiceone.nl",
"port": null,
"username": "example7k2q",
"password": "…",
"extension": "301",
"registration": {
"registered": true,
"registered_at": "2026-11-03T10:00:00+01:00",
"checked_at": "2026-11-03T10:05:12+01:00"
},
"instructions": "Register a SIP client with this username and password on the server. Then dial 901 …"
}GET /v1/mobile-subscriptions/{id}/sip-credentials, with thesip:readpermission. Only whensip_channel.statusisactive, otherwise 404 withdetails.sip_channel_status.- The password is only in this response, never in lists or webhooks, and the response has
Cache-Control: no-store. Store it as carefully as your API key. - If the subscription has a browser login, our gateway uses the channel: this endpoint then returns 409
invalid_state. A channel cannot be used in the browser and in your own SIP client at the same time. portisnullwhen VoiceOne sets no fixed port: your SIP client’s default applies.extensioncan benullwhile it is not known yet. VoiceOne reads the registration (registered,registered_at) at most once every ten minutes;checked_atsays when.
Logging in
- Register your SIP client with
usernameandpasswordonserver. - From that client, dial 901 once, then enter the user’s extension and the PIN (0000 by default). The user is then logged in on this channel.
- If the client’s IP address is on your account’s IP whitelist, the user is logged in automatically from then on.
Calling in the browser
With our WebRTC gateway, a user calls from the browser, for example in your CRM, over the SIP channel of their Mobiel Compleet subscription. You need no SIP server or softphone of your own: the browser speaks WebRTC to the gateway, and the gateway registers the channel with our SIP platform.
- Calling in the browser is enabled per account; request it at [email protected]. Until then,
POST …/webrtc-loginreturns 403webrtc_disabled. - The subscription is Mobiel Compleet and has an active SIP channel (
POST …/sip-channel, then the webhooksubscription.sip_channel_ready). - One browser login per subscription. Do not also use that channel in your own SIP client.
How it works
POST /v1/mobile-subscriptions/{id}/webrtc-login, without a body and with anIdempotency-Key. Response 201 with the login (object: "webrtc_login"), without the password. If one already exists, 200 with that login.GET /v1/mobile-subscriptions/{id}/webrtc-loginwith thesip:readpermission: username, password,websocket_urlandice_servers(TURN with the same login). The response hasCache-Control: no-store.- Hand these details to the user’s browser over your own signed-in session and register with a SIP-over-WebSocket library such as JsSIP.
readyistrueonce the gateway knows the login (within about a minute). - New password:
POST …/webrtc-login/reset-password; the old one stops working within a minute. Revoke:POST …/webrtc-login/revoke; calls in progress are disconnected.
GET /v1/mobile-subscriptions/{id}/webrtc-login
{
"object": "webrtc_credentials",
"subscription_id": "ms_1234",
"environment": "live",
"username": "123456-k3x9qa",
"password": "…",
"sip_uri": "sip:[email protected]",
"websocket_url": "wss://webrtc.voiceone.nl/ws",
"ice_servers": [
{
"urls": ["turns:turn.voiceone.nl:443?transport=tcp", "turn:turn.voiceone.nl:3478"],
"username": "123456-k3x9qa",
"credential": "…"
}
],
"ready": true,
"limits": { "max_concurrent": 2, "max_calls_per_hour": 120, "max_call_seconds": 7200,
"allow_international": false, "allow_premium": false },
"online": true,
"online_at": "2026-11-03T14:05:03+01:00"
}Example with JsSIP
// login = the response of GET /v1/mobile-subscriptions/{id}/webrtc-login,
// fetched by your server and handed to the signed-in user's browser.
const ua = new JsSIP.UA({
sockets: [new JsSIP.WebSocketInterface(login.websocket_url)],
uri: login.sip_uri,
password: login.password,
register: true,
session_timers: false,
});
const pcConfig = { iceServers: login.ice_servers };
ua.on('newRTCSession', (e) => {
if (e.originator === 'remote') {
e.session.answer({ mediaConstraints: { audio: true, video: false }, pcConfig });
}
});
ua.start();
// Call out:
const session = ua.call('0612345678', { mediaConstraints: { audio: true, video: false }, pcConfig });The password ends up in the user’s browser: give every user their own login, do not keep it in localStorage, and revoke the login when someone leaves.
What you can call
- Dutch numbers, 0800, 14 numbers, internal numbers (2–4 digits), 112 and 901 (log in with extension and PIN). International and premium numbers (0900, 0906, 0909, 18xx) only if VoiceOne enables them for the login.
- Default limits per login: 2 calls at a time, 120 outgoing calls per hour and 2 hours per call. VoiceOne can adjust them per login; they are in
limits. - Incoming calls to the number ring in the browser while the user is registered.
- If the subscription is blocked or has ended, the account is blocked, or the API is disabled for your account, the login stops working within a minute.
Rejections (SIP status)
- 401
- Unknown login or wrong password when registering.
- 403
- This login may not call this number (international, premium or another domain).
- 404
- Not a valid number.
- 486
- Login limit reached (at a time or per hour).
- 503
- The gateway is temporarily full; try again shortly.
Webhooks
In One, set one or more addresses per environment and choose the events (none selected means all). Every change requires two-factor verification. Each address gets its own secret (whsec_…), which you can view and renew in One. An event is created within about a minute of the change.
The address must start with https://, use a domain name (not an IP address), be reachable from the internet and use port 443 or 8443.
{
"id": "evt_9c2f0a1b7d4e8c3a5f6b2d1e",
"object": "event",
"type": "subscription.active",
"environment": "live",
"created_at": "2026-11-03T14:05:40+01:00",
"data": { "object": { "object": "mobile_subscription", "id": "ms_1234", … } }
}Headers
- VoiceOne-Event-Id
- The
idof the event. Deduplicate on it: an event can arrive more than once, for example after a timeout. - VoiceOne-Event-Type
- The event type, such as
subscription.active. - VoiceOne-Signature
t=<unix time>,v1=<hex>, wherev1 = HMAC-SHA256(secret, "<t>.<raw body>").
Verifying the signature
Verify every event before you process it: compute the HMAC over the raw body exactly as received, compare it with v1 in constant time, and reject it if t is more than five minutes off.
import crypto from 'node:crypto';
// rawBody: the request body exactly as received (a string, not re-serialised JSON).
export function verifyVoiceOne(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest();
const given = Buffer.from(parts.v1 || '', 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}<?php
function verifyVoiceOne(string $rawBody, string $signatureHeader, string $secret): bool {
parse_str(str_replace(',', '&', $signatureHeader), $parts);
$t = (int)($parts['t'] ?? 0);
if ($t === 0 || abs(time() - $t) > 300) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, (string)($parts['v1'] ?? ''));
}
$raw = file_get_contents('php://input');
if (!verifyVoiceOne($raw, $_SERVER['HTTP_VOICEONE_SIGNATURE'] ?? '', (string)getenv('VOICEONE_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}Delivery
- A 2xx within 10 seconds counts as delivered. Redirects are not followed.
- Otherwise VoiceOne retries after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours (six attempts). After that it stops; One shows this and lets you resend the event.
- Order is not guaranteed. Use
created_atand the object in the event as the current state. - Events are kept for 30 days. The test button in One sends an event of type
test.ping.
Events
- subscription.
created - New subscription accepted.
- subscription.
awaiting_ approval - Above the daily limit: waiting for approval by VoiceOne.
- subscription.
needs_ attention - Needs manual work and takes longer, but is not rejected.
- subscription.
active - The subscription works.
- subscription.
esim_ ready - The eSIM is available. Fetch the codes with
GET …/{id}(withesim:read). - subscription.
number_ assigned - The mobile number is known.
- subscription.
blocked - Blocked.
- subscription.
unblocked - The block was lifted.
- subscription.
cancellation_ scheduled - Cancelled.
cancellationholds the dates. - subscription.
ended - The subscription has stopped.
- subscription.
cancelled - The order was cancelled before delivery.
- subscription.
esim_ swap_ ready - A new eSIM is ready to be installed.
- subscription.
esim_ swap_ completed - The new eSIM has been installed.
- subscription.
sip_ channel_ ready - The SIP channel is ready. Fetch the details with
sip-credentials. - porting.
planned - Number porting is scheduled, or the date moved (
number.porting.planned_date). - porting.
completed - The number has been transferred.
- porting.
rejected - Number porting was rejected. VoiceOne will work out what is needed.
Sandbox
- Same address, endpoints and responses, with a
vo_test_key.environmentis then"sandbox". - Nothing is real: no orders, invoices or emails are created. IDs start with
ms_test_, numbers with+316000(do not call them) and the activation code isLPA:1$sandbox.voiceone.nl$TEST-…. - A new subscription becomes
activeby itself after about a minute. So does a SIP channel, about a minute after the request, if the subscription is active. - In the sandbox,
GET …/sip-credentialsreturns the same real VoiceOne test channel for every subscription (shared_test_channel: true), so you can really try registering. If it has not been set up yet: 503unavailable. - A browser login in the sandbox is simulated (
simulated: true,ready: false): it does not work on the gateway. Real calling in the browser is live only.
Simulating events
With POST /v1/sandbox/simulate (with an Idempotency-Key) you put a sandbox subscription in a state and send the matching webhook. Possible events:
subscription.activesubscription.needs_attentionsubscription.blockedsubscription.unblockedporting.plannedporting.completedsubscription.esim_swap_completedsubscription.endedsubscription.sip_channel_ready
POST /v1/sandbox/simulate
{ "event": "subscription.active", "subscription": "ms_test_8f2k1a" }subscription.esim_swap_completed works after an esim-swap, subscription.ended after a cancel, and subscription.sip_channel_ready after a sip-channel on an active subscription.
Support
Questions about the API, or does something not work as described here? Email [email protected] and include the request_id from the response.
Support for the API and other development questions is by email only, at this address. Not through regular customer service, chat or phone.
Outages and maintenance are listed on voiceone.nl/status.
Open data without a key (prices, domain names, outages) is at For developers.