Shipping API
Redeem a vaulted card for physical delivery. The asset is burned on chain and CollectorCrypt ships the card.
The flow is five calls:
- Authenticate — get an access token, or use an API key.
- Save an address —
POST /shipping-address/create. - Prepare —
POST /redeem/preparereturns unsigned transactions and a price. - Sign and submit — sign every transaction, then
POST /blockchain/:outboundShipmentId/burn. - Track —
GET /outbound-shipment/:id.
POST /redeem/estimate gives a priced preview before step 3. It is optional.
Card identifiers covers Solana and EVM cards. The signing and submit steps are written for Solana cards; EVM cards are in Redeeming EVM cards.
Base URLs
| Environment | Base URL |
|---|---|
| Production | https://api.collectorcrypt.com |
| Devnet | https://dev-api.collectorcrypt.com |
Authentication
Three credentials work here.
| Credential | Header | Can finish a redemption? |
|---|---|---|
| Access token from wallet sign-in | Authorization: Bearer cca_... | Yes |
| Partner identity token | Authorization: Bearer <identity token> | Yes — the acting wallet signs |
| API key | Authorization: Bearer ccsk_... plus X-CC-Customer | EVM only — see Using an API key |
Both require CollectorCrypt to register you first. Email support@collectorcrypt.com to get set up; you will be issued a partnerAppId and your hostnames will be added to an allowlist. For wallet sign-in, say whether your users sign in with Solana or EVM wallets.
Wallet sign-in
Step 1 — get a nonce. POST /auth/wallet/nonce, public.
| Field | Type | Required | Notes |
|---|---|---|---|
wallet | string | yes | Base58 Solana address, or a 0x address for an EVM wallet. A Solana smart wallet signs in with a transaction instead — see Smart wallets. |
partnerAppId | string | yes | Issued by support. Any unrecognised value returns 400 Unknown partner. |
domain | string | yes | Bare hostname — no scheme, no port, no path. Must be on your allowlist. |
uri | string | yes | Absolute URL, echoed into the signed message. |
chain | solana | evm | no | Default solana. |
chainId | string | no | EVM only. Decimal chain id — see EVM wallets. |
{ "nonce": "<uuid>", "expiresAt": 1787241600000, "message": "<the exact text to sign>" }
Step 2 — sign message byte for byte. Do not edit whitespace, and do not build the text yourself: it embeds a value you cannot know. The parser accepts exactly one form — LF line endings, exactly 11 lines, fixed field order.
The nonce is single-use and valid for 5 minutes. The Expiration Time line inside the message text shows a longer window; the 5-minute nonce is what governs.
Step 3 — exchange the signature. POST /auth/wallet/verify, public.
| Field | Type | Required | Notes |
|---|---|---|---|
message | string | yes | The exact text from step 1. |
signature | string | one of | Solana: base58 ed25519, 64 bytes, over the raw UTF-8 bytes of message. EVM: 0x hex from personal_sign. |
proofTransaction | string | one of | Solana smart wallets only, instead of signature — see Smart wallets. |
chain | solana | evm | no | Default solana. Must match the nonce. |
Send exactly one of signature and proofTransaction. Both, or neither, returns 400 Send exactly one of signature or proofTransaction.
{ "accessToken": "cca_...", "refreshToken": "ccr_...", "expiresAt": 1787242500000 }
Both tokens are opaque. Do not parse them. The access token lasts 15 minutes, the refresh token 7 days.
POST /auth/wallet/refresh with { "refreshToken": "..." } rotates both. The old refresh token dies immediately, so a replay returns 401 Refresh token not found or already used.
A session lasts at most 30 days from sign-in. Refreshing rotates the tokens but keeps the original sign-in time, so the last refresh token of a session expires with the session, which can be sooner than 7 days. After that, refresh returns a 401 — Refresh token not found or already used or Session expired; sign in again — and the user signs in again from step 1.
POST /auth/wallet/logout with { "refreshToken": "..." } ends the session. Send the access token in the Authorization header too, or it stays valid for the rest of its 15 minutes.
An access token from this flow reaches the shipping routes below and nothing else. Anything else returns a bare 403 with no message.
Smart wallets
A smart wallet's address is owned by a program, not a key, so it cannot sign message. It proves control on chain instead: the wallet sends a memo, and you pass that transaction's signature to verify in place of a message signature. The session you get back is the same.
Any smart wallet whose program can send a memo as the wallet works. The example below uses Crossmint.
Step 1 — get a nonce, exactly as above, with the smart wallet's address as wallet. The response carries one more field:
{ "nonce": "<uuid>", "expiresAt": 1787241600000, "message": "<text>", "proofMemo": "cc-sign-in:<64 hex characters>" }
proofMemo is cc-sign-in: followed by the SHA-256 hex digest of the UTF-8 bytes of message. Use it as returned. It appears only for a program-owned address; an ordinary wallet does not get one and signs message as usual.
Step 2 — send one transaction from the wallet with a single SPL Memo instruction:
- program
MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr(Memo v2 — v1 is refused), - one account, the smart wallet's address, as a signer,
- data
proofMemo, UTF-8.
With the Crossmint wallets SDK:
import {
Connection,
PublicKey,
TransactionInstruction,
TransactionMessage,
VersionedTransaction,
} from '@solana/web3.js'
const MEMO_PROGRAM = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr')
const { message, proofMemo } = nonceResponse
const walletKey = new PublicKey(solanaWallet.address)
const memo = new TransactionInstruction({
programId: MEMO_PROGRAM,
keys: [{ pubkey: walletKey, isSigner: true, isWritable: false }],
data: Buffer.from(proofMemo, 'utf8'),
})
const { blockhash } = await connection.getLatestBlockhash()
const transaction = new VersionedTransaction(
new TransactionMessage({
payerKey: walletKey,
recentBlockhash: blockhash,
instructions: [memo],
}).compileToV0Message(),
)
const { hash } = await solanaWallet.sendTransaction({ transaction })
sendTransaction returns the hash once Crossmint reports the transaction succeeded. It costs one network fee, charged the way your Crossmint project pays fees.
Step 3 — verify. POST /auth/wallet/verify with { "message": "<the exact text from step 1>", "proofTransaction": "<hash>" }. CollectorCrypt reads the transaction from chain and issues the same tokens as a signed sign-in.
The whole round trip — nonce, transaction, verify — must finish inside the nonce's 5 minutes.
The proof is accepted only when every one of these holds:
- the transaction succeeded and is confirmed,
- the memo lists the wallet as a signer — only the wallet's own program can sign for it,
- the memo text equals
proofMemofor this exactmessage.
A proof is bound to one nonce. It cannot be reused for a later sign-in, even by the same wallet.
| Status | Message | What to do |
|---|---|---|
| 401 | Proof transaction not found or not yet confirmed | Wait for confirmation and call verify again. The nonce is not used up. |
| 503 | Could not read the proof transaction; retry | Retry. The nonce is not used up. |
| 401 | Proof transaction does not sign this message | The transaction failed, the memo text is wrong, or the memo does not list the wallet as a signer. Start again from step 1. |
| 400 | proofTransaction is for program wallets only; sign the message instead | The address is an ordinary wallet. Use signature. |
Every verify call counts toward the sign-in rate limit — 10 attempts per wallet per network address in 5 minutes — so do not poll verify in a tight loop.
A smart wallet redeems differently too: see Redeeming from a smart wallet.
EVM wallets
EVM wallets use the same three calls — Sign-In with Ethereum (EIP-4361). A partnerAppId is registered for one kind of wallet: an app set up for Solana sign-in refuses EVM, and the reverse. If you need both, ask support for two.
What changes:
- Send
"chain": "evm"on both nonce and verify. Without it on verify, the message is read as Solana and refused. walletis0xplus 40 hex characters, in any casing. The message repeats the casing you sent; CollectorCrypt compares addresses case-insensitively.chainIdsets theChain IDline the wallet displays. An EVM address is the same on every chain, so it does not change which account signs in. Omit it for Base.
| Network | Production | Devnet |
|---|---|---|
| Base | 8453 | 84532 |
| Ethereum | 1 | 11155111 |
| ApeChain | 33139 | 33111 |
| Monad | 143 | 10143 |
| Arbitrum | 42161 | 421614 |
| Robinhood Chain | 4663 | 46630 |
- Sign
messagewithpersonal_sign(EIP-191) and send the0xhex result assignature. With viem:
const signature = await walletClient.signMessage({ account, message })
- Smart contract wallets need no extra step. When the signature does not recover to the address, CollectorCrypt asks the wallet contract on Base — ERC-1271, or ERC-6492 for a wallet that is not deployed yet.
proofTransactionis Solana only.
The tokens, refresh, logout and session limits are the same as above. The account has no Solana wallet.
| Status | Message | What to do |
|---|---|---|
| 400 | Partner is not configured for SIWE auth | This partnerAppId is registered for Solana sign-in. |
| 400 | Wallet is not a 20-byte EVM address | Send 0x plus 40 hex characters. |
| 400 | Unsupported EVM chain id "<id>" (supported: ...) | Use an id from the table for this environment, or omit chainId. |
| 400 | proofTransaction is Solana only | Send signature. |
| 401 | Signature is not valid hex | Send the 0x hex string the wallet returned. |
| 401 | Bad signature | The signature is not from wallet over this exact message. Start again from step 1. |
Partner identity token
If you authenticate your own users through your own Privy app, send that user's
identity token as the bearer token. CollectorCrypt verifies it against your app's public
JWKS, so no secret is exchanged. Your partnerAppId must be registered first.
Only a wallet the token has verified can be acted on — one where Privy recorded that the user proved control of that exact address. An address attached to your Privy user without a verification is ignored, whatever else the token contains.
Choosing which wallet to act as
A customer may have more than one verified Solana wallet — typically the one your Privy app issued them, plus any they connected themselves.
By default CollectorCrypt acts as the wallet your Privy app issued. If the token verifies no issued wallet, the first verified Solana wallet in the token is used.
That default is right when you deliver assets to the wallet you issued, which is the common case.
To choose a different one, name it with X-CC-Wallet:
curl -X POST https://api.collectorcrypt.com/redeem/estimate \
-H 'Authorization: Bearer <identity token>' \
-H 'X-CC-Wallet: 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU' \
-H 'Content-Type: application/json' \
-d '{"shippingAddressId":"<id>","nftAddresses":["<mint>"]}'
| Header | Required | Notes |
|---|---|---|
X-CC-Wallet | no | A base58 Solana address the same token has verified. Case-sensitive — base58 is, so a lowercased address is a different string and will be refused. |
The header only ever chooses between wallets the token already verified; it cannot introduce one. Naming anything else returns:
{ "statusCode": 403, "message": "x-cc-wallet must name a wallet this token has verified", "error": "Forbidden" }
Send it on every request in a redemption, not just the first. Each call resolves the acting wallet independently, so omitting it later in the flow silently falls back to the default.
Redemption burns the asset, so the wallet you act as has to be the one holding it, and it signs
the burn. Naming a wallet that does not hold the cards returns
400 This item is no longer available in your account from /redeem/estimate.
Save a shipping address
POST /shipping-address/create
| Field | Type | Required | Notes |
|---|---|---|---|
streetAddress | string | yes | |
city | string | yes | |
state | string | yes | Required even where the concept does not apply. A code or a name; stored normalised to the subdivision name, so "CA" becomes "California". |
country | string | yes | Alpha-2 (US), alpha-3 (USA), or the full name. |
fullName | string | no | Send it — the carrier needs it. |
apartment | string | no | |
zip | string | no | Needed for correct US rates. |
phoneNumber | string | yes* | |
email | string | yes* | Saved to the customer's account, not to the address. |
isDefault | boolean | no | Your first address is always the default. |
* Required unless the customer's account already has one, in which case that value is used and you can omit the field. The carrier needs both to print a label, so an address without them is refused.
POST /partner/customers creates a customer with neither, so send both on the first address you create for them. After that you can leave them out.
Any field not on this list returns a 400. Returns the created row; keep id as your shippingAddressId.
PATCH /shipping-address/update/:id follows the same rules. Omitting phoneNumber keeps the stored one rather than clearing it.
Errors:
| Body | Cause |
|---|---|
400 Sorry, we do not ship to <country> at this time | Restricted destination. Ukraine is blocked per-region, so the message can name a region. |
400 A phone number is required for shipping. … | No phone in the body, on the address, or on the account. |
400 An email address is required for shipping. … | No email in the body or on the account. |
400 That email address is already in use by another account. | Belongs to another of your customers. Nothing was saved. |
409 This customer has a verified contact email. They must change it themselves. | Send the address without email. |
400 Invalid request. | A required address field is missing. The message does not name the field. |
GET /shipping-address lists your saved addresses.
Card identifiers
nftAddresses takes each card's nftAddress exactly as the API returns it. List your
customer's cards with GET /cards/:wallet, where :wallet is the wallet they signed in with.
It is public and needs no token. Each card is in filterNFtCard[].
| Chain | nftAddress | Example |
|---|---|---|
| Solana | The asset's base58 address | 34wdTG128smHw9VM8NnHc4nohaZAEsnGgGSh2d8ZAKJo |
| EVM | <chain>-<contract>-<tokenId> | base-0x1ffd5353ae2f29758b811fa9f9146c1f59e6e9e7-492 |
For an EVM card:
<chain>is the lowercase chain name, such asbase.- The separators are hyphens.
base:0x…:492,0x…:492,8453:0x…:492and a bare token id are not recognised and return404 Cards not found. - The chain and contract are case-insensitive, so a checksummed contract works.
- The token id is decimal and must match exactly:
078and78are different tokens.
curl -X POST https://api.collectorcrypt.com/redeem/prepare \
-H 'Authorization: Bearer cca_...' \
-H 'Content-Type: application/json' \
-d '{"shippingAddressId":"<id>","nftAddresses":["base-0x1ffd5353ae2f29758b811fa9f9146c1f59e6e9e7-492"]}'
A token's metadata has a Collector Crypt ID attribute. It can name the same physical card's
record on another chain: a Base token can carry the ID of the Solana asset it was minted from.
Redeeming that record returns 400 This item is no longer available in your account, because
your customer holds the Base token, not the Solana one. Send the nftAddress of the token your
customer holds.
Estimate
POST /redeem/estimate — optional, priced preview.
| Field | Type | Required |
|---|---|---|
nftAddresses | string[] | yes, at least 1. See Card identifiers. |
shippingAddressId | string | yes |
deliveryCompany | string | no |
payCustomsDuties | boolean | no |
This endpoint accepts only these four fields. Reposting a /redeem/prepare body returns a 400.
{
"price": 80, "insurancePrice": 0, "feesPrice": 0, "shippingPrice": 5.99,
"total": 5.99, "numberOfCards": 1, "customsDutiesEstimate": 0,
"breakdown": { "region": "USA", "declaredValue": 80, "numberOfCards": 1,
"numberOfMoonbirdsPacks": 0, "numberOfSealedPacks": 0, "lines": [], "notes": [] }
}
price is the declared value of the cards, not a charge — do not show it as one. What the
customer pays is total.
customsDutiesEstimate is always returned, whether or not you opted in — show it next to your opt-in control.
breakdown.lines[] entries are { code, label, amount, qty?, unitPrice? }.
Iterate lines and display each entry's own label and amount. Do not switch exhaustively on code, and do not rebuild the total from a fixed set of codes. Different item types — cards, comics, watches, sealed product — produce different lines, more codes exist than any one order shows, and new ones are added without notice. total is authoritative.
breakdown.region is one of USA, Canada, Europe, AustraliaNewZealand, RestOfWorld.
Errors: 404 Cards not found: <addresses> · 404 Shipping address not found for this user · 400 whose message is the rejection reasons flattened into one space-joined sentence (e.g. This item is not redeemable (status: Burned)). The per-card error code and nftAddress are not returned, so re-check the cards yourself to find out which ones failed.
Prepare
POST /redeem/prepare
| Field | Type | Required | Notes |
|---|---|---|---|
nftAddresses | string[] | yes | See Card identifiers. |
shippingAddressId | string | yes | |
coin | USDC | USDT | no | Default USDC. |
paymentMethod | crypto | card | no | Default crypto. |
deliveryCompany | string | no | Default "ups". |
comment | string | no | |
email | string | no | Required in effect when paymentMethod is card and your account has no email. |
payCustomsDuties | boolean | no | |
paymentRail | solana | evm | no | Where the fee is paid: Solana USDC, or once on one of the EVM cards' chains (USDC on Base, USDG on Robinhood). Default evm when the account has only an EVM wallet, otherwise solana. See Redeeming EVM cards. |
payerWallet | string | no | Base58 Solana address that pays instead of your user. It has to sign — see Paying for your user. Solana payment only. |
There is no insurance field — insurance is automatic, and sending the field returns 400 ["property insurance should not exist"].
{
"outboundShipmentId": "...",
"transactions": ["<base64 unsigned>"],
"delistTransactions": [],
"totalCost": 5.99,
"submitUrl": "/blockchain/<outboundShipmentId>/burn",
"breakdown": { }
}
transactions— sign every entry. The shipping-fee instruction is appended to the first one, so withpayerWalletset that entry carries two signatures: your payer's and your user's.delistTransactions— usually[]. Non-empty when a card is held in an external marketplace escrow and must be released first. Sign and submit these too; omit them and the burn legs fail on chain.totalCost— 0 for card payment, and 0 for a shipment that is already paid. Do not render it unconditionally as "the price".
Calling prepare again with identical input returns the same shipment with fresh transactions. That is the correct recovery when a blockhash expires. Changing the address, toggling payCustomsDuties, or changing payerWallet (including leaving it out) deliberately creates a new shipment.
Errors: everything from estimate, plus 400 Card payment requires a contact email. Send `email` with this request. · 400 Crypto payment requires a Solana wallet. Use card payment. · 400 payerWallet is only supported when paying in Solana USDC · 403 payerWallet is not a permitted wallet · 400 account <address> balance <n> USDC not enough, naming the payer's address when you sent one.
Prepare re-checks the phone and email, so it can also return those 400s from Save a shipping address. Estimate does not — you can price an address before you have collected them. An older address missing either will pass estimate and fail prepare; add them with PATCH /shipping-address/update/:id and re-prepare, which reuses the same order.
Submit the signed transactions
POST /blockchain/:outboundShipmentId/burn
| Field | Type | Notes |
|---|---|---|
transactions | string[] | Base64 signed copies of every entry from prepare. |
delistTransactions | string[] | Signed copies, when prepare returned any. |
The set of transactions prepare issued is held for 15 minutes. After that every leg is refused and you must call prepare again.
The response is HTTP 200 with a bare JSON array, failures first:
[{ "error": null, "transactionId": "...", "transactionUrl": "https://..." }]
There is no id, status or transactionUrls. Inspect every element — a 200 with a non-null error on any element means that leg did not land.
Re-posting the identical body is safe, but legs already recorded come back as { "error": "Duplicate transaction result", ... }. Do not read that as a failure.
Errors:
| Status | Message | Meaning |
|---|---|---|
| 403 | Transaction was not issued by this server | A transaction this server did not build for you, legs mixed from two different prepare calls, or a batch older than 15 minutes. |
200 [] | You sent no transactions at all — this is not an error, and nothing was burned. | |
| 403 | The transactions submitted are not the complete set this server issued | A leg is missing, duplicated, or from another prepare call. |
| 403 | These transactions were not issued for this shipment | Wrong outboundShipmentId. |
| 409 | shipment <id> is awaiting card payment confirmation | Card payment not yet confirmed. |
| 409 | with delistErrors | A de-list leg failed. Nothing was burned. |
| 404 | The shipment is not yours. | |
| 400 | Burn failed: <reason> | No leg landed. Nothing was burned and no fee moved. |
If a burn leg fails, see Complete a partial burn. Do not prepare again.
Redeeming EVM cards
For a card on an EVM chain, the customer's EVM wallet sends the burn itself. CollectorCrypt signs nothing, and there are no base64 transactions.
Which rail pays
- A customer with only an EVM wallet pays on EVM by default (
"paymentRail": "evm"). - A customer who also has a Solana wallet pays in Solana USDC unless you send
"paymentRail": "evm". That payment comes back intransactions: sign and submit it as in Submit the signed transactions. - An
evm-rail order can hold cards from several EVM chains. The fee is paid once, on one chain: Base if the order has a Base card, otherwise whichever of the order's chains in the table below holds the most cards (a tie goes to the chain name that sorts first).payment.chainnames it. - The fee is paid in that chain's token, from the table below. Cards on a chain not in the table are burned in burn-only groups. The order is refused only when none of its chains is in the table.
- The wallet needs that token on the paying chain, and gas on every chain it burns on.
| Chain | Token | Production | Devnet |
|---|---|---|---|
| Base | USDC | 0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 | 0x036cbd53842c5426634e7929541ec2318f3dcf7e |
| Robinhood Chain | USDG | 0x5fc5360d0400a0fd4f2af552add042d716f1d168 | 0x7e955252e15c84f5768b83c41a71f9eba181802f |
Prepare names the token in payment.token. Both tokens have 6 decimals.
What prepare returns
On the evm rail, evmCallBundle holds every call the wallet sends on the paying chain, in
order: approve, that chain's burns, pay. evmTransactions lists the burns on every chain.
This order has one Base card and one Robinhood card, so Base pays:
{
"outboundShipmentId": "cmuogaspa000vzs6xserruhbd",
"evmCallBundle": {
"chainId": 8453,
"atomicPreferred": true,
"calls": [
{ "kind": "approve", "to": "0x...", "value": "0", "data": "0x..." },
{ "kind": "batchBurn", "to": "0x...", "value": "0", "data": "0x..." },
{ "kind": "pay", "to": "0x...", "value": "0", "data": "0x..." }
],
"payment": {
"chain": "base", "chainId": 8453, "payer": "0x...", "token": "0x...", "spender": "0x...",
"amountRaw": "5990000", "memo": "cmuogaspa000vzs6xserruhbd", "amountDue": "5.99"
}
},
"payment": { },
"evmTransactions": [
{ "chain": "base", "chainId": 8453, "tokenIds": ["492"], "owner": "0x...",
"burnTxs": [{ "to": "0x...", "data": "0x...", "chainId": 8453, "gas": "..." }] },
{ "chain": "robinhood", "chainId": 4663, "tokenIds": ["2389"], "owner": "0x...",
"burnTxs": [{ "to": "0x...", "data": "0x...", "chainId": 4663, "gas": "..." }] }
],
"submitUrl": "/blockchain/<outboundShipmentId>/burn"
}
approvegrants the exact amount due, never an unlimited allowance.- There can be more than one
batchBurn. Send them all. paymentrepeatsevmCallBundle.payment.amountRawis in the token's 6-decimal units (5990000is 5.99).memois theoutboundShipmentId; it is what ties the payment to the order.chainIdis the paying chain. An order paid on Robinhood returns"chain": "robinhood",chainId4663(devnet46630), and USDG astoken.evmTransactionslists only the burns, per chain: theburnTxs, thetokenIdsthey burn, and theownerthat must send them. Burns onevmCallBundle.chainIdare already inevmCallBundle.calls; do not send them again. Each entry on another chain is a burn-only group: you send itsburnTxsyourself.- With no
evmCallBundle, send everyevmTransactionsentry. That happens when the order is paid another way or nothing is owed.
Send the calls
Send everything from payment.payer, the wallet holding the cards. Each send goes to one chain,
named by its chainId; a wallet connected to another chain must switch first.
- Burn-only groups. For each
evmTransactionsentry whosechainIdis notevmCallBundle.chainId, send itsburnTxson that chain, in order: as onewallet_sendCallsbatch, or oneeth_sendTransactioneach. - The payment bundle, last, on
evmCallBundle.chainId:- Wallets with EIP-5792, such as Coinbase Smart Wallet: send
evmCallBundle.callsas onewallet_sendCallsbatch. It lands as one transaction, so that chain's burns and the payment succeed or fail together. - Other wallets: send each call with
eth_sendTransaction, in order, and wait for each receipt before sending the next. The burn comes beforepay, so if a burn reverts, stop. Nothing has been charged.
- Wallets with EIP-5792, such as Coinbase Smart Wallet: send
With the payment bundle last, a burn that reverts on any chain stops the order before money
moves. If the bundle fails, resend only the calls without a successful receipt; a
wallet_sendCalls batch that reverted can be sent again whole. The calls from prepare do not
expire. Before resending pay, check GET /outbound-shipment/:id: PaymentReceived or
Pending means it is paid. A pay that landed shows there only after its confirmations, about
5 minutes, so wait that long first. A second pay charges the customer again and is not
credited.
After sending
You do not need to call /burn. CollectorCrypt reads the burns on every chain and the Paid
event on the paying chain, and matches them to the order by memo. A burn is usually recorded
within seconds. The payment is credited once it has enough confirmations, usually within about
5 minutes.
To report a burn yourself, send its hash, one entry per chain:
curl -X POST https://api.collectorcrypt.com/blockchain/<outboundShipmentId>/burn \
-H 'Authorization: Bearer cca_...' \
-H 'Content-Type: application/json' \
-d '{"evmTransactions":[{"chain":"base","txHash":"0x..."},{"chain":"robinhood","txHash":"0x..."}]}'
GET /outbound-shipment/:id shows progress:
PaymentPending: every burn is recorded and no payment has been seen. The amount is fixed on the order, so theapproveandpaycalls from prepare still apply. Send them.PaymentReceived: the payment is recorded and a burn is not. Send the missing chain'sburnTxsfrom prepare.Pending: the payment and every card's burn, on every chain, are recorded.
Errors from prepare on this path: 400 Crypto payment requires an EVM wallet. Use card payment.
(paymentRail: "evm" on an account with no EVM wallet) · 400 User does not have an EVM wallet
· 400 EVM payment is not supported on <chains> (none of the order's chains has a token in the
table above; the message lists them, comma-separated).
Redeeming from a smart wallet
A smart wallet cannot sign the transactions CollectorCrypt builds, so for one prepare returns them unsigned for the wallet to send itself, and you report what landed.
Prepare exactly as above. For a smart wallet the response has transactions: [] and a smartWalletTransactions array instead. Each entry is a base64 unsigned v0 transaction with the wallet as fee payer. Each one:
- burns up to four cards, with the wallet as payer and authority,
- sends the rent those burns release back to CollectorCrypt,
- carries the memo
cc shipment: <outboundShipmentId>, signed by the wallet, - and, for the first entry only, pays the shipping fee.
Send every entry through your wallet provider, unchanged. With the Crossmint wallets SDK:
import { VersionedTransaction } from '@solana/web3.js'
const hashes: string[] = []
for (const entry of prepareResponse.smartWalletTransactions) {
const transaction = VersionedTransaction.deserialize(Buffer.from(entry, 'base64'))
const { hash } = await solanaWallet.sendTransaction({ transaction })
hashes.push(hash)
}
Report the hashes: POST /blockchain/:outboundShipmentId/burn with { "solanaSignatures": ["<hash>", "..."] }, and nothing else in the body. CollectorCrypt reads each transaction from chain. It credits one that carries the shipment memo signed by the wallet and burns at least one of the shipment's cards with the wallet as payer and authority. The fee counts as paid only on an exact match. The response is the same array as a signed submit.
Send the entries unchanged. A transaction whose fee transfer was removed still burns its cards, but the fee stays owed, and once no cards are left the order cannot ship until support resolves it.
| Status | Message | What to do |
|---|---|---|
| 400 | Burn report failed: not found or not yet confirmed or lookup failed; retry | Report the same hash again in a few seconds. Nothing was recorded. |
| 400 | Burn report failed: ... with any other reason | The transaction failed or belongs to another shipment. If nothing on the order has been credited, prepare again; otherwise call complete. |
| 400 | solanaSignatures are for smart wallets; submit signed transactions instead | The account's wallet is an ordinary one. |
| 409 | shipment is cancelled | Contact support. |
Reporting the same hash twice is safe. Reports, prepares, resumes and completes on a smart-wallet shipment share one limit of 10 a minute per account.
If the flow is interrupted, call POST /redeem/complete/:outboundShipmentId, not prepare: prepare refuses cards that are already burned. Before building anything, complete finds the shipment's transactions that landed but were never reported and credits them. It then returns legs only for the cards left, in smartWalletTransactions (transactions stays []); send and report them exactly as above. If feeReCollected is true, the fee rides on smartWalletTransactions[0]. Once everything is credited, complete answers 400 burn already complete for this shipment. The fee is never charged twice.
Resume, prepare and complete can also answer:
| Status | Message | What to do |
|---|---|---|
| 503 | A transaction for this shipment is still confirming; retry shortly | A landed transaction is not readable yet. Retry in a few seconds. |
| 503 | A card on this shipment burned in a transaction not credited yet; retry shortly | Retry in a few seconds, or report that transaction's hash. |
| 409 | A transaction for this shipment needs review; contact support | A transaction bound to this shipment did something CollectorCrypt did not build. Contact support with the order id. |
| 409 | shipment is cancelled | Contact support. |
Limits for now:
- Core cards only. A shipment with any other card type is refused at prepare.
- The wallet must hold every card. A card listed on a marketplace has to be delisted first.
- No
payerWallet. - With card payment,
smartWalletTransactionsstays empty until the payment is confirmed; then callPOST /redeem/resume/:outboundShipmentId, which returns them insmartWalletTransactions.
Complete a partial burn
A submit that lands some legs and not others leaves the order half done: the cards in the landed legs are burned, the rest are still in the customer's wallet, and the shipping fee has been paid once. Do not prepare again — that opens a second order and charges shipping twice — and do not re-post the old bundle, whose blockhashes have expired.
POST /redeem/complete/:outboundShipmentId
No body. Send the bearer token the customer prepared with — a wallet sign-in access token or a partner identity token, with the same X-CC-Wallet if you used one. The customer's wallet has to sign again, so this is not a server-to-server call.
{
"outboundShipmentId": "cmfz3k9ab0001l8x1qv6t2d7e",
"transactions": ["<base64 unsigned>", "<base64 unsigned>"],
"delistTransactions": [],
"totalCost": 0,
"feeReCollected": false,
"remainingCards": 3,
"submitUrl": "/blockchain/cmfz3k9ab0001l8x1qv6t2d7e/burn"
}
| Field | Notes |
|---|---|
transactions | Fresh legs for only the cards not yet burned. Sign every entry. |
delistTransactions | As in prepare. Usually []. |
remainingCards | How many cards these legs burn. Check it against your own count — see Which cards burned. |
totalCost | 0 in the normal case: the fee landed with your first submit and is not charged again. |
feeReCollected | false normally. true means no fee payment for this order was found on chain, so totalCost is the fee and it rides on transactions[0] again. Card-paid orders are never re-collected. |
For a smart wallet, transactions is [] and the legs are in smartWalletTransactions: see Redeeming from a smart wallet.
Other fields may appear; rely only on these.
Errors: 404 the order is not yours · 400 shipment is cancelled · 400 shipment is awaiting card payment — cannot complete · 400 burn already complete for this shipment — every card is burned and there is nothing left to do.
Sign and submit
The same as after prepare. Each entry is a serialized Solana transaction with CollectorCrypt as fee payer and no signatures on it yet. Deserialize it, have the customer's wallet sign, serialize it back to base64, and post the whole set to submitUrl within 15 minutes.
import { VersionedTransaction } from '@solana/web3.js'
const { transactions, delistTransactions, submitUrl } = completeResponse
// Delists first, then burns — one prompt for the customer.
const unsigned = [...delistTransactions, ...transactions].map((b64) =>
VersionedTransaction.deserialize(Buffer.from(b64, 'base64')),
)
const signed = await wallet.signAllTransactions(unsigned)
const b64 = signed.map((tx) => Buffer.from(tx.serialize()).toString('base64'))
const res = await fetch(`https://api.collectorcrypt.com${submitUrl}`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
delistTransactions: b64.slice(0, delistTransactions.length),
transactions: b64.slice(delistTransactions.length),
}),
})
const results = await res.json() // [{ error, transactionId, transactionUrl }], failures first
CollectorCrypt adds the fee payer's signature when you submit, so the bundle is never fully signed on your side and you cannot broadcast it yourself. If the order was prepared with payerWallet and feeReCollected is true, the payer signs transactions[0] again, as at prepare. When it is false there is no fee leg and only the customer signs.
Every call to complete issues a new set, and a submit must be exactly one set. Sign and submit the response you just received; mixing legs from two calls returns 403 The transactions submitted are not the complete set this server issued.
Which cards burned
The submit reply is per transaction, not per card, and one transaction can burn several cards. To know which mints are gone, ask about the mint:
GET /cards/publicNft/:mint/market— public, no token.statusandnftStatusreadBurnedonce a confirmed transaction burned that mint. Any other value means the burn is not recorded for that mint, and complete will build a leg for it.- DAS
getAsset/getAssetBatchon your own RPC —burnt: trueat the top level of the asset. Older Token Metadata mints may instead reportburnt: falsewithtoken_info.supplyof0; treat that as burned too. A live card hasburnt: falseand, wheretoken_infois present,supplyof1.
The number of mints still live should equal remainingCards. If it does not, stop and email support with the order id.
The retry loop
- Read every element of the submit reply. A non-null
erroris a leg that did not land.Duplicate transaction resultis not a failure. - Call complete, sign everything it returns, submit the whole set.
- Repeat from 1 until complete answers
400 burn already complete for this shipment. The order'sstatusthen moves toPending.
If the same leg fails twice with the same error, the cause is not an expired blockhash. Check the mint's ownership.owner on DAS — it has to be the signing wallet — and contact support@collectorcrypt.com with the order id and the error text.
Paying for your user
Send payerWallet on prepare and the USDC comes out of that wallet instead of
your user's. Everything else is unchanged: your user still signs the burns, the
cards still leave their account, and the order still belongs to them.
The payer is a wallet you control. CollectorCrypt never holds its key and never signs for it, so it has to sign the bundle itself:
- Prepare with
payerWallet. - Your payer signs
transactions[0]— the entry carrying the fee. - Your user signs every entry, including that one.
- Submit the complete set as usual. Both signatures must be in place before you submit; a missing one fails simulation and nothing is broadcast.
The order of the two signatures does not matter.
Three things to know:
- The payer needs the USDC, not your user. Prepare checks the payer's balance and fails naming the payer's address if it is short. A user with no USDC at all can redeem this way.
- Solana only. Sending
payerWalleton an order that settles any other way is a400, not a silent no-op. - The payer is fixed at prepare.
payerWalletis part of what identifies an order. Re-preparing the same cart with the samepayerWalletreturns the same order with fresh transactions; re-preparing with a different one, or without it, creates a separate order and leaves the sponsored one untouched. Retry with thepayerWalletyou started with, so the bill cannot fall back to your user.
You cannot name a CollectorCrypt wallet as the payer — that returns
403 payerWallet is not a permitted wallet.
Track a shipment
GET /outbound-shipment lists your shipments, newest first. GET /outbound-shipment/:id returns one.
Optional query parameters on the list route: status (Active or Past) and search. Note that Active includes Delivered, so the two sets overlap — do not union them or you will count delivered shipments twice. These apply to a wallet-sign-in session only — with an API key they are accepted and ignored, and the list returns every shipment for the customer named by X-CC-Customer, so filter client-side.
Documented fields per shipment:
id · customId · status · numberOfCards · cardIds · deliveryCompany · trackingIds · trackingUrls · shippingCost · insuranceCost · feesCost · totalCost · typeCurrency · createdAt · updatedAt
All cost fields and numberOfCards are strings. Other fields may appear; rely only on the ones listed here.
With an API key the projection is narrower: numberOfCards and typeCurrency are not selected and come back undefined. The full list above is what a wallet-sign-in token gets.
Statuses: Created (prepared, not yet paid or burned — the first one you will read) · PaymentPending · PaymentReceived · Pending (paid and burned, accepted by the warehouse) · Processing · Shipped · Delivered · ActionRequired · Cancelled. Treat this as a set, not a sequence — PaymentPending and PaymentReceived are alternatives to Pending on the card-payment and EVM paths, and a shipment can skip several.
GET /outbound-shipment/:id does not return 404. Treat an empty 200 as "not found".
Using an API key
Send the key as a bearer token, plus a header naming which of your customers the call acts for:
curl https://api.collectorcrypt.com/shipping-address \
-H 'Authorization: Bearer ccsk_...' \
-H 'X-CC-Customer: your-customer-id'
Create the customer mapping first with POST /partner/customers, body { "externalId": "your-customer-id" }. It returns { "userId": "...", "created": true } and is idempotent. This is the one key route that does not take X-CC-Customer.
A key carries scopes, and reaches only these routes:
| Route | Scope |
|---|---|
GET/POST /shipping-address, /shipping-address/create | shipping-address |
GET/DELETE /shipping-address/:id, PATCH /shipping-address/update/:id | shipping-address |
GET /outbound-shipment, GET /outbound-shipment/:id | outbound-shipment |
POST /redeem/estimate, POST /redeem/prepare | redeem |
POST/GET /partner/inbound-shipments, GET /partner/inbound-shipments/:id, PATCH /partner/inbound-shipments/:id/tracking | inbound-shipment |
POST /partner/customers | customers:provision |
POST /marketplace/cards/:nftAddress/request-buyback, POST /marketplace/cards/request-buyback-bulk | buyback |
request-buyback flags a card your customer owns as open to an offer from Collector Crypt, and returns { "ok": true }. The bulk form takes { "nftAddresses": [...] }, up to 200, and answers with updated and skipped lists so a partial batch tells you which ones did not take.
It queues a review; it is not a sale and not a price quote. If we make an offer, an EVM card receives it as an OpenSea (Seaport) bid in USDC against the token. Accepting is the customer's wallet's job, not ours — the seller signs the Seaport fulfilment and pays the gas, so for a custodial wallet that leg belongs in your own flow.
A card must be owned by the customer named in X-CC-Customer, and be minted and not burned. Anything else is a 403 or 400 on the single route, or a skipped entry with a reason on the bulk one.
POST /blockchain/:outboundShipmentId/burn is reachable with an API key, but a key may report EVM transaction hashes only — { evmTransactions: [{ chain, txHash }] }. Sending Solana transactions on a key is refused: that half of the route has CC co-sign and broadcast, which a machine credential has no need of. Solana redemptions still need a wallet sign-in session.
A key also gives you a private rate-limit allowance instead of one shared with every caller on your network. It does not raise the sign-in limits.
Errors: 401 Invalid API key for every key failure — unknown, wrong, revoked, expired or disabled. 403 This API key does not carry the '<scope>' scope. 400 x-cc-customer header is required on this route, which you also get for a customer id that does not exist.
This API does not read an x-api-key header. Use Authorization: Bearer.
To request a key, email support@collectorcrypt.com and say which scopes you need.
Consigning items into the vault
Send us physical items by declaring the mints they were tokenised as. Unlike
every other key route this one takes no X-CC-Customer — a consignment is
made by your company, not on behalf of one of your customers, so the key alone
identifies the owner.
POST /partner/inbound-shipments
| Field | Type | Required | Notes |
|---|---|---|---|
nftAddresses | string[] | yes | Base58 Solana mints. Deduplicated for you. Max 2,000 per call — split a large consignment across several. |
externalRef | string | no | Your own reference for the consignment. Recorded, but not returned by any read — keep your own map from our shipment id to it. (The externalRef on each declared line is a different field, and is returned.) |
trackingId | string | no | Omit it if you have no label yet and add it later with PATCH /partner/inbound-shipments/:id/tracking, body { "trackingId": "..." }. |
declaredValue | number | no | USD, for the whole consignment. |
curl -X POST https://api.collectorcrypt.com/partner/inbound-shipments \
-H 'Authorization: Bearer ccsk_...' \
-H 'Content-Type: application/json' \
-d '{ "nftAddresses": ["7xK...", "9aB..."], "externalRef": "TRUCK-2026-08-27" }'
Each address is resolved against our catalogue and routed to one of three intake paths. We decide this from our own record of the token, not from its metadata, so how you spell your traits cannot change where an item lands:
kind | When | What happens next |
|---|---|---|
Cert | The token is a graded single with a cert we can look up | An expected item is created immediately; scan-in at our vault advances it |
Bulk | The token is sealed or bulk product | Reconciled against a shelf count on arrival |
CardRaw | Anything else, including raw ungraded cards | Identified on arrival, when our sorting bench assigns it a scannable handle |
An address we do not hold comes back as a rejected line, not an error. One
unindexed token never fails the whole manifest — check state and note per
line.
Polling progress
GET /partner/inbound-shipments takes page, step (max 200) and status.
GET /partner/inbound-shipments/:id returns one consignment with its lines:
{
"id": "2026082742S12345",
"status": "Processing",
"counts": { "declared": 1, "received": 1, "rejected": 1 },
"declaredLines": [
{ "kind": "Cert", "state": "Received", "externalRef": "7xK...",
"vaultItem": { "status": "Vaulted", "gradingId": "12345678",
"gemrateCardName": "1999 Pokemon Base Charizard" } },
{ "kind": "CardRaw", "state": "Declared", "externalRef": "9aB..." },
{ "kind": "CardRaw", "state": "Rejected", "externalRef": "3mQ...",
"note": "not found in the CollectorCrypt catalogue" }
]
}
The three counts are per-state and sum to the number of lines — declared means "not yet received",
not "total declared". A rejected line always carries kind: "CardRaw", because we cannot classify a
token we do not hold, so branch on state rather than kind.
state is one of Declared, Received or Rejected, and for Cert and
CardRaw lines it is derived from where the physical item actually is — so it
advances on its own as our vault handles the item. There are no webhooks: poll
at whatever cadence your key's rate limit allows.
Errors
Error bodies are not uniform — branch on the HTTP status, never on the presence of a field.
{ "statusCode": 404, "message": "Shipping address not found for this user", "error": "Not Found" }
{ "statusCode": 400, "message": "Invalid request." }
{ "statusCode": 429, "message": "Too many requests — nonce requests for this wallet. Retry in 60s.", "retryAfter": 60 }
message is always a string. Validation failures and per-card rejections are flattened into one space-joined sentence rather than JSON — there is nothing to parse, and card addresses are not preserved. Branch on the HTTP status, not on the text.
| Status | Meaning |
|---|---|
| 400 | Bad input. May carry JSON inside message. |
| 401 | Token or key invalid or expired. |
| 403 | Out of scope for this credential. |
| 404 | Card or address not found. |
| 409 | State conflict — payment pending, or a de-list failed. |
| 429 | Rate limited. Read retryAfter from the body. |
Sign-in requests are rate limited per wallet and per network address. On a 429, read retryAfter and retry after it elapses.
Send a non-empty User-Agent. A request without one is refused at the edge and never reaches the API.
Need help? support@collectorcrypt.com