Off-chain lifecycle
The whole purchase flow with retries: customer, draw, reveal, buyback or redemption.
The most common integration: a user buys a pack, opens it, and then sells the card back or has it shipped. This guide walks through it end to end, including what to do when a request times out. Every call is server-side, and everything here works in the sandbox.
find or create customer → list vending machines → order (sealed) → reveal
→ re-fetch cards → buyback or redemption → re-fetch operation and cards
| Step | Call |
|---|---|
| 1 | POST /api/v2/customers; on 409, GET /api/v2/customers/by-external-id/{externalUserId} |
| 2 | GET /api/v2/vending-machines |
| 3 | POST /api/v2/operations with kind: "order" and a stored Idempotency-Key |
| 4 | POST /api/v2/customers/{customerId}/cards/reveal for sealed cards |
| 5 | GET /api/v2/customers/{customerId}/cards |
| 6 | POST /api/v2/operations with kind: "buyback" or kind: "redemption" |
| 7 | GET /api/v2/operations/{operationId} and step 5 again |
Rules that make retries safe
- Map users to customers once. Store
externalUserId → customerIdin your database. Creating a customer that already exists returns409 conflict; look it up instead. - Create your own record before each operation. Insert a purchase (or sale, or shipment) row first, derive the
Idempotency-Keyfrom its ID, and reuse that key on every retry, including after a timeout. A replay returns the original operation withreplayed: trueand charges nothing twice. - Never reuse a key for a different request. That returns
409 idempotency_conflict. - Re-read after every change. Card state is the source of truth; do not infer it from the operation you just sent.
- Branch on
error.code, not on the status.409has four meanings:
error.code | Meaning | What to do |
|---|---|---|
insufficient_balance | Your balance cannot cover the charge. | Tell the user to try later; top up your balance. |
out_of_stock | The vending machine ran out. | Refresh the catalog and offer another pack. |
conflict | A card is not in a state that allows the action. | Re-fetch the cards and update your UI. |
idempotency_conflict | The key was used for a different request. | A bug in your key derivation; do not retry. |
Retry 5xx responses and network errors with the same key and a backoff. Do not retry 4xx responses without changing something.
Which actions to offer
A card is held, and can be bought back or redeemed, exactly when:
card.status === "active" && card.sealed === false && card.onchain === null
A sealed card (sealed: true) can only be revealed or, before refundableUntil, refunded. See the full matrix.
Reference implementation
TypeScript, server-only. db stands for your own persistence layer; replace it with your ORM.
// packflip.ts
const baseUrl = process.env.PACKFLIP_BASE_URL ?? "https://sandbox.partner.packflip.xyz";
const apiKey = process.env.PACKFLIP_API_KEY!; // never sent to the browser
export class PackflipError extends Error {
constructor(readonly status: number, readonly code: string, message: string) {
super(message);
}
}
type Init = { method?: string; body?: unknown; idempotencyKey?: string; safeToRetry?: boolean };
async function once<T>(path: string, init: Init): Promise<T> {
const response = await fetch(`${baseUrl}/api/v2${path}`, {
method: init.method ?? (init.body ? "POST" : "GET"),
headers: {
Authorization: `Bearer ${apiKey}`,
...(init.body ? { "Content-Type": "application/json" } : {}),
...(init.idempotencyKey ? { "Idempotency-Key": init.idempotencyKey } : {}),
},
body: init.body ? JSON.stringify(init.body) : undefined,
signal: AbortSignal.timeout(20_000),
});
const json = response.status === 204 ? undefined : await response.json();
if (!response.ok) throw new PackflipError(response.status, json?.error?.code, json?.error?.message);
return json as T;
}
/** Retries network errors and 5xx for GETs, operations with an idempotency key, and calls marked safe. */
export async function packflip<T>(path: string, init: Init = {}): Promise<T> {
const retryable = !init.body || Boolean(init.idempotencyKey) || init.safeToRetry;
for (let attempt = 0; ; attempt += 1) {
try {
return await once<T>(path, init);
} catch (error) {
const transient = !(error instanceof PackflipError) || error.status >= 500;
if (!retryable || !transient || attempt === 3) throw error;
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
}
}
}
type Card = {
id: string;
status: string;
sealed: boolean;
name: string | null;
buybackUsd: string | null;
onchain: unknown | null;
};
type Operation = { id: string; status: string; cards: Card[]; chargedUsd: string; creditedUsd: string; replayed: boolean };
export const isHeld = (card: Card) => card.status === "active" && !card.sealed && card.onchain === null;
// customers.ts
export async function customerFor(userId: string): Promise<string> {
const saved = await db.packflipCustomer.find(userId);
if (saved) return saved.customerId;
let customer: { id: string };
try {
customer = await packflip("/customers", { body: { externalUserId: userId } });
} catch (error) {
if (!(error instanceof PackflipError && error.code === "conflict")) throw error;
customer = await packflip(`/customers/by-external-id/${encodeURIComponent(userId)}`);
}
await db.packflipCustomer.save(userId, customer.id);
return customer.id;
}
// purchase.ts
export async function buyPack(userId: string, vendingMachineId: number) {
const customerId = await customerFor(userId);
// The record exists before the call, so every later attempt sends the same key.
const purchase = await db.purchase.create({ userId, customerId, vendingMachineId, status: "pending" });
return submitPurchase(purchase);
}
/** Also run by a job for purchases left pending by a crash or timeout. */
export async function submitPurchase(purchase: { id: string; customerId: string; vendingMachineId: number }) {
try {
const operation = await packflip<Operation>("/operations", {
body: {
kind: "order",
mode: "offchain",
customerId: purchase.customerId,
vendingMachineId: purchase.vendingMachineId,
quantity: 1,
reveal: "sealed",
},
idempotencyKey: `purchase-${purchase.id}`,
});
// On a replay this is the original operation; nothing was charged twice.
await db.purchase.update(purchase.id, { status: "paid", operationId: operation.id });
return operation.cards; // sealed: names and images are null until revealed
} catch (error) {
if (error instanceof PackflipError && error.status < 500) {
await db.purchase.update(purchase.id, { status: "failed", reason: error.code });
if (error.code === "out_of_stock") throw new UserFacingError("This pack just sold out.");
if (error.code === "insufficient_balance") throw new UserFacingError("Packs are unavailable right now.");
}
throw error; // transient: the purchase stays pending for the job to resubmit
}
}
export async function openPack(customerId: string, cardIds: string[]) {
// Revealing an already revealed card changes nothing, so this call needs no
// idempotency key and can be retried. It cannot be undone.
const { data } = await packflip<{ data: Card[] }>(`/customers/${customerId}/cards/reveal`, {
body: { cardIds },
safeToRetry: true,
});
return data;
}
export async function listCards(customerId: string) {
const { data } = await packflip<{ data: Card[] }>(`/customers/${customerId}/cards?limit=200`);
return data.map((card) => ({ ...card, canSellOrShip: isHeld(card) }));
}
// sell-or-ship.ts
export async function sellBack(customerId: string, cardIds: string[]) {
const sale = await db.sale.create({ customerId, cardIds, status: "pending" });
const operation = await packflip<Operation>("/operations", {
body: { kind: "buyback", mode: "offchain", customerId, cardIds },
idempotencyKey: `sale-${sale.id}`,
});
// creditedUsd is what Packflip credited you; what you pay the user is your decision.
await db.sale.update(sale.id, { status: "done", operationId: operation.id, creditedUsd: operation.creditedUsd });
return listCards(customerId);
}
export async function ship(customerId: string, cardIds: string[], address: Address, email?: string) {
const shipment = await db.shipment.create({ customerId, cardIds, status: "pending" });
const operation = await packflip<Operation & { shipment: { id: string; status: string } }>("/operations", {
body: {
kind: "redemption",
mode: "offchain",
customerId,
cardIds,
shipmentInfo: {
country: address.country, // a value from GET /shipping-countries, sent verbatim
name: address.name,
phone: address.phone,
address1: address.address1,
...(address.address2 ? { address2: address.address2 } : {}),
city: address.city,
...(address.state ? { state: address.state } : {}),
...(address.postalCode ? { postalCode: address.postalCode } : {}),
},
...(email ? { notifications: { channels: ["customer_email"], customerEmail: email } } : {}),
},
idempotencyKey: `shipment-${shipment.id}`,
});
await db.shipment.update(shipment.id, { status: operation.shipment.status, operationId: operation.id });
// Later updates arrive as shipment.updated webhooks; re-read the operation when one arrives.
}
On a 409 conflict from sellBack or ship, call listCards and refresh your UI: the card was already sold, shipped, or minted.
Testing it in the sandbox
- Add test credit (
POST /api/v2/sandbox/credits). - Run
buyPack, thenopenPackandlistCards. - Call
sellBackfor one card andshipfor another. - Move the test shipment along with
PUT /api/v2/operations/{operationId}/shipmentand check that your webhook handler receivesshipment.updated. - Call
submitPurchaseagain for a paid purchase and confirm the response hasreplayed: trueand your balance did not change.