SentaduSentadu

eSIMs for people who wander. Buy before you fly, be online when you land.

PT. Digital One Global

Tomorowland III Tegal, Central Java ID 52462

VisaMastercardJCBAmerican ExpressDiners Club
PayPalUnionPayQRISE-walletBank VARetail

Product

  • Local
  • Bundles
  • Regional
  • Global
  • Destinations
  • Unlimited
  • Umrah and Hajj

Help

  • How it Works
  • eSIM Internet Routing
  • FAQ
  • Supported Devices
  • Share your story
  • Support

For Partners

  • Dashboard
  • Become a Partner
  • Business Solutions
  • Resellers
  • Docs
  • Agent Skills

Company

  • About
  • Investor (Private)Soon
  • Careers
  • Terms & Conditions
  • Privacy Policy
  • Refund Policy
VisaMastercardJCBAmerican ExpressDiners Club
PayPalUnionPayQRISE-walletBank VARetail

© 2026 PT. Digital One Global. All rights reserved.

Ask Many
Sentadu
Sentadu
LocalBundlesRegionalGlobalDestinationsUnlimited
API documentation

Skills for AI agents

One folder of instructions an AI coding agent reads before it writes code. Sentadu ships one so an agent building on the /v1 API already knows the endpoints, the auth scheme and the Rupiah rules, instead of guessing from a half-read docs page.

Contents

  • SKILL.md
  • README.md
  • LICENSE.md
  • references/endpoints.md
  • references/errors.md
  • references/sandbox.md
  • references/webhooks.md
  • scripts/smoke.sh
  • scripts/verify-webhook.sh

Download all of it

Nine files, one folder. Copy it into .claude/skills/ or .agents/skills/ and the agent picks it up on its next run.

Download .zipDownload .tar.gz

9 files · 36 KB

zip opens directly on Windows. tar.gz is already on macOS and Linux.

Installing it

How a skill gets discovered depends on the agent, not on Sentadu.

AgentWhere the folder goes
Claude Code.claude/skills/sentadu-api/
Codex.agents/skills/sentadu-api/

Copy the folder in, commit it, and the agent picks it up on its next run. A skill is just files: there is no service to register with and no key to activate.

What an agent may safely assume

Base URL
https://api.sentadu.com/v1
Auth header
Authorization: Bearer sdk_live_<prefix>.<secret>
Error body
application/json { error: { code, message, details } }
Charge currency
IDR
Rate limit (Reseller)
60 rpm
Rate limit (Enterprise)
300 rpm

The live values are on the API docs page and in lib/developers/endpoints.ts. This table is for orientation, not a replacement.

What is in the bundle

Every file is rendered below, exactly as it appears in the download. Click a filename to open it on its own.

#SKILL.md8058 B

---
name: sentadu-api
description: Use when building or debugging an integration against the Sentadu /v1 eSIM API. Covers ordering eSIMs programmatically, reading usage, topping up, suspending or revoking, checking the IDR wallet, receiving signed webhooks, and testing against the sandbox. Trigger this whenever a task mentions Sentadu, sdk_live_ or sdk_test_ keys, api.sentadu.com, or buying travel eSIMs from code.
---

# Sentadu API

Buy and manage travel eSIMs programmatically. Fifteen resource paths across sixteen methods,
behind one bearer token, settled in Rupiah from a prepaid wallet.

Base URL: `https://api.sentadu.com/v1`

## Five rules that are not negotiable

These are product invariants, not preferences. Do not reason around them.

1. **Money is IDR.** Every price and every charge is Indonesian Rupiah. `price.idr_minor` is
   whole rupiah multiplied by 100. Never convert, never send another currency, never round a
   charge yourself. The server computes the amount.
2. **The wallet is a ledger, not a number.** You cannot set a balance. Credits and debits are
   append-only entries. A purchase is paid from the wallet at order time; if the balance is
   short the order fails with `insufficient_wallet` (HTTP 402), which is a money problem and
   not a malformed request.
3. **The server owns the price.** Send a `package_code`. Do not send an amount, a total or a
   discount. Sending `{"amount": ...}` is ignored at best and a bug in your integration at
   worst, because the price you computed will drift from the catalogue.
4. **Webhooks are verified over the RAW body.** Read the bytes as they arrived, recompute
   `HMAC-SHA256(secret, "<t>.<rawBody>")`, compare in constant time, and reject a timestamp
   older than 300 seconds. Re-serialising the parsed object changes the bytes and the check
   fails. See `references/webhooks.md`.
5. **Sandbox keys never move real money.** A `sdk_test_` key writes only to sandbox tables and
   returns fake orders, fake eSIMs and a fake balance. It is safe to run destructive tests
   against. A test key still needs an active subscription.

## First call

```bash
export SENTADU_API_KEY='sdk_live_xxxxxxxx.your-secret-here'
export SENTADU_BASE='https://api.sentadu.com/v1'
curl -s "$SENTADU_BASE/me" -H "Authorization: Bearer $SENTADU_API_KEY"
```

