Sell eSIMs from your own system
Issue eSIMs, top them up and follow their status over HTTPS. Every purchase uses the prepaid balance of your account, at your wholesale price.
Getting started
Create a key in your partner panel under API. We show it once, so store it on your server and never put it in a browser or an app. Send it with every request.
curl https://panel.esimdesk.app/api/v1/balance \
-H "Authorization: Bearer esk_live_..."The base URL is https://panel.esimdesk.app/api/v1. Requests and responses use JSON. All prices are in USD. The limit is 120 requests per minute for each account.
Balance
GET /balance
Returns your current prepaid balance.
{ "balance_usd": 100 }Packages
GET /packages?location=IS
Lists what you can sell. Leave out location to get every package, or pass a country or region code.
{
"packages": [{
"code": "IS_1_7",
"name": "Iceland 1GB 7Days",
"location": "IS",
"countries": ["IS"],
"data_bytes": 1073741824,
"validity_days": 7,
"wholesale_price_usd": 2.15,
"retail_price_usd": 2.9
}]
}You pay wholesale_price_usd. retail_price_usd is the price from your margin settings and is only a suggestion, because you charge your customers yourself.
Orders
POST /orders
Buys an eSIM and deducts the wholesale price from your balance.
| Field | Required | Description |
|---|---|---|
package_code | Yes | Code from the package list. |
external_id | Yes | Your unique reference, up to 100 characters. Repeating it returns the same order instead of buying a second eSIM, so retries are safe. |
customer_email | No | The address of your customer. |
language | No | Language of the customer page: en, pl, de, es, fr, it or pt. Your store default applies if you leave it out. |
curl -X POST https://panel.esimdesk.app/api/v1/orders \
-H "Authorization: Bearer esk_live_..." \
-H "Content-Type: application/json" \
-d '{"package_code":"IS_1_7","external_id":"order-1001","customer_email":"client@example.com"}'{
"id": "32dfb067-0ea4-4d41-827c-cd633a75f533",
"external_id": "order-1001",
"status": "ready",
"package_code": "IS_1_7",
"package_name": "Iceland 1GB 7Days",
"customer_email": "client@example.com",
"wholesale_price_usd": 2.15,
"created_at": "2026-11-03T08:30:27.294Z",
"esim": {
"state": "new",
"iccid": "8985...",
"activation_code": "LPA:1$...",
"page_url": "https://your-store.example/order/...",
"data_used_bytes": 0,
"data_total_bytes": 1073741824,
"expires_at": "2027-05-02T08:30:27.000Z"
}
}Order status is processing, ready, failed or cancelled. esim stays null until the order is ready. A failed order returns the wholesale price to your balance.
Give your customer page_url, or use activation_code in your own installation screen.
GET /orders/{id}
Returns one order, with fresh status and data usage.
GET /orders
Returns your 100 latest orders as { "orders": [...] }.
eSIM states
| esim.state | Meaning |
|---|---|
provisioning | The eSIM is being prepared. |
new | Issued and not installed. It can still be cancelled. |
onboard | Installed on a device. |
in_use | Using data. |
used_up | All data is used. |
suspended | Temporarily suspended. |
expired | Validity has ended. |
cancelled | Cancelled. |
deleted | Removed from the device. |
unknown | The state is not available right now. Check again later. |
Cancel an eSIM
POST /orders/{id}/cancel
Works only before installation and only if the eSIM has not been topped up. A successful cancellation returns the wholesale price of the order to your balance. You refund your customer yourself. If the eSIM can no longer be cancelled, you get 409 cannot_cancel and your balance stays the same.
Top up an eSIM
Adds data to an eSIM you already issued. Not every package can be topped up, and the available top-ups depend on the eSIM. An eSIM that has been topped up can no longer be cancelled.
GET /orders/{id}/topups
Lists the top-ups for this eSIM and the ones already made.
{
"packages": [{
"code": "TOPUP_1",
"name": "Top-up 1GB 7Days",
"data_bytes": 1073741824,
"validity_days": 7,
"wholesale_price_usd": 1.38,
"retail_price_usd": 1.86
}],
"topups": []
}POST /orders/{id}/topups
| Field | Required | Description |
|---|---|---|
package_code | Yes | Code from the top-up list of this eSIM. |
external_id | Yes | Your unique reference. Repeating it returns the same top-up with "duplicate": true and charges nothing. |
curl -X POST https://panel.esimdesk.app/api/v1/orders/ORDER_ID/topups \
-H "Authorization: Bearer esk_live_..." \
-H "Content-Type: application/json" \
-d '{"package_code":"TOPUP_1","external_id":"topup-1001"}'{
"id": "e4962d06-e3d2-4d21-bd49-84c3170f4720",
"external_id": "topup-1001",
"order_id": "32dfb067-0ea4-4d41-827c-cd633a75f533",
"status": "completed",
"package_code": "TOPUP_1",
"package_name": "Top-up 1GB 7Days",
"wholesale_price_usd": 1.38,
"created_at": "2026-11-03T08:30:27.294Z"
}Top-up status is processing, completed or failed. A failed top-up returns 422 and the money goes back to your balance. If it stays processing, wait for the webhook or check the list again. We keep retrying it in the background.
Webhooks
Add a public HTTPS address in your partner panel under API. We send a POST request for each event and show the secret for signatures once.
| Event | Sent when |
|---|---|
order.ready | The eSIM has been issued. |
order.failed | The order failed and the money went back to your balance. |
order.cancelled | The eSIM was cancelled. |
esim.updated | The eSIM state changed, for example from new to onboard. |
esim.topped_up | A top-up was completed. |
esim.topup_failed | A top-up failed and the money went back to your balance. |
test | You sent a test event from the panel. |
{
"id": "1f0c7a52-6f5d-4a7e-9a55-1f5b8d2a9c10",
"type": "order.ready",
"created_at": "2026-11-03T08:30:27.294Z",
"data": {
"order_id": "32dfb067-0ea4-4d41-827c-cd633a75f533",
"external_id": "order-1001",
"state": "new"
}
}Reply with any 2xx status within 8 seconds. We retry failed deliveries with growing delays for about two days. The same event can arrive twice and events can arrive out of order, so ignore an id you have already handled and read GET /orders/{id} for the current status. Status changes of installed eSIMs can take up to about an hour to reach you.
Verify the signature
Sign timestamp.raw_body with HMAC-SHA256 and your secret, then compare the result with the header. Use the raw body, before any JSON parsing, and reject old timestamps.
import { createHmac, timingSafeEqual } from 'node:crypto'
function isValid(rawBody, headers, secret) {
const ts = headers['x-esimdesk-timestamp']
const got = Buffer.from(headers['x-esimdesk-signature'] || '')
const want = Buffer.from('sha256=' + createHmac('sha256', secret).update(ts + '.' + rawBody).digest('hex'))
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300
return fresh && got.length === want.length && timingSafeEqual(got, want)
}Errors
Errors use the HTTP status and this body:
{ "error": { "code": "insufficient_balance", "message": "Your balance does not cover this package" } }| Code | Status | What to do |
|---|---|---|
invalid_request | 400 | Fix the fields in the request. |
unauthorized | 401 | Check the API key. |
insufficient_balance | 402 | Add funds to your balance. |
account_suspended | 403 | Contact us about your account. |
not_found | 404 | Check the order ID. |
package_not_found | 404 | Get the current package list. |
not_available | 404 | This top-up is not offered for this eSIM. Get the current top-up list. |
cannot_cancel | 409 | The eSIM can no longer be cancelled. |
not_eligible | 409 | This eSIM cannot be topped up. |
rate_limited | 429 | Slow down and retry after a minute. |
daily_limit | 429 | Your daily order limit is reached. Try tomorrow or contact us. |
provider_unavailable | 502 | Retry with the same external_id. |