> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionicfi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Server SDK (@ionicfi/sdk)

> Typed Node client for every API endpoint.

The server-side TypeScript SDK. It holds your secret key, so it runs on your
backend only, never in a browser. Method signatures, required fields, and
webhook event shapes match the [API reference](/api-reference/overview)
exactly.

## Install

```bash theme={null}
npm install @ionicfi/sdk
```

Requires Node 18 or later. This is an ES module package; CommonJS projects
can `require()` it on Node 20.19+ / 22.12+, or use dynamic `import()` on
older runtimes.

## Quickstart

Construct a client with a secret key and create a checkout session:

```ts theme={null}
import { Ionic } from "@ionicfi/sdk";

const secretKey = process.env.IONIC_SECRET_KEY;
if (!secretKey) {
  throw new Error("IONIC_SECRET_KEY is required and was not set");
}
const ionic = new Ionic({ token: secretKey });

const session = await ionic.checkout.sessions.create({
  mode: "payment",
  line_items: [{ price_id: "price_Ab1Cd2Ef3Gh4Ij5Kl6Mn7Op8", quantity: 1 }],
  success_url: "https://example.com/return?session_id={CHECKOUT_SESSION_ID}",
  cancel_url: "https://example.com/",
});

console.log(session.url); // redirect the buyer here
```

## Webhooks

Verify inbound deliveries with `webhooks.unwrap()`. It checks the signature
and replay window, then returns a typed event. Pass the **raw** request body
exactly as received; a body that's been parsed and re-serialized by framework
middleware will not verify:

```ts theme={null}
import { WebhookParseError, WebhookVerificationError, webhooks } from "@ionicfi/sdk";

app.post("/webhooks/ionic", async (req, res) => {
  let event;
  try {
    event = webhooks.unwrap(req.rawBody, req.headers, process.env.IONIC_WEBHOOK_SECRET);
  } catch (err) {
    if (err instanceof WebhookVerificationError || err instanceof WebhookParseError) {
      return res.status(400).send(`webhook rejected: ${err.message}`);
    }
    throw err;
  }

  if (event.type === "checkout.session.completed") {
    await fulfill(event.data.object); // typed CheckoutSession
  }
  res.status(200).json({ received: true });
});
```

