# Packflip Partner API: complete guides Source: https://partner.packflip.xyz/llms-full.txt. Exact schemas: https://partner.packflip.xyz/openapi.json. --- # Sandbox quickstart Go from an empty organization to a drawn card in about ten minutes. Everything here runs in the sandbox, so no real money or cards are involved. ## 1. Create an organization and a key 1. Sign in at [sandbox.partner.packflip.xyz](https://sandbox.partner.packflip.xyz/dashboard). The sandbox and production share one login, but their data is separate. 2. Create or select an organization. API keys, balance, and customers belong to the organization, not to you. 3. Open **API keys**, create a key, and copy it. It starts with `pk_` and is shown only once. ```bash export PACKFLIP_BASE_URL=https://sandbox.partner.packflip.xyz export PACKFLIP_API_KEY=pk_... ``` Keep the key on your server. Every request sends it as a bearer token. ## 2. Add test credit Operations draw from a prepaid USD balance. In the sandbox you can add test credit instead of sending testnet USDC, either with **Add $100 test credit** in the console or through the API: ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/sandbox/credits" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" ``` ```json { "ledgerEntryId": "…", "creditedUsd": "100.000000", "balance": { "currency": "USD", "cash": "100.000000", "bonus": "0.000000", "total": "100.000000" } } ``` Test credit can be added once an hour, up to a $1,000 balance. The endpoint returns `404` in production. ## 3. Pick a vending machine A vending machine is a pack you can sell. Note its `id` and `priceUsd`. ```bash curl "$PACKFLIP_BASE_URL/api/v2/vending-machines" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" ``` ## 4. Create a customer A customer represents one of your users and owns the cards they draw. Set `externalUserId` to your own user ID so you can look the customer up later. ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/customers" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalUserId": "user_123" }' ``` The response contains the customer ID, for example `cus_…`. ## 5. Draw a pack Orders are commerce writes, so they need an `Idempotency-Key`. Generate a new one for each purchase and reuse it only when retrying that same purchase. ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/operations" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-user_123-0001" \ -d '{ "kind": "order", "customerId": "cus_…", "vendingMachineId": 1, "quantity": 1 }' ``` The response is an operation with `status: "completed"`, the amount charged in `chargedUsd`, and the drawn cards in `cards`. Send the same request again and you get the same operation with `replayed: true`, and no second charge. ## 6. Show the cards ```bash curl "$PACKFLIP_BASE_URL/api/v2/customers/cus_…/cards" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" ``` Each card has a `name`, an `image` URL with an `imageSrcset` for responsive images, and `buybackUsd`, its current buyback value. ## 7. Buy a card back If your user does not want a card, buy it back. The value is credited to your balance. ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/operations" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: buyback-user_123-0001" \ -d '{ "kind": "buyback", "mode": "offchain", "customerId": "cus_…", "cardIds": ["card_…"] }' ``` ## Next steps - [Core concepts](https://partner.packflip.xyz/docs/concepts): accounts, customers, cards, and operations. - [Cards and operations](https://partner.packflip.xyz/docs/cards): sealed packs, refunds, redemption, and shipping. - [Webhooks](https://partner.packflip.xyz/docs/webhooks): react to deposits and operations without polling. - [Going live](https://partner.packflip.xyz/docs/going-live): what changes in production. --- # 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](https://partner.packflip.xyz/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(path: string, init: Init): Promise { 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(path: string, init: Init = {}): Promise { const retryable = !init.body || Boolean(init.idempotencyKey) || init.safeToRetry; for (let attempt = 0; ; attempt += 1) { try { return await once(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 { 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("/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("/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("/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. --- # Core concepts ## Environments | Environment | Base URL | Network | Money | | --- | --- | --- | --- | | Production | `https://partner.packflip.xyz` | Base | Real USDC and real cards | | Sandbox | `https://sandbox.partner.packflip.xyz` | Base Sepolia | Test credit and a synthetic catalog | Both environments run the same API and share one console login, so an organization exists in both. Everything else is separate: API keys, balances, customers, cards, and webhook endpoints. A sandbox key does not work in production, and the reverse. ## Account and organization Your Partner account is an organization in the console. Every API key acts for the whole organization, and every request is scoped to it. There is no partner ID in URLs: the key identifies the account. `GET /api/v2/account` returns the account, including its public ID (`par_…`). ## Balance and ledger Operations are paid from a prepaid USD balance with two buckets: - **cash**: value you funded, plus buyback credits. - **bonus**: promotional value granted by Packflip. It is spent before cash. A refund returns value to the bucket it was spent from. `GET /api/v2/balance` returns `cash`, `bonus`, and `total`. Every change is an immutable entry in the ledger (`GET /api/v2/ledger-entries`), with a `kind` of `TOPUP`, `BONUS`, `BUYBACK`, `SPEND`, or `ADJUSTMENT`. All money values are decimal strings with six places, such as `"25.000000"`. Parse them with a decimal type, never as floating-point numbers. ## Catalog A vending machine (`GET /api/v2/vending-machines`) is a pack with a fixed `priceUsd`. Each draw selects a card at random according to the machine's published odds and stock. Each machine has three image fields: - `image`: the pack artwork, possibly animated. Show this one. - `imageSrcset`: the same artwork at 256, 512, and 1024 pixels wide, for `srcset`. `null` when resizing is unavailable. - `staticImage`: a still frame for first paint, for example as a placeholder while `image` loads or where animation is unwanted. It can be an empty string; fall back to `image`. ## Customers A customer (`cus_…`) is one of your end users as Packflip sees them: a pseudonymous identity that cards and operations attach to. Packflip uses customers to understand purchase behaviour across packs, and they will power the reports you see in the console. - `externalUserId` is optional. Set it to your own user ID when your customers map to users, then find a customer with `GET /api/v2/customers/by-external-id/{externalUserId}`. Once set, it cannot be moved to another customer. Leave it out when that does not fit your product, for example when cards go to randomly chosen winners. - `attributes` is an optional JSON object of analytics properties, such as `{ "plan": "pro", "country": "JP", "channel": "campaign-2026-09" }`. Updates merge into the stored object, and a key set to `null` is removed. Limits: 50 keys, values nested at most two levels, 8 KB in total, and keys may not start with `$`. Customers hold no contact details and no wallets. Shipping details are sent with each redemption, and the destination wallet with each mint. Do not put names, email addresses, or other directly identifying data in `attributes`. ```bash curl -X PATCH "$PACKFLIP_BASE_URL/api/v2/customers/cus_…" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "attributes": { "plan": "pro", "trial": null } }' ``` ## Cards A card (`card_…`) is a real graded collectible, held by Packflip for the customer until they decide what to do with it. | Status | Meaning | | --- | --- | | `active` | Owned by the customer: held by Packflip, or minted to their wallet. | | `reserved` | Legacy state for cards drawn before they were minted. New integrations do not create it. | | `buyback_pending` | A minted card waiting for its on-chain burn. | | `bought_back` | Sold back to Packflip. | | `redeemed` | Shipped, or being shipped, to the customer. | | `refunded` | A sealed card that was refunded. | | `cancelled` | The draw was cancelled and the card returned to stock (legacy draw-and-mint orders only). | A card also has: - `sealed`: `true` while the card is hidden. Its `name`, `image`, `buybackUsd`, and `metadata` are `null` until it is revealed. - `onchain`: set once a mint is authorized. `onchain.mintedAt` stays `null` until the mint transaction confirms. - `buybackUsd`: the current market buyback value. It changes over time; it is not a quote. ### What you can do with a card Every action needs `status` to be `active`. The other two fields decide the rest: | `sealed` | `onchain` | State | Allowed actions | | --- | --- | --- | --- | | `true` | `null` | Sealed | Reveal; refund until `refundableUntil` | | `false` | `null` | Held | Off-chain buyback, redemption, mint | | `false` | set, `mintedAt` is `null` | Mint pending | Submit or cancel the mint (see [On-chain cards](https://partner.packflip.xyz/docs/onchain)) | | `false` | set, `mintedAt` is set | Minted | On-chain buyback | A card is **held**, and eligible for off-chain buyback and redemption, exactly when `status` is `active`, `sealed` is `false`, and `onchain` is `null`. A cancelled or expired mint clears `onchain`, so the card is held again. Any other state returns `409 conflict` for the off-chain actions. ## Operations Every change to cards or balance is an operation (`op_…`), created with `POST /api/v2/operations`: | `kind` | `mode` | What it does | | --- | --- | --- | | `order` | `offchain` | Draws cards and charges the pack price. | | `refund` | `offchain` | Refunds sealed cards within their refund window. | | `buyback` | `offchain` | Buys back held cards and credits their value. | | `buyback` | `onchain` | Buys back minted cards once their NFTs are burned. | | `redemption` | `offchain` | Ships held cards to the customer and charges shipping. | | `mint` | `onchain` | Withdraws held cards as NFTs to the customer's wallet. | An operation's `status` is `pending`, `awaiting_chain`, `completed`, `failed`, or `cancelled`. Off-chain operations complete within the request. On-chain operations return `awaiting_chain` until the transactions confirm. See [On-chain cards](https://partner.packflip.xyz/docs/onchain). --- # Funding your balance Production operations are paid from a prepaid USD balance that you top up with USDC on Base. In the sandbox you can also use test credit. ## Deposit address Each organization has one dedicated deposit address per environment. Find it on the console's **Funding** page, or: ```bash curl "$PACKFLIP_BASE_URL/api/v2/funding/receiving-address" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" ``` ```json { "receivingWallet": { "address": "0x…", "chainId": 8453, "asset": "USDC" } } ``` | Environment | Network | Token | | --- | --- | --- | | Production | Base (`8453`) | Circle USDC `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | | Sandbox | Base Sepolia (`84532`) | Circle USDC `0x036CbD53842c5426634e7929541eC2318f3dCF7e` | The address never changes, and every confirmed USDC transfer into it is credited 1 USDC = 1 USD, with no minimum. **Only send that USDC contract on that network.** Other tokens and networks are not credited and may be lost. ## How deposits are credited 1. A transfer reaches the address. Packflip records it as `detected` and emits `balance.topup.detected`. 2. Packflip verifies the receipt on-chain: the right token, the right recipient, and a successful transaction. 3. The deposit becomes `confirmed`, a `TOPUP` ledger entry is added to cash, and `balance.topup.confirmed` is emitted. If verification fails, the deposit becomes `failed` and `balance.topup.failed` is emitted. Deposits are normally detected within a minute. If one seems missing, scan the address now: ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/funding/refresh" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" ``` List deposits with `GET /api/v2/funding-transactions`. ## Funding intents (optional) A funding intent records how much you plan to send, which helps reconcile top-ups on your side. It is not credit and does not reserve anything. ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/funding-intents" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "requestedUsd": "500.00" }' ``` ## Sandbox test credit In the sandbox, `POST /api/v2/sandbox/credits` adds $100 of cash balance without any on-chain transfer. It works once an hour per organization and only tops a balance up to $1,000. A refused request returns `409 conflict`, with a `Retry-After` header when the hourly limit applies. In production the endpoint returns `404`. You can still test the real deposit flow in the sandbox by sending Base Sepolia USDC, which Circle's faucet provides, to your sandbox deposit address. ## Low balance An operation that costs more than your spendable balance fails with `409 insufficient_balance` and changes nothing. Watch `GET /api/v2/balance`, or `balance.topup.confirmed` events, and top up before you run out. --- # Cards and operations Every change to cards or balance is an operation, created with `POST /api/v2/operations`. This guide covers the off-chain operations; minting is covered in [On-chain cards](https://partner.packflip.xyz/docs/onchain). For the whole flow in one place, with retries, see [Off-chain lifecycle](https://partner.packflip.xyz/docs/lifecycle). Each request needs an `Idempotency-Key` header. See [Errors and retries](https://partner.packflip.xyz/docs/errors). ## Draw a pack ```json { "kind": "order", "customerId": "cus_…", "vendingMachineId": 1, "quantity": 3, "reveal": "on_create", "metadata": { "cartId": "c_981" } } ``` - `quantity` is 1 to 10. The charge is `priceUsd × quantity`, returned as `chargedUsd`. - `metadata` is any JSON up to 20 KB. It is stored on the operation and returned as is. - The drawn cards are in the response's `cards` array. Possible failures: `409 insufficient_balance`, `409 out_of_stock`, and `404 not_found` for an unknown customer or an unavailable vending machine. ## Sealed packs With `"reveal": "on_create"` (the default), cards are revealed at once and cannot be refunded. With `"reveal": "sealed"`, cards stay hidden: `sealed` is `true`, their `name`, `image`, and `buybackUsd` are `null`, and `refundableUntil` shows when the refund window closes. This lets you build an "open the pack" moment, or let users change their mind. Reveal cards when the user opens them. Revealing cannot be undone. ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/customers/cus_…/cards/reveal" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "cardIds": ["card_…", "card_…"] }' ``` Refund sealed cards before they are revealed and before `refundableUntil`. The cards return to stock, and each card's share of the pack price is credited back to the bucket (cash or bonus) it was paid from. ```json { "kind": "refund", "customerId": "cus_…", "cardIds": ["card_…"] } ``` A sealed card whose window has passed counts as revealed. Buyback, redemption, and minting all require a revealed card; a sealed one returns `409 conflict`. See [what you can do with a card](https://partner.packflip.xyz/docs/concepts#what-you-can-do-with-a-card) for the full rule. ## List a customer's cards `GET /api/v2/customers/{customerId}/cards?limit=50` returns cards newest first, up to 200 per request. For images, use `image` as the default source and `imageSrcset` for responsive sizes: ```html {name} ``` `buybackUsd` is the current market value Packflip pays for the card. It follows the market and can change between two requests. ## Buy back held cards ```json { "kind": "buyback", "mode": "offchain", "customerId": "cus_…", "cardIds": ["card_…"] } ``` The operation completes immediately. The sum of the cards' current buyback values is credited to your cash balance and returned as `creditedUsd`. Up to 10 cards per operation. What you pay your user for a buyback is up to you; Packflip credits your balance. For cards that have been minted, use `"mode": "onchain"`. See [On-chain cards](https://partner.packflip.xyz/docs/onchain). ## Redeem cards for shipping Redemption ships the physical cards to your customer. Check where you can ship, and the fee, first: ```bash curl "$PACKFLIP_BASE_URL/api/v2/shipping-countries" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" ``` ```json { "data": [{ "country": "Japan", "feeUsd": "3.000000" }] } ``` `country` is the English country name Packflip ships to. Send it back verbatim as `shipmentInfo.country`; an ISO 3166-1 alpha-2 code such as `JP` also works. ```json { "kind": "redemption", "mode": "offchain", "customerId": "cus_…", "cardIds": ["card_…"], "shipmentInfo": { "country": "JP", "name": "Hanako Yamada", "phone": "+81 …", "address1": "…", "city": "…", "postalCode": "…" } } ``` - `shipmentInfo.country` is required: an English country name or an ISO 3166-1 alpha-2 code. Unknown values return `400`. - `shipmentInfo` is a JSON object up to 20 KB, stored with the shipment. The API validates only `country`; Packflip's fulfilment team reads the rest, so send these fields: | Field | Required to ship | Notes | | --- | --- | --- | | `country` | Yes (validated) | From `GET /api/v2/shipping-countries`, or an alpha-2 code. | | `name` | Yes | Recipient's full name, in the script the carrier expects for that country. | | `phone` | Yes | With country code, for example `+81 90 1234 5678`. Carriers call it on delivery problems. | | `address1` | Yes | Street address. | | `address2` | No | Building, apartment, or unit. | | `city` | Yes | City or locality. | | `state` | Where used | State, province, or prefecture. | | `postalCode` | Where used | Required in countries that use postal codes. | Collect all of them in your checkout. If Packflip cannot deliver to the address, the shipment moves to `exception` and `statusReason` says why. - The shipping fee is charged to your balance. - Add `notifications` to have Packflip email about the shipment (optional): ```json "notifications": { "channels": ["customer_email", "partner_email"], "customerEmail": "fan@example.com" } ``` `customer_email` emails `customerEmail`, which is required with that channel. `partner_email` emails the notification address set in the Console under **Webhooks**; without one the request returns `400`. Emails go out when the shipment is created, shipped, delivered, or hits an exception, at most once per status. Customer emails name your organization and come from Packflip; replies go to Packflip support. The sandbox only sends `partner_email`. The operation's `shipment` has a `status` of `created`, `shipped`, `in_transit`, `delivered`, or `exception`, plus `carrier`, `trackingNumber`, and `trackingUrl` once known. Packflip ships the cards and updates the shipment; each change emits [`shipment.updated`](https://partner.packflip.xyz/docs/webhooks). Shipment events do not repeat the recipient's address. ### When a shipment cannot go out If Packflip cannot ship, for example because the address is incomplete, the shipment moves to `exception` and `statusReason` says what to fix. You get `shipment.updated`, and the notification email set in the Console receives an email even if the redemption did not ask for `partner_email`. Correct the address while the shipment is `created` or `exception`: ```bash curl -X PUT "$PACKFLIP_BASE_URL/api/v2/operations/op_…/shipment/address" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "shipmentInfo": { "country": "Japan", "name": "Hanako Yamada", "phone": "+81 …", "address1": "…", "city": "…", "postalCode": "…" } }' ``` - Send the complete `shipmentInfo`; it replaces the stored one. - The country cannot change, because the shipping fee was charged for it. Returns `400` otherwise; contact support to ship elsewhere. - The shipment returns to `created`, `statusReason` is cleared, `addressUpdatedAt` is set, and `shipment.updated` is sent. Packflip ships it from there. - Once the shipment is `shipped`, the call returns `409 conflict`. `shipment.updated` is always sent, whether or not you ask for emails. The operation's `shipment.notifications.emails` shows which emails went out. In the sandbox nobody ships anything, so move a test shipment along yourself with `PUT /api/v2/operations/{operationId}/shipment`. Production returns `403` for that call. ## Look up an operation `GET /api/v2/operations/{operationId}` returns the operation with its `cards` (cards it created) and `affectedCards` (cards it acted on). --- # On-chain cards Cards are held by Packflip by default. When a user wants the NFT, you can mint the card to their wallet on Base. A minted card can still be bought back, by burning the NFT. On-chain actions are authorized by Packflip and sent from the **customer's wallet**. Packflip never holds your users' keys, and your API key never signs transactions. | Environment | Chain | | --- | --- | | Production | Base (`8453`) | | Sandbox | Base Sepolia (`84532`) | ## 1. Request a mint Minting is free: nothing is charged to your balance. The customer pays gas. `walletAddress` is the wallet that receives the NFTs; the customer does not need to register it first. ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/operations" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: mint-user_123-0001" \ -d '{ "kind": "mint", "customerId": "cus_…", "cardIds": ["card_…"], "walletAddress": "0xabc…" }' ``` The operation returns with `status: "awaiting_chain"` and one entry per card in `onchainOrders`: ```json { "id": "oco_…", "cardId": "card_…", "chainId": 84532, "vendingMachineAddress": "0x…", "collectionAddress": "0x…", "tokenId": "1234", "buyerAddress": "0xabc…", "priceBaseUnits": "0", "deadline": "2026-09-17T08:15:00.000Z", "calldata": "0x…", "transactionHash": null, "confirmedAt": null } ``` The cards must be revealed and held by the customer. Addresses are returned in lowercase. ## 2. Send the transaction from the customer's wallet Send `calldata` to `vendingMachineAddress` with no value, before `deadline` (15 minutes after the authorization is issued). With viem: ```ts import { createWalletClient, custom } from "viem"; import { baseSepolia } from "viem/chains"; const wallet = createWalletClient({ chain: baseSepolia, transport: custom(window.ethereum) }); const [account] = await wallet.requestAddresses(); const hash = await wallet.sendTransaction({ account, to: order.vendingMachineAddress, data: order.calldata, }); ``` The transaction must come from `buyerAddress`, on `chainId`. ## 3. Wait for confirmation Packflip watches the chain and confirms the mint on its own. When every card in the operation is minted, the operation becomes `completed`, each card's `onchain.mintedAt` is set, and `operation.completed` is emitted. To speed this up, report the hash as soon as you have it. This is optional: ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/operations/op_…/onchain-orders/oco_…/submit" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "transactionHash": "0x…" }' ``` A mint is accepted only if the transaction succeeded and either matches the signed calldata or mints that token to the wallet. Otherwise the order gets a `failureReason` and `operation.onchain_failed` is emitted. If you think an event was missed, `POST /api/v2/operations/{operationId}/refresh` checks the chain immediately. ## Expired authorizations If `deadline` passes before the transaction lands, choose one: - **Re-sign**: `POST /api/v2/operations/{operationId}/resign` issues fresh signatures for the same token IDs and cards. Returns `409` if nothing has expired. - **Cancel**: `POST /api/v2/operations/{operationId}/cancel` cancels the mint. The cards stay held, and `operation.onchain_cancelled` is emitted. Returns `409` while any signature is still valid. Both first check the chain, so a transaction that landed late is confirmed rather than cancelled. ## Buy back a minted card ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/operations" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: buyback-onchain-user_123-0001" \ -d '{ "kind": "buyback", "mode": "onchain", "customerId": "cus_…", "cardIds": ["card_…"] }' ``` The operation returns `awaiting_chain` with one entry per card in `onchainBurns`, and the cards become `buyback_pending`. From the wallet that holds the NFT: 1. Approve the handler once per collection: call `setApprovalForAll(handlerAddress, true)` on `collectionAddress`. 2. Send `calldata` to `handlerAddress` before `deadline`. ```ts import { erc721Abi } from "viem"; await wallet.writeContract({ account, address: burn.collectionAddress, abi: erc721Abi, functionName: "setApprovalForAll", args: [burn.handlerAddress, true], }); await wallet.sendTransaction({ account, to: burn.handlerAddress, data: burn.calldata }); ``` When the burn confirms, the card becomes `bought_back`, the buyback value quoted when the operation was created is credited to your cash balance, and `buyback.completed` is emitted once every burn in the operation is settled. You can report the hash early with `POST /api/v2/operations/{operationId}/onchain-burns/{onchainBurnId}/submit`. Expired burn authorizations can be re-signed or cancelled the same way as mints. A cancelled burn leaves the card minted and `active`. ## Sandbox notes The sandbox mints on Base Sepolia from a test collection. You need Base Sepolia ETH for gas, available from public faucets. If on-chain actions are not configured in an environment, these requests return `503 service_unavailable`. --- # Webhooks Webhooks tell your server when something changes, so you do not have to poll: deposits, operations, and shipments. ## Add an endpoint In the console, open **Webhooks** and add an endpoint URL. Choose **All events**, or pick specific event types. Copy the signing secret: it is shown only once. You can also manage endpoints with the API: ```bash curl -X POST "$PACKFLIP_BASE_URL/api/v2/webhooks" \ -H "Authorization: Bearer $PACKFLIP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/packflip", "subscribedEvents": ["*"] }' ``` The response includes `signingSecret` once. Endpoint URLs must be public HTTPS URLs on port 443. Sandbox and production endpoints are configured separately. | Action | API | | --- | --- | | List endpoints | `GET /api/v2/webhooks` | | Change URL or events | `PATCH /api/v2/webhooks/{id}` with `url` or `subscribedEvents` | | Disable or enable | `PATCH` with `active`, or `DELETE` to disable | | Rotate the secret | `PATCH` with `"rotateSigningSecret": true`; the response has the new secret | | Delivery history | `GET /api/v2/webhooks/{id}/deliveries` | | Resend an event | `POST /api/v2/webhooks/{id}/replay` with `{ "eventId": "evt_…" }` | ## Events | Type | When | | --- | --- | | `balance.topup.detected` | A USDC deposit reached your address and is being verified. | | `balance.topup.confirmed` | A deposit was verified and credited. | | `balance.topup.failed` | A detected deposit failed verification. | | `operation.completed` | An order or mint finished. | | `operation.awaiting_chain` | Mint or on-chain buyback authorizations are ready to submit. | | `operation.onchain_failed` | A reported mint transaction failed or did not match. | | `operation.onchain_cancelled` | An expired mint authorization was cancelled. | | `operation.cancelled` | An on-chain operation ended with nothing confirmed. | | `buyback.completed` | A buyback finished and its credit was posted. | | `refund.completed` | Sealed cards were refunded. | | `redemption.completed` | Cards were redeemed for shipping. | | `shipment.updated` | Packflip changed a shipment's status or tracking. | Subscribing to `*` also delivers event types added in the future. Ignore types you do not handle. ## Payload Every delivery is a `POST` with a JSON body: ```json { "id": "evt_…", "type": "balance.topup.confirmed", "apiVersion": "2026-09-12", "environment": "production", "createdAt": "2026-09-17T08:00:00.000Z", "partnerId": "par_…", "data": { "fundingTransactionId": "ftx_…", "amountUsd": "500.000000", "chainId": 8453, "transactionHash": "0x…" } } ``` `data` depends on the type and always carries the IDs you need to fetch the full object, such as `operationId` and `customerId`. Treat events as notifications: when in doubt, fetch the current state from the API. ### `shipment.updated` Sent whenever Packflip changes a redemption's shipment: its status, carrier, tracking, or note. Re-recording the same values sends nothing. ```json { "type": "shipment.updated", "data": { "operationId": "op_…", "customerId": "cus_…", "previousStatus": "created", "changedFields": ["status", "carrier", "trackingNumber", "trackingUrl"], "shipment": { "id": "shp_…", "status": "shipped", "carrier": "Yamato", "trackingNumber": "1234-5678-9012", "trackingUrl": "https://…", "statusReason": null, "updatedAt": "2026-09-18T02:00:00.000Z" } } } ``` - `status` moves through `created`, `shipped`, `in_transit`, and `delivered`. `exception` means the shipment is on hold; `statusReason` explains why. For an address problem, correct it with `PUT /api/v2/operations/{operationId}/shipment/address` (see [Cards](https://partner.packflip.xyz/docs/cards)); the shipment returns to `created`. - `changedFields` can include `shipmentInfo` when the address was corrected. The event never repeats the address; fetch the operation to read it. - Use this event to notify your customer in your own product. Packflip emails them only when the redemption asked for `customer_email` (see [Cards](https://partner.packflip.xyz/docs/cards)). - In the sandbox, move a shipment along yourself with `PUT /api/v2/operations/{operationId}/shipment` to test your handler. ## Verify the signature Each delivery has three headers: - `webhook-id`: the event ID - `webhook-timestamp`: Unix time in seconds - `webhook-signature`: `v1,` followed by the hex HMAC-SHA256 of `{webhook-timestamp}.{raw body}`, keyed with the endpoint's signing secret Verify against the **raw** request body, before parsing JSON, and reject old timestamps to prevent replays. ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyPackflipWebhook(rawBody: string, headers: Headers, secret: string) { const timestamp = headers.get("webhook-timestamp") ?? ""; const signature = headers.get("webhook-signature") ?? ""; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = `v1,${createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")}`; const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } // Next.js route handler export async function POST(request: Request) { const rawBody = await request.text(); if (!verifyPackflipWebhook(rawBody, request.headers, process.env.PACKFLIP_WEBHOOK_SECRET!)) { return new Response("invalid signature", { status: 400 }); } const event = JSON.parse(rawBody); // Deduplicate on event.id, then handle event.type. return new Response(null, { status: 204 }); } ``` ## Delivery and retries - Respond with any `2xx` status within 10 seconds. Do slow work after responding. - Other responses, timeouts, and connection errors are retried with exponential backoff (2, 4, 8, … minutes, at most an hour apart), up to 8 attempts. After that the delivery is marked `failed`; replay it once your endpoint is fixed. - Deliveries can arrive more than once and out of order. Deduplicate on `id` and do not assume ordering. - Disabling an endpoint pauses its deliveries. Events are matched to endpoints when they happen, so changing subscriptions does not add deliveries for past events. The console's **Webhooks** page shows recent deliveries, their status, and the last response code. --- # Errors, idempotency, and retries ## Error shape Every error response has the same body, so you can branch on `error.code`: ```json { "error": { "code": "insufficient_balance", "message": "Insufficient spendable balance." } } ``` | Code | Status | What to do | | --- | --- | --- | | `invalid_request` | 400 | Fix the request. `message` names the field. Do not retry unchanged. | | `unauthorized` | 401 | Send a valid key as `Authorization: Bearer pk_…`, for the right environment. | | `forbidden` | 403 | The key cannot perform this action. | | `not_found` | 404 | The resource does not exist in this account and environment. | | `conflict` | 409 | The current state does not allow the action, for example a sealed or already used card. Fetch the resource and decide. | | `idempotency_conflict` | 409 | The `Idempotency-Key` was already used with different data. Use a new key for a new request. | | `insufficient_balance` | 409 | Top up, then retry with the same key. | | `out_of_stock` | 409 | The vending machine cannot supply the quantity. | | `service_unavailable` | 503 | A dependency is not ready. Retry later with backoff. | Unrecognized fields in a request body are rejected with `400`, so typos surface immediately. Responses, on the other hand, may gain new fields at any time: ignore fields you do not know. ## Idempotency `POST /api/v2/operations` requires an `Idempotency-Key` header of 1 to 255 characters. - The first request with a key creates the operation and returns `201`. - Repeating the **same** request with the same key returns the original operation with `200` and `"replayed": true`. Nothing is charged or credited again. - Reusing the key with **different** data returns `409 idempotency_conflict`. - Keys are scoped to your account and environment and do not expire. Derive the key from your own record, such as `order-{yourOrderId}`, and store it before calling the API. If a request times out or fails with a network error, retry with the same key: you will get the operation that was created, or create it now. Other writes are safe to retry for different reasons: - `POST /api/v2/customers` with an `externalUserId` returns `409 conflict` if the customer exists. Fetch it with `GET /api/v2/customers/by-external-id/{externalUserId}`. - Reveal, refresh, and webhook changes can be repeated without side effects beyond the first. ## Retries Retry network errors, `5xx`, and `503` with exponential backoff and jitter, for example 1, 2, 4, 8 seconds, up to a minute. Do not retry `4xx` responses other than `409 insufficient_balance` after a top-up. If you receive `429 Too Many Requests`, wait for the `Retry-After` header before retrying. Keep concurrency per account modest; Packflip may apply rate limits to protect the service. ## Lists List endpoints return the newest items first and accept `limit`: | Endpoint | Default | Maximum | | --- | --- | --- | | `GET /api/v2/customers` | 50 | 200 | | `GET /api/v2/customers/{id}/cards` | 50 | 200 | | `GET /api/v2/ledger-entries` | 50 | 200 | | `GET /api/v2/funding-transactions` | 50 | 100 | | `GET /api/v2/funding-intents` | 50 | 100 | Keep your own record of operations and customers rather than paging through everything. ## Money and time - Amounts are decimal strings with six places, such as `"12.500000"`. Use a decimal library; never parse them as floats. - Timestamps are ISO 8601 in UTC. - IDs are opaque strings with a type prefix: `par_`, `cus_`, `card_`, `op_`, `oco_`, `ocb_`, `shp_`, `wh_`, `evt_`, `ftx_`. Do not parse them. ## Versioning The API version is a date, currently `2026-09-12`, shown in the [OpenAPI document](https://partner.packflip.xyz/openapi.json) and in each webhook's `apiVersion`. Additive changes can ship at any time. Breaking changes are announced at least 30 days in advance. --- # Going live The sandbox and production run the same API. Moving to production changes the base URL, the keys, and the money. ## Checklist 1. **Review the terms.** Read the [Partner Terms of Service](https://partner.packflip.xyz/terms). You are responsible for offering card packs lawfully where your users live, including age limits and disclosure of odds and prices. 2. **Open production.** Sign in at [partner.packflip.xyz](https://partner.packflip.xyz/dashboard) with the same account and select the same organization. Its production data starts empty. 3. **Create production keys.** Create new keys in production and store them in your production secret store. Sandbox keys do not work there. 4. **Switch the base URL** to `https://partner.packflip.xyz`. 5. **Fund the balance.** Send USDC **on Base** to your production deposit address. Start with a small transfer and wait for it to be credited. There is no test credit in production. 6. **Add production webhooks.** Endpoints are per environment. Store the new signing secret, and check `environment` in each payload so a sandbox event can never be processed as a real one. 7. **Mint on Base.** Production mints use chain ID `8453`; make sure your wallet connection targets Base, not Base Sepolia. 8. **Monitor.** Watch your balance, failed webhook deliveries in the console, and `409 insufficient_balance` responses. ## Keep environments apart - Use separate configuration for each environment: base URL, API key, and webhook secret together. - Never point a production build at the sandbox, or the reverse. The console marks the sandbox with an orange banner. - Never send real users' personal data to the sandbox. ## Security - Keep API keys on your servers. Never ship them in web or mobile apps. - Name keys after where they run, and revoke any key you believe has leaked. Revocation takes effect immediately. - Verify every webhook signature. - Everyone in your organization can see its keys, balance, and customers. Review members regularly. ## Support Email [support@packflip.xyz](mailto:support@packflip.xyz) with your account ID (`par_…`) and any operation, card, or event IDs involved. --- # Building with a coding agent These docs are written to be read by coding agents such as Claude Code, Cursor, or Codex, as well as by people. ## Machine-readable resources | Resource | URL | Use it for | | --- | --- | --- | | `llms.txt` | [/llms.txt](https://partner.packflip.xyz/llms.txt) | An index of every guide, with links. | | `llms-full.txt` | [/llms-full.txt](https://partner.packflip.xyz/llms-full.txt) | All guides in one Markdown file, for a single fetch. | | OpenAPI 3.1 | [/openapi.json](https://partner.packflip.xyz/openapi.json) | Exact request and response schemas. Generate types or a client from it. | | Guides as Markdown | `/docs/{guide}.md`, for example [/docs/quickstart.md](https://partner.packflip.xyz/docs/quickstart.md) | Fetching one guide without HTML. | Both hosts serve the same documents; the prompt below reads them from the sandbox. If your agent's browser cannot open `text/markdown`, use `llms-full.txt` or the HTML pages at `/docs/{guide}`. ## A prompt to start with Paste this into your agent, and adjust the first paragraph to your product: ```text Integrate the Packflip Partner API into this project so users can buy card packs, see their cards, and sell cards back. Read https://sandbox.partner.packflip.xyz/llms-full.txt first, and use https://sandbox.partner.packflip.xyz/openapi.json as the source of truth for request and response shapes. Follow the "Off-chain lifecycle" guide in it for the purchase flow. Requirements: - Call the API only from server code. Read PACKFLIP_BASE_URL, PACKFLIP_API_KEY, and PACKFLIP_WEBHOOK_SECRET from the environment. Default the base URL to https://sandbox.partner.packflip.xyz. - Create one Packflip customer per user, with externalUserId set to our user ID, and store the returned cus_ ID. On 409, look the customer up by external ID. - Send an Idempotency-Key on every POST /api/v2/operations. Derive it from a database record created before the call, and reuse it on retries. - Treat money as decimal strings. Never convert to floating point. - Add a webhook route that verifies webhook-signature against the raw body, deduplicates on the event id, and refreshes state from the API. - Branch on error.code, not on the HTTP status: 409 can be insufficient_balance, out_of_stock, conflict, or idempotency_conflict. - Offer off-chain buyback and redemption only for cards whose status is active, sealed is false, and onchain is null. Re-fetch cards after every operation. - Write tests against the sandbox or with recorded responses; do not call production from tests. ``` ## Rules an integration must follow 1. **Keys stay on the server.** An API key controls your whole balance. 2. **One environment per configuration.** A base URL, key, and webhook secret always belong together. Sandbox is `https://sandbox.partner.packflip.xyz`; production is `https://partner.packflip.xyz`. 3. **Idempotency on every operation.** Retries must reuse the key; new purchases must use a new one. 4. **Decimal money.** Amounts are strings such as `"1.500000"`. 5. **Strict requests, tolerant responses.** Unknown request fields are rejected; unknown response fields must be ignored. 6. **Webhooks are hints.** Verify, deduplicate, and re-read the resource. 7. **On-chain transactions come from the user's wallet.** The API returns calldata; it never takes custody of user keys. ## A minimal client ```ts // packflip.ts: server-only const baseUrl = process.env.PACKFLIP_BASE_URL ?? "https://sandbox.partner.packflip.xyz"; const apiKey = process.env.PACKFLIP_API_KEY!; export class PackflipError extends Error { constructor(readonly status: number, readonly code: string, message: string) { super(message); } } export async function packflip( path: string, init: { method?: string; body?: unknown; idempotencyKey?: string } = {}, ): Promise { 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, cache: "no-store", }); if (response.status === 204) return undefined as T; const json = await response.json(); if (!response.ok) throw new PackflipError(response.status, json.error?.code, json.error?.message); return json as T; } // Example: draw one pack for a customer. export function drawPack(customerId: string, vendingMachineId: number, orderId: string) { return packflip<{ id: string; status: string; cards: unknown[]; replayed: boolean }>("/operations", { body: { kind: "order", customerId, vendingMachineId, quantity: 1 }, idempotencyKey: `order-${orderId}`, }); } ``` For typed responses, generate types from the OpenAPI document, for example with `npx openapi-typescript https://sandbox.partner.packflip.xyz/openapi.json -o packflip.d.ts`. For a complete purchase flow with retries, see [Off-chain lifecycle](https://partner.packflip.xyz/docs/lifecycle). ## Guides - [Sandbox quickstart](https://partner.packflip.xyz/docs/quickstart) - [Off-chain lifecycle](https://partner.packflip.xyz/docs/lifecycle) - [Core concepts](https://partner.packflip.xyz/docs/concepts) - [Funding your balance](https://partner.packflip.xyz/docs/funding) - [Cards and operations](https://partner.packflip.xyz/docs/cards) - [On-chain cards](https://partner.packflip.xyz/docs/onchain) - [Webhooks](https://partner.packflip.xyz/docs/webhooks) - [Errors, idempotency, and retries](https://partner.packflip.xyz/docs/errors) - [Going live](https://partner.packflip.xyz/docs/going-live)