Skip to content
Prepaid, no subscription trap. Top up a balance and we draw the monthly fee from it.
tcggraph

Webhooks

Signed, retried, ordered per endpoint. Subscribe to the events that matter instead of polling the catalog.

Events

EventFires when
set.publishedA new set is indexed and its cards are queryable.
card.createdA card appears, including spoilers ahead of a set release.
card.updatedErrata, oracle changes or a corrected collector number.
card.legality.changedA ban list or rotation moved a card between formats.
price.threshold.crossedA card you watch crossed a price you configured.

Registering an endpoint

Scope a subscription to specific events and games so you are not woken up by traffic you do not care about.

curl -X POST "https://api.tcggraph.com/v1/webhooks" \
  -H "Authorization: Bearer $TCGGRAPH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/tcggraph",
    "events": ["set.published", "price.threshold.crossed"],
    "games": ["pokemon", "one-piece"]
  }'

The response carries a signing secret once and never again. It cannot be stored hashed the way an API key is — computing the HMAC on every delivery needs the secret itself — so treat it as a credential and rotate it from the dashboard if it leaks.

Watching a price

price.threshold.crossed is the one event you configure rather than merely subscribe to. A watch names a card and a market: €25 on Cardmarket and $25 on TCGplayer are different instructions, and a card can sit on opposite sides of the two at once. The currency follows from the source, so it is not yours to set.

curl -X POST "https://api.tcggraph.com/v1/watches" \
  -H "Authorization: Bearer $TCGGRAPH_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "cardId": "mtg_bd8fa327-dd4",
    "source": "cardmarket",
    "direction": "below",
    "threshold": 25.00
  }'

It fires on arrival, not on state: below means the price crossed down through your figure, so a card that stays cheap for a month is one event and not thirty. The first price we ever record for a card crosses nothing, because it did not move — we simply started looking. Send threshold in major units or thresholdCents as a whole number, and optionally finish and listType to watch a foil or a buylist figure instead of the default retail normal.

Deliveries are never metered, on any plan. What is capped is how many cards you may watch — 500 on Starter, 25,000 on Growth, 100,000 on Scale — because that is the only number that bounds what free delivery costs us.

Payload

{
  "id": "evt_01JQ8Z3M2K",
  "type": "card.legality.changed",
  "createdAt": "2026-08-30T18:04:11Z",
  "data": {
    "cardId": "ygo_89631139",
    "game": "yugioh",
    "format": "advanced",
    "from": "limited",
    "to": "forbidden"
  }
}

Verifying signatures

Every delivery carries a TCGGraph-Signature header of the form t=<timestamp>,v1=<hex>, an HMAC-SHA256 over timestamp.body. Always verify against the raw body, before JSON parsing.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, header: string, secret: string) {
  const [timestamp, signature] = header.split(",").map((p) => p.split("=")[1]);

  // Reject anything older than five minutes to blunt replay attacks.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Delivery guarantees

  • At-least-once delivery. Deduplicate on the event id.
  • Retries at 1s, 10s, 1m, 10m, 1h and 6h. A 2xx within ten seconds counts as success.
  • Endpoints failing continuously for 24 hours are disabled, and their queued deliveries are given up on. The dashboard shows the reason and re-enables on one click.
  • Deliveries to one endpoint are ordered: a failing event holds the queue behind it rather than letting a later event overtake it and leave you with older state.