@letisim/connect-sdk
v0.2.0
Published
Official server-side TypeScript SDK for the Letisim Connect API — typed errors, safe retries, idempotency, cursor iterators and webhook verification
Maintainers
Readme
@letisim/connect-sdk
Official server-side client for Letisim Connect — the eSIM reseller API. It reaches every operation in its packaged runtime-derived OpenAPI document and adds typed errors, bounded retries, idempotency, cursor iterators, delivery polling and webhook verification. The package and production API may gain additive v2 operations independently; use the packaged contract for the exact surface supported by an installed SDK version.
The SDK is for trusted server code only. Never put a Letisim secret key in browser JavaScript, a mobile app or public source control. Browser storefronts use a short-lived WidgetSession minted by your server.
npm install @letisim/connect-sdkRequires a runtime with fetch and WebCrypto: Node.js 20+, Bun, Deno, Cloudflare Workers or
Vercel Edge. Both ESM (import) and CommonJS (require) are shipped.
Prove the integration before writing code
LETISIM_API_KEY=lts_test_… npx letisim-connect doctordoctor authenticates, prints your environment, granted scopes, contract version and rate limit,
then — on a Test key — runs a full sandbox purchase: offer → quote → order → delivery → eSIM. It
never prints your key or the installation assets. It refuses to spend real money on a Live key.
First sandbox order
import { ConnectClient } from "@letisim/connect-sdk";
const connect = new ConnectClient({ apiKey: process.env.LETISIM_API_KEY! });
const offers = await connect.offers.list({ country: "TR", limit: 50 });
const selected = offers.data[0];
if (!selected) throw new Error("No executable offer for TR");
const quote = await connect.quotes.create(
{ offerId: selected.id },
{ idempotencyKey: "quote-booking-42" },
);
const order = await connect.orders.create({
quoteId: quote.data.id,
externalRef: "booking-42",
deliveryMode: "partner",
recipient: { reference: "traveller-42", locale: "ru" },
});
const delivered = await connect.orders.waitForDelivery(order.data.id);
if (!delivered.esim) throw new Error("Delivered order has no eSIM");
// `installation` carries the secret LPA/QR assets. Return them only to the intended customer,
// and never log this response.
const esim = await connect.esims.retrieve(delivered.esim.id);
const link = await connect.orders.deliveryLinks.create(
delivered.id,
{ expiresInMinutes: 120, maxViews: 3 },
{ idempotencyKey: "delivery-booking-42" },
);
console.log(link.data.url);Test and Live are selected by the API key. Test uses isolated simulated money and fulfilment; there is no environment header or parameter.
Client options
const connect = new ConnectClient({
apiKey: process.env.LETISIM_API_KEY!,
baseUrl: "https://api.letisim.com/partner/api/v2",
timeoutMs: 15_000, // deadline for one attempt, headers and body
totalTimeoutMs: 60_000, // hard deadline for the call including retries
maxRetries: 3,
retryBaseDelayMs: 250,
retryMaxDelayMs: 5_000,
maxRetryAfterMs: 60_000, // ceiling applied to a server Retry-After
appInfo: "acme-booking/2.4", // appended to User-Agent
fetch: globalThis.fetch,
});Override baseUrl only for an approved edge, a local contract test or a private deployment.
Every method accepts the same options as its last argument, so a single call can carry its own
idempotencyKey, signal, timeoutMs, maxRetries or extra headers.
Errors
Catch the class you care about; code stays the stable branch key.
import {
ConnectApiError,
ConnectConflictError,
ConnectRateLimitError,
} from "@letisim/connect-sdk";
try {
await connect.orders.create(input);
} catch (error) {
if (error instanceof ConnectRateLimitError) {
await sleep(error.retryAfterMs ?? 1_000);
} else if (error instanceof ConnectConflictError && error.code === "quote_expired") {
// Create a new Quote and ask the customer to reconfirm the price.
} else if (error instanceof ConnectApiError) {
console.error({ code: error.code, requestId: error.requestId });
}
throw error;
}| Class | When |
| --- | --- |
| ConnectAuthError | 401 / 403 — missing, revoked, wrong-environment or under-scoped key |
| ConnectValidationError | 400 — fix the request; params.field points at the input |
| ConnectNotFoundError | 404 — not in this Account and environment |
| ConnectConflictError | 409 — idempotency conflict, repriced or spent quote, unavailable action |
| ConnectRateLimitError | 429 — carries retryAfterMs and live rateLimit |
| ConnectServerError | 5xx — transient; idempotent work is safe to retry |
| ConnectConnectionError | no HTTP response at all |
| ConnectTimeoutError | attempt or total deadline elapsed |
| ConnectDeliveryFailedError | an Order reached a terminal non-delivered state |
| ConnectWebhookError | webhook timestamp, signature or body rejected |
Branch on code, never on a human message. Include requestId, your externalRef, the orderId
and a timestamp when contacting support — never an API key, LPA string or full ICCID.
Retry policy
| Operation | Automatic retry | Recovery rule |
| --- | --- | --- |
| GET, DELETE, CSV export | timeout, 408, 429, transient 5xx | Exponential backoff with jitter. A server Retry-After is obeyed in full, up to maxRetryAfterMs. |
| Writes with Idempotency-Key | same key and same body | Pass a booking-derived key when the operation maps to a business transaction. |
| Natural-key writes (externalRef) | identical request | Orders, Accounts, Recipients and StorefrontApps deduplicate on externalRef. Changing the payload returns a conflict. |
| PATCH, createPriceBook, createAccountKey | never | After an unknown response, read the resource or collection before deciding. |
| validation, capability, balance and policy errors | never | Fix the request, refresh capability or price, fund the Account, or route to a person. |
No call can exceed totalTimeoutMs, whatever the retry budget says. When a Retry-After does not
fit inside the remaining deadline, the SDK raises ConnectRateLimitError instead of sleeping past
your own timeout.
createOrder is protected by the Account-scoped externalRef. After a timeout, repeat the same
payload or reconcile Orders — never invent a new reference.
Response metadata
const { body, requestId, rateLimit, attempts } = await connect.orders.list().withResponse();
if (rateLimit.remaining !== null && rateLimit.remaining < 50) await slowDown();Pagination
Collections expose page methods and iterators. Cursors are opaque: never parse, store or manufacture them.
for await (const offer of connect.offers.iterate({ country: "TR", limit: 100 })) {
await upsertOffer(offer);
}
const recent = await connect.orders.iterate({ limit: 100 }).toArray({ limit: 1_000 });Iterators exist for offers, catalog products, orders, recipients, esims,
actionRequests and transactions. If the API returns cursor_expired, restart the same
normalized query from the first page.
accounts.list() has no cursor: it is a bounded single-shot listing that reports truncation
through meta.hasMore. Never treat a full page as the complete Account tree.
Delivery state
Signed webhooks are the primary production signal. waitForDelivery is a bounded recovery helper:
it polls with an increasing interval, returns a delivered or completed Order, throws
ConnectDeliveryFailedError for a terminal failure and ConnectWaitTimeoutError with lastOrder
at its deadline. A timeout is not proof that no Order exists.
Webhooks
Verify the exact raw request bytes before JSON parsing. Verification is async and uses WebCrypto, so the same code runs on Node, Workers and Deno.
import { verifyWebhook, isOrderEvent } from "@letisim/connect-sdk";
async function receive(rawBody: Uint8Array, headers: Headers) {
const event = await verifyWebhook({
secret: process.env.LETISIM_WEBHOOK_SECRET!,
rawBody,
headers,
});
// Deliveries are at-least-once and may arrive out of order. Store event.id behind a unique
// constraint, acknowledge duplicates, and apply only a newer data.stateRevision.
if (isOrderEvent(event) && event.type === "order.delivered") {
await markDelivered(event.data.orderId, event.data.stateRevision);
}
return event;
}For Hono, Next route handlers, Workers, Deno and Bun there is a Fetch-API adapter:
import { verifyWebhookRequest } from "@letisim/connect-sdk";
export async function POST(request: Request) {
const event = await verifyWebhookRequest(request, { secret: process.env.LETISIM_WEBHOOK_SECRET! });
return Response.json({ received: event.id });
}On Express, mount express.raw({ type: "application/json" }) for the webhook route and pass
req.body together with req.headers. Parsing and re-serializing the body before verification
invalidates the signature. See examples/.
Testing your integration
@letisim/connect-sdk/testing provides an in-memory Connect double: the sandbox purchase path with
no network, no API key and no Letisim account.
import { createMockConnect } from "@letisim/connect-sdk/testing";
const mock = createMockConnect({ provisioningPolls: 2 });
const order = await issueTravelEsim(mock.client, booking); // your code
expect(mock.requests).toContainEqual(expect.objectContaining({ method: "POST", path: "/orders" }));
// Drive your webhook handler with a correctly signed payload:
const signed = await mock.signWebhook(event, "whsec_test");
await receive(signed.body, signed.headers);Use a Test key against the real API to validate the contract; use the double to test your own logic.
Compatibility policy
- SDK and API versions are related but distinct.
meta.apiVersiondescribes the server contract; the package version describes this client release. - While the SDK is
0.x, an incompatible client change ships in a new minor with a migration note in CHANGELOG.md. Standard SemVer applies after1.0. - Removing or changing a public API field requires a new API major version and a migration guide.
- Deprecated SDK members remain for at least two minor releases and 90 days, whichever is longer.
The flat 0.1 method surface (
connect.createOrder(...)) still works and will not be removed before0.4. - Additive fields and enum values may appear. Ignore unknown response fields and keep an explicit fallback for unknown enum values at application boundaries.
Generating your own client
The contract is public and machine-readable:
https://api.letisim.com/partner/api/v2/openapi.jsonThe same document is packaged as @letisim/connect-sdk/openapi.json. If you work in PHP, Python,
Go or C#, generate a client from that URL — the contract is identical to the one this SDK uses.
Maintainers regenerate the packaged document and its types from the runtime routes with:
npm run generate -w @letisim/connect-sdkGenerated files are never edited by hand, and a second generation must produce no diff before a release.
License
MIT — see LICENSE.
