PackflipPartner

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
StepCall
1POST /api/v2/customers; on 409, GET /api/v2/customers/by-external-id/{externalUserId}
2GET /api/v2/vending-machines
3POST /api/v2/operations with kind: "order" and a stored Idempotency-Key
4POST /api/v2/customers/{customerId}/cards/reveal for sealed cards
5GET /api/v2/customers/{customerId}/cards
6POST /api/v2/operations with kind: "buyback" or kind: "redemption"
7GET /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.codeMeaningWhat to do
insufficient_balanceYour balance cannot cover the charge.Tell the user to try later; top up your balance.
out_of_stockThe vending machine ran out.Refresh the catalog and offer another pack.
conflictA card is not in a state that allows the action.Re-fetch the cards and update your UI.
idempotency_conflictThe 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

  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.