Webhooks

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.

Registering an endpoint

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.

Event types not live yet

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.

TypeFires whenPayload
card.price_updatedA card’s market value moved past your configured thresholdcard_id, game, previous, current, change_pct, condition | grade
card.addedA new card is ingested, usually a set release or a late-revealed secret rarecard_id, game, expansion_id
card.updatedCard metadata was corrected (artist, rarity, a fixed image)card_id, game, changed[]
expansion.releasedAn expansion’s release date passes and its cards go liveexpansion_id, game, total
sealed.price_updatedA sealed product’s market value moved past your thresholdproduct_id, game, previous, current, change_pct
population.updatedA grading company published a new report for a card you watchcard_id, game, company, total, gem_rate

Filters not live yet

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.

FilterEffect
gameOnly events for one game id
expansion_idOnly cards in one expansion
card_ids[]A watchlist of up to 5,000 ids per endpoint. The right filter for portfolio tracking.
min_change_pctSuppress noise: only fire when the move is at least this large in either direction
gradeOnly graded-tier moves, e.g. { "company": "PSA", "grade": "10" }

Verifying a delivery

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.

Delivery and retries

  • Respond 2xx within 10 seconds. Queue the work; don't do it inline.
  • Anything else (including a 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.
  • Subscribe to everything by omitting 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.
  • Events are not ordered. Two price updates for the same card can arrive out of order, so compare the payload's own timestamp before writing rather than assuming arrival order.
  • Inspect what we sent and what you returned with delivery history, the fastest way to debug a handler without redeploying.

Reconcile anyway

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.