Docs · Watch webhooks & events
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.
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
}
}
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
}
}
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.
| kind | fires when | detail shape |
|---|---|---|
| grade_change | The merchant's latest grade differs from the grade recorded on the watch. | {"from": "A", "to": "B", "score": 71.2} |
| score_drop | The latest score fell at least LEGIT_WATCH_SCORE_DROP points (default 10) below the recorded score. | {"from": 88.5, "to": 71.2, "threshold": 10} |
| outage | One cycle attempted 3 or more probes with zero successes while the recorded state was ok. | {"attempts": 3, "ok": 0} |
| recovered | A successful probe returns while the recorded state was outage. | {"attempts": 3, "ok": 2} |
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.
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 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}
}
]
}
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.
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.