Skip to main content

Shipping API

Reference implementation: github.com/daxherrera/cc-partner-test

The Shipping API lets a partner app redeem CollectorCrypt NFTs for physical delivery of the underlying cards. The flow is three calls:

  1. SubmitPOST /redeem/prepare returns the unsigned transactions and an itemized fee breakdown.
  2. Pay — the user's wallet signs the returned transactions (USDC shipping payment + NFT burn, bundled).
  3. ExecutePOST /blockchain/:outboundShipmentId/burn broadcasts the signed transactions. CollectorCrypt handles fulfillment from there.

There's an optional fourth call, POST /redeem/estimate, that returns the same fee breakdown without creating a shipment — use it to render a price preview before the user commits.

Base URL

EnvironmentBase URL
Productionhttps://api.collectorcrypt.com
Devnethttps://dev-api.collectorcrypt.com

All endpoints below are relative to the base URL.

Authentication

Two paths into the Shipping API, picked per partner at onboarding. Both end the same way: a Bearer token on every request.

Track A — Privy identity tokensTrack B — Native SIWS
WhoApps with their own Privy appWallet vendors / dApps that don't use Privy
Auth proofIdentity JWT from your Privy appSolana wallet signature over a SIWS message
Embedded walletsSupportedNot applicable — user already has a self-custody wallet
Card payments via CoinflowCC web only (not exposed to partners)Not available — crypto only
Endpoints unlockedFull user-self surfaceWithdraw wizard + addresses + status reads
Reference repocc-partner-test (/)cc-partner-test (/siws)

Track A — Privy identity tokens

Every request must carry a Privy identity token in the Authorization header:

Authorization: Bearer <privy-identity-token>

The token is the JWT produced by your own Privy app's getIdentityToken(). CollectorCrypt verifies it against your Privy app's public JWKS, finds (or creates) the corresponding CC user row keyed on the wallet from the token's linked_accounts, and authorizes the request.

No app secrets are exchanged. No cross-app linking is required.

Required Privy dashboard settings on your app

  1. Settings → Authentication → Return user data in an identity token must be on. Without it, the identity token doesn't carry linked_accounts, and CC can't read the wallet.
  2. Settings → Domains → Allowed Origins — add the URL your users reach your app at.

Track B — Native SIWS

For wallet vendors and dApps where users already have a Solana wallet (Phantom, Solflare, Backpack, etc.) and you don't want to put them through a Privy sign-in flow. CollectorCrypt verifies a Solana signature and issues you a short-lived opaque session token to use as the Bearer token.

Three-call setup, then the resulting access token is interchangeable with a Track A token for the endpoints listed in Endpoint scope.

1. Mint nonce — POST /auth/wallet/nonce

Request body:

