# 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](/docs/onchain). For the whole flow in one place, with retries, see [Off-chain lifecycle](/docs/lifecycle).

Each request needs an `Idempotency-Key` header. See [Errors and retries](/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](/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
<img src="{image}" srcset="{imageSrcset}" sizes="(min-width: 768px) 320px, 50vw" alt="{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](/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`](/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).