A healthy answer is `200` with `{ "data": { "tier": ..., "status": ... } }`. Run
`scripts/smoke.sh` to do this plus a package listing in one go, and to fail loudly if the
envelope is not what the client expects.

## The envelope

Every successful response is `{ "data": <payload> }`, plus `"meta"` on a paginated list:

```json
{
  "data": [ ... ],
  "meta": { "page": 1, "perPage": 24, "total": 312, "totalPages": 13 }
}
```

Every failure is `{ "error": { "code", "message", "details"? } }` with a stable `code`. Branch
on `code`, never on `message`. The full set is in `references/errors.md`.

## The shape of a task

Read the reference for the area you are working in, and only that one.

| Task | Read |
| --- | --- |
| Pick a package and place an order | `references/endpoints.md` (Packages, Orders) |
| Read usage, suspend, resume, revoke, top up | `references/endpoints.md` (eSIMs) |
| Show or reconcile a balance | `references/endpoints.md` (Wallet) |
| Handle a failure or build a retry branch | `references/errors.md` |
| Receive events instead of polling | `references/webhooks.md` |
| Test without spending money | `references/sandbox.md` |

## Ordering an eSIM

The minimum viable flow. Every step is one call.

1. `GET /packages?country=JP&limit=50` and pick a `package_code`. Prices carry
   `usd_minor`, `idr_minor` and `currency`.
2. `POST /orders` with `{ "package_code": "...", "quantity": 1 }` and an
   `Idempotency-Key` header. The key is what makes a retry safe: a key that already produced
   an order returns THAT ORDER, whatever the new body says. It does not compare bodies, so a
   key is a promise about the request, not a cache key you can reuse for a different one.
3. The order comes back `PAID`, `FULFILLMENT` or `ACTIVE`. Poll `GET /orders/{id}` until it is
   `ACTIVE`. Do not rely on `order.active` alone: a fast-fulfilling order may emit it, but an
   order fulfilled later emits only `order.paid` and never `order.active`.
4. `GET /esims/{id}?include=secrets` once the eSIM is `READY`, and hand `lpa` or
   `qr_code_url` to the customer. `include=secrets` adds `pin` and `puk`; request it only when
   you are about to show them, and never log the response.

A `DAILY` (unlimited) package additionally requires `validity_days`. Omitting it is
`invalid_request`, not a silent default.

## Reading state without wasting calls

- `GET /orders?status=ACTIVE` and `GET /esims?status=ACTIVE` filter server-side. Filtering a
  full list in your client is a slower way to get the same answer.
- `meta.total` is the count you want for a dashboard. Do not sum a page.
- Usage is `GET /esims/{id}/usage`, returning `used_bytes`, `remaining_bytes` and
  `percent_used` as of `measured_at`. It is not real time; the provider measures on an
  interval, so do not poll it more than a few times an hour.

## Rate limits

Limits are per account, not per key, and are enforced in one-minute windows.

| Tier | Requests per minute |
| --- | --- |
| Reseller | 60 |
| Enterprise | 300 |
| Enterprise with add-ons | up to 4600 |

Read the live values from the response headers rather than assuming: `X-RateLimit-Limit`,
`X-RateLimit-Remaining` (present only when a shared counter is available), `X-RateLimit-Reset`
(unix seconds). A `429` carries `Retry-After` in seconds. Back off by that value; do not retry
immediately, and do not retry a `429` more than a few times before surfacing it.

`X-Sentadu-Subscription: grace` on a success means the subscription lapsed and is in its
seven-day grace window. Treat it as a warning to renew, not as an error.

## Pagination

`page` is 1-based and clamped. `limit` is bounded per endpoint (1 to 100 for packages and
transactions, default 20 for orders and eSIMs). Loop until `meta.page >= meta.totalPages`, and
stop if `data` is empty even when `totalPages` says otherwise, which is what a concurrent
write looks like.

## Common mistakes

| Symptom | Cause |
| --- | --- |
| `401 invalid_api_key` | Key mangled by a shell or a `.env` line break. The secret is base64url, so it can contain `-` and `_`; quote it. |
| `403 subscription_inactive` | The account has no live subscription, or it expired past the grace window. |
| `402 insufficient_wallet` | Not enough balance. Top up the wallet; retrying does not help. |
| `409 idempotency_conflict` | The key is already attached to an order that has NOT settled, or the header and body keys disagree. A key on a settled order returns that order instead. |
| `409 package_unavailable` | The package exists but is not currently purchasable. Re-list rather than retrying. |
| Webhook signature never verifies | The body was parsed and re-serialised before hashing. Hash the raw bytes. |

## Scripts

- `scripts/smoke.sh` checks that a key works and that the envelope is intact. Run it first
  when an integration misbehaves, so you know whether the problem is your code or the account.
- `scripts/verify-webhook.sh` recomputes a signature from a captured body and header, which is
  the fastest way to prove a receiving endpoint's verification is correct.

