Owner or API key
POST /v1/webhook
Set or clear a signed HTTPS webhook. Empty url clears it. Private hosts and plain http are rejected.
Owner or API key. Body {"url":"https://example.com/udiwatch"}. An empty string clears the URL and the secret. The URL must be https. Rejected: http, localhost, names ending in .local or .internal, and a host that is itself a private, loopback, link-local, or reserved IP. A hostname is not resolved before that check, so a public name that later points at a private address is not blocked. Do not point the webhook at your own metadata service.
curl -sS -X POST "$BASE/v1/webhook" \
-H "X-API-Key: $UW_KEY" -H 'Content-Type: application/json' \
-d '{"url":"https://example.com/udiwatch"}'
An account can store up to ten URLs. Each one has its own whsec_… secret. POST /v1/webhooks adds a URL. DELETE /v1/webhooks/{id} removes one. POST /v1/webhooks/{id}/test sends watch.test to that URL. POST /v1/webhook still sets or clears the first URL. A sync that finds diffs posts the same body to every saved URL.
What we POST
POST /v1/webhook/test sends watch.test. A sync that found diffs sends watch.changed. Both use the same body:
{
"id": "whd_…",
"type": "watch.changed",
"created_at": "2026-09-30T12:00:00+00:00",
"email": "[email protected]",
"events": [{
"id": 1,
"subject": "16977660098955",
"label": "suction Catheter",
"summary": "Market status: On the market → No longer on the market",
"changes": [{"field": "Market status", "old": "On the market", "new": "No longer on the market"}]
}]
}
Headers:
- Content-Type: application/json
- User-Agent: UdiWatch/1.0 (+https://udiwatch.com)
- X-UdiWatch-Timestamp: unix seconds
- X-UdiWatch-Signature: sha256= plus HMAC-SHA256 of timestamp + "." + raw body, key is the secret
- X-UdiWatch-Event: watch.changed or watch.test
- X-UdiWatch-Delivery: the same id as in the body
Three attempts, timeout 5 seconds, then a pause of 0.4 seconds and 0.8 seconds. A failed delivery does not fail the sync. The event is already stored. GET /v1/me then includes webhook_last_at, webhook_last_ok, and webhook_last_error. Verify the raw bytes, not a re-serialized object.
import hashlib, hmac
def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
return hmac.compare_digest(expected, signature)
const crypto = require("crypto");
function verify(secret, timestamp, rawBody, signature) {
const mac = crypto.createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
const expected = Buffer.from("sha256=" + mac);
const got = Buffer.from(signature);
return expected.length === got.length && crypto.timingSafeEqual(expected, got);
}
Reject timestamps you consider too old for your own clock. We do not send a replay window.

UdiWatch