Skip to main content

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​

  1. Open right after the sale. A pack must be opened within 2 hours of being issued.
  2. 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.
  3. playerRef and clientRef are your own opaque ids. Never put a name, email, phone number or handle in them.
  4. Buybacks close 72 hours after the award. About a day later a kept card moves into vault custody and can ship.
  5. List reads can lag by a few seconds. GET /packs/{memo} is always current.
  6. 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" }
  • playerRef is required, ^[A-Za-z0-9_-]{1,128}$.
  • clientRef is optional, ^[A-Za-z0-9_.:-]{1,128}$. The same clientRef returns 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= (default custody) and GET /cards?status=&playerRef=&limit=&cursor= return:
    { "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": "…" }
    Pass nextCursor back as cursor. limit defaults to 50, max 200. A playerRef you have never issued to returns 404 on /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 }. status is draft, issued, settled or void.
  • 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 clientSeed and 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-key and send clientSeed = your signature over the memo, as unpadded base64url. A seed you send must be that signature; anything else returns 400 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" }
  • PUT sets or rotates the key. publicKey is the 32-byte key in base64url; anything else returns 400 INVALID_PUBLIC_KEY and changes nothing.
  • GET returns { "publicKey": … }, null when none is registered.
  • DELETE clears it. From then on, sending a clientSeed returns 400 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/verify shows that key as seedPubkey: 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 key6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw
memoe2ea-00000000-0000-4000-8000-000000000001
clientSeedd8Tuy1t51JKaq9y0XukdEWvy1fxnrfKN1bQ05eT6ASgMGDl1AzZz5wOYo0AN5--QAEaWrq1NiTVIJuXgEur5Cw
alpha8ac322eb53900672b8835f90e97b3fb7683376129ba97e111d5518dec6fbfab1

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) (\0 is 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.

HTTPcodeWhat to do
400INVALID_JSON, INVALID_PLAYER_REF, INVALID_CLIENT_REF, UNKNOWN_MACHINE, INVALID_SEED, INVALID_PUBLIC_KEY, INVALID_STATUS, INVALID_MEMOFix the request
401UNAUTHORIZEDCheck your API key
404NOT_FOUNDUnknown, or not yours
409BUYBACK_WINDOW_CLOSED, BACKING_RELEASED, MACHINE_NOT_ELIGIBLEFinal; do not retry
410PACK_EXPIREDNot opened within 2 hours; refund your user or issue a new pack
202PROCESSINGStill finishing; poll
503RAIL_PAUSED, MACHINE_OFF, OFF_BALANCE, MACHINE_LOWRetry later; nothing was written
503VRF_UNAVAILABLE, SELECTION_UNAVAILABLE, NO_INVENTORYRetry the open; the stored seed gives the same roll
500INTERNALRetry with the same clientRef

Shipping errors are on Invoiced Shipping.