`smoke.sh` reads `SENTADU_API_KEY`, `SENTADU_SANDBOX_KEY` (with `--sandbox`) and `SENTADU_BASE`,
and defaults the base to `https://api.sentadu.com/v1` when it is unset.

`verify-webhook.sh` reads no environment at all: it takes `<secret> <signature-header>
<body-file>` as three positional arguments. Neither script writes a key to disk.

## Known limits

- `quantity` above 1 is refused rather than partially priced. Place one order per eSIM.
- A revoked eSIM cannot be resumed or topped up. That is terminal.
- Top-ups are only possible while the eSIM has remaining validity.
- There is no endpoint to delete an account or a key from the API. Use the developer console.

#README.md3073 B

# sentadu-api

An agent skill for the [Sentadu](https://sentadu.com) eSIM API.

Install it and your coding agent already knows the endpoints, the auth scheme, the error set
and the Rupiah rules before it writes the first line of your integration. Without it, an agent
reconstructs the API from a docs page it half read, and the mistakes it makes are the
expensive kind: a currency conversion that should not happen, a retry that places a second
order, a webhook signature verified against a re-serialised body.

## Install

Copy this folder into wherever your agent looks for skills:

| Agent | Path |
| --- | --- |
| Claude Code | `.claude/skills/sentadu-api/` |
| Codex | `.agents/skills/sentadu-api/` |

```sh
cp -r sentadu-api .claude/skills/
```

Nothing to register and no key to activate. A skill is files.

## What is in it

```
sentadu-api/
├── SKILL.md                 the workflow, and five rules that are not negotiable
├── references/
│   ├── endpoints.md         all 16 methods, params and return shapes
│   ├── errors.md            the error codes, what each one means for retries
│   ├── webhooks.md          the 4 events, signature verification, retry schedule
│   └── sandbox.md           what is fake, what is real, how to reset
└── scripts/
    ├── smoke.sh             check a key works and the envelope is intact
    └── verify-webhook.sh    recompute a signature from a captured request
```

`SKILL.md` is deliberately short and mostly points at the references. An agent reads the whole
of it when the skill triggers, and only opens the reference for the part of the API it is
actually working on.

## The scripts

Both read from the environment and never write a key to disk.

```sh
export SENTADU_API_KEY='sdk_live_xxxxx.your-secret'

./scripts/smoke.sh                  # live key
./scripts/smoke.sh --sandbox        # uses SENTADU_SANDBOX_KEY

./scripts/verify-webhook.sh whsec_abc 't=1750000000,v1=6f1c...' ./body.json
```

`smoke.sh` fails with a specific reason, so it tells you whether the problem is the key, the
account, or the response shape. `verify-webhook.sh` hashes the file's bytes exactly as they
are, which is the whole point: the usual cause of a signature that never verifies is a body
that was parsed and re-serialised on the way to the hash.

## Get a key

Create one in the developer console at [sentadu.com/dashboard](https://sentadu.com/dashboard).
A key needs an active subscription: Reseller is a one-off Rp 150.000 and never expires,
Enterprise is Rp 888.000 a year at provider cost. A `sdk_test_` key exercises the same flow
against the sandbox without spending anything.

## Keeping it current

The facts here are copied from the API's own source, and the tests that ship with the site fail
if a rate limit or a price in this skill disagrees with the running API. If you fork this, keep
`references/endpoints.md` in step with `GET /me` and the console, and prefer reading a live
value over trusting a number written here.

## Licence

MIT. See `LICENSE.md`.

#LICENSE.md1245 B

MIT License

Copyright (c) 2026 PT. Digital One Global

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

The MIT licence above covers this skill folder. The Sentadu API it describes is
a commercial service, governed by the terms at https://sentadu.com/legal/terms.

#references/endpoints.md6923 B

# Endpoints

All paths below are relative to `https://api.sentadu.com/v1`. Every response is wrapped in
`{ "data": ... }`; a paginated list also carries `"meta"`.

| # | Method | Path | Purpose |
| --- | --- | --- | --- |
| 1 | GET | `/me` | The key's account: tier, status, limit, mode |
| 2 | GET | `/packages` | Browse the catalogue |
| 3 | GET | `/packages/{package_code}` | One package |
| 4 | POST | `/orders` | Buy |
| 5 | GET | `/orders` | List orders |
| 6 | GET | `/orders/{id}` | One order |
| 7 | POST | `/orders/{id}/cancel` | Cancel before fulfilment |
| 8 | GET | `/esims` | List eSIMs |
| 9 | GET | `/esims/{id}` | One eSIM, optionally with secrets |
| 10 | GET | `/esims/{id}/usage` | Data consumed |
| 11 | POST | `/esims/{id}/topups` | Buy more data on an eSIM |
| 12 | POST | `/esims/{id}/suspend` | Pause it |
| 13 | POST | `/esims/{id}/unsuspend` | Resume it |
| 14 | POST | `/esims/{id}/revoke` | Terminal: cannot be undone |
| 15 | GET | `/wallet` | Balance |
| 16 | GET | `/wallet/transactions` | Ledger entries |

## Account

### `GET /me`

The cheapest liveness check, and the call that tells you what the key is allowed to do.

```json
{
  "data": {
    "tier": "RESELLER",
    "status": "ACTIVE",
    "expires_at": null,
    "rate_limit_rpm": 60,
    "key_mode": "LIVE",
    "sandbox_available": true,
    "pricing_mode": "STORE"
  }
}
```

- `tier`: `RESELLER` or `ENTERPRISE`
- `status`: `ACTIVE`, `GRACE` or `EXPIRED`
- `expires_at`: ISO timestamp, or `null` for a Reseller key, which never expires
- `key_mode`: `LIVE` or `TEST`
- `pricing_mode`: `STORE` (retail) or `WHOLESALE` (provider cost)
- `sandbox_available`: true when the account holds any live subscription

## Packages

### `GET /packages`

| Query | Values | Notes |
| --- | --- | --- |
| `country` | ISO-2, e.g. `JP` | Filters to packages usable there |
| `coverage` | `SINGLE`, `MULTI`, `REGIONAL` | Comma-separated |
| `type` | `FIXED`, `DAILY` | `DAILY` is the unlimited family |
| `sort` | `price_asc`, `price_desc`, `volume_desc` | Default is by name |
| `page` | 1-based | Clamped |
| `limit` | 1 to 100 | Default 24 |

A package carries `package_code`, `slug`, `name`, `coverage`, `plan_type`, `duration_days`,
`volume_bytes`, `speed`, `fup_policy`, `support_top_up`, `country_count`, `countries` and
`price`. Note `duration_days` and `volume_bytes`, which are the real field names:

```json
"price": { "usd_minor": 780, "idr_minor": 12800000, "currency": "USD" }
```

`idr_minor` is whole rupiah multiplied by 100. Display `idr_minor / 100`.

### `GET /packages/{package_code}`

One object, same shape. A code that does not exist is `not_found` (404).

## Orders

### `POST /orders`

```json
{ "package_code": "JP-5GB-30D", "quantity": 1 }
```

Send `Idempotency-Key` as a header (or `idempotency_key` in the body).

A key that already produced a SETTLED order returns that order, and the body is NOT compared,
so reusing a key for a different purchase silently returns the first one. Treat a key as bound
to one logical request and never reuse it.

`idempotency_conflict` (409) happens in two cases only: the key is attached to an order that
has not settled yet, or the header key and the body key disagree.

`quantity` above 1 is refused with `invalid_request`, in LIVE mode. In TEST mode the quantity is
ignored and one sandbox eSIM is created, so do not use the sandbox to validate that refusal.

Returns the order with its `esims[]`:

```json
{
  "data": {
    "id": "…",
    "order_number": "SUD-…",
    "status": "ACTIVE",
    "amount_idr_minor": 12800000,
    "created_at": "…",
    "esims": [ { "id": "…", "iccid": "…", "status": "READY" } ]
  }
}
```

### `GET /orders`

| Query | Values |
| --- | --- |
| `status` | `PENDING_PAYMENT`, `PAID`, `FULFILLMENT`, `ACTIVE`, `EXPIRED`, `CANCELLED`, `FAILED` |
| `page` | 1-based |
| `limit` | 1 to 100, default 20 |

### `GET /orders/{id}`

One order, same shape. Another account's order is `not_found`, never `403`.

### `POST /orders/{id}/cancel`

Cancels before fulfilment and refunds the wallet. Cancelling an already cancelled order is
`idempotency_conflict` (409). An order that has already fulfilled cannot be cancelled.

## eSIMs

### `GET /esims`

| Query | Values |
| --- | --- |
| `status` | `READY`, `ACTIVE`, `SUSPENDED`, `EXPIRED`, `USED_UP`, `CANCELLED` |
| `page`, `limit` | As above, default 20 |

### `GET /esims/{id}`

Add `?include=secrets` to receive `pin` and `puk`. Request it only when you are about to show
them to the customer; do not log a response that contains them.

```json
{
  "data": {
    "id": "…",
    "iccid": "…",
    "status": "ACTIVE",
    "lpa": "LPA:1$…",
    "qr_code_url": "https://…",
    "smdp_status": "…",
    "esim_status": "…",
    "apn": "…"
  }
}
```

### `GET /esims/{id}/usage`

```json
{
  "data": {
    "esim_id": "…",
    "iccid": "…",
    "total_bytes": 5368709120,
    "used_bytes": 1073741824,
    "remaining_bytes": 4294967296,
    "percent_used": 20,
    "measured_at": "…"
  }
}
```

Not real time. The provider measures on an interval, so poll at most a few times an hour.

### `POST /esims/{id}/topups`

```json
{ "package_code": "JP-1GB-7D" }
```

Only while the eSIM still has validity. An expired or revoked eSIM is
`esim_not_toplup_able` (409).

### `POST /esims/{id}/suspend`, `/unsuspend`, `/revoke`

Each returns `{ "esim_id": "…", "requested": "suspend" | "unsuspend" | "revoke",
"current_status": "<the eSIM's status right now>" }`.

`current_status` is what the provider last reported, NOT the result of your request: the
provider's status webhook is the single writer, so the new status arrives there a moment later.

`revoke` is terminal in LIVE mode: a revoked eSIM cannot be resumed or topped up, and
attempting either is `esim_not_activatable` (409).

**The sandbox does not enforce this.** `revoke` then `unsuspend` on a sandbox eSIM answers 200
with status `READY`, because the sandbox state machine only refuses `CANCELLED`. Do not use the
sandbox to prove that your code handles the live refusal.

## Wallet

### `GET /wallet`

```json
{ "data": { "balance_idr_minor": 97169500, "currency": "IDR", "sandbox": false } }
```

`balance_idr_minor` is whole rupiah multiplied by 100. A test key returns the sandbox balance
with `"sandbox": true`.

### `GET /wallet/transactions`

| Query | Values |
| --- | --- |
| `limit` | 1 to 100, default 50 |

Each entry has a `type` of `TOPUP`, `REFUND`, `REVERSAL`, `ADJUSTMENT`, `PURCHASE` or
`REFERRAL`, plus `amount_idr_minor`
and `created_at`. Always empty for a test key, because sandbox money is not ledgered.

## What no endpoint does

- No endpoint sets a balance. Top up from the console.
- No endpoint returns a price you should send back. Prices are read, never written.
- No endpoint deletes an account or a key. Use `/account/developers`.
- No endpoint converts currency. Everything is already IDR.

#references/errors.md4190 B

# Errors

Every failure is one object with a stable `code`. Branch on `code`. The `message` is for a
human reading a log and may be reworded at any time.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "validation failed",
    "details": [ { "path": "package_code", "message": "Required" } ]
  }
}
```

`details` is present only when per-field information exists, which in practice means
`invalid_request`.

## The closed set

| Code | HTTP | When | What to do |
| --- | --- | --- | --- |
| `invalid_api_key` | 401 | Missing, malformed or revoked bearer | Fix the credential. Do not retry. |
| `subscription_inactive` | 403 | No live subscription, or expired past grace | Renew in the console. Do not retry. |
| `rate_limited` | 429 | Over the per-minute limit | Sleep `Retry-After` seconds, then retry. |
| `invalid_request` | 400 | Body or query failed validation | Read `details`, fix the request. Do not retry. |
| `not_found` | 404 | Does not exist, or is another account's | Do not retry. |
| `insufficient_wallet` | 402 | Balance does not cover the purchase | Top up. Retrying cannot help. |
| `package_unavailable` | 409 | Exists but is not purchasable | Re-list packages. Do not retry the same code. |
| `esim_not_toplup_able` | 409 | Expired or unsupported for top-up | Terminal for that eSIM. |
| `esim_not_activatable` | 409 | The eSIM's state refuses the action | Terminal. Check `status` first. |
| `idempotency_conflict` | 409 | Key attached to an UNSETTLED order, or header/body keys disagree | Wait, or use a fresh key. A settled key returns its order instead. |
| `internal_error` | 500 | Server-side fault | Retry with backoff, a couple of times. |

**A wrong HTTP verb does not reach this set.** Next.js answers a method the route does not
export with its own plain-text `405` before any handler runs, so there is no `{error:{code}}`
envelope for that case. Treat a non-JSON body with status 405 (or a 404 for a path that does not
exist) as "wrong URL or verb", not as an API error.

`404` is used where a naive API would use `403`. A resource belonging to another account looks
exactly like one that does not exist, on purpose, because confirming existence is itself a
leak. Do not read a `404` as "maybe it exists but is not mine".

## Why `402` and not `400`

An empty wallet is not a malformed request. The request was well formed, the account is
entitled, and the only thing missing is money. A `400` would send a retry loop into a spin and
a `402` sends it to a top-up. Keep them apart in your branching.

## Retry policy

| Status | Retry? |
| --- | --- |
| 400, 401, 403, 404, 405 | No. The request or the account is wrong. |
| 402 | No. Money has to arrive first. |
| 409 | No, except `idempotency_conflict`, where a fresh key makes it a new request. |
| 429 | Yes, after `Retry-After` seconds. |
| 500 | Yes, with exponential backoff, a couple of attempts. |

A safe retry is one that carries the same `Idempotency-Key` as the attempt it is repeating. If
your retry generates a new key, you are not retrying, you are placing a second order.

## Wire failures

A dropped connection mid-`POST /orders` is the case idempotency exists for. The order may have
been created. Retry with the SAME `Idempotency-Key`: if the first attempt settled, you get that
order back and the body is not compared. Never resolve this by listing orders and guessing which
one is yours, and never send a different request under the same key, because it will be answered
with the earlier order rather than refused.

## Validation details

`invalid_request` carries `details`, an array of `{ path, message }`. `path` is the field name
as it appears in the request, so `package_code` or `validity_days`.

A `DAILY` package without `validity_days` is the most common one:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "validity_days is required for a DAILY (unlimited) package",
    "details": [ { "path": "validity_days", "message": "Required" } ]
  }
}
```