An unverifiable delivery throws `WebhookVerificationError` (bad signature,
missing headers, stale timestamp) or `WebhookParseError` (authenticated body
that isn't a webhook envelope). Respond `400` in both cases so the sender's
retry logic kicks in. Never `200` a delivery you didn't process.

**Fulfill from the webhook, not from the browser redirect.** The buyer's
`success_url` redirect proves they returned to your site; it doesn't prove
the payment completed. In `success_url`/`return` handling, check the session
with `checkout.sessions.retrieve`, and do the actual order
fulfillment when `checkout.session.completed` arrives.

The signing secret is shown once, in the response from
`webhookEndpoints.create`. Store it immediately; it isn't retrievable
afterward.

## Typed imports

Every resource shape is exported alongside the client:

```ts theme={null}
import type { PaymentIntent, CheckoutSession } from "@ionicfi/sdk";
```

## Error handling

Every failed API call throws `IonicApiError`. Catch that one class and branch
on `statusCode` and the error body; it carries the parsed API error envelope
plus a `requestId` to quote when contacting support:

```ts theme={null}
import {
  CardError,
  InvalidRequestError,
  ApiError,
  IonicApiTimeoutError,
} from "@ionicfi/sdk";

try {
  await ionic.paymentIntents.confirm({ id: intentId });
} catch (err) {
  // Check the timeout FIRST: IonicApiTimeoutError extends IonicApiError, so a
  // broader check would swallow it.
  if (err instanceof IonicApiTimeoutError) {
    // No response arrived, which does NOT mean the operation failed: it may
    // have completed. Retrieve the resource rather than creating it again,
    // or you risk charging twice.
    await refreshPaymentStatus(intentId);
  } else if (err instanceof CardError) {
    // err.detail.decline_code carries the issuer's reason.
    showDeclineMessage(err.detail?.message);
  } else if (err instanceof InvalidRequestError) {
    // Retrying unchanged fails identically; fix the request.
    log.error("bad request", err.detail?.code, err.requestId);
  } else if (err instanceof ApiError) {
    // On a mutating call this does NOT prove the operation did not happen.
    await refreshPaymentStatus(intentId);
  } else {
    throw err;
  }
}
```

The hierarchy is `CardError`, `InvalidRequestError`, `AuthenticationError`,
`PermissionError`, `NotFoundError`, `ConflictError`, `RateLimitError` and
`ApiError`, all extending `IonicApiError`. There is exactly one of each in the
package: the per-resource names the API reference uses (`BadRequestError`,
`UnauthorizedError`, ...) are aliases of these, so `IonicApi.checkout.BadRequestError`
and `InvalidRequestError` are the same class and `instanceof` cannot pick a
wrong one.

Every error carries `statusCode`, a `requestId` worth quoting to support, and
`detail`: the typed error body (`code`, `message`, `decline_code`,
`decline_type`, `retryable`, `doc_url`), or `undefined` when the response had
no parsable envelope, as with some `502` responses. `errorDetail(err)` reads the same
value from an `unknown`.

The specific class comes from the statuses an endpoint documents.
`InvalidRequestError`, `AuthenticationError`, `PermissionError`,
`RateLimitError` and `ApiError` are documented on every endpoint, so those
always arrive as their class.

`CardError`, `NotFoundError` and `ConflictError` are documented only where the
endpoint can produce them: a list call cannot 404, and a customer lookup cannot
raise a card decline. Anything undocumented arrives as `IonicApiError` with the
correct `statusCode` and `detail`, so it is still catchable.

## Reliability

* **Idempotency keys are automatic.** Every mutating request (POST, PUT,
  PATCH, DELETE) carries an `Idempotency-Key`; the SDK generates one per call
  when you don't supply your own, so the automatic retries below replay the
  original operation instead of repeating it (a second charge, a second
  refund). To also make your own application-level retries safe, pass the
  same `"Idempotency-Key"` field with the same request body: the original
  response is replayed instead of the operation running twice.

  Replay is performed by the API. API key creation is the one write it does
  not cover, because those keys can be created by a platform rather than a
  single merchant. A retried key creation may therefore produce a second key;
  list your keys to see whether the first request completed before creating
  another one.
* **Retries.** Requests that fail with `408`, `429`, `502`, `503`, or `504`
  are retried automatically with backoff. A bare `500` is never retried: on
  a mutating endpoint it can mean the operation already committed.
* **Auto-pagination.** List calls on `paymentIntents`, `charges`, `refunds`,
  `customers`, `invoices`, `subscriptions`, `creditNotes`, `paymentMethods`,
  `setupIntents`, `catalog.products`, `catalog.prices`, `paymentLinks`, and
  `checkout.sessions`, `webhookEndpoints`, and `apiKeys` return a `Page` that
  exposes `hasNextPage()` / `getNextPage()` and is directly `for await`-able.
  Iteration advances with `starting_after` cursors, so rows created while you
  page are never skipped or repeated:

  ```ts theme={null}
  const page = await ionic.paymentIntents.list({ limit: 100 });
  for await (const intent of page) {
    console.log(intent.id);
  }
  ```

  If the first request carried an `offset`, continuation requests drop it;
  iteration advances by cursor alone. API-key and webhook-endpoint lists
  preserve their legacy full-result behavior when `limit` is omitted; the
  returned `Page` is terminal in that case.

## Security

* The secret key (`sk_v1_test_…` / `sk_v1_live_…`) authenticates every
  request in this SDK. Keep it server-side only, read from an environment
  variable. Never ship it to a browser or commit it to source control.
* The webhook signing secret (`whsec_…`) is returned once, at endpoint
  creation. If you lose it, call `webhookEndpoints.rotateSecret()` for a new
  one; the previous secret is revoked immediately, and it isn't recoverable
  otherwise.
* Test keys (`_test_`) return `livemode: false` objects and never move real
  money. Live keys (`_live_`) do. Keep the two separate in your environment
  configuration and never charge a live key from a test script.
