Legit

Docs · Watch webhooks & events

The sentinel, documented

POST /v1/watch puts a 30-day change sentinel on one tracked x402 merchant. When the trust state really moves, LEGIT records an event and, if you armed an https webhook_url, POSTs it to your agent the moment it fires. This page is the contract.

Webhook payload anatomy

One POST per event, JSON body, 5 second timeout. The body always has the same six keys: watch_id, address, event, kind, ts and detail. event and kind carry the same value: kind is an additive alias that matches the event feed rows, and event is never removed. The detail object depends on the event kind (table below).

{
  "watch_id": "9f1c2a4b6d8e4f0a1b3c5d7e9f0a1b2c",
  "address": "G7V7U7H6IYRP7BIZO2KV6ODGQPESMMRDCWBYT3GIMNHTNK3VCKWSSKD55A",
  "event": "grade_change",
  "kind": "grade_change",
  "ts": "2026-08-10T12:00:00",
  "detail": {
    "from": "A",
    "to": "B",
    "score": 71.2
  }
}

Case study: two real payloads from the wild

These are the first two webhook deliveries a production integrator (Poncho) received, verbatim from the event log: watch d53f0e31… on the horoscope merchant HVIQFX…, a grade_change D → C at score 74.96, then C → C+ at 75.13 about sixteen hours later. Note what detail.score is: the score AT the transition, frozen in the event. It is not a live score; pull /v1/check for the current one.

{
  "watch_id": "d53f0e3164144e2b962fb4ed6689cae6",
  "address": "HVIQFXXUHJYHQH4TXLHOXVOXQPWJRDETOSBWIHSSL5BMMH56AKOIAQL7RM",
  "event": "grade_change",
  "kind": "grade_change",
  "ts": "2026-08-11T08:53:15.177442",
  "detail": {
    "from": "D",
    "to": "C",
    "score": 74.96
  }
}
{
  "watch_id": "d53f0e3164144e2b962fb4ed6689cae6",
  "address": "HVIQFXXUHJYHQH4TXLHOXVOXQPWJRDETOSBWIHSSL5BMMH56AKOIAQL7RM",
  "event": "grade_change",
  "kind": "grade_change",
  "ts": "2026-08-12T00:38:15.177442",
  "detail": {
    "from": "C",
    "to": "C+",
    "score": 75.13
  }
}

Delivery intel for receivers

Fingerprint: webhook POSTs come from LEGIT's indexer with the stock python-httpx User-Agent (no custom header yet); do not whitelist on a fancy agent string. Latency: events fire inside the indexer cycle, so expect the POST about 2-3 minutes after the ts in the body. Semantics: detail.score is the score at the transition, not the live score. Kind vs event: resolved. Older consumers read event, the feed reads kind, and the webhook now carries both with the same value, so either key parses.

Event kinds and exact triggers

kindfires whendetail shape
grade_changeThe merchant's latest grade differs from the grade recorded on the watch.{"from": "A", "to": "B", "score": 71.2}
score_dropThe latest score fell at least LEGIT_WATCH_SCORE_DROP points (default 10) below the recorded score.{"from": 88.5, "to": 71.2, "threshold": 10}
outageOne cycle attempted 3 or more probes with zero successes while the recorded state was ok.{"attempts": 3, "ok": 0}
recoveredA successful probe returns while the recorded state was outage.{"attempts": 3, "ok": 2}

Dedup by state: silence is correct

Events fire on real transitions only and dedup by state, never by time: while the state that fired an event is unchanged, no repeat event is written. So a quiet feed is a healthy system, not a broken one. No transition means no event, and no event means the merchant you watch is exactly as trustworthy as it was yesterday.

Delivery policy v1: best effort, never retried

Each event is POSTed exactly once. A webhook that times out, answers an error or is unreachable is counted in the worker stats and logged, and v1 never retries it. Your receiver should treat the webhook as a nudge, not a queue.

The doorbell can break; the event is never lost

The webhook is only the doorbell. The source of truth is the event feed at GET /v1/watches/{watch_id}/events: newest first, capped at 200 events, free to read with the watch id. Events stay readable after the watch expires, until the garbage collector deletes the watch and its events LEGIT_WATCH_GC_DAYS (default 7) past expiry. If your receiver was down, poll the feed and replay what you missed.

{
  "watch_id": "9f1c2a4b6d8e4f0a1b3c5d7e9f0a1b2c",
  "count": 2,
  "cap": 200,
  "events": [
    {
      "ts": "2026-08-10T12:00:00",
      "kind": "grade_change",
      "detail": {"from": "A", "to": "B", "score": 71.2}
    },
    {
      "ts": "2026-08-09T23:45:00",
      "kind": "recovered",
      "detail": {"attempts": 3, "ok": 2}
    }
  ]
}

The watch id is a bearer capability

Whoever knows the watch UUID reads its status and event feed for free at GET /v1/watches/{watch_id} and GET /v1/watches/{watch_id}/events. Treat it like a capability URL: keep it out of public logs, shared screenshots and client-side code you do not control.

For x402 builders: the bazaar and the OpenAPI are one source

When a paid endpoint answers 402, the PaymentRequired document carries an extensions.bazaar block: info.input, info.inputSchema and an output example with its schema, so a paying agent can shape the call from the payment challenge alone. The OpenAPI document at /openapi.json describes the same endpoints, and its requestBody schemas are derived from the exact same registry (app/discovery.py), so the two can never drift apart. The trap to avoid (D-116): do not treat one as a replacement for the other. Read the bazaar block to pay and shape the call; read the OpenAPI to integrate, generate clients and validate.

Create a watch: POST /v1/watch with {"address": "...", "webhook_url": "https://..."} - paid per call in USDC over x402, $0.004 on Algorand / $0.005 on Base. Reading the status and the event feed afterwards is free.