Changes & Webhooks
Last verified: 2026-06-20 (Task #389 — Visa Intelligence API docs).
The change-detection feed surfaces edits the crawler detects on official
sources — fee schedule updates, new requirements, removed documents,
reclassifications. You can consume it two ways: poll the change feed,
or subscribe with a signed webhook. Both require the visa:changes
scope.
Polling the change feed
GET /v1/visa/changes returns a cursor-paginated stream of change
events, newest first. By default only pending and reviewed events
appear — dismissed events are excluded unless you ask for them with
reviewStatus=dismissed.
curl -s "https://api.travelmode.ai/v1/visa/changes?countryCode=FR&severity=high&limit=20" \
-H "X-Visa-Intel-Key: vsk_live_..."
{
"items": [
{
"id": 9001,
"countryCode": "FR",
"countryName": "France",
"sourceId": 42,
"sourceAuthorityName": "France-Visas",
"changeType": "content_changed",
"kind": "updated",
"severity": "medium",
"summary": "Short-stay fee schedule page updated.",
"llmSummary": "The published fee table changed for several visa categories.",
"detectedBy": "crawler",
"payload": { "diffRatio": 0.12 },
"affectedDocumentIds": [555],
"reviewStatus": "reviewed",
"occurredAt": "2026-06-19T22:14:00.000Z"
}
],
"nextCursor": "eyJpZCI6OTAwMX0="
}
Paging
Pass the returned nextCursor back via ?cursor= until it comes back
null:
async function* allChanges(key, params = "") {
let cursor = null;
do {
const qs = new URLSearchParams(params);
if (cursor) qs.set("cursor", cursor);
const res = await fetch(
`https://api.travelmode.ai/v1/visa/changes?${qs}`,
{ headers: { "X-Visa-Intel-Key": key } },
);
if (!res.ok) throw new Error(`changes failed: ${res.status}`);
const page = await res.json();
yield* page.items;
cursor = page.nextCursor;
} while (cursor);
}
Useful filters
| Param | Notes |
|---|---|
countryCode | ISO-2 destination filter. |
severity | One or more of low,medium,high,critical (legacy info,minor,major still appear on old rows). Repeat the param or comma-separate. |
changeType | One or more change types. |
kind | added, updated, removed, reclassified. |
reviewStatus | Defaults to pending,reviewed; pass dismissed to include dismissed events. |
sourceId / snapshotId | Scope to a single source or crawl snapshot. |
since / until | ISO-8601 bounds on occurredAt. |
Subscribing with webhooks
For production you usually want push delivery instead of polling.
Webhook management is customer-scoped: it requires a vsk_live_…
customer key with visa:changes. The development master key is rejected
(visa_webhook_master_key_forbidden).
Register an endpoint
The request body is snake_case (camelCase aliases are also accepted):
curl -s -X POST "https://api.travelmode.ai/v1/visa/webhooks" \
-H "X-Visa-Intel-Key: vsk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/visa",
"description": "EU high-severity changes",
"country_codes": ["FR", "DE", "ES"],
"min_severity": "high"
}'
{
"webhook_id": "wh_8c0f3c2c",
"url": "https://example.com/hooks/visa",
"description": "EU high-severity changes",
"country_codes": ["FR", "DE", "ES"],
"min_severity": "high",
"status": "active",
"secret": "whsec_a1b2c3d4e5f6...",
"secret_prefix": "whsec_a1b2",
"created_at": "2026-06-20T12:00:00.000Z"
}
The
secretis returned exactly once. Store it now — only its hash is persisted, and subsequent list/get calls return onlysecret_prefix.
Body fields:
| Field | Required | Notes |
|---|---|---|
url | yes | Must be https:// and resolve to a public address. Private/loopback targets are rejected with visa_webhook_url_blocked. |
description | no | 1–500 chars. |
country_codes | no | 1–250 ISO-2 codes; omit/null to receive every country. |
min_severity | no | low (default), medium, high, or critical. Events below this are not delivered. |
metadata | no | Arbitrary JSON echoed back to you. |
Manage endpoints
# List your webhooks (no secrets, only secret_prefix)
curl -s "https://api.travelmode.ai/v1/visa/webhooks" \
-H "X-Visa-Intel-Key: vsk_live_..."
# Fetch one
curl -s "https://api.travelmode.ai/v1/visa/webhooks/wh_8c0f3c2c" \
-H "X-Visa-Intel-Key: vsk_live_..."
# Disable one (soft-delete; history retained)
curl -s -X DELETE "https://api.travelmode.ai/v1/visa/webhooks/wh_8c0f3c2c" \
-H "X-Visa-Intel-Key: vsk_live_..."
Debugging delivery
When an endpoint isn't receiving events, inspect recent attempts:
curl -s "https://api.travelmode.ai/v1/visa/webhooks/wh_8c0f3c2c/attempts?limit=20" \
-H "X-Visa-Intel-Key: vsk_live_..."
Each attempt records status, response_status, response_time_ms, a
response_body_excerpt, error_message, the request_signature /
request_timestamp we sent, and next_retry_at if a retry is pending.
Repeated failures eventually disable the endpoint
(disabled_reason is set, and a visa.webhook_endpoint_disabled event
is recorded).
Verifying the signature
Every delivery is signed. Recompute an HMAC over the timestamp and raw
body with your stored secret and compare it (constant-time) against the
signature header before trusting the payload. Reject deliveries whose
timestamp is too old to blunt replay attacks. Event types you may
receive: visa.change_detected, visa.webhook_delivery_failed, and
visa.webhook_endpoint_disabled.
Polling vs. webhooks
Polling GET /changes | Webhooks | |
|---|---|---|
| Best for | Backfill, ad-hoc queries, reconciliation | Real-time reaction |
| Cost | Counts against your hourly quota | Push — no polling spend |
| Delivery guarantee | You control it | At-least-once with retries |
A common pattern: subscribe with a webhook for live reaction, and run a
periodic GET /changes sweep (using since) to reconcile anything a
delivery might have missed.