Gacha Invoiced API
The Invoiced API lets a partner with its own accounts, payments and storefront sell CollectorCrypt gacha packs to its users. You issue a pack, your user opens it, and the card they win is held for them by CollectorCrypt. Your user can sell it back inside the buyback window, or keep it and have it shipped (Invoiced Shipping). Nothing is paid on-chain: once a month you receive an invoice that nets packs, buybacks and shipping.
What you must know
- Open right after the sale. A pack must be opened within 2 hours of being issued.
- The roll is fixed once the pack is opened. The seed is stored before anything is rolled, and a retry gives the same result. Signing the seed yourself is optional; see Seed.
playerRefandclientRefare your own opaque ids. Never put a name, email, phone number or handle in them.- Buybacks close 72 hours after the award. About a day later a kept card moves into vault custody and can ship.
- List reads can lag by a few seconds.
GET /packs/{memo}is always current. - A draft invoice is provisional. Only an issued invoice is a bill.
Base URL and authentication
https://gacha.collectorcrypt.com/api/v1
Send your API key on every request, as x-api-key: <key> or Authorization: Bearer <key>. A missing, invalid,
expired or deactivated key gets 401 UNAUTHORIZED. Another partner's packs, cards and users return 404.
List the machines your key can sell with GET /api/v1/machines. Use a machine's code as packType.
Packs
POST /packs — issue
{ "packType": "pokemon_50", "playerRef": "user-8431", "clientRef": "order-77120" }
playerRefis required,^[A-Za-z0-9_-]{1,128}$.clientRefis optional,^[A-Za-z0-9_.:-]{1,128}$. The sameclientRefreturns the same pack with"replay": true.
{
"replay": false,
"memo": "abcde-5f3c2b1a-…",
"packType": "pokemon_50",
"playerRef": "user-8431",
"clientRef": "order-77120",
"price": 50,
"currency": "USD",
"buybackPct": 90,
"odds": { "epic": 0.03, "rare": 0.07, "uncommon": 0.2, "common": 0.7 },
"poolVersion": "1289",
"expiresAt": "2026-10-01T14:02:11.000Z"
}
price is what you are invoiced for the pack.
POST /packs/{memo}/open
Send an empty body, or { "clientSeed": "…" } if you sign your own seeds.
{
"success": true,
"replay": false,
"memo": "abcde-5f3c2b1a-…",
"roll": 41203377,
"rarity": "rare",
"rarityLabel": "Rare",
"prizeTier": 2,
"nftAddress": "7xKX…",
"cardName": "Charizard ex",
"nft": { "…": "card metadata" },
"insuredValue": 120,
"buybackCredit": 108,
"buybackExpiresAt": "2026-10-04T12:02:14.000Z"
}
Opening again returns the stored award with "replay": true. 202 PROCESSING means an earlier open is still
finishing: poll GET /packs/{memo}. buybackCredit is fixed when the card is won.
GET /packs/{memo}
The pack, its award and where the card is now. state is one of issued, expired, processing, held (buyback
open, or waiting to move to the vault), in_vault (shippable), shipped, bought_back.
{
"memo": "abcde-…", "packType": "pokemon_50", "playerRef": "user-8431", "clientRef": "order-77120",
"price": 50, "buybackPct": 90, "createdAt": "…", "expiresAt": "…", "seeded": true, "state": "held",
"award": { "roll": 41203377, "rarity": "rare", "prizeTier": 2, "nftAddress": "7xKX…", "cardName": "…",
"nft": { }, "insuredValue": 120, "buybackCredit": 108, "awardedAt": "…" },
"buybackOpen": true, "buybackExpiresAt": "…", "buyback": null, "releasedAt": null, "shipmentId": null
}
POST /packs/{memo}/buyback and GET /packs/{memo}/buyback
POST sells the card back for its buybackCredit, netted on your invoice. Repeating it returns the same result
with "replay": true.
{ "success": true, "replay": false, "memo": "abcde-…", "creditAmount": 108, "ownershipSeq": "918273" }
GET checks eligibility without changing anything:
{ "memo": "abcde-…", "eligible": true, "creditAmount": 108, "buybackExpiresAt": "…" }
When eligible is false, reason is PROCESSING, ALREADY_BOUGHT_BACK or BUYBACK_WINDOW_CLOSED.
Users and cards
GET /users/{playerRef}returns counts by state and the number of cards still eligible for buyback:{ "playerRef": "user-8431", "counts": { "held": 2, "inVault": 5, "shipped": 1, "boughtBack": 3 }, "eligibleBuybacks": 2 }GET /users/{playerRef}/cards?status=custody|shipped|bought_back|all&limit=&cursor=(defaultcustody) andGET /cards?status=&playerRef=&limit=&cursor=return:Pass{ "cards": [ { "memo": "…", "playerRef": "…", "nftAddress": "…", "nft": { }, "insuredValue": 120,
"acquiredAt": "…", "state": "in_vault", "buybackOpen": false, "buybackExpiresAt": "…",
"inVault": true, "shippable": true, "releasedAt": null, "releasedReason": null, "shipmentId": null } ],
"nextCursor": "…" }nextCursorback ascursor.limitdefaults to 50, max 200. AplayerRefyou have never issued to returns404on/users/{playerRef}and its sub-resources.GET /cards/{nftAddress}returns{ nftAddress, current, history }. A card can recur if it was bought back and won again.
Invoices and ledger
- Invoices cover UTC calendar months. A row is billed in the month its event happened (award, buyback, or shipment paid); a row missed by an issued month rolls onto the next invoice.
total = packs − partner fee − buybacks + shipping + adjustments + carried forward. A negative month is carried into the next issued invoice.GET /invoices?limit=&cursor=returns{ invoices, nextCursor };GET /invoices/{id}returns{ invoice, adjustments }.statusisdraft,issued,settledorvoid.GET /ledger?limit=&cursor=&invoiceId=is an ascending event feed (at,type,id,memo,playerRef,amount,shipmentId,invoiceId). Types:pack.created,pack.awarded,card.bought_back,card.escrowed(moved to vault custody),shipment.created,shipment.paid,shipment.cancelled,invoice.issued,invoice.settled.invoiceId=lists exactly one invoice's rows.- A new event can land just behind your cursor. When polling, re-read the last few minutes and de-duplicate by
type+id.
Seed (optional)
Every roll comes from a seed that is stored before the roll and never changes.
- Omit
clientSeedand we generate a random seed. Nothing to set up. - Sign your own so anyone can check that we could not choose the seed: register your ed25519 public key with
PUT /seed-keyand sendclientSeed= your signature over the memo, as unpadded base64url. A seed you send must be that signature; anything else returns400 INVALID_SEED.
You choose per open: with a key registered you can still omit clientSeed and get a random seed.
openssl genpkey -algorithm ed25519 -out seed.pem
openssl pkey -in seed.pem -pubout -outform DER | tail -c 32 | base64 | tr '+/' '-_' | tr -d '=' # your public key
PUT /seed-key, GET /seed-key, DELETE /seed-key
One key per partner, shared by all your API keys. Your private key never leaves you.
curl -X PUT "https://gacha.collectorcrypt.com/api/v1/seed-key" -H "x-api-key: <your key>" -H "Content-Type: application/json" \
-d '{"publicKey":"6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw"}'
# → 200 { "publicKey": "6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw" }
PUTsets or rotates the key.publicKeyis the 32-byte key in base64url; anything else returns400 INVALID_PUBLIC_KEYand changes nothing.GETreturns{ "publicKey": … },nullwhen none is registered.DELETEclears it. From then on, sending aclientSeedreturns400 INVALID_SEED; omitting it still works.- Rotating only affects packs opened afterwards. Each pack keeps the key it was opened under, so older packs still
verify, and
GET /vrf/verifyshows that key asseedPubkey: compare it with your own.
// Node
import { createPrivateKey, sign } from 'node:crypto';
import { readFileSync } from 'node:fs';
const key = createPrivateKey(readFileSync('seed.pem'));
const clientSeed = sign(null, Buffer.from(memo, 'utf8'), key).toString('base64url');
# Python (PyNaCl)
import base64, nacl.signing
sk = nacl.signing.SigningKey(seed32) # the 32-byte private seed
sig = sk.sign(memo.encode()).signature
client_seed = base64.urlsafe_b64encode(sig).rstrip(b'=').decode()
Test vector (private seed = 32 bytes of 0x07):
| public key | 6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw |
| memo | e2ea-00000000-0000-4000-8000-000000000001 |
| clientSeed | d8Tuy1t51JKaq9y0XukdEWvy1fxnrfKN1bQ05eT6ASgMGDl1AzZz5wOYo0AN5--QAEaWrq1NiTVIJuXgEur5Cw |
| alpha | 8ac322eb53900672b8835f90e97b3fb7683376129ba97e111d5518dec6fbfab1 |
Verifying a roll
GET /vrf/verify?memo= is public and needs no key. It returns seedSource (partner or server) and checks:
alpha = sha256("ccgacha-ext-v1\0" + memo + "\0" + clientSeed)(\0is a zero byte);- the ECVRF-EDWARDS25519-SHA512-TAI proof, and that the roll equals
(first 8 bytes of beta mod 100,000,000) + 1; - for a partner seed, that it is your signature over the memo under the key registered when it was used.
valid is true when all of them hold. An unknown or unopened memo returns 404 NOT_FOUND; 404 PROCESSING means an
open is still finishing.
Errors
Every error body is { "error": "…", "code": "…" }; error says what to fix. 503s include a Retry-After header.
| HTTP | code | What to do |
|---|---|---|
| 400 | INVALID_JSON, INVALID_PLAYER_REF, INVALID_CLIENT_REF, UNKNOWN_MACHINE, INVALID_SEED, INVALID_PUBLIC_KEY, INVALID_STATUS, INVALID_MEMO | Fix the request |
| 401 | UNAUTHORIZED | Check your API key |
| 404 | NOT_FOUND | Unknown, or not yours |
| 409 | BUYBACK_WINDOW_CLOSED, BACKING_RELEASED, MACHINE_NOT_ELIGIBLE | Final; do not retry |
| 410 | PACK_EXPIRED | Not opened within 2 hours; refund your user or issue a new pack |
| 202 | PROCESSING | Still finishing; poll |
| 503 | RAIL_PAUSED, MACHINE_OFF, OFF_BALANCE, MACHINE_LOW | Retry later; nothing was written |
| 503 | VRF_UNAVAILABLE, SELECTION_UNAVAILABLE, NO_INVENTORY | Retry the open; the stored seed gives the same roll |
| 500 | INTERNAL | Retry with the same clientRef |
Shipping errors are on Invoiced Shipping.