{
"wallet": "<base58 solana pubkey>",
"partnerAppId": "<your partner slug>",
"domain": "your-app.example.com",
"uri": "https://your-app.example.com"
}
FieldDescription
walletThe user's Solana wallet pubkey. Base58, 32 bytes.
partnerAppIdYour CC partner slug (or your Privy app id if you've never set a slug).
domainBare hostname (no scheme, no port). Must be on your partner record's allowedSiwsDomains list — anti-phishing.
uriThe URL shown inside the SIWS message body. Typically your app's origin.

Response:

{
"nonce": "8f3c1d2e-4b91-4a0c-9e0f-2a7c1d8f9a6b",
"expiresAt": 1746234600000,
"message": "your-app.example.com wants you to sign in with your Solana account:\n7xKXt…\n\n..."
}

message is the EIP-4361 / Sign-In With Solana canonical text. Have the user sign it verbatim — no whitespace edits, no re-encoding. The nonce is one-time-use and valid for five minutes.

2. Sign the message in the user's wallet

// Phantom / window.solana
const messageBytes = new TextEncoder().encode(message);
const { signature } = await window.solana.signMessage(messageBytes, 'utf8');
const signatureB58 = bs58.encode(signature);

The signature is ed25519 over the UTF-8 bytes of message. Base58-encode the 64-byte result before sending it back.

3. Verify signature — POST /auth/wallet/verify

Request body:

{
"message": "<the exact message returned by /nonce>",
"signature": "<base58 ed25519 signature>"
}

Response:

{
"accessToken": "cca_8f3c1d2e-4b91-4a0c-9e0f-2a7c1d8f9a6b",
"refreshToken": "ccr_a40f8e2b-…",
"expiresAt": 1746235500000
}

Both tokens are opaque random ids — cca_ prefix for access, ccr_ for refresh. The server holds the trusted claims (userId, wallet, partnerAppId) in Redis under those ids; treat the token as a session cookie and do not parse it client-side. Access lifetime: 15 minutes. Refresh is single-use, rotates on every refresh; lifetime 7 days.

Use the access token

Standard Bearer header on every Shipping API call:

Authorization: Bearer <accessToken>

Refresh — POST /auth/wallet/refresh

{ "refreshToken": "<current refresh token>" }

Returns a fresh {accessToken, refreshToken, expiresAt} triple. The previous refresh token is invalidated immediately — using it twice is a 401.

Logout — POST /auth/wallet/logout

{ "refreshToken": "<current refresh token>" }

Send the access token in the Authorization: Bearer header on this call too — the server revokes both in one round-trip so a leaked access token can't keep calling the API for the remainder of its 15-minute TTL.

Endpoint scope (Track B)

Track B access tokens are intentionally narrower than Track A. They unlock:

  • GET /auth/wallet/me — session info
  • GET /shipping-address, GET /shipping-address/:id, POST /shipping-address/create, PATCH /shipping-address/update/:id, DELETE /shipping-address/:id
  • GET /outbound-shipment, GET /outbound-shipment/:id
  • POST /redeem/estimate, POST /redeem/prepare (crypto payment only)
  • POST /blockchain/:outboundShipmentId/burn
  • POST /redeem/resume/:shipmentId, POST /redeem/complete/:shipmentId — the recovery pair, see Recovering a stuck redemption

Any other authenticated route returns 403. This is by design — wallet-only users don't have email/identity setup, so endpoints that depend on those (profile mutations, anything Coinflow-backed) are excluded.

Limitations (Track B)

  • Crypto payment only. POST /redeem/prepare with paymentMethod: "card" returns 400. Use USDC or USDT.
  • Wallets already linked to a Privy account are blocked. If the wallet has a CC user row with a privyDid, SIWS returns 409 Conflict. The user must use the Privy login path instead. Prevents the wallet-signature path from being used to bypass 2FA / email recovery that may be set up on the Privy-backed account.
  • No EVM support. SIWS only accepts Solana signatures. EVM-only redemptions aren't reachable via Track B.

Onboarding with CollectorCrypt

Before your tokens are accepted, send CC:

  1. Track A or Track B
  2. Track A: your Privy app ID
  3. Track B: the hostname(s) your SIWS messages will use as domain
  4. The URL origins your users will hit CC from (used for CORS)
  5. A short name for the integration

Starter repo

A working Next.js reference implementation is at daxherrera/cc-partner-test.

  • / shows the Track A handshake plus the redeem flow.
  • /siws shows the Track B handshake, wallet detection, message signing with window.solana, and the same redeem flow against the CC-issued Bearer token.

Shipping addresses

/redeem/prepare requires a shippingAddressId, so create an address first. All routes are scoped to the authenticated user and available to Track A and Track B.

MethodPathPurpose
GET/shipping-addressList the caller's addresses.
GET/shipping-address/:idOne address.
POST/shipping-address/createCreate an address.
PATCH/shipping-address/update/:idUpdate an address.
DELETE/shipping-address/:idRemove an address.

Create / update request body:

{
"fullName": "Ada Lovelace",
"country": "United States of America",
"streetAddress": "123 Example St",
"apartment": "",
"city": "San Francisco",
"state": "California",
"zip": "94103",
"phoneNumber": "+15555550100",
"email": "buyer@example.com",
"isDefault": true
}
FieldTypeRequiredDescription
fullNamestringYesRecipient name.
countrystringYesFull country name (e.g. "United States of America").
streetAddressstringYesStreet line.
apartmentstringNoApartment / unit.
citystringYesCity.
statestringYesFull state / region name (e.g. "California").
zipstringNoPostal code.
phoneNumberstringNoContact phone for the carrier.
emailstringNoContact email for shipment-update notifications. Send it for wallet (SIWS) users so they get updates; Privy users get it from their login and can omit it.
isDefaultbooleanNoMark this address as the default.

The flow

1. Submit — POST /redeem/prepare

Resolves the NFTs, creates a pending shipment, and returns the unsigned transactions the user must sign.

Request Body:

{
"nftAddresses": ["NFT_MINT_ADDRESS_1", "NFT_MINT_ADDRESS_2"],
"shippingAddressId": "shippingAddr_...",
"coin": "USDC",
"deliveryCompany": "ups",
"comment": "",
"email": "buyer@example.com",
"insurance": false
}
FieldTypeRequiredDescription
nftAddressesstring[]YesOn-chain identifiers of the cards to redeem. Solana mints, EVM contract addresses, etc. — must match Card.nftAddress values for cards owned by the authenticated user.
shippingAddressIdstringYesAn existing ShippingAddress.id belonging to the user. Create one with POST /shipping-address/create first.
coin"USDC" | "USDT"NoCurrency for the shipping payment leg. Defaults to "USDC".
deliveryCompanystringNoCarrier hint, e.g. "ups". Defaults to "ups".
commentstringNoFree-form note shown to fulfillment.
emailstringNoContact email for shipment-update notifications. Send it for wallet (SIWS) users so they get updates; Privy users get it from their login and can omit it.
payCustomsDutiesbooleanNoInternational destinations only. When true and the destination owes duties, the estimated import duty is added to totalCost and the parcel ships DDP (carrier bills CC, not the recipient). Adds a customs_duties line to breakdown.lines. Changing it between calls builds a new shipment — see idempotency below.
insurancebooleanNoIgnored — insurance is auto-applied for shipments over $5,000 (see breakdown below).

Response:

{
"outboundShipmentId": "shipment_...",
"transactions": ["<base64-unsigned-tx>", "..."],
"delistTransactions": ["<base64-unsigned-tx>"],
"evmTransactions": [{ "chain": "ethereum", "txs": ["..."] }],
"totalCost": 14.99,
"submitUrl": "/blockchain/shipment_.../burn",
"breakdown": {
"region": "USA",
"declaredValue": 700,
"numberOfCards": 10,
"numberOfMoonbirdsPacks": 0,
"numberOfSealedPacks": 0,
"lines": [
{ "code": "shipping_base", "label": "Base shipping (USA, first card)", "amount": 5.99, "unitPrice": 5.99 },
{ "code": "shipping_additional", "label": "Additional cards (9 × $3.00)", "amount": 27, "qty": 9, "unitPrice": 3 },
{ "code": "shipping_signature", "label": "Signature required (package value ≥ $500)", "amount": 3, "unitPrice": 3 }
],
"notes": []
}
}
FieldDescription
outboundShipmentIdThe shipment record. Use this in step 3.
transactionsArray of base64 unsigned Solana transactions. Sign with the user's Privy Solana wallet (signAllTransactions). Empty/absent if the cart contains no Solana cards.
evmTransactionsPer-chain EVM transactions, if applicable. Sign each with the user's Privy EVM wallet.
totalCostTotal amount in coin the user pays at signing time. Already factored into the bundled USDC transfer in transactions.
submitUrlConvenience: the URL to POST signed transactions to in step 3.
breakdownSee Shipping breakdown below.

The endpoint is idempotent on (user, exact card set, paymentMethod, shippingAddressId, payCustomsDuties) — a retry with identical inputs returns the existing pending shipment instead of creating a duplicate.

Changing the address or the duty choice deliberately builds a new shipment rather than reusing the old one: costs are frozen on the shipment row at creation and that stored total is what gets charged, so reusing a shipment across a price-changing input would charge the previous price.

2. Pay — sign the transactions

Sign every transaction returned in step 1 with the user's Privy wallet.

// Solana — signedTxs is an array of base64-encoded signed transactions
const txs = response.transactions.map(t =>
VersionedTransaction.deserialize(Buffer.from(t, 'base64'))
);
const signedBytesList = await privySolanaWallet.signAllTransactions(txs);
const signedTxs = signedBytesList.map(b => Buffer.from(b).toString('base64'));

// EVM — sign each tx in each chain group with the corresponding wallet

The user's wallet must hold at least totalCost in coin — the shipping fee transfers atomically with the NFT burn, so a balance shortfall fails the entire bundle.

3. Execute — POST /blockchain/:outboundShipmentId/burn

Broadcasts the signed transactions. CC validates them, submits them on-chain, and (after on-chain confirmation) hands the shipment off to its fulfillment pipeline.

Request Body:

{
"transactions": ["<base64-signed-tx>", "..."],
"delistTransactions": ["<base64-signed-tx>"],
"evmTransactions": [
{ "chain": "ethereum", "signedTx": "<base64>", "txHash": "0x..." }
]
}
FieldTypeRequiredDescription
transactionsstring[]ConditionalSigned Solana transactions from step 2. Every transaction the prepare response returned must be sent back — CC rejects a partial or mixed set.
delistTransactionsstring[]ConditionalSigned copies of delistTransactions from the prepare response. Only present when a card is listed on Magic Eden: the NFT is held by the ME escrow, so it must be delisted before it can burn. Omitting them is accepted but the burn for those cards will fail on-chain.
evmTransactionsobject[]ConditionalSigned EVM transactions, one per chain. Required if evmTransactions was non-empty in the prepare response.

Response:

{
"id": "shipment_...",
"status": "Pending",
"transactionUrls": ["https://explorer.solana.com/tx/..."]
}

After a Pending (or higher) status, the shipment is locked in. CC's ShipStation integration picks it up and the physical cards ship to the address on shippingAddressId. Track status with GET /outbound-shipment/:id.

Recovering a stuck redemption

The burn transactions from /redeem/prepare carry a recent blockhash, which expires after roughly 60–90 seconds. A user who leaves the wallet prompt open past that window gets blockhash not found, and a single failed leg can leave the shipment part-burned. Do not replay a cached prepare payload to recover — it has the same stale blockhash. Use these instead. Both are available to Track A and Track B tokens.

POST /redeem/resume/:shipmentId

Rebuilds the burn transactions for a paid but not-yet-burned shipment with a fresh blockhash. Same response shape as /redeem/prepare, so the signing step is unchanged.

Preconditions — a violation returns 400, or 404 if the shipment is not the caller's:

  • shipment belongs to the caller
  • payment is confirmed
  • not every card is already burned
  • status is not Cancelled
  • status is not ActionRequired with cards already partially burned — that is a true partial-burn and needs complete below, not resume

POST /redeem/complete/:shipmentId

For a partially burned shipment. Rebuilds burn transactions for only the cards not yet burned; already-burned assets produce no burn instruction and are skipped automatically.

The shipping fee is re-collected only when an on-chain check proves it was never actually paid — a stuck crypto order can carry a confirmed payment flag while its fee-bearing transaction reverted. Card-flow orders never re-collect on-chain, because Coinflow already settled. A completed order is never double-charged.

Preconditions: shipment owned by the caller; not Cancelled; not already fully burned; and for card flow, payment must be confirmed.

Which one to call

Shipment stateCall
Paid, nothing burned, blockhash expiredresume
Paid, some cards burned, some notcomplete
Every card burnedneither — the shipment is done
Cancelledneither — both return 400

Estimate (optional) — POST /redeem/estimate

Same inputs and same breakdown shape as /redeem/prepare, but does not create a shipment or build any transactions. Use it to render a fee preview on every cart / address change in your UI.

Request Body:

{
"nftAddresses": ["NFT_MINT_ADDRESS"],
"shippingAddressId": "shippingAddr_..."
}

Response:

{
"price": 700,
"insurancePrice": 0,
"feesPrice": 0,
"shippingPrice": 35.99,
"total": 35.99,
"numberOfCards": 10,
"breakdown": { "...": "same shape as /redeem/prepare" }
}

Get shipments

Read the caller's outbound shipments to track fulfillment status, carriers, and tracking numbers after the burn. Both routes are scoped to the authenticated user — a token only ever sees its own shipments, and there's no way to fetch another user's shipment by id. Available to Track A and Track B tokens.

List — GET /outbound-shipment

Returns an array of the caller's shipments, newest first.

Query parameters (all optional):

ParamTypeDescription
status"Active" | "Past""Active" returns shipments still in flight (ActionRequired, PaymentPending, PaymentReceived, Pending, Processing, Shipped). "Past" returns Delivered. Omit to return every status.
searchstringCase-insensitive filter over the shipment's card names / grading IDs and tracking numbers.

Response: 200 OK — a JSON array. Each element carries the shipment plus its cards and a per-vault count. Selected fields:

[
{
"id": "shipment_...",
"customId": "CC-10231",
"status": "Shipped",
"numberOfCards": "2",
"deliveryCompany": "ups",
"trackingIds": ["1Z..."],
"trackingUrls": ["https://www.ups.com/track?tracknum=1Z..."],
"shippingCost": "8.99",
"insuranceCost": "0",
"feesCost": "0",
"totalCost": "8.99",
"typeCurrency": "USDC",
"cardIds": ["card_...", "card_..."],
"shippingAddress": { "fullName": "…", "country": "…", "...": "…" },
"createdAt": "2026-06-20T18:04:11.000Z",
"updatedAt": "2026-06-21T09:12:55.000Z",
"cards": { "cards": [ { "id": "card_...", "itemName": "…", "...": "…" } ] }
}
]
FieldDescription
idShipment id — the same value returned by /redeem/prepare and used in /blockchain/:id/burn.
customIdHuman-readable shipment number.
statusOne of Created, PaymentPending, PaymentReceived, Pending, Processing, Shipped, Delivered, ActionRequired, Cancelled.
numberOfCardsCard count (string).
deliveryCompanyCarrier.
trackingIds / trackingUrlsTracking numbers and their carrier URLs. Empty until the label is purchased.
shippingCost / insuranceCost / feesCost / totalCostItemized costs, as strings, in typeCurrency.
cardIdsIds of the cards in the shipment.
shippingAddressAddress the shipment is going to.
cards.cardsThe cards in the shipment.

Other fields may appear in the response; rely only on the ones documented here.

Single — GET /outbound-shipment/:id

Returns one shipment owned by the caller, with the same shape as a list element (plus each card's offers). Returns the shipment object on success; an empty body if the id doesn't exist or isn't owned by the caller.

Shipping breakdown

breakdown is returned by both /redeem/estimate and /redeem/prepare and explains every charge as a list of itemized lines. Render it directly to your users — labels are pre-formatted.

type ShippingBreakdownLine = {
code:
| 'shipping_base'
| 'shipping_additional'
| 'shipping_pack_surcharge'
| 'shipping_signature'
| 'shipping_moonbirds'
| 'insurance'
label: string // e.g. "Additional cards (9 × $3.00)"
amount: number // dollars
qty?: number // e.g. 9 cards, 36 packs
unitPrice?: number // e.g. 3.00, 0.18, 0.005
}

type ShippingBreakdown = {
region: 'USA' | 'Canada' | 'Europe' | 'AustraliaNewZealand' | 'RestOfWorld'
declaredValue: number
numberOfCards: number
numberOfMoonbirdsPacks: number
numberOfSealedPacks: number
lines: ShippingBreakdownLine[]
notes: string[] // e.g. customs disclaimer for international destinations
}

Rate summary

RegionBase (1st card)Each additionalSignaturePack surcharge
USA$5.99$3.00+$3.00 if value ≥ $500$0.18 / pack
Canada$20.99$3.00included$0.25 / pack
Europe$29.99$3.00included$0.25 / pack
Australia / NZ$34.99$3.00included$0.25 / pack
Rest of World$34.99$3.00included$0.25 / pack
  • Pack surcharge applies to sealed cards (booster packs, booster boxes, etc.). A booster box has 36 packs.
  • Insurance is auto-applied at 0.5% of declared value when the package value exceeds $5,000.
  • Customs / duties / VAT on international shipments are the buyer's responsibility; CC does not collect them at checkout.

Requirements & gotchas

  • The user must own each card on-chain in the wallet on their CC user row (the same wallet derived from your Privy identity token).
  • The user must have a ShippingAddress — create one with POST /shipping-address/create before calling /redeem/prepare.
  • The user pays shipping. The totalCost is bundled into the burn transaction; insufficient balance fails the bundle.
  • Send a non-empty User-Agent. The edge WAF blocks requests without one (NoUserAgent_HEADER, inside the AWS common rule set). It returns a bare 403 with no body and never reaches the API, so it will not appear in any error you can correlate with a request id. Some HTTP clients omit the header by default.
  • Coinflow card payments are CC's web flow only. Track A partners can surface their own crypto checkout; Track B explicitly rejects paymentMethod: "card".

Errors

All error responses use this shape:

{
"statusCode": 400,
"message": "human-readable error",
"error": "Bad Request"
}
StatusMeaning
400Bad request — invalid card set, missing fields, signing/broadcast failure. Track B: paymentMethod: "card" or domain not on the partner's allowlist.
401Missing/invalid token. Track A: bad Privy identity token. Track B: bad SIWS signature, expired nonce, expired/revoked access token, or invalid refresh token.
403Track A: token's aud is not a registered partner. Track B: route is not allow-listed for SIWS tokens, OR the partner record's authMode is not siws.
404Card or shipping address not found / not owned by the user
409Track B: wallet is already linked to a CC user with a Privy account — log in via Privy instead.
429Rate limit exceeded