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

# Bill Payments — Node.js

> Browse the SKU catalog, charge your Chipi credits balance for a bill payment, and poll for settlement. Pure REST + customer JWT — no end-user wallet, no on-chain signing.

This page shows the full purchase flow — browse catalog → debit credits → poll for settlement — using the public REST API and `@chipi-stack/backend` for the read-only calls. The purchase itself is a plain HTTP POST because the SDK's `purchaseSku` is wired to an internal wallet-pays-on-chain path that doesn't apply to API-key integrations.

## Install

You only need the SDK for the read calls (`getSkuList`, `getSku`, `getSkuPurchase`). The purchase POST is plain `fetch`, so you can skip the SDK entirely if you want zero dependencies.

<CodeGroup>
  ```bash npm theme={null}
  npm install @chipi-stack/backend
  ```

  ```bash pnpm theme={null}
  pnpm add @chipi-stack/backend
  ```

  ```bash yarn theme={null}
  yarn add @chipi-stack/backend
  ```
</CodeGroup>

## Initialise the SDK

```ts theme={null}
import { ChipiServerSDK } from "@chipi-stack/backend";

const sdk = new ChipiServerSDK({
  apiPublicKey: process.env.CHIPI_PUBLIC_KEY!,
  apiSecretKey: process.env.CHIPI_SECRET_KEY!,
});
```

## Resolve a customer JWT

Every bill purchase requires `Authorization: Bearer <JWT>` in addition to the `x-api-key` header. The JWT is issued by *your* auth provider — Chipi validates it against the JWKS rule you configured for this API key (typically `https://clerk.yourdomain.com/.well-known/jwks.json`).

In a server-side flow, you'd typically receive the JWT from your authenticated session for the user making the purchase:

```ts theme={null}
// however you obtain a signed session token for the requesting user —
// e.g. from Clerk's server SDK, NextAuth, your custom JWT service, etc.
const bearerToken: string = await getCurrentUserJwt();
```

## Browse the catalog

`getSkuList` returns a paginated response. `GetSkuListQuery` supports four filters today (all typed in `@chipi-stack/types@14.4.0`): `provider` (`"TET" | "CHIPI"`), `category` (`SkuCategory` enum — the upstream classification), `chipiCategory` (the admin-curated taxonomy code, e.g. `"RECARGAS"`, `"GIFT_CARDS"`, `"GENERAL"`, `"TELEFONIA"`), and `carrierName` (case-insensitive substring match against the carrier).

```ts theme={null}
const result = await sdk.getSkuList(
  { chipiCategory: "RECARGAS", carrierName: "Telcel", limit: 50 },
  bearerToken,
);

console.log(result.total, "matching SKUs");
console.log(result.data[0]); // { id, name, fixedAmount, carrierName, ... }
```

The response is a `PaginatedResponse<Sku>`: `{ data, total, page, limit, totalPages }`.

## Buy a SKU

The SDK's `purchaseSku` requires an on-chain wallet path that's reserved for Chipi's internal PWA. **For API-key integrations, call the REST endpoint directly.** Same auth headers as the read calls, body matches the `CreateSkuPurchaseInput` DTO:

```ts theme={null}
const body = {
  // Used purely as the idempotency key for the credits debit. Any
  // unique string is fine — UUID, your internal order id, "smoke-<ts>",
  // etc. Re-sending the same value within today returns the existing
  // Transaction row instead of double-charging.
  transactionHash: `order-${crypto.randomUUID()}`,
  skuId: chosenSku.id,
  skuReference: userPhoneNumber,         // phone, account, etc.
  currencyAmount: chosenSku.fixedAmount, // must match SKU's fixedAmount
  chain: "STARKNET",
  token: "USDC",
  walletAddress: "0x0", // required by the DTO but unused for credits flow
  orgMarkup: 5,         // optional, defaults to 0; recorded for analytics
};

const res = await fetch("https://api.chipipay.com/v1/sku-purchases", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": process.env.CHIPI_PUBLIC_KEY!,
    Authorization: `Bearer ${bearerToken}`,
  },
  body: JSON.stringify(body),
});

if (!res.ok) {
  throw new Error(`Purchase failed: ${res.status} ${await res.text()}`);
}

const transaction = await res.json();
const purchaseId: string = transaction.id;
console.log("Purchase created:", purchaseId, "status:", transaction.status);
```