## Reading a failure in a log

Every response, success or failure, carries `X-Request-Id`. Quote it when reporting a problem;
it is how a specific call is found in the server's logs.

#references/sandbox.md3712 B

# Sandbox

A `sdk_test_` key writes only to sandbox tables. Orders, eSIMs, top-ups and every state change
are synthetic, and the balance is not real money.

Two things are real: the catalogue and its prices. `GET /packages` reads the live catalogue in
both modes, so a sandbox order is priced through the same ladder a live one would be.

## Preconditions

A test key still needs an active subscription. There is no free tier, and the sandbox is not a
way around that. Without a live subscription every call is `subscription_inactive` (403), the
same as with a live key.

## What is fake

| Thing | Value |
| --- | --- |
| Starting balance | Rp 500.000 (`50000000` in `idr_minor`) |
| Order number | `TST-` prefix, never collides with a real `SUD-` |
| ICCID | `899900` plus twelve hex characters |
| LPA | `LPA:1$test.sentadu.com$<matching id>` |
| `pin` / `puk` | `0000` and `00000000` |
| Order status | Immediately `ACTIVE` |
| Usage | 5 GiB total, 512 MiB used |

The LPA points at `test.sentadu.com`, which serves no SM-DP+ server. A sandbox eSIM installs
nowhere. That is the point: it exercises the whole flow without a device.

