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

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.

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": "",
"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.
insurancebooleanNoIgnored — insurance is auto-applied for shipments over $5,000 (see breakdown below).

Response:

{
"outboundShipmentId": "shipment_...",
"transactions": ["<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) — a retry returns the existing pending shipment instead of creating a duplicate.

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>", "..."],
"evmTransactions": [
{ "chain": "ethereum", "signedTx": "<base64>", "txHash": "0x..." }
]
}
FieldTypeRequiredDescription
transactionsstring[]ConditionalSigned Solana transactions from step 2. Required if transactions was non-empty in the prepare response.
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.

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" }
}

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.
  • 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