The response is the freshly-created `Transaction` row. `status` is `PENDING` initially. The `ledgerEntryId` field references the debit Chipi just took against your credits balance.

## Poll for settlement

`getSkuPurchase` is a read; the SDK works fine here.

```ts theme={null}
async function waitForSettlement(id: string) {
  for (let i = 0; i < 12; i++) {
    const purchase = await sdk.getSkuPurchase(id, bearerToken);
    if (purchase.status === "SUCCESS" || purchase.status === "FAILED") {
      return purchase;
    }
    await new Promise((r) => setTimeout(r, 2_500));
  }
  throw new Error("Settlement timeout — set up a webhook for production");
}

const final = await waitForSettlement(purchaseId);
console.log(final.status, "file:", final.skuFileNumber);
```

Most transactions settle in under 5 seconds. Gift cards with pin-code delivery can take longer.

For production traffic, prefer a webhook over polling — configure one at `/configure/notifications` in the dashboard. Chipi delivers `sku-purchase.completed`, `sku-purchase.failed`, and `sku-purchase.refunded` events with HMAC-signed bodies.

## DEV sandbox

When the `x-api-key` header carries a DEV key (`pk_dev_...`), the entire purchase flow is sandboxed:

* **No real credits debited.** Your `OrgBalance.availableUsd` is never touched.
* **No carrier call.** TET is never invoked.
* **Deterministic SUCCESS.** Every purchase succeeds within milliseconds.

Sandbox responses are tagged so your integration code can distinguish them:

```ts theme={null}
// DEV response shape (key fields)
{
  id: "tx-...",
  status: "SUCCESS",                  // always SUCCESS in DEV
  ledgerEntryId: "le-dev-<uuid>",     // synthetic, not a real DB row
  skuFileNumber: "dev-sandbox-<ts>",  // not a real carrier receipt
}
```

You can integrate freely against `pk_dev_...` without worrying about credits or accidentally recharging a real phone. Swap the API key to `pk_prod_...` (and point the SKU lookup at the production catalog) and the same code path runs against the real carrier with no other changes.

## Putting it together

```ts theme={null}
import { ChipiServerSDK } from "@chipi-stack/backend";
import * as crypto from "crypto";

async function payTelcelRecharge(
  sdk: ChipiServerSDK,
  apiPublicKey: string,
  bearerToken: string,
  userPhone: string,
) {
  // 1. Find the right SKU — say, Telcel $200 prepaid
  const list = await sdk.getSkuList(
    { chipiCategory: "RECARGAS", carrierName: "Telcel", limit: 50 },
    bearerToken,
  );
  const sku = list.data.find((s) => s.fixedAmount === 200);
  if (!sku) throw new Error("SKU not found");

  // 2. Debit credits + queue fulfillment
  const res = await fetch("https://api.chipipay.com/v1/sku-purchases", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": apiPublicKey,
      Authorization: `Bearer ${bearerToken}`,
    },
    body: JSON.stringify({
      transactionHash: `order-${crypto.randomUUID()}`,
      skuId: sku.id,
      skuReference: userPhone,
      currencyAmount: 200,
      chain: "STARKNET",
      token: "USDC",
      walletAddress: "0x0",
    }),
  });
  if (!res.ok) throw new Error(`Purchase failed: ${res.status}`);
  const { id: purchaseId } = (await res.json()) as { id: string };

  // 3. Wait for settlement
  for (let i = 0; i < 12; i++) {
    const p = await sdk.getSkuPurchase(purchaseId, bearerToken);
    if (p.status === "SUCCESS") return p;
    if (p.status === "FAILED") throw new Error("Purchase failed");
    await new Promise((r) => setTimeout(r, 2_500));
  }
  throw new Error("Settlement timeout — set up a webhook for production");
}
```

> ✅ Verified against the live API on **2026-05-19** — exact pattern used in the production smoke purchase for `sku-VIR020` (Virgin \$20 MXN). 201 + PENDING from the POST, SUCCESS within 5 seconds on the first poll, real TET file number returned, recharge landed on a live phone.

## What's next

* Browse the catalog visually in the dashboard at `/admin/bills` to discover what's available.
* Configure your per-transaction markup at `/configure/skus`.
* Set up a webhook at `/configure/notifications` so you don't have to poll.
* See the [React guide](/services/bills/react) and the [Python guide](/services/bills/python) for idiomatic versions of the same REST + JWT pattern.