## What is real

- The catalogue, and every price on it.
- The rate limiter, so a load test in the sandbox hits the same wall.
- Validation and the error set. A malformed request fails the same way here.
- The rate limiter, so a load test hits the same wall.

**What is NOT mirrored, and each one will bite an integration that relies on it:**

- **No webhooks.** A `sdk_test_` order queues no `order.*` events at all. Register nothing and
  expect nothing; use a LIVE key against a throwaway endpoint to test a receiver.
- **`quantity` is ignored**, not refused. `quantity: 5` creates one eSIM and answers 200.
- **`?status` is ignored** on `GET /orders` and `GET /esims`; both return every sandbox row.
- **Idempotency is not applied.** The header is accepted and has no effect, so a retried
  `POST /orders` creates a second sandbox order.

## Differences from live

- `GET /wallet` returns the sandbox balance with `"sandbox": true`.
- `GET /wallet/transactions` is always empty, because sandbox money is never ledgered.
- A successful sandbox top-up reports `amount_idr_minor: 0` and status `APPLIED`.
- Order responses report `pricing_mode: "SANDBOX"`.

## Resetting the balance

There is no API for this. The reset lives in `POST /account/developers` (the console), and is
limited to three resets per 24 hours. Spend it deliberately: a reset discards the whole
sandbox balance and restores Rp 500.000.

