@ripdotfun/cardos-sdk is the official client for every CardOS API — mystery packs, instant
packs, custodial wallets, sell-back, redemption, revenue share, webhooks and the Card Data
catalog. Zero runtime dependencies, ESM + CJS, and every response typed from the API's own
field names. Node ≥ 20, Bun, Deno, Cloudflare Workers, Vercel Edge and the browser.
Every endpoint page in these docs carries an SDK tab next to its curl snippet, showing the same call through the client and tracking the same "Try it" inputs.
pnpm add @ripdotfun/cardos-sdk # or: npm i @ripdotfun/cardos-sdk · bun add @ripdotfun/cardos-sdk
Current version 0.1.2 — npm · GitHub.
import { CardOS } from "@ripdotfun/cardos-sdk";
const cardos = new CardOS({
apiKey: process.env.CARDOS_API_KEY!, // required — sent as X-API-Key
environment: "production", // "production" (default) | "staging"
// baseUrl: "https://api.example/cardos", // your proxy; overrides environment
game: "pokemon", // default game for cards/expansions/sealed
timeoutMs: 30_000, // per request
maxRetries: 2 // 429 / 5xx / network
}); Keep the key on the server. A partner key can spend end-user balances, create
buyback offers and register webhooks, so it belongs in a backend, a serverless function or an
edge route — never in a browser or mobile bundle. The SDK runs in the browser only so that a
same-origin proxy of yours can front the API: point baseUrl at that proxy and keep
the key behind it. See Authentication.
| Option | Default | Notes |
|---|---|---|
apiKey | — | Your key, rip_v1_…, sent as X-API-Key. Required. |
environment | "production" | "production" → https://api.getcardos.com (Base mainnet), "staging" → https://staging-service.rip.fun (Base Sepolia). |
baseUrl | — | Your own proxy or a mock server. Wins over environment. |
game | "pokemon" | Default for Card Data calls; override per call with params.game. |
timeoutMs | 30000 | Per request. Override per call. |
maxRetries | 2 | GETs, DELETEs, and POSTs carrying an idempotency key. |
fetch / defaultHeaders | globalThis.fetch | A polyfill, a tracing wrapper or a test double; headers merged into every request. |
Every method also takes per-call overrides — signal, timeoutMs, headers, maxRetries — alongside its own params.
The headline flow: a tier is a price point, buying one draws real cards out of the vault, and the reveal is random. This is the custodial model — the user's balance pays, nobody signs anything. Endpoint pages: list tiers, custodial purchase, purchase status.
// 1. What you can sell. Price, EV and slot count are read live from the pool.
const tiers = await cardos.gacha.catalog({ active: true });
// 2. Buy one pack from the end user's CardOS balance. 202 — RESERVED.
const purchase = await cardos.gacha.purchase({
tier_id: 3,
external_user_id: user.id
});
// 3. Wait for the reveal. Polls with backoff; usually a few seconds.
const { items } = await cardos.gacha.waitForReveal(purchase.id);
// → [{ name: 'Umbreon VMAX', value_usd: '412.50', image_url: '…', card_id: 'swsh7-215' }]
// 4. What the user owns.
const collection = await cardos.gacha.collection({ external_user_id: user.id }); purchase() needs an Idempotency-Key; the SDK mints one per call so a
network retry can never double-charge. Pass idempotency_key to own it — mint it when
the user taps buy and reuse it across your retry loop. waitForReveal resolves on FULFILLED / PARTIALLY_FULFILLED and throws TerminalStateError on REFUNDED or FAILED. Poll for the "opening…" animation; drive
fulfilment server-side off the purchase.fulfilled webhook.
Top up the balance with cardos.wallet.depositAddress(), watch it with wallet.balance(), and audit
it with wallet.ledger().
The default model: the end user holds their own funds and their own cards. CardOS hands you the transactions, your wallet layer gets the user to send them, and you record the hash. The SDK never touches a private key. Endpoint pages: prepare, submit — and the full sequence on End-to-end flows.
// 1. Ask for the calls. Creates nothing.
const prep = await cardos.gacha.prepare({ wallet_address: user.wallet, tier_id: 3 });
// 2. The END USER sends these as transactions — signing a message does nothing
// on-chain. Dispatch on call.kind ("erc20-approve", then "purchase"), never
// on the description or the 4-byte selector.
const transaction_hash = await wallet.sendCalls(prep.calls);
// 3. Record the hash once it is mined. 202.
const purchase = await cardos.gacha.submit({
wallet_address: user.wallet,
tier_id: 3,
transaction_hash
});
const revealed = await cardos.gacha.waitForReveal(purchase.id); cardos.instant has the same prepare / submit pair for
instant packs — a real booster pack, bought and opened in one transaction:
const packs = await cardos.instant.catalog();
const prep = await cardos.instant.prepare({
wallet_address: user.wallet,
packet_type_id: 41
});
const transaction_hash = await wallet.sendCalls(prep.calls);
const purchase = await cardos.instant.submit({
wallet_address: user.wallet,
packet_type_id: 41,
transaction_hash
});
const delivered = await cardos.instant.waitForDelivery(purchase.id);
delivered.cards; Instant calls carry no kind discriminator, so dispatch on position: calls[0] is the USDC approve, calls[1] the buy-and-open. Delivery is
asynchronous — up to ~90 s while the VRF settles, which is why waitForDelivery allows 180 s.
Search, get and price cards, expansions and sealed product across Pokémon, One Piece and Azuki.
The q grammar and every filter are documented under Search & filtering; pricing under Pricing data. Endpoint pages: search cards, get a card, card pricing.
const cardos = new CardOS({ apiKey, game: "pokemon" }); // default game
const page = await cardos.cards.search({
q: 'rarity:"Special Illustration Rare" raw_price:[100 TO *] -types:water',
orderBy: "-raw_price",
include: "prices", // embeds pricing on every row — no surcharge
page_size: 100
});
page.items; // Card[]
page.totalCount; // total matches
const card = await cardos.cards.get("swsh7-215", { include: "prices" });
const prices = await cardos.cards.prices("swsh7-215");
const printings = await cardos.cards.printings("swsh7-215");
const sets = await cardos.expansions.search({ q: "release_date:[2024-01-01 TO *]" });
const inSet = await cardos.expansions.cards("sv3pt5", { include: "prices" });
const boxes = await cardos.sealed.search({ q: "product_type:booster_box expansion.id:sv3pt5" }); One credit per catalog read, flat — a page of 100 with include: "prices" costs the
same as one card without them. See API credits.
// Card Data lists are a NumberedPage; the partner API returns an OffsetPage.
// Both are AsyncIterable over ITEMS, not pages.
for await (const card of await cardos.cards.search({ q: "expansion.id:sv3pt5" })) {
console.log(card.id, card.name, card.pricing?.market);
}
const page = await cardos.gacha.listPurchases({ status: "FULFILLED", limit: 100 });
page.items;
page.hasMore;
const next = await page.next(); // the next page, or null
const all = await page.all(); // drain into one array all() and for await keep fetching until the server says stop, so bound
them: the partner API caps offset at 10 000 and Card Data caps page × page_size at 10 000. Narrow the query rather than paging deeper — see Best practices.
Register an endpoint with cardos.webhooks.register() — the signing_secret comes back exactly once, at creation — then verify every
delivery. Verification lives in a separate entry point, @ripdotfun/cardos-sdk/webhooks, so a request handler can import it without the HTTP client.
It is built on Web Crypto, so it runs unchanged on Node, Bun, Deno, Workers and Edge. The full
event catalogue is in the webhooks guide.
import { constructEvent, WebhookSignatureError } from "@ripdotfun/cardos-sdk/webhooks";
// Express: express.raw, NEVER express.json() — re-serialising breaks the HMAC.
app.post("/hooks/cardos", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await constructEvent({
payload: req.body, // the raw Buffer (or a string)
headers: req.headers,
secret: process.env.CARDOS_WEBHOOK_SECRET!
});
} catch (err) {
// Bad signature, tampered body, or older than 5 minutes → never retry it.
return res.status(err instanceof WebhookSignatureError ? 400 : 500).end();
}
res.json({ received: true }); // ack fast, then do the slow work
switch (event.event) { // discriminated union — data narrows
case "purchase.fulfilled":
for (const item of event.data.items ?? []) console.log(item.name, item.value_usd);
break;
case "purchase.refunded":
refundInApp(event.data.purchase_id);
break;
}
event.delivery_id; // X-Mystery-Delivery — dedupe on this
}); verifyWebhookSignature() is the boolean-only version; it resolves true or throws WebhookSignatureError saying which check failed — it
never resolves false, so a forgotten await cannot pass silently.
Deliveries retry up to 6 times, so a slow consumer will see the same event twice: dedupe
on event.delivery_id.
Every failed HTTP call throws a CardOSError, or a subclass keyed by status. Branch
on err.code — the stable machine string — not on err.message, which is
for humans and may change. Full envelope on Errors.
| Class | Status | Typical code |
|---|---|---|
ValidationError | 400 | invalid_tier, unknown_field, parse_error, invalid_address |
AuthenticationError | 401 | unauthorized — missing / invalid key |
InsufficientFundsError | 402 | insufficient_funds, insufficient_credits |
PermissionError | 403 | missing scope, or a non-partner key |
NotFoundError | 404 | not_found, token_not_found, value_unknown |
ConflictError | 409 | sold_out, idempotency_mismatch, redemption_exists, webhook_limit |
UnprocessableError | 422 | shape accepted, semantics rejected |
RateLimitError | 429 | rate_limited — see retryAfterMs |
ServiceUnavailableError | 503 | overloaded, timeout, relayer_disabled, instant_disabled |
ServerError | 5xx | internal_error |
Non-HTTP failures have their own classes, so a catch can tell "the API said no" from
"we never heard back": ConnectionError, TimeoutError, PollTimeoutError, TerminalStateError, WebhookSignatureError. Every CardOSError carries status, code, message, details, requestId, retryAfterMs, retryable and the method / path that failed.
import {
CardOSError, ConflictError, InsufficientFundsError, RateLimitError
} from "@ripdotfun/cardos-sdk";
try {
await cardos.gacha.purchase({ tier_id: 3, external_user_id: user.id });
} catch (err) {
if (err instanceof InsufficientFundsError) return topUp(user);
if (err instanceof ConflictError && err.code === "sold_out") return offerAnotherTier();
if (err instanceof RateLimitError) return retryAfter(err.retryAfterMs ?? 1000);
if (err instanceof CardOSError) {
console.error(err.status, err.code, err.message, err.requestId, err.details);
}
throw err;
} The client retries automatically on 429, 502+ and network errors, honouring Retry-After with jittered exponential backoff — for GETs, DELETEs and POSTs that
carry an idempotency key (which money-moving POSTs do by default). A POST without one is never
retried. See Rate limits.
Every waitFor* helper takes the same options:
await cardos.gacha.waitForReveal(purchase.id, {
intervalMs: 1_500, // first gap between polls
maxIntervalMs: 5_000, // cap as it backs off
backoffFactor: 1.5,
timeoutMs: 120_000, // give up after this long
signal: ac.signal,
onPoll: (value, attempt) => console.log(attempt, (value as Purchase).status)
}); Defaults are 1 500 ms → 5 000 ms with a 120 s timeout, except instant.waitForDelivery, which allows 180 s. Polling is for a foreground
"opening…" experience; for anything server-side, use webhooks.
Field names are the API's own — snake_case, no camelCase mirrors. Money is a string,
never a JSON number ("12.500000"); never Number() it. Statuses are
string-literal unions, so a switch is exhaustive and a typo is a compile error. No any appears in any exported signature, and types are exported alongside the client: import type { Purchase, Card, WebhookEvent } from "@ripdotfun/cardos-sdk".
Source, changelog and runnable examples: https://github.com/ripdotfun/cardos-sdk. Found a gap? Tell us.