# Off-chain lifecycle

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.

```text
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 → customerId` in your database. Creating a customer that already exists returns `409 conflict`; look it up instead.
- **Create your own record before each operation.** Insert a purchase (or sale, or shipment) row first, derive the `Idempotency-Key` from its ID, and reuse that key on every retry, including after a timeout. A replay returns the original operation with `replayed: true` and 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.** `409` has 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:

```ts
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](/docs/concepts#what-you-can-do-with-a-card).

## Reference implementation

TypeScript, server-only. `db` stands for your own persistence layer; replace it with your ORM.

```ts
// 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;
```

```ts
// 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;
}
```

```ts
// 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) }));
}
```

```ts
// 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

1. Add test credit (`POST /api/v2/sandbox/credits`).
2. Run `buyPack`, then `openPack` and `listCards`.
3. Call `sellBack` for one card and `ship` for another.
4. Move the test shipment along with `PUT /api/v2/operations/{operationId}/shipment` and check that your webhook handler receives `shipment.updated`.
5. Call `submitPurchase` again for a paid purchase and confirm the response has `replayed: true` and your balance did not change.