## Terminal states

The sandbox enforces the same lifecycle as production, so a test can prove your error handling:

- Cancelling an already cancelled order is `idempotency_conflict` (409).
- Suspend, unsuspend or revoke on a cancelled order is `esim_not_activatable` (409).
- A top-up on a cancelled or revoked eSIM is `esim_not_toplup_able` (409).
- A sandbox balance that runs out is `insufficient_wallet` (402), with a message pointing at
  the console reset.

## A workflow that proves an integration

1. `GET /me` and confirm `key_mode: "TEST"` and `status: "ACTIVE"`.
2. `GET /packages?limit=5` and take a `package_code`.
3. `POST /orders` with an `Idempotency-Key`, and assert the same key returns the same order.
4. `GET /esims/{id}?include=secrets` and assert `pin` is `0000`.
5. `POST /esims/{id}/topups` and assert `amount_idr_minor` is `0`.
6. `POST /esims/{id}/revoke`, then `POST /esims/{id}/unsuspend`: the sandbox answers **200
`READY`**, because its state machine only refuses `CANCELLED`. LIVE answers `409`
`esim_not_activatable`. Assert the sandbox behaviour you actually get, and do not use this
step as proof that the live refusal is handled.

#references/webhooks.md4698 B

# Webhooks

Instead of polling `GET /orders/{id}`, register an endpoint and receive an event.

Configure the URL and read the signing secret in `/dashboard/webhooks`. A secret looks like
`whsec_<base64url>` and is shown once.

## Events

Four, and only four:

| Event | Fires when |
| --- | --- |
| `order.paid` | Money settled and the order is being fulfilled |
| `order.active` | The eSIM is provisioned and usable |
| `order.failed` | Fulfilment failed permanently |
| `order.cancelled` | The order was cancelled and refunded |

The eSIM is usable at `order.active`. Waiting for it is what makes polling unnecessary.

## Request

