UdiWatch

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.

POST /v1/importPOST /v1/webhook/test