One signed, retrying delivery pipeline serves every CardOS product. This page is the mechanics of it — registering an endpoint, verifying a delivery, what gets retried and for how long. Events cost no credits; the polling they replace does.
Catalog events are not live yet. The registry today carries the commerce
catalogue only — the purchase.*, instant_purchase.*, buyback.*, sellback.*, redemption.*, payout.*, pool.* and deposit.credited events listed in
the webhooks guide. Catalog events (price moves, new
cards, new expansions, fresh population reports) are described below as what they will be:
subscribing to one today is rejected with 400 invalid_event_types, and a filters block is not read at all — it is
accepted and ignored, so don't rely on one to narrow anything. Until they ship, keep the
catalog in sync with a scheduled re-read.
POST /api/v1/webhooks
{
"url": "https://your-app.example/hooks/cardos",
"event_types": ["purchase.fulfilled", "redemption.updated"]
}
→ 201 { "id": 41, "url": "…", "event_types": [...], "is_active": true,
"signing_secret": "<64 hex chars>" } // shown once, store it now The signing secret is returned once, at creation. Store it immediately, because we cannot show it to you again, only rotate it. Private, loopback and link-local URLs are rejected; the endpoint must be publicly reachable over HTTPS. You can register up to 20 endpoints per key.
The registration endpoints themselves are documented under Webhooks in the sidebar. One registry serves both
CardOS products, so the same four endpoints manage Card Data and Gacha subscriptions. Which events you get depends
entirely on the event_types you subscribe to.
The catalog events, and the payload each will carry. For the events you can subscribe to today, see the webhooks guide: one registry, one signing scheme, one delivery log, so everything on this page applies to both catalogues.
| Type | Fires when | Payload |
|---|---|---|
card.price_updated | A card’s market value moved past your configured threshold | card_id, game, previous, current, change_pct, condition | grade |
card.added | A new card is ingested, usually a set release or a late-revealed secret rare | card_id, game, expansion_id |
card.updated | Card metadata was corrected (artist, rarity, a fixed image) | card_id, game, changed[] |
expansion.released | An expansion’s release date passes and its cards go live | expansion_id, game, total |
sealed.price_updated | A sealed product’s market value moved past your threshold | product_id, game, previous, current, change_pct |
population.updated | A grading company published a new report for a card you watch | card_id, game, company, total, gem_rate |
Without filters, card.price_updated would be a firehose, since we reprice millions
of cards — so these will land with it. A filters object sent today is ignored, not
honoured, which is the worse of the two failures: send one and you would get everything.
| Filter | Effect |
|---|---|
game | Only events for one game id |
expansion_id | Only cards in one expansion |
card_ids[] | A watchlist of up to 5,000 ids per endpoint. The right filter for portfolio tracking. |
min_change_pct | Suppress noise: only fire when the move is at least this large in either direction |
grade | Only graded-tier moves, e.g. { "company": "PSA", "grade": "10" } |
Every request carries X-Mystery-Signature (formatted t=<unix_ms>,sha256=<hex>), X-Mystery-Timestamp (unix milliseconds) and X-Mystery-Delivery (a unique id). Sign the string `${timestamp}.${rawBody}` with your secret and compare the sha256= value in constant time, against the raw body, before any JSON parsing or middleware
rewriting.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, headers, secret) {
const sig = headers['x-mystery-signature']; // t=<unix_ms>,sha256=<hex>
const ts = headers['x-mystery-timestamp']; // unix MILLISECONDS
if (!sig || !ts) return false;
// Reject replays before spending time on the HMAC. Timestamps are in
// milliseconds, so compare against Date.now() directly.
if (Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false;
// The header carries two comma-separated parts; sign against the sha256 one.
const provided = String(sig)
.split(',')
.find((part) => part.startsWith('sha256='))
?.slice('sha256='.length);
if (!provided) return false;
const expected = createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
// timingSafeEqual throws on a length mismatch, so guard before comparing.
const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
} Reject stale timestamps. Without the age check, a captured request can be replayed forever. Five minutes is a reasonable window.
Dedupe on X-Mystery-Delivery. Delivery is at-least-once: a
successful handler that times out on the response will be retried. Handlers must be idempotent.
2xx within 10 seconds. Queue the work; don't do it inline.3xx, which we never follow) is retried with exponential
backoff — 30s, 1m, 2m, 4m and so on, capped at 1 hour — and dropped after 6 attempts.event_types entirely. An unknown event type
anywhere in the array rejects the whole registration with 400 invalid_event_types, naming the ones it did not recognise.Webhooks are an optimization, not a guarantee. Run a low-frequency reconciliation, a daily sweep of the cards you track, so a dropped event can't leave you permanently stale. See Best practices for the caching model this fits into.