Skip to main content
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.
npm install @chipi-stack/backend

Initialise the SDK

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:
// 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).
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:
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.
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:
// 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

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 and the Python guide for idiomatic versions of the same REST + JWT pattern.