# Price alerts and webhooks

A price alert watches one printing's Cardmarket **trend** price (EUR) and POSTs a signed JSON event to your webhook endpoint when its condition is met. Alerts are checked once a day, after Cardmarket's daily price guide is imported (about 07:00 UTC).

## 1. Register an endpoint

Your endpoint must be a public **https** URL. The signing secret is returned only when you create the endpoint (or rotate its secret), so store it.

```graphql
mutation { createWebhookEndpoint(url: "https://example.com/hooks/raritydb") { secret webhookEndpoint { id } } }
```

```bash
raritydb webhooks add https://example.com/hooks/raritydb
raritydb webhooks test <id>          # sends a signed "ping" now and shows the response
```

## 2. Create alerts

```graphql
mutation($endpoint: ID!) {
  createPriceAlert(cardCode: "sv03.5-006", printingKey: "holo", condition: TREND_ABOVE, threshold: 50, webhookEndpointId: $endpoint) {
    id card { name } printing { key }
  }
}
```

```bash
raritydb alerts add sv03.5-006 --above 50 --webhook <id>
raritydb alerts add base1-4 --printing <key> --change 15 --webhook <id>
raritydb alerts list
```

| Condition | Threshold | Fires |
|---|---|---|
| `TREND_ABOVE` | EUR | The day the trend rises to the threshold or above (not again while it stays there) |
| `TREND_BELOW` | EUR | The day the trend falls to the threshold or below |
| `CHANGE_ABOVE_PERCENT` | percent | Every day the trend moves up or down by at least that much from the previous price |

Pass `printingKey` when the card has more than one printing with a Cardmarket price (see `Card.printings`). Printings without a Cardmarket product have no prices to watch. Each alert fires at most once a day.

Your plan allows 50 alerts and 3 endpoints (`viewer { priceAlertLimit webhookEndpointLimit }`).

## The event

```http
POST /hooks/raritydb
Content-Type: application/json
X-RarityDB-Event: price_alert.triggered
X-RarityDB-Delivery: 4b0c5c1e-…            (unique per delivery; use it to ignore duplicates)
X-RarityDB-Signature: t=1792742400,v1=5f2c…
```

```json
{
  "event": "price_alert.triggered",
  "alert": { "id": "…", "condition": "TREND_ABOVE", "threshold": 50.0 },
  "card": { "code": "sv03.5-006", "name": "Charizard ex", "number": "006", "set": { "code": "sv03.5", "name": "151" } },
  "printing": { "key": "holo", "finish": "holo", "stamps": [], "subtype": null },
  "price": { "source": "cardmarket", "currency": "EUR", "asOf": "2026-10-23", "trend": 52.1, "previousTrend": 48.9,
             "previousAsOf": "2026-10-22", "changePercent": 6.54 }
}
```

Answer with any 2xx status within 10 seconds. Anything else is retried 5 times with increasing waits. After 5 failed deliveries in a row, the endpoint shows `failingSince` until one succeeds. `viewer { webhookEndpoints { deliveries { status responseCode error } } }` shows the last 20.

## Verify the signature

The signature is an HMAC-SHA256 of `"{t}.{raw body}"` with your endpoint's secret, hex encoded, in the `v1` part. Check it against the **raw** body, and reject old timestamps (5 minutes is a good window):

```ts
import { verifyWebhook } from "@raritydb/sdk";

if (!(await verifyWebhook(rawBody, request.headers["x-raritydb-signature"], process.env.RARITYDB_WEBHOOK_SECRET!))) {
  return new Response("bad signature", { status: 400 });
}
```

```ruby
t, v1 = header.split(",").to_h { |part| part.split("=", 2) }.values_at("t", "v1")
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
valid = ActiveSupport::SecurityUtils.secure_compare(expected, v1.to_s) && (Time.now.to_i - t.to_i).abs < 300
```

If a secret leaks, rotate it (`rotateWebhookSecret`, `raritydb webhooks rotate <id>`); the old one stops working immediately.

## From an agent (MCP)

The `webhook_endpoints` and `price_alerts` tools take an `action` (`list`, `create`, `delete`, and for endpoints `rotate_secret` and `test`). The general `graphql_query` tool is read-only.
