Valdyr

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

  1. Sign the user in

    Start the email login, then verify the code.

  2. Load the user and their customers

    Read the profile, then the customers that the user can act for.

  3. Show subscriptions and usage

    List the subscriptions of a customer, then read the usage of each one.

  4. Sell a change through an order

    Create an order, add a line item, then collect the payment.

  5. 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 with POST /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.

  1. Build the order

    Create the order and add the line items, as the previous section shows. Do not create a payment session.

  2. Create a handoff token

    Send POST /orders/{orderId}/handoff-token.
  3. Send the user to the checkout

    Open {CHECKOUT_URL}/storefront/checkout?claim={handoffToken} in the browser of the user.

  4. Wait for the order

    Poll GET /orders/{orderId} until state is COMPLETED.
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:

  1. It sends the token to POST /orders/claim-handoff and gets the order.
  2. It replaces the cart of the user with this order.
  3. It asks the user to log in, if the user is not logged in to the checkout.
  4. It reads the price of the order again. It does not use the price that your application showed.
  5. 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

RuleValue
Lifetime1 hour. The expiresAt field gives the exact time.
Order stateThe order must be PENDING or PENDING_PAYMENT.
More tokensEach request creates a new token. The earlier tokens stay valid until they expire.
ReuseA claim does not consume the token. The token works until it expires or the order closes.
Lost tokenWe keep only a hash of the token. Create a new token.
RevocationNo operation revokes a token. A submitted or cancelled order stops all of its tokens.

Errors

StatusOperationCause
404POST /orders/{orderId}/handoff-tokenThe order does not exist.
404POST /orders/claim-handoffThe token is unknown or expired.
412BothThe 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-eligibility tells whether a number can move to your network
  • POST /tools/check-network-coverage tells whether an address has coverage
  • POST /tools/validate-address corrects and completes a postal address
  • POST /tools/search-devices and POST /tools/get-device-info find 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