@chipi-stack/chipi-react exposes the read side of the Bills flow as React Query hooks (useGetSkuList, useGetSku, useGetSkuPurchase). The purchase itself is a plain useMutation calling POST /v1/sku-purchases — the SDK’s usePurchaseSku hook wraps an on-chain wallet path that’s reserved for Chipi’s internal PWA, not what you want here.
Every endpoint on this page is guarded by Chipi’s BearerTokenGuard and requires both an x-api-key header and a customer JWT in the Authorization: Bearer header. The JWT is issued by your auth provider (Clerk, Auth0, BetterAuth, your own); Chipi validates it against the JWKS URL you register for this API key.
Install
npm install @chipi-stack/chipi-react @tanstack/react-query
Wrap your app in ChipiProvider
ChipiProvider accepts your ChipiSDKConfig. It mounts a built-in QueryClient, so you don’t need to provide one separately for Chipi hooks.
import { ChipiProvider } from "@chipi-stack/chipi-react";
export function App({ children }) {
return (
<ChipiProvider
config={{
apiPublicKey: process.env.NEXT_PUBLIC_CHIPI_PUBLIC_KEY!,
}}
>
{children}
</ChipiProvider>
);
}
Resolve a customer JWT
Every hook + the purchase mutation need a customer JWT. Wrap your auth-provider’s session-token getter in a getBearerToken function. With Clerk:
import { useAuth } from "@clerk/nextjs";
export function useBearerToken() {
const { getToken } = useAuth();
return () => getToken(); // returns Promise<string | null>
}
The same pattern works with Auth0 (getAccessTokenSilently), BetterAuth (session.token), or any provider that exposes a signed session token. The token is sent verbatim in the Authorization: Bearer header and validated against your registered JWKS URL.
Browse the catalog — useGetSkuList
Filters are typed in @chipi-stack/types@14.4.0: provider ("TET" | "CHIPI"), category (SkuCategory enum), chipiCategory (curated taxonomy code), carrierName (case-insensitive substring).
import { useGetSkuList } from "@chipi-stack/chipi-react";
function TelcelOptions() {
const getBearerToken = useBearerToken();
const { data, isLoading, isError } = useGetSkuList({
query: {
chipiCategory: "RECARGAS",
carrierName: "Telcel",
limit: 20,
},
getBearerToken,
});
if (isLoading) return <p>Loading…</p>;
if (isError) return <p>Failed to load catalog</p>;
return (
<ul>
{data.data.map((sku) => (
<li key={sku.id}>
{sku.name} — ${sku.fixedAmount} MXN
</li>
))}
</ul>
);
}
The hook is automatically disabled until getBearerToken returns a non-null token, so it’s safe to render before sign-in.
data shape: PaginatedResponse<Sku> — { data: Sku[], total, page, limit, totalPages }.
Look up a single SKU — useGetSku
import { useGetSku } from "@chipi-stack/chipi-react";
function SkuDetail({ skuId }: { skuId: string }) {
const getBearerToken = useBearerToken();
const { data: sku, isLoading } = useGetSku({ id: skuId, getBearerToken });
if (isLoading || !sku) return <p>Loading…</p>;
return <p>{sku.name} costs ${sku.fixedAmount} MXN</p>;
}
Submit a purchase — custom useMutation against /v1/sku-purchases
Use React Query’s useMutation to wrap a plain fetch. The request body matches the CreateSkuPurchaseInput DTO on chipi-back. transactionHash is purely an idempotency key — any unique string is fine; re-sending the same value within the day returns the existing Transaction row instead of double-charging.
import { useMutation } from "@tanstack/react-query";
interface PurchaseInput {
skuId: string;
skuReference: string; // phone, account, etc.
currencyAmount: number; // must match sku.fixedAmount
orgMarkup?: number; // analytics; defaults to 0
}
function useSubmitPurchase() {
const getBearerToken = useBearerToken();
return useMutation({
mutationFn: async (input: PurchaseInput) => {
const bearerToken = await getBearerToken();
if (!bearerToken) throw new Error("Sign in first");
const res = await fetch(
`${process.env.NEXT_PUBLIC_CHIPI_API_URL}/v1/sku-purchases`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.NEXT_PUBLIC_CHIPI_PUBLIC_KEY!,
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify({
transactionHash: `order-${crypto.randomUUID()}`,
skuId: input.skuId,
skuReference: input.skuReference,
currencyAmount: input.currencyAmount,
chain: "STARKNET",
token: "USDC",
walletAddress: "0x0", // required by the DTO but unused for credits flow
orgMarkup: input.orgMarkup ?? 0,
}),
},
);
if (!res.ok) throw new Error(`Purchase failed: ${res.status}`);
return res.json(); // Transaction
},
});
}
The mutation returns a Transaction. id is what you pass to useGetSkuPurchase to poll for settlement.
Poll for settlement — useGetSkuPurchase
import { useGetSkuPurchase } from "@chipi-stack/chipi-react";
function PurchaseStatus({ purchaseId }: { purchaseId: string }) {
const getBearerToken = useBearerToken();
const { data: purchase } = useGetSkuPurchase({
id: purchaseId,
getBearerToken,
queryOptions: {
// React Query refetches while status is non-terminal
refetchInterval: (q) => {
const s = q.state.data?.status;
return s === "SUCCESS" || s === "FAILED" ? false : 2_500;
},
},
});
if (!purchase) return <p>Loading…</p>;
if (purchase.status === "SUCCESS") return <p>✅ Done — file {purchase.skuFileNumber}</p>;
if (purchase.status === "FAILED") return <p>❌ Failed</p>;
return <p>{purchase.status}…</p>;
}
For production traffic, prefer a webhook over polling — configure one at /configure/notifications in the dashboard.
DEV sandbox
When NEXT_PUBLIC_CHIPI_PUBLIC_KEY is a DEV key (pk_dev_...), every purchase 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 UI can distinguish them — ledgerEntryId starts with le-dev- and skuFileNumber starts with dev-sandbox-. Swap to pk_prod_... (and point the catalog reads at the production data) and the same code path runs against the real carrier.
Putting it together
import { useState } from "react";
import { useGetSkuList, useGetSkuPurchase } from "@chipi-stack/chipi-react";
export function TelcelRecharge({ userPhone }: { userPhone: string }) {
const getBearerToken = useBearerToken();
const [purchaseId, setPurchaseId] = useState<string | null>(null);
const { data: list } = useGetSkuList({
query: { chipiCategory: "RECARGAS", carrierName: "Telcel", limit: 20 },
getBearerToken,
});
const submit = useSubmitPurchase();
const { data: purchase } = useGetSkuPurchase({
id: purchaseId ?? "",
getBearerToken,
queryOptions: {
enabled: !!purchaseId,
refetchInterval: (q) => {
const s = q.state.data?.status;
return s === "SUCCESS" || s === "FAILED" ? false : 2_500;
},
},
});
const buy = async (sku: any) => {
const result = await submit.mutateAsync({
skuId: sku.id,
skuReference: userPhone,
currencyAmount: sku.fixedAmount,
});
setPurchaseId(result.id);
};
return (
<div>
{list?.data.map((sku) => (
<button key={sku.id} onClick={() => buy(sku)}>
${sku.fixedAmount} MXN
</button>
))}
{purchase && <p>Status: {purchase.status}</p>}
</div>
);
}
What’s next
- Need server-side billing instead? See the Node guide — same REST pattern with
fetch + customer JWT.
- Need to filter by carrier? Use
carrierName — case-insensitive substring match.
- Need credits to actually charge for purchases? See Billing & Credits.
✅ Verified against the live API on 2026-05-19 — same REST contract used in the production smoke purchase against Legaria (transaction tx-75456794-f4b8-4835-8282-0f89f1964eda, sku-VIR020 Virgin $20 MXN, settled SUCCESS in < 5s).