ChoEsim Partner API: guide
Contents: Authentication | Idempotency | Errors | Rate limits | Pagination | Versioning | Wallet | Order lifecycle | Cancellations | Webhooks | Data protection | Security
The OpenAPI file is the reference for every field. This guide explains how the pieces work together.
Basics
- Base URL:
https://{host}/partner/v1. The production host is not defined yet. - Send and receive JSON.
POSTrequests needContent-Type: application/json, otherwise you get415 unsupported_media_type. Bodies larger than 10 KB get413 payload_too_large. - Money is an integer in US cents (
amount_minor) withcurrency: USD. There are no floating-point amounts. - Every response has a
Request-Idheader. Quote it when you contact support. - Do not call the API from a browser. The partner API sets no CORS headers. It is for servers.
- "Per day" limits count per UTC day.
Authentication
Send Authorization: Bearer <api key> on every request.
Key types. cesim_live_... keys work on live data and your prepaid wallet. cesim_test_... keys work on the sandbox only.
Scopes. A key has one or more scopes. A call without the needed scope gets 403 forbidden_scope.
| Scope | Allows |
|---|---|
catalog:read | GET /plans, GET /plans/{id}, POST /quotes |
orders:read | GET /orders, GET /orders/{id}, GET /esims/{id}, GET /esims/{id}/usage, GET /events |
orders:write | POST /orders, POST /orders/{id}/cancel, POST /sandbox/wallet/topup (test keys only) |
wallet:read | GET /wallet, GET /wallet/entries |
webhooks:manage | webhook endpoints, POST /events/{id}/resend |
GET /me needs any valid key.
Key handling.
- A key is shown once, when you create it. ChoEsim stores only a hash.
- You create keys yourself in the partner portal after you apply; they are not created through the API. Default scopes are
catalog:read,orders:read,orders:writeandwallet:read;webhooks:manageis added only if you ask for it when you create the key. - You can ask for an IP allow-list of your server addresses. By default the list is empty.
- Never put a key in a URL, a log, a repository or client-side code.
- An unknown, revoked or expired key gets
401 invalid_api_key. The reason is not revealed. - Revocation takes effect on the next request.
- Rotation: you can hold up to 2 active keys per mode. Rotate a key in the partner portal (or ask partner support to do it). The new key is shown once, and the old key keeps working for 7 days after the rotation, then stops. If a key leaks, revoke it in the portal at once (or ask partner support).
- If a key leaks, revoke it in the portal at once, or tell ChoEsim partner support so the key can be revoked.
Idempotency
An idempotency key lets you repeat a request safely. This matters when a network call times out and you do not know if it worked.
Where it is required. POST /orders, POST /orders/{order_id}/cancel and POST /sandbox/wallet/topup need an Idempotency-Key header. A missing key gives 400 idempotency_key_required.
Format. 1 to 64 characters from A-Za-z0-9_:.-. A UUID is recommended.
Rules.
| Situation | Result |
|---|---|
| Same key, same body | The current state of the same order, with Idempotent-Replayed: true. The status code follows that state (see below). |
| Same key, different body, after the first request was accepted | 422 idempotency_key_reuse. |
| Same key while the first request is still running | 409 idempotency_in_progress. Retry shortly. A running cancel is not taken over by a second request with the same key. |
| Key older than 7 days | Keys are kept for 7 days. After that the key is forgotten. |
Status code of a replay. The code reflects the current state of the order, not the first answer:
| Order state at replay time | POST /orders replay | POST /orders/{id}/cancel replay |
|---|---|---|
fulfilled | 201 | n/a |
accepted or processing | 202 | n/a |
failed or cancelled | 200 | 200 (when cancelled) |
cancelling | n/a | 202 |
So a first answer of 202 becomes 201 on a later replay once the eSIMs are ready.
A refusal before any money is taken does not use up the key. If an order is refused before the wallet is debited (for example 409 price_changed, a validation error, or a 503), nothing is stored for that key. You may send a corrected request with the same key, or with a new key. Both work.
Your keys are scoped to you. They are separate from other partners' keys, and separate between test and live mode.
The reference
Every order carries a reference that you choose. It is unique forever per mode. A second order with the same reference gets 409 duplicate_reference, and the error body includes the existing order_id.
Use both: an Idempotency-Key for safe retries, and a reference that matches your own order id. The reference protects you after the 7 days are over.
Timeouts are not failures
POST /orders waits up to about 15 seconds. If the order is not complete, you get 202 and status: processing.
If a call times out on your side, or returns 202:
- Do not create a second order.
- Poll
GET /orders/{id}, or repeat the same request with the sameIdempotency-Key.
If you lost the order id, use GET /orders?reference=....
Errors
Errors use RFC 9457 application/problem+json. Every error body has these members:
| Member | Meaning |
|---|---|
type | A URN like urn:choesim:problem:price_changed. |
title | A short title. |
status | The HTTP status. |
detail | A human-readable sentence. Optional. |
instance | The request path. Optional. |
code | A stable machine-readable code. Use this in your code. |
request_id | Matches the Request-Id header. |
Some errors add members: retry_after, balance_minor, required_minor, expected_total_minor, current_total_minor, order_id, limit and errors[] (each with a JSON pointer into your body and a detail). Ignore members you do not know.
Match on code, not on title or detail. The text can change.
{
"type": "urn:choesim:problem:price_changed",
"title": "Price changed",
"status": 409,
"detail": "The total is now different from expected_total_minor. Nothing was debited.",
"instance": "/partner/v1/orders",
"code": "price_changed",
"request_id": "req_7c1e2f94",
"expected_total_minor": 2520,
"current_total_minor": 2600
}
Error codes
| HTTP | code | Cause | What your client should do |
|---|---|---|---|
| 400 | invalid_request | Malformed JSON or a bad query parameter. | Fix the request. Do not retry unchanged. |
| 400 | idempotency_key_required | Idempotency-Key is missing. | Add the header. |
| 401 | invalid_api_key | The key is missing, unknown, revoked or expired. | Check the key. Do not retry in a loop. |
| 402 | insufficient_balance | The wallet is lower than the total. Nothing was debited. The body has balance_minor and required_minor. | Top up, then send again. |
| 403 | forbidden_scope | The key lacks the scope, or a test-only endpoint was called with a live key. | Ask ChoEsim for the right scope, or use the right key. |
| 403 | ip_not_allowed | The caller IP is not on the key's allow-list. | Call from an allowed server, or ask ChoEsim to update the list. |
| 403 | partner_suspended | Your partner account is suspended. | Contact partner support. |
| 403 | wallet_frozen | Your wallet is frozen. | Contact partner support. |
| 404 | not_found | No such resource, or it belongs to another partner. The two cases look the same. | Check the id and the mode (test or live). |
| 409 | idempotency_in_progress | A request with this key is still running. | Wait, then repeat the same request. |
| 409 | duplicate_reference | An order with this reference already exists. The body has its order_id. | Read that order. Do not create another. |
| 409 | price_changed | The total differs from expected_total_minor. Nothing was debited. The body has expected_total_minor and current_total_minor. | Decide whether to accept the new total, then send again (same key or new key). |
| 409 | esim_not_cancellable | An eSIM cannot be cancelled (for example it is used, or the supplier refuses). | Do not retry. |
| 409 | cancel_window_expired | The order is older than cancel_window_days. Nothing was changed. | Do not retry. Contact partner support if you think this is wrong. |
| 413 | payload_too_large | The body is larger than 10 KB. | Send a smaller body. |
| 415 | unsupported_media_type | Content-Type is not application/json. | Fix the header. |
| 422 | invalid_request | The body fails validation: unknown field, out-of-range value, or customer_reference looks like personal data. Check errors[]. | Fix the request. |
| 422 | idempotency_key_reuse | The key was used with a different body. | Use a new key for a new request. |
| 422 | plan_unavailable | The plan cannot be sold to you now. Nothing was debited. | Refresh your plan list. |
| 422 | order_limit_exceeded | A safety limit was hit. The limit member says which one: max_order, daily_spend or open_orders. Nothing was debited. | Reduce the order, or wait. Check GET /me. |
| 422 | cancel_limit_exceeded | The daily cancellation cap is reached. | Try again after 00:00 UTC. |
| 429 | rate_limited | Too many requests. Retry-After says how long to wait. | Wait that many seconds, then retry. |
| 500 | internal_error | An unexpected error on the ChoEsim side. Only the request_id is exposed. Nothing is lost silently. | For POST /orders, check GET /orders?reference=... before you retry. Repeating the same request with the same Idempotency-Key is always safe. Quote request_id to support. |
| 503 | orders_disabled | Ordering is paused (maintenance or a kill switch). Nothing was debited. | Retry after Retry-After. |
| 503 | supplier_unavailable | Orders are paused for a short time because of a supplier problem. Nothing was debited. | Retry after Retry-After. |
| 503 | supplier_capacity | ChoEsim cannot take the order now. Nothing was debited. | Retry after Retry-After. If it repeats, contact support. |
After a 4xx or 503, an order is never left half-created.
Never treat 5xx, a timeout or a 202 as proof that no order exists. Check by reference first.
A 429 response looks like this:
{
"type": "urn:choesim:problem:rate_limited",
"title": "Too many requests",
"status": 429,
"detail": "Request rate exceeded for this key.",
"instance": "/partner/v1/orders",
"code": "rate_limited",
"request_id": "req_7c1e2f9c",
"retry_after": 12
}
A 500 response looks like this:
{
"type": "urn:choesim:problem:internal_error",
"title": "Internal error",
"status": 500,
"instance": "/partner/v1/orders",
"code": "internal_error",
"request_id": "req_7c1e2fa0"
}
Rate limits
Limits apply per key and per request class. Each response carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window. |
X-RateLimit-Remaining | Requests left in the window. |
X-RateLimit-Reset | Seconds until the window resets. |
A 429 always has Retry-After (seconds).
Default targets. These are default targets and may change. The live limit is always in the response headers.
| Request class | Default |
|---|---|
| Read | 60 requests per minute per key |
| Quote | 30 requests per minute per key |
| Create order | 10 requests per minute per key, burst of 3 |
| Cancel | 5 requests per minute per key |
| Cancellations per day | 5 per UTC day per partner (daily_cancel_cap in GET /me) |
Other safety limits are returned by GET /me: max_order_minor, daily_spend_cap_minor, max_open_orders, daily_cancel_cap, cancel_window_days, max_items_per_order (30) and max_quantity_per_line (10). The first three are set for each partner by ChoEsim. They are checked in the database.
How to behave:
- Read
Retry-Afterand wait that long. Add a little random delay. - Do not retry in a tight loop.
- Reuse
GET /plansresponses for up to 1 minute (Cache-Control: private, max-age=60). GET /esims/{id}/usagenever calls the supplier, so polling it is cheap. It still counts toward your rate limit.
Pagination
List endpoints use cursors: GET /plans, GET /orders, GET /wallet/entries, GET /events.
limit: 1 to 100, default 25.cursor: the opaquenext_cursorfrom the previous page. Do not parse it.- A list response has
object: "list",data,has_moreandnext_cursor. Whenhas_moreisfalse,next_cursorisnull.
Order of results: orders and wallet entries come newest first. Events come oldest first, so you can resume from next_cursor.
GET /orders supports the filters status, created_from, created_to and reference. In a list, esims is left out. Fetch one order to get activation data.
Versioning and deprecation
The major version is in the path: /partner/v1.
Not breaking (can happen in v1):
- Adding a field.
- Adding an endpoint.
- Adding a new value to an output enum (for example a new order status, event type or error code).
Your client must ignore fields and enum values it does not know.
Breaking (needs v2): removing or renaming a field, changing the meaning of a field, or making validation stricter.
Deprecation. When a version or endpoint is deprecated, responses carry Deprecation and Sunset headers, and the changelog explains the change. ChoEsim gives at least six months notice before it removes or changes the behaviour of an endpoint in a way that breaks existing integrations. For urgent security, legal or abuse reasons the notice can be shorter, and ChoEsim tells you as soon as it can.
Webhook payloads carry api_version: "v1".
Wallet and ledger
You pay from a prepaid wallet in USD. A live wallet and a test wallet are separate.
GET /walletshowsbalance_minor,status(activeorfrozen) andas_of.- The balance is never negative. An order above the balance is refused with
402 insufficient_balance. - The wallet is debited when the order is created, in the same step. There is no separate hold.
- A
frozenwallet refuses new orders with403 wallet_frozen.
Top-ups. Live wallets are topped up in the partner portal: you enter the amount and pay by card through OnePay. The card is charged in Vietnamese dong at the system exchange rate with no margin, and ChoEsim bears the gateway fees. Where a bank transfer has been agreed, the portal shows the bank details and the exact transfer reference, and bank fees on your side are yours. There is no top-up API for live wallets. The minimum is USD 50 for the first top-up and USD 25 for each later top-up. The wallet balance cannot exceed three times your daily spend limit.
Ledger. GET /wallet/entries is an append-only list of every change, newest first. Each entry has:
| Field | Meaning |
|---|---|
seq | Running number of the entry. |
kind | topup, debit_order, credit_refund, adjustment or reversal. |
amount_minor | Signed. Debits are negative. |
balance_after_minor | Balance after this entry. |
order_id | The order, if there is one. |
ref | A reference, for example a bank transfer reference. |
Every entry carries the running balance, so you can rebuild and check a statement.
{
"id": "wle_0f1e2d3c",
"object": "wallet_entry",
"seq": 3,
"kind": "debit_order",
"amount_minor": -2520,
"currency": "USD",
"balance_after_minor": 7480,
"order_id": "po_test_3b8e4c1a-6a53-4f0b-9c33-0f3c2f6e1a10",
"ref": null,
"created_at": "2026-11-01T09:30:00Z"
}
Reconcile every day: the sum of amount_minor should equal the balance.
A wallet.low_balance event is sent when the wallet falls below a threshold agreed with ChoEsim. A wallet.credited event is sent when a top-up or refund is credited.
The balance is an advance payment for ChoEsim eSIMs. It earns no interest, cannot be withdrawn as cash or transferred, and an unused balance is refunded only to the bank account that paid it, after bank fees and amounts you owe. The details are in the partner terms.
Order lifecycle
| Status | Meaning |
|---|---|
accepted | The wallet is debited. Delivery has not started. |
processing | In progress. See status_detail. |
fulfilled | The eSIMs are ready. |
failed | The order definitely failed. The wallet is credited. |
cancelling | A cancellation is under way. |
cancelled | Cancelled, and the wallet is credited. |
status_detail can be:
under_review: ChoEsim staff are checking the outcome. The wallet stays debited until the outcome is proven.awaiting_credentials: the eSIMs are bought, but the activation data is not stored yet.null.
POST /orders
|-- 201 fulfilled
|-- 202 processing --> fulfilled
| --> processing / under_review --> fulfilled | failed
|-- 4xx / 503 (nothing debited)
fulfilled --> cancelling --> cancelled
Things to know:
failedmeans a proven failure. An unknown outcome is never reported asfailed. Noorder.failedevent is sent for an unknown outcome.- A failed order has a
failure_code. A refund shows inrefundwithamount_minor,currencyandcredited_at. - A
201response carries theesimsarray. The array holds activation secrets. - Each
esimhas astatusthat is the last status reported by the supplier, for exampleGOT_RESOURCE,IN_USE,USED_UPorCANCEL. Do not rely on a fixed list: new values can appear. - Each read of activation data is recorded in an audit log.
- Version 1 sells fixed-data plans only. Daily (per-day) plans are not available.
- ChoEsim does not guarantee coverage, speed or device compatibility.
coverageis reported by the supplier. You are responsible for telling your customer to check that their phone supports eSIM and is unlocked.
GET /esims/{esim_id}/usage returns the last known data usage. It is built from notifications ChoEsim has received. It can be late or empty. A null field means unknown. It does not mean zero.
Cancellations
POST /orders/{order_id}/cancel cancels a whole order of unused eSIMs and credits your wallet. It needs an Idempotency-Key and the orders:write scope.
Rules:
- Unused eSIMs only. It is allowed only if every eSIM in the order is unused and the supplier accepts the cancellation. Otherwise you get
409 esim_not_cancellable. - Cancellation window. You can cancel only within
cancel_window_daysof the day the order was created. The default is 14 days. ChoEsim can set a different value for your account. Read your value inGET /me. An older order is refused with409 cancel_window_expired, and nothing is changed. - Daily cap. A daily cancellation cap applies, counted per UTC day (default 5, see
daily_cancel_capinGET /me). Over the cap you get422 cancel_limit_exceeded. - Whole order only. Partial cancellation is not supported.
- Credit. The wallet is credited in full, once, only after every eSIM is confirmed cancelled.
- If some eSIMs cannot be cancelled, the order moves to
cancelling. ChoEsim staff resolve it. No automatic credit is made. - Retries are safe. If you retry after a partial failure, eSIMs already cancelled are not cancelled twice. A cancel that is still running is not taken over by a second request with the same key: you get
409 idempotency_in_progressuntil it ends.
Responses: 200 when cancelled and credited (or a replay of an earlier success). 202 if the cancellation is not finished within the wait budget. Then poll the order, or wait for order.cancelled.
Refund rules beyond this (for example technical faults) are in the partner terms.
Webhooks
Webhooks are an optimisation. GET /orders/{id} is the source of truth. Reconcile with GET /events and GET /orders.
Endpoints
POST /webhook-endpointscreates one. At most 3 per mode.- URL rules:
https, port 443, public address, redirects are not followed. A rejected URL returns422 invalid_requestwitherrors[].pointerset to/url. enabled_eventsis a list of event types, or"*"for all (default).- The
secretis returned once, in the create response.GET /webhook-endpointsnever returns it. DELETE /webhook-endpoints/{id}removes one.POST /webhook-endpoints/{id}/rotate-secretissues a new secret. The old secret also signs for 24 hours. During that time every delivery carries two signatures.
An endpoint has a status of active or disabled, plus disabled_reason and last_success_at.
ChoEsim can disable an endpoint that keeps failing. The rule is 5 abandoned events in a row, or no success for 4 days, or a 410 answer from your endpoint. ChoEsim then writes to your contact address. Ask partner support to re-enable it.
Events
| Event | When | data members |
|---|---|---|
order.fulfilled | Every eSIM of the order is ready. Fetch the order for activation data. | order_id, reference, status |
order.failed | The order definitely failed and the wallet was credited. Never sent for an unknown outcome. | order_id, reference, status, failure_code |
order.cancelled | Cancellation finished and the wallet was credited. | order_id, reference, status |
order.delayed | The order has been processing longer than expected. Informational, no action needed. | order_id, reference, status |
esim.status_changed | The supplier-reported status of an eSIM changed. | esim_id, order_id, esim_status |
wallet.credited | A top-up or refund was credited. | balance_minor, currency, entry_id (optional) |
wallet.low_balance | The wallet fell below the threshold agreed with ChoEsim. | balance_minor, currency, entry_id (optional) |
ping | An endpoint was created or re-enabled. | endpoint_id (optional) |
Every event has this envelope. The id equals the webhook-id header.
Payloads are thin. They contain ids and status, never activation data.
Examples of the data member for each event:
{ "id": "evt_1f9c2a7e", "object": "event", "type": "order.fulfilled", "api_version": "v1", "livemode": false, "created": "2026-11-01T09:30:02Z",
"data": { "order_id": "po_test_3b8e4c1a-6a53-4f0b-9c33-0f3c2f6e1a10", "reference": "partner-order-10042", "status": "fulfilled" } }
{ "id": "evt_2a8d3b6f", "object": "event", "type": "order.failed", "api_version": "v1", "livemode": false, "created": "2026-11-01T09:31:02Z",
"data": { "order_id": "po_test_5c1d8e72-1b2a-4c3d-9e8f-6a7b5c4d3e2f", "reference": "partner-order-10044", "status": "failed", "failure_code": "price_changed" } }
{ "id": "evt_4c0f5d9b", "object": "event", "type": "order.cancelled", "api_version": "v1", "livemode": false, "created": "2026-11-02T10:00:00Z",
"data": { "order_id": "po_test_3b8e4c1a-6a53-4f0b-9c33-0f3c2f6e1a10", "reference": "partner-order-10042", "status": "cancelled" } }
{ "id": "evt_5d1a6eac", "object": "event", "type": "order.delayed", "api_version": "v1", "livemode": false, "created": "2026-11-01T09:41:00Z",
"data": { "order_id": "po_test_9d2b7a44-0c1d-4e5f-8a12-7b6c5d4e3f21", "reference": "partner-order-10043", "status": "processing" } }
{ "id": "evt_3b9e4c8a", "object": "event", "type": "esim.status_changed", "api_version": "v1", "livemode": true, "created": "2026-11-02T08:00:00Z",
"data": { "esim_id": "esm_example", "order_id": "po_example", "esim_status": "IN_USE" } }
{ "id": "evt_6e2b7fbd", "object": "event", "type": "wallet.credited", "api_version": "v1", "livemode": false, "created": "2026-11-01T09:00:00Z",
"data": { "balance_minor": 10000, "currency": "USD", "entry_id": "wle_0f1e2d3b" } }
{ "id": "evt_7f3c80ce", "object": "event", "type": "wallet.low_balance", "api_version": "v1", "livemode": false, "created": "2026-11-01T09:35:00Z",
"data": { "balance_minor": 900, "currency": "USD" } }
{ "id": "evt_80d491df", "object": "event", "type": "ping", "api_version": "v1", "livemode": false, "created": "2026-11-01T09:00:00Z",
"data": { "endpoint_id": "whe_8f2a1c9d" } }
Headers
| Header | Meaning |
|---|---|
webhook-id | The event id. Identical on every retry. Use it to de-duplicate. |
webhook-timestamp | Unix time in seconds of this attempt. It changes on each retry. |
webhook-signature | One or two space-separated values like v1,<base64>. Two appear during secret rotation. |
Verify the signature
The signing method follows the Standard Webhooks style.
- The endpoint secret is
whsec_followed by standard base64 of the key bytes. Stripwhsec_and base64-decode the rest. The decoded bytes are the HMAC key. Do not use the base64 text as the key. - Build the signed string:
{webhook-id}.{webhook-timestamp}.{raw body}. - Signature =
base64(HMAC-SHA256(key, signed string)), sent asv1,<signature>. - Use the raw request body, byte for byte. Do not parse and re-serialise it.
- Reject a timestamp more than 5 minutes old.
- Compare in constant time.
- Accept the request if any of the signatures in the header matches.
Test vector. Use it to check your code. It is a documentation key, never a real secret.
| Item | Value |
|---|---|
| Secret | whsec_Y2hvZXNpbS1kb2NzLWV4YW1wbGUta2V5LTMyYnl0ZXMh |
webhook-id | evt_1f9c2a7e |
webhook-timestamp | 1793525402 |
| Body | the exact text below, on one line |
Expected webhook-signature | v1,yOGM2hgcarOxufvwHvPAvpRmv8QfypZQYDforcG5N50= |
{"id":"evt_1f9c2a7e","object":"event","type":"order.fulfilled","api_version":"v1","livemode":false,"created":"2026-11-01T09:30:02Z","data":{"order_id":"po_test_3b8e4c1a-6a53-4f0b-9c33-0f3c2f6e1a10","reference":"partner-order-10042","status":"fulfilled"}}
The timestamp in the vector is old on purpose. The sample functions take an optional "now" value so that you can test with it.
Node.js (version 18 or later), file verify-webhook.mjs:
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // 5 minutes
// rawBody: Buffer or string, exactly as received.
// headers: object with lower-case header names.
// nowSeconds: only set it in tests.
export function verifyWebhook(rawBody, headers, secret, nowSeconds = Date.now() / 1000) {
if (!secret) throw new Error("webhook secret is not configured");
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatureHeader = headers["webhook-signature"];
if (!id || !timestamp || !signatureHeader) return false;
const ts = Number(timestamp);
if (!Number.isInteger(ts)) return false;
if (Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
for (const part of signatureHeader.split(" ")) {
const [version, signature] = part.split(",");
if (version !== "v1" || !signature) continue;
const given = Buffer.from(signature, "base64");
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) {
return true;
}
}
return false;
}
Test with the vector, file verify-webhook.test.mjs. Run node verify-webhook.test.mjs. It prints ok when it passes.
import assert from "node:assert/strict";
import { verifyWebhook } from "./verify-webhook.mjs";
const secret = "whsec_Y2hvZXNpbS1kb2NzLWV4YW1wbGUta2V5LTMyYnl0ZXMh";
const body =
'{"id":"evt_1f9c2a7e","object":"event","type":"order.fulfilled","api_version":"v1","livemode":false,"created":"2026-11-01T09:30:02Z","data":{"order_id":"po_test_3b8e4c1a-6a53-4f0b-9c33-0f3c2f6e1a10","reference":"partner-order-10042","status":"fulfilled"}}';
const headers = {
"webhook-id": "evt_1f9c2a7e",
"webhook-timestamp": "1793525402",
"webhook-signature": "v1,yOGM2hgcarOxufvwHvPAvpRmv8QfypZQYDforcG5N50=",
};
const now = 1793525402;
assert.equal(verifyWebhook(Buffer.from(body), headers, secret, now), true);
assert.equal(verifyWebhook(Buffer.from(body + " "), headers, secret, now), false); // changed body
assert.equal(verifyWebhook(Buffer.from(body), headers, secret, now + 400), false); // too old
console.log("ok");
An Express handler that uses it:
import express from "express";
import { verifyWebhook } from "./verify-webhook.mjs";
const SECRET = process.env.CHOESIM_WEBHOOK_SECRET; // whsec_REPLACE_ME
const app = express();
// express.raw keeps req.body as the untouched Buffer.
app.post("/hooks/choesim", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWebhook(req.body, req.headers, SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Store event.id, reply, then process (see "Write idempotent handlers").
res.status(200).send("ok");
queueForProcessing(event); // your own function
});
app.listen(3000);
Python (version 3.9 or later), file verify_webhook.py:
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300 # 5 minutes
def verify_webhook(raw_body: bytes, headers, secret: str, now=None) -> bool:
"""headers: a mapping with lower-case names (Flask's request.headers also works).
now: seconds since the epoch; only set it in tests."""
if not secret:
raise ValueError("webhook secret is not configured")
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not msg_id or not timestamp or not signature_header:
return False
try:
ts = int(timestamp)
except ValueError:
return False
current = time.time() if now is None else now
if abs(current - ts) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
for part in signature_header.split(" "):
version, _, signature = part.partition(",")
if version != "v1" or not signature:
continue
try:
given = base64.b64decode(signature)
except ValueError:
continue
if hmac.compare_digest(given, expected):
return True
return False
Test with the vector, file test_verify_webhook.py. Run python3 test_verify_webhook.py. It prints ok when it passes.
from verify_webhook import verify_webhook
SECRET = "whsec_Y2hvZXNpbS1kb2NzLWV4YW1wbGUta2V5LTMyYnl0ZXMh"
BODY = (
'{"id":"evt_1f9c2a7e","object":"event","type":"order.fulfilled","api_version":"v1",'
'"livemode":false,"created":"2026-11-01T09:30:02Z","data":{"order_id":'
'"po_test_3b8e4c1a-6a53-4f0b-9c33-0f3c2f6e1a10","reference":"partner-order-10042",'
'"status":"fulfilled"}}'
).encode("utf-8")
HEADERS = {
"webhook-id": "evt_1f9c2a7e",
"webhook-timestamp": "1793525402",
"webhook-signature": "v1,yOGM2hgcarOxufvwHvPAvpRmv8QfypZQYDforcG5N50=",
}
NOW = 1793525402
assert verify_webhook(BODY, HEADERS, SECRET, now=NOW) is True
assert verify_webhook(BODY + b" ", HEADERS, SECRET, now=NOW) is False # changed body
assert verify_webhook(BODY, HEADERS, SECRET, now=NOW + 400) is False # too old
print("ok")
A Flask handler that uses it:
import os
from flask import Flask, request
from verify_webhook import verify_webhook
SECRET = os.environ.get("CHOESIM_WEBHOOK_SECRET") # whsec_REPLACE_ME
app = Flask(__name__)
@app.post("/hooks/choesim")
def choesim_hook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify_webhook(raw, request.headers, SECRET):
return "invalid signature", 400
# Store the event id, reply, then process after the reply.
return "ok", 200
Retries, order and replays
- Reply with any
2xxwithin 10 seconds. Do the work after you reply. - A
410tells ChoEsim to stop sending. The endpoint is disabled. - Other responses and timeouts are retried. Schedule: right away, then after 30 seconds, 5 minutes, 30 minutes, 2 hours, 6 hours, 24 hours and 48 hours (about 3.4 days in total). After that the event is abandoned. The schedule may change; the changelog will say so.
- A redirect (
3xx) counts as a failure. It is not followed. - Each retry has a new
webhook-timestampand a new signature, but the samewebhook-id. - Delivery order is not guaranteed. An event can arrive more than once.
- To get an event again, call
POST /events/{event_id}/resend(needswebhooks:manage). It uses the samewebhook-id, so a receiver that de-duplicates ignores it if it already handled the event. Response:202withobject: "event_resend". - Events are kept for 90 days. If your endpoint was down, list missed events with
GET /events?created_from=...and process them.
Write idempotent handlers
- Verify the signature first. Reject with
400if it fails. - Look up
webhook-idin your own store. If it is already there, reply200and stop. - Save the
webhook-id, then reply200. - Process after the reply. Use a queue.
- Do not trust the payload alone. Call
GET /orders/{id}and act on what it says. A late or repeated event must not undo a newer state. - Make the processing step safe to run twice. For example, deliver an eSIM to your customer only if you have not already done so for that
order_id.
Data protection
Only what the API specification says:
- The order request carries no end-customer personal data. Use
customer_reference, your own opaque id. A value with an@sign or nine or more digits in a row is rejected. Do not put names, e-mail addresses or phone numbers in it or inreference. - ChoEsim never e-mails your customers. You deliver the activation string.
- The activation string (
lpa) is a secret. Whoever has it can install the eSIM. It is sent withCache-Control: no-store. It is never in a webhook. - ChoEsim keeps the ICCID and the activation string for the order.
- Each read of activation data is recorded in an audit log.
Do not put personal data of your customers into customer_reference or any other field. ChoEsim deletes activation codes (LPA) 90 days after the installation window ends and keeps order records and customer_reference for 12 months. Roles and the rest are in the partner terms.
Security recommendations
- Call the API from your server only. Never from a browser or a mobile app.
- Keep keys and webhook secrets in a secret manager. Use separate keys for test and live.
- Ask for an IP allow-list for live keys.
- Give each key the fewest scopes it needs. For example, a reporting job needs only
orders:readandwallet:read. - Do not log the
Authorizationheader, activation strings or webhook secrets. Log theRequest-Idinstead. - Verify every webhook with the raw body and a 5-minute timestamp window. Compare signatures in constant time.
- Use only HTTPS endpoints on port 443. Do not rely on a hidden URL as protection.
- Rotate the webhook secret with
rotate-secretif you think it leaked. During the 24-hour overlap, your verifier must accept either signature. - Build the QR code on your side. Do not log it or send it through third parties that store it.
- Set your own daily limits. Alert on unusual spend. Check
GET /wallet/entriesagainst your own records. - If you think a key leaked: stop using it and tell ChoEsim partner support at once.