Authentication
Every request carries a bearer token. There is no other credential and no query-string form.
Authorization: Bearer sdk_live_ab12cd34.<secret>
- Authentication
- Keys look like sdk_live_ab12cd34.<secret>. The part before the dot identifies the key; everything after it is the secret.
- We store only a hash of the key. If you lose it, revoke it and create a new one.
- A live key moves real money. A test key uses the sandbox balance and never touches the provider.
- A key has no expiry of its own. It works while the account's subscription is active or in its grace period.
- Revoking a key is immediate and permanent.
Packages
The catalogue is the same one the public store sells. Prices are returned in USD minor units and IDR minor units; the IDR figure is what will be charged.
- What you pay per package depends on your tier. A Reseller key sees the public store price. An Enterprise key sees provider cost, with no markup.
- A DAILY package is unlimited data per day with a throttle after the daily allowance. Its duration_days is a placeholder of 1; pass validity_days when ordering.
- country_count and countries describe coverage. A SINGLE plan covers one country; REGIONAL and MULTI cover several.
GET/v1/packages
Every purchasable package, priced for your tier.
Parameters
country ISO-2 code · optionalOnly packages covering this country.coverage SINGLE,MULTI,REGIONAL · optionalComma-separated coverage kinds.type FIXED | DAILY · optionalFixed-data or unlimited-per-day plans.sort price_asc | price_desc | volume_desc · optionalDefault is by name.page integer · optional1-based. Clamped to the last page.limit integer 1-100 · optionalDefault 24.
Response
{
"data": [{
"package_code": string,
"slug": string,
"name": string,
"coverage": "SINGLE" | "MULTI" | "REGIONAL",
"plan_type": "FIXED" | "DAILY",
"duration_days": number,
"volume_bytes": number,
"speed": string | null,
"fup_policy": string | null,
"support_top_up": boolean,
"country_count": number,
"countries": [{ "code": string, "name": string }],
"price": { "usd_minor": number, "idr_minor": number, "currency": "USD" },
"wholesale": boolean
}],
"meta": { "page": number, "perPage": number, "total": number, "totalPages": number }
}
GET/v1/packages/{package_code}
One package by its SKU.
Parameters
No parameters.
Response
The same object as one element of `GET /v1/packages`.
Orders
An order debits your wallet and provisions an eSIM in one call. There is no payment step to poll: the response already carries the order's status and its eSIMs when fulfilment is fast enough.
- Send an Idempotency-Key header. A retry with the same key returns the same order instead of buying a second one. Keys are unique per account, so two developers can both use 'order-1'.
- quantity provisions that many eSIMs under one order, for one wallet debit. It is capped at 10.
- If the wallet does not cover the price, the API answers 402 before any order exists, so a failed purchase does not clutter your order list.
- Cancellation follows the same rules as the store: an eSIM that has never been activated, inside the cancellation window. The wallet is refunded.
POST/v1/orders
Buy a package. Debits the wallet and provisions an eSIM.
Parameters
Idempotency-Key header · optionalA retry with the same key returns the same order.
Request body
{
"package_code": "JP-5GB-30D",
"validity_days": 30,
"quantity": 1,
"idempotency_key": "9f2c-order-1"
}
Response
{
"data": {
"id": string,
"order_number": "SUD-...",
"status": "PAID" | "FULFILLMENT" | "ACTIVE",
"package_code": string,
"quantity": number,
"validity_days": number | null,
"amount_idr_minor": number,
"pricing_mode": "STORE" | "WHOLESALE",
"created_at": string,
"paid_at": string | null,
"esims": [{ "id": string, "iccid": string, "lpa": string, "status": string }]
}
}
GET/v1/orders
Orders created by your integration, newest first.
Parameters
status order status · optionalPENDING_PAYMENT, PAID, FULFILLMENT, ACTIVE, EXPIRED, CANCELLED, FAILED.page integer · optional1-based.limit integer 1-100 · optionalDefault 20.
Response
A list of order objects, with `meta`.
GET/v1/orders/{id}
One order, by id or order_number.
Parameters
No parameters.
Response
POST/v1/orders/{id}/cancel
Cancel an order whose eSIM was never activated.
Parameters
No parameters.
Response
The order, now `CANCELLED`.
eSIMs
An eSIM belongs to one package and one order. Reading it gives you the install details; the lifecycle actions change what the provider does with it.
- lpa is the activation string a phone reads from a QR code, and qr_code_url is a hosted image of the same thing. pin and puk are returned only when you ask for ?include=secrets.
- status is derived from the provider's own fields plus the expiry date, so it can say EXPIRED even while the provider still reports the eSIM as in use.
- A top-up needs a package the provider offers for this eSIM. The account page's picker and this endpoint use the same list, so a code from there will always work here. Send an Idempotency-Key and a retried call returns the top-up it already bought.
- suspend and unsuspend work only on an activated eSIM. revoke is permanent and refunds nothing; use cancel on the order instead when the eSIM was never used.
GET/v1/esims
eSIMs owned by the account.
Parameters
status provider status · optionalIN_USE, USED_UP, GOT_RESOURCE, CANCEL.page integer · optional1-based.limit integer 1-100 · optionalDefault 20.
Response
{
"data": [{
"id": string,
"iccid": string | null,
"package_code": string,
"status": "READY" | "ACTIVE" | "SUSPENDED" | "EXPIRED" | "USED_UP" | "CANCELLED",
"total_bytes": number,
"used_bytes": number,
"remaining_bytes": number,
"activated_at": string | null,
"expires_at": string | null,
"created_at": string
}],
"meta": { ... }
}
GET/v1/esims/{id}
One eSIM, with its install details.
Parameters
include secrets · optionalAdds `pin` and `puk`.
Response
The list shape plus `lpa`, `qr_code_url`, `smdp_status`, `esim_status`, `apn`.
GET/v1/esims/{id}/usage
Data used and remaining, as last measured.
Parameters
No parameters.
Response
{
"esim_id": string,
"iccid": string | null,
"total_bytes": number,
"used_bytes": number,
"remaining_bytes": number,
"percent_used": number,
"measured_at": string | null
}
POST/v1/esims/{id}/topups
Buy more data for an eSIM. Debits the wallet.
Parameters
No parameters.
Request body
{ "package_code": "JP-1GB-7D" }
Response
{
"id": string,
"esim_id": string,
"package_code": string,
"amount_idr_minor": number,
"status": string,
"created_at": string
}
POST/v1/esims/{id}/suspend
Temporarily suspend an activated eSIM.
Parameters
No parameters.
Response
{ "esim_id": string, "status": "SUSPEND" }
POST/v1/esims/{id}/unsuspend
Re-enable a suspended eSIM.
Parameters
No parameters.
Response
{ "esim_id": string, "status": "UNSUSPEND" }
POST/v1/esims/{id}/revoke
Permanently revoke an eSIM. No refund.
Parameters
No parameters.
Response
{ "esim_id": string, "status": "REVOKE" }
Wallet
Purchases settle from your Sentadu wallet in Rupiah. Top up from the account page, then spend through the API.
- The wallet is IDR only. There is no second currency and no conversion on this endpoint.
- API purchases carry no service fee. The fee you paid when topping up already covered the gateway transaction.
- Every credit and debit appears in the ledger, newest first, with the reference that caused it. A sandbox key gets an empty list, because sandbox money is not ledgered.
GET/v1/wallet
Wallet balance in IDR, or the sandbox balance for a TEST key.
Parameters
No parameters.
Response
{ "balance_idr_minor": number, "currency": "IDR", "sandbox": boolean }
GET/v1/wallet/transactions
Wallet ledger entries, newest first.
Parameters
limit integer 1-100 · optionalDefault 50. Always empty for a TEST key.
Response
{
"data": [{
"id": string,
"amount_idr_minor": number,
"balance_after_idr_minor": number,
"type": "TOPUP" | "PURCHASE" | "REFUND" | "REFERRAL",
"description": string | null,
"reference_type": string | null,
"reference_id": string | null,
"created_at": string
}]
}
Webhooks
We POST every event you subscribe to your endpoint, and retry a failing endpoint for a day before giving up.
order.paidorder.activeorder.failedorder.cancelled
Each request carries X-Sentadu-Signature: t=<unix>,v1=<hex>. It is an HMAC-SHA256 over the string '<t>.<raw body>', keyed with your endpoint's secret.
X-Sentadu-Signature: t=1750000000,v1=6f1c...
Verify the signature against the raw request body, not a re-serialised object, and reject a timestamp older than five minutes.
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=")),
);
const timestamp = Number(parts.t);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
);
}
A non-2xx response is retried after 1 minute, 5, 30, 2 hours and 12 hours. After five failures the delivery is marked FAILED and shown in your dashboard.
Endpoints must be https and reachable from the public internet. Private, loopback and link-local addresses are refused.
Sandbox
A Test key runs the whole API against a fake account. The catalogue and prices are real, so the numbers you see match production.
- Orders are TST- numbers, eSIMs have synthetic ICCIDs and an LPA that points at test.sentadu.com, and installs go nowhere. No provider call is made.
- The sandbox starts with Rp 500.000 and can be reset from the console. A purchase that exceeds it returns the same insufficient_wallet a live key would.
- A test key needs an active subscription, like a live one.
Rate limits
The limit is per key, counted per minute. A Reseller key gets 60 requests/minute; Enterprise starts at 300 and can buy more.
- Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. A refusal is 429 with Retry-After.
- Enterprise add-ons stack and expire with the subscription year. They are bought from the developer console.
- The limit is shared across your account's keys only through your subscription, not through a global pool: each key is counted separately.
| Tier | Requests/minute | Per year |
|---|
| Reseller | 60 | Rp 150.000 once |
| Enterprise | 300 | Rp 888.000 |
| +300 add-on | 300 | Rp 450.000 |
| +1200 add-on | 1200 | Rp 1.500.000 |
| +2800 add-on | 2800 | Rp 3.000.000 |
Maximum self-serve: 4600 requests/minute.
Errors
Every failure is one object with a stable code. Branch on code, never on message.
Shape
{
"error": {
"code": "insufficient_wallet",
"message": "Wallet balance Rp 40.000 does not cover Rp 62.000.",
"details": [{ "path": "package_code", "message": "..." }]
}
}
| Codes | HTTP | |
|---|
| invalid_api_key | 401 | The Authorization bearer token is missing, malformed or revoked. |
| subscription_inactive | 403 | The account's API subscription has expired. Renew it to resume access. |
| rate_limited | 429 | Too many requests in the current window. Respect Retry-After. |
| invalid_request | 400 | The request body or query string failed validation. |
| not_found | 404 | The resource does not exist, or does not belong to this account. |
| insufficient_wallet | 402 | The Sentadu wallet balance does not cover this purchase. |
| package_unavailable | 409 | The package exists but is not available for purchase. |
| esim_not_toplup_able | 409 | This eSIM cannot be topped up (expired or unsupported). |
| esim_not_activatable | 409 | This eSIM is in a state that refuses the requested action. |
| idempotency_conflict | 409 | The same Idempotency-Key was used with a different request body. |
| method_not_allowed | 405 | This endpoint does not accept that HTTP method. |
| internal_error | 500 | Something failed on our side. Retry with backoff. |
Ready to build?
Create an API key, pick a plan, and send your first request in minutes.