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:
- Submit —
POST /redeem/preparereturns the unsigned transactions and an itemized fee breakdown. - Pay — the user's wallet signs the returned transactions (USDC shipping payment + NFT burn, bundled).
- Execute —
POST /blockchain/:outboundShipmentId/burnbroadcasts 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
| Environment | Base URL |
|---|---|
| Production | https://api.collectorcrypt.com |
| Devnet | https://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 tokens | Track B — Native SIWS | |
|---|---|---|
| Who | Apps with their own Privy app | Wallet vendors / dApps that don't use Privy |
| Auth proof | Identity JWT from your Privy app | Solana wallet signature over a SIWS message |
| Embedded wallets | Supported | Not applicable — user already has a self-custody wallet |
| Card payments via Coinflow | CC web only (not exposed to partners) | Not available — crypto only |
| Endpoints unlocked | Full user-self surface | Withdraw wizard + addresses + status reads |
| Reference repo | cc-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
- 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. - 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"
}
| Field | Description |
|---|---|
wallet | The user's Solana wallet pubkey. Base58, 32 bytes. |
partnerAppId | Your CC partner slug (or your Privy app id if you've never set a slug). |
domain | Bare hostname (no scheme, no port). Must be on your partner record's allowedSiwsDomains list — anti-phishing. |
uri | The 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 infoGET /shipping-address,GET /shipping-address/:id,POST /shipping-address/create,PATCH /shipping-address/update/:id,DELETE /shipping-address/:idGET /outbound-shipment,GET /outbound-shipment/:idPOST /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/preparewithpaymentMethod: "card"returns400. 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 returns409 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:
- Track A or Track B
- Track A: your Privy app ID
- Track B: the hostname(s) your SIWS messages will use as
domain - The URL origins your users will hit CC from (used for CORS)
- 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./siwsshows the Track B handshake, wallet detection, message signing withwindow.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
}
| Field | Type | Required | Description |
|---|---|---|---|
nftAddresses | string[] | Yes | On-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. |
shippingAddressId | string | Yes | An existing ShippingAddress.id belonging to the user. Create one with POST /shipping-address/create first. |
coin | "USDC" | "USDT" | No | Currency for the shipping payment leg. Defaults to "USDC". |
deliveryCompany | string | No | Carrier hint, e.g. "ups". Defaults to "ups". |
comment | string | No | Free-form note shown to fulfillment. |
insurance | boolean | No | Ignored — 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": []
}
}
| Field | Description |
|---|---|
outboundShipmentId | The shipment record. Use this in step 3. |
transactions | Array of base64 unsigned Solana transactions. Sign with the user's Privy Solana wallet (signAllTransactions). Empty/absent if the cart contains no Solana cards. |
evmTransactions | Per-chain EVM transactions, if applicable. Sign each with the user's Privy EVM wallet. |
totalCost | Total amount in coin the user pays at signing time. Already factored into the bundled USDC transfer in transactions. |
submitUrl | Convenience: the URL to POST signed transactions to in step 3. |
breakdown | See 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..." }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
transactions | string[] | Conditional | Signed Solana transactions from step 2. Required if transactions was non-empty in the prepare response. |
evmTransactions | object[] | Conditional | Signed 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
| Region | Base (1st card) | Each additional | Signature | Pack surcharge |
|---|---|---|---|---|
| USA | $5.99 | $3.00 | +$3.00 if value ≥ $500 | $0.18 / pack |
| Canada | $20.99 | $3.00 | included | $0.25 / pack |
| Europe | $29.99 | $3.00 | included | $0.25 / pack |
| Australia / NZ | $34.99 | $3.00 | included | $0.25 / pack |
| Rest of World | $34.99 | $3.00 | included | $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 withPOST /shipping-address/createbefore calling/redeem/prepare. - The user pays shipping. The
totalCostis 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"
}
| Status | Meaning |
|---|---|
400 | Bad request — invalid card set, missing fields, signing/broadcast failure. Track B: paymentMethod: "card" or domain not on the partner's allowlist. |
401 | Missing/invalid token. Track A: bad Privy identity token. Track B: bad SIWS signature, expired nonce, expired/revoked access token, or invalid refresh token. |
403 | Track 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. |
404 | Card or shipping address not found / not owned by the user |
409 | Track B: wallet is already linked to a CC user with a Privy account — log in via Privy instead. |
429 | Rate limit exceeded |