```
POST /your/endpoint
Content-Type: application/json
X-Sentadu-Signature: t=1750000000,v1=6f1c...
X-Sentadu-Event: order.active
X-Sentadu-Delivery: <delivery id>
```

```json
{
  "id": "<delivery id>",
  "event": "order.active",
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": { }
}
```

`data` is NOT the `GET /orders/{id}` shape. It is a small event-specific object, and the keys
differ per event:

| Event | `data` keys |
| --- | --- |
| `order.paid`, `order.active` | `order_id`, `order_number`, `status`, `package_code`, `amount_idr_minor`, `esims` |
| `order.failed` | `order_id`, `order_number`, `status`, `reason` |
| `order.cancelled` | `order_id`, `order_number`, `status`, `refund_idr_minor` |

Note `order_id`, not `id`. Read the order with `GET /orders/{order_id}` when you need the full
shape.

Respond with a 2xx. Anything else counts as a failure.

## Verifying the signature

The signed string is `<t>.<raw body>`, hashed with `HMAC-SHA256` using the endpoint's secret,
hex encoded. `t` is unix seconds and is part of the signed string, which is what makes a
replayed body detectable.

```js
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),
  );
}
```

Three things that break it, in order of how often they happen:

1. **Hashing a re-serialised body.** `JSON.parse` then `JSON.stringify` reorders keys and
   changes whitespace, so the bytes no longer match. Read the raw body: `await req.text()` in a
   Node handler, or the raw buffer in your framework, and hash that.
2. **Comparing with `===`.** Use a constant-time compare. A timing leak on a signature check is
   a real weakness, not a theoretical one.
3. **Skipping the timestamp check.** Without it, a captured request replays forever.

`scripts/verify-webhook.sh` does this recomputation from a captured body and header, which
turns "the signature never verifies" into a concrete answer in one command.

## Retries

A failed delivery is retried until five attempts have been made in total, then it is parked.

| After attempt | Waits before the next |
| --- | --- |
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | nothing. Parked as `FAILED`. |

The longest wait, 12 hours, is in the ladder but a delivery that reaches it has already
exhausted its five attempts, so in practice the schedule above is what a receiver sees. The
endpoint is never disabled, so a later event still tries.

Retries mean **at-least-once delivery**. The same event can arrive twice, and out of order. Make
your handler idempotent on `id`, and do not assume `order.paid` always arrives before
`order.active`. If order matters, read the order with `GET /orders/{id}` when an event arrives
rather than trusting the arrival order.

## Endpoint requirements

- HTTPS only. Plain HTTP is refused.
- Publicly resolvable. Loopback, link-local and private addresses are refused, and the address
  is re-checked at delivery time so a DNS rebind cannot point it somewhere private.
- At most ten endpoints per account.
- Respond within ten seconds. Do the slow work asynchronously; a timeout counts as a failure.

## Testing a receiver

**The sandbox does not emit webhooks.** A `sdk_test_` order queues no `order.*` events, so
`/dashboard/webhooks` shows nothing for it and a receiver cannot be proven against a test key.

Test a receiver by pointing it at a throwaway endpoint and placing one small LIVE order, or by
replaying a captured delivery from `/dashboard/webhooks` against your handler. The signature
format and the retry ladder are the production ones, so a handler that verifies a replayed
delivery correctly will verify a live one.

#scripts/smoke.sh2260 B

#!/usr/bin/env sh
# Prove an API key works and that the response envelope is what a client expects.
#
# Usage:
#   export SENTADU_API_KEY='sdk_live_xxxxx.your-secret'
#   ./smoke.sh            # live
#   ./smoke.sh --sandbox   # use a sdk_test_ key from SENTADU_SANDBOX_KEY
#
# Exits non-zero with a specific message on the first problem, so a failure tells
# you which half of the integration is wrong: the account, the key, or the shape.

set -eu

BASE="${SENTADU_BASE:-https://api.sentadu.com/v1}"
KEY="${SENTADU_API_KEY:-}"

if [ "${1:-}" = "--sandbox" ]; then
  KEY="${SENTADU_SANDBOX_KEY:-}"
  [ -n "$KEY" ] || { echo "FAIL: SENTADU_SANDBOX_KEY is not set" >&2; exit 2; }
fi

[ -n "$KEY" ] || {
  echo "FAIL: SENTADU_API_KEY is not set" >&2
  echo "  export SENTADU_API_KEY='sdk_live_xxxxx.your-secret'" >&2
  exit 2
}

# The secret is base64url and may contain - and _, which an unquoted shell word
# will not always survive. Quoting here and everywhere below is deliberate.
request() {
  curl -sS --max-time 20 \
    -H "Authorization: Bearer $KEY" \
    -H "Accept: application/json" \
    "$BASE$1"
}

echo "base:  $BASE"
echo "key:   ${KEY%%[.]*}.[redacted]"

echo
echo "== GET /me =="
ME="$(request /me)" || { echo "FAIL: request failed" >&2; exit 1; }

case "$ME" in
  *'"code"'*'invalid_api_key'*) echo "FAIL: key rejected. Check for a newline or a lost character." >&2; exit 1 ;;
  *'"code"'*'subscription_inactive'*) echo "FAIL: key is valid but the account has no live subscription." >&2; exit 1 ;;
  *'"data"'*) : ;;
  *) echo "FAIL: response is not the {data} envelope: $ME" >&2; exit 1 ;;
esac

echo "$ME" | grep -o '"tier":"[A-Z]*"' | head -1
echo "$ME" | grep -o '"status":"[A-Z]*"' | head -1
echo "$ME" | grep -o '"key_mode":"[A-Z]*"' | head -1

echo
echo "== GET /packages?limit=3 =="
PKG="$(request '/packages?limit=3')" || { echo "FAIL: request failed" >&2; exit 1; }

case "$PKG" in
  *'"data"'*) : ;;
  *) echo "FAIL: package list is not the {data} envelope: $PKG" >&2; exit 1 ;;
esac

COUNT="$(echo "$PKG" | grep -o '"package_code"' | wc -l | tr -d ' ')"
[ "$COUNT" -gt 0 ] || { echo "FAIL: package list came back empty" >&2; exit 1; }
echo "packages in this page: $COUNT"

echo
echo "OK: the key works and the envelope is intact."

#scripts/verify-webhook.sh2343 B

#!/usr/bin/env sh
# Recompute a Sentadu webhook signature from a captured request.
#
# Usage:
#   ./verify-webhook.sh <secret> <signature-header> <body-file>
#   ./verify-webhook.sh whsec_abc123 't=1750000000,v1=6f1c...' ./body.json
#
# Prints OK or FAIL with the expected value, so a receiver that never verifies
# can be diagnosed without guessing. The most common cause is hashing a
# re-serialised body: this script hashes the file's bytes exactly as they are.

set -eu

SECRET="${1:-}"
HEADER="${2:-}"
BODY_FILE="${3:-}"

[ -n "$SECRET" ] || { echo "usage: $0 <secret> <signature-header> <body-file>" >&2; exit 2; }
[ -n "$HEADER" ] || { echo "usage: $0 <secret> <signature-header> <body-file>" >&2; exit 2; }
[ -f "$BODY_FILE" ] || { echo "FAIL: no such file: $BODY_FILE" >&2; exit 2; }

# Header looks like: t=1750000000,v1=6f1c...
T="$(printf '%s' "$HEADER" | tr ',' '\n' | sed -n 's/^t=//p')"
V1="$(printf '%s' "$HEADER" | tr ',' '\n' | sed -n 's/^v1=//p')"

[ -n "$T" ] || { echo "FAIL: no t= in the signature header" >&2; exit 2; }
[ -n "$V1" ] || { echo "FAIL: no v1= in the signature header" >&2; exit 2; }

# The signed string is "<t>.<rawBody>", not just the body.
#
# THE BODY IS CONCATENATED AS BYTES, NOT VIA `$(cat FILE)`. Command substitution
# strips EVERY trailing newline, and a JSON file that ends in one (which is what
# an editor writes) then hashes differently from what the server signed. This
# script exists to prove a receiver's verification is right, so a false FAIL here
# sends someone debugging code that was already correct. `{ printf; cat; }` keeps
# the bytes exactly, and the timestamp is prepended in the same subshell so the
# hash reads one continuous stream.
EXPECTED="$({ printf '%s.' "$T"; cat "$BODY_FILE"; } \
  | openssl dgst -sha256 -hmac "$SECRET" -r \
  | cut -d' ' -f1)"

NOW="$(date +%s)"
AGE=$((NOW - T))
if [ "$AGE" -gt 300 ]; then
  echo "WARN: timestamp is ${AGE}s old. A receiver should reject it (limit 300s)."
elif [ "$AGE" -lt -300 ]; then
  echo "WARN: timestamp is in the future by $((0 - AGE))s."
fi

if [ "$EXPECTED" = "$V1" ]; then
  echo "OK: signature matches."
else
  echo "FAIL: signature does not match." >&2
  echo "  expected: $EXPECTED" >&2
  echo "  received: $V1" >&2
  echo "  Most likely cause: the body was parsed and re-serialised before hashing." >&2
  exit 1
fi

Writing one for Sentadu

  1. 1Point at the source, do not copy it. Link the endpoint table to the docs page and name the files that hold the truth. A skill that pastes a price or a rate limit goes stale the day the price changes, and the agent will believe it over the live page.
  2. 2State the invariants as commands. This API charges IDR only, and the wallet ledger holds IDR only: both are rules an agent must not reason its way around.
  3. 3Say what fails. An agent that knows insufficient_wallet is a 402 and not a 400 writes a better retry branch than one guessing from the name.

Related

Read the full API reference, or see which plan fits you.

DocumentationPartnership
LocalBundlesRegionalGlobalDestinationsUnlimited