Build for your users
Build an app or a portal on the End-User API: sign a user in, show their subscriptions, and sell a change through an order
Build an application that acts for one of your users. The user signs in, reads their subscriptions and their remaining data, buys a topup, changes their plan, and protects a lost SIM. Every request goes to the End-User API, which limits the request to what that user can see and do.
Prerequisites
You need all of these before you start:
- Impersonation key: An API key with the access type Impersonate user, created in the portal
- Users with customers: Each user belongs to one customer or more, and each customer has subscriptions
- Payment provider: A configured payment provider, for an order that costs money
Every request sends the key in X-API-Key and the token of the user in Authorization: Bearer.
Read End-User authentication for the login flow and for what the
API refuses. The base URL is {BASE_URL}/api/v2/end-user.
Overview
Sign the user in
Start the email login, then verify the code.
Load the user and their customers
Read the profile, then the customers that the user can act for.
Show subscriptions and usage
List the subscriptions of a customer, then read the usage of each one.
Sell a change through an order
Create an order, add a line item, then collect the payment.
Protect the line
Block a lost SIM, or pause and restore a subscription.
Step 1: Sign the user in
Start the login with POST /auth/email/start, then verify the code with POST /auth/email/verify.
The response carries the accessToken of the user. End-User
authentication has the full flow with samples.
Step 2: Load the user and their customers
Read the profile of the user. The customers array names each customer that the user can act for:
curl "{BASE_URL}/api/v2/end-user/accounts/me" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN"const headers = {
'X-API-Key': API_KEY,
Authorization: `Bearer ${userToken}`,
};
const loadUser = async () => {
const response = await fetch('{BASE_URL}/api/v2/end-user/accounts/me', { headers });
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to load the user: ${error.message}`);
}
return response.json();
};{
"userId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "Emma Johnson",
"email": "emma.johnson@example.com",
"customers": [
{
"customerId": "c2d1a3e4-1b2c-4d5e-8f90-123456789abc",
"name": "Emma Johnson",
"customerType": "CONSUMER"
}
]
}A user with more than one customer picks one. GET /customers lists the same customers with
their full details. GET /customers/{customerId}/product-catalog gives the product offerings
that this customer can buy.
Step 3: Show subscriptions and usage
List the subscriptions of the customer, then read one subscription for its detail page:
curl "{BASE_URL}/api/v2/end-user/subscriptions?customerId=c2d1a3e4-1b2c-4d5e-8f90-123456789abc" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN"
curl "{BASE_URL}/api/v2/end-user/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN"const listSubscriptions = async (customerId) => {
const query = new URLSearchParams({ customerId });
const response = await fetch(`{BASE_URL}/api/v2/end-user/subscriptions?${query}`, { headers });
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to list subscriptions: ${error.message}`);
}
const { items } = await response.json();
return items;
};The subscription status is one of PENDING, ACTIVATED, BLOCKED, CANCELLED, PAUSED,
or SUSPENDED. The response of each subscription has the same shape as on the Platform API.
The Customer self-service guide shows a full
subscription and a full usage response.
Read the usage of the current period to show the data, voice, and SMS meters:
curl "{BASE_URL}/api/v2/end-user/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/usage" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN"For an eSIM subscription, GET /subscriptions/{subscriptionId}/esim/qrcode gives the activation
code and a QR code image.
Do not cache a QR code. Request a new one when the user opens the installation screen. Each code
expires at its expiresAt, and anybody who scans it can install the eSIM profile.
Step 4: Sell a change through an order
On the End-User API, every change that costs money goes through an order. A topup, a plan change, and a new subscription are each one line item on an order. The order prices the change, and a payment session collects the money.
First read what the user can buy. GET /subscriptions/{subscriptionId}/product-offering-options
lists the plans that a subscription can change to, with the date each change takes effect.
GET /subscriptions/{subscriptionId}/addon-options lists the addons and the topups.
Then create the order for the customer, and add the line item:
# Create the order
curl -X POST "{BASE_URL}/api/v2/end-user/orders" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"customerType": "CONSUMER",
"customer": { "customerId": "c2d1a3e4-1b2c-4d5e-8f90-123456789abc" }
}'
# Add a topup to a subscription
curl -X POST "{BASE_URL}/api/v2/end-user/orders/9b2e4f10-7c3d-4a1e-b5f6-0d8c7e6a5b4c/line-items" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"lineItem": {
"type": "ADDON",
"lineItemId": "topup-0",
"productOfferingId": "addon-data-5gb",
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4"
}
}'const createTopupOrder = async (customerId, subscriptionId, productOfferingId) => {
const created = await fetch('{BASE_URL}/api/v2/end-user/orders', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ customerType: 'CONSUMER', customer: { customerId } }),
});
if (!created.ok) {
const error = await created.json();
throw new Error(`Failed to create the order: ${error.message}`);
}
const { orderId } = await created.json();
const added = await fetch(`{BASE_URL}/api/v2/end-user/orders/${orderId}/line-items`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
lineItem: { type: 'ADDON', lineItemId: 'topup-0', productOfferingId, subscriptionId },
}),
});
if (!added.ok) {
const error = await added.json();
throw new Error(`Failed to add the line item: ${error.message}`);
}
return orderId;
};A plan change is a SUBSCRIPTION_CHANGE line item instead:
{
"lineItem": {
"type": "SUBSCRIPTION_CHANGE",
"lineItemId": "change-0",
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"changeType": "PLAN_CHANGE",
"planChange": { "productOfferingId": "cell-unlimited" }
}
}Then read the order. The pricing.totalMinor field tells you what happens next:
- If the total is
0, submit the order withPOST /orders/{orderId}/submit. A cheaper plan prices to zero, because the order charges only the difference for the days already paid. - If the total is more than
0, create a payment session, then send the user to its hosted page:
curl -X POST "{BASE_URL}/api/v2/end-user/payment-sessions" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"orderId": "9b2e4f10-7c3d-4a1e-b5f6-0d8c7e6a5b4c",
"paymentProvider": "STRIPE",
"hosted": true,
"returnUrl": "https://app.example.com/orders/9b2e4f10-7c3d-4a1e-b5f6-0d8c7e6a5b4c"
}'The response carries hostedUrl. When the payment succeeds, the order submits itself. Poll
GET /orders/{orderId} until state is COMPLETED, or read the status from the order page of the
user.
An order with no pricing at all is one that the API did not price. Do not submit it. Show the
user an error, and read the validation field of the order for the cause.
Hand the order to the checkout
You can let the hosted checkout collect the payment instead of your application. Your application builds the order. The checkout prices the order again, collects the payment, and submits the order. Ask us for the checkout URL of your brand.
Build the order
Create the order and add the line items, as the previous section shows. Do not create a payment session.
Create a handoff token
SendPOST /orders/{orderId}/handoff-token.Send the user to the checkout
Open
{CHECKOUT_URL}/storefront/checkout?claim={handoffToken}in the browser of the user.Wait for the order
PollGET /orders/{orderId}untilstateisCOMPLETED.
curl -X POST "{BASE_URL}/api/v2/end-user/orders/9b2e4f10-7c3d-4a1e-b5f6-0d8c7e6a5b4c/handoff-token" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN"const handOffToCheckout = async (orderId) => {
const response = await fetch(`{BASE_URL}/api/v2/end-user/orders/${orderId}/handoff-token`, {
method: 'POST',
headers,
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to create the handoff token: ${error.message}`);
}
const { handoffToken } = await response.json();
const query = new URLSearchParams({ claim: handoffToken });
window.location.assign(`{CHECKOUT_URL}/storefront/checkout?${query}`);
};{
"handoffToken": "oht_f47ac10b58cc4372a5670e02b2c3d479",
"orderId": "9b2e4f10-7c3d-4a1e-b5f6-0d8c7e6a5b4c",
"expiresAt": "2026-07-15T11:00:00Z"
}What the checkout does
When the user opens the link, the checkout does these steps:
- It sends the token to
POST /orders/claim-handoffand gets the order. - It replaces the cart of the user with this order.
- It asks the user to log in, if the user is not logged in to the checkout.
- It reads the price of the order again. It does not use the price that your application showed.
- It collects the payment. If the total is
0, it submits the order without a payment.
Your application does not call POST /orders/claim-handoff. Only a checkout calls it.
Token rules
| Rule | Value |
|---|---|
| Lifetime | 1 hour. The expiresAt field gives the exact time. |
| Order state | The order must be PENDING or PENDING_PAYMENT. |
| More tokens | Each request creates a new token. The earlier tokens stay valid until they expire. |
| Reuse | A claim does not consume the token. The token works until it expires or the order closes. |
| Lost token | We keep only a hash of the token. Create a new token. |
| Revocation | No operation revokes a token. A submitted or cancelled order stops all of its tokens. |
Errors
| Status | Operation | Cause |
|---|---|---|
404 | POST /orders/{orderId}/handoff-token | The order does not exist. |
404 | POST /orders/claim-handoff | The token is unknown or expired. |
412 | Both | The order is not PENDING or PENDING_PAYMENT. |
Give the token only to the user that the order is for. A person with the token can see, change, and pay for the order.
Step 5: Protect the line
When the user reports a lost or stolen SIM, block it at once:
curl -X POST "{BASE_URL}/api/v2/end-user/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/block-sim" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "X-Idempotency-Key: block-1d7f9c3b"A block takes effect at once, and the user cannot reverse it. Only a new SIM card brings the subscription back into service, and the End-User API does not issue one. Tell the user to contact the operator.
A pause is different. The user stops the service and the payments for a time, then restores them:
# Pause from the first day of the next month
curl -X POST "{BASE_URL}/api/v2/end-user/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/pause" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "scheduledAt": "2026-08-01" }'
# Restore at once
curl -X POST "{BASE_URL}/api/v2/end-user/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/restore" \
-H "X-API-Key: $API_KEY" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'The user can restore only a pause that the user made. A suspension by the operator, for non-payment or for fraud, stays until the operator lifts it.
Tools
The /tools operations help the user before a purchase. Each one takes the same two
credentials:
POST /tools/check-porting-eligibilitytells whether a number can move to your networkPOST /tools/check-network-coveragetells whether an address has coveragePOST /tools/validate-addresscorrects and completes a postal addressPOST /tools/search-devicesandPOST /tools/get-device-infofind a device and its eSIM support
What stays on the Platform API
The End-User API has no invoices, no saved payment methods, no user management, and no cancellation. Do these from your own backend with a full-access key on the Platform API, as the Customer self-service guide shows. Keep the full-access key on your backend. It never belongs in an app that you distribute.
Error handling
A 403 Forbidden with internalCode 4337 means that the key has full API access. A 401 Unauthorized means that the user token is absent, invalid, or expired. Start a new login. For
every other error, read Errors.
Next steps
- Read the End-User API reference for every operation and schema.
- Read Versioning before you pin your key to a revision.
- Read Idempotency before you retry an order request.