Polling the lookup endpoint to detect policy changes works, but it's wasteful: visa rules update a few dozen times per year, worldwide. Webhooks flip the relationship β Orizn calls your endpoint when a relevant change ships, and your integration stays quiet the rest of the time.
Overview
A webhook is an HTTPS endpoint on your server that Orizn POSTs JSON to. Each event represents one visa-policy change for one passport-destination pair. You can scope subscriptions to specific destinations (e.g. only Japan) or specific passports, or both.
The flow is:
- Register a subscription via the API (see below).
- Orizn POSTs an event payload to your URL when a matching policy change ships.
- Your endpoint returns
2xxwithin 10 seconds. Anything else is treated as a failure and retried.
Subscribe to events
Create a subscription with a single POST. The response includes the subscription id and the secretyou'll use to verify signatures β store it securely, it's only shown once.
curl https://api.orizn.app/webhooks \
-X POST \
-H "Authorization: Bearer $ORIZN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/orizn-webhook",
"events": ["visa.policy_changed"],
"destinations": ["JPN", "VNM", "USA"],
"description": "Production policy-change listener"
}'You can also create and manage subscriptions from your dashboard, which is usually the easier path for one-off endpoints.
Event types
| Event | Fires when |
|---|---|
visa.policy_changed | Any field of a passport-destination requirement changes (requirement enum, stay days, fee, processing time, β¦). |
visa.policy_announced | A change has been officially announced but is not yet in effect. Includes effective_from. |
visa.source_updated | A government source URL changes; the underlying rule may or may not have changed. |
webhook.ping | Sent on subscription creation and on demand from the dashboard, so you can verify the endpoint is reachable. |
Payload schema
Every webhook delivery has the same envelope: id, type, created_at, api_version, and a data object whose shape depends on type. Here's a representativevisa.policy_changed payload:
{
"id": "evt_2NfBz8m9hPq2WK1J",
"type": "visa.policy_changed",
"created_at": "2026-05-30T09:14:22Z",
"api_version": "2026-04-01",
"data": {
"destination": "JPN",
"passport": "VNM",
"previous": {
"requirement": "visa_required",
"stay_days": null,
"fee_usd": 30
},
"current": {
"requirement": "e_visa",
"stay_days": 15,
"fee_usd": 21
},
"effective_from": "2026-06-01T00:00:00Z",
"sources": [
{ "name": "MOFA Japan press release", "url": "https://www.mofa.go.jp/press/release/β¦" }
]
}
}The previous and current objects are partial β they include only the fields that changed plus the relevant context. Always read current as the new source of truth.
Verify the signature
Every delivery includes an Orizn-Signature header so you can prove the request actually came from Orizn (and isn't a forged POST from an attacker who guessed your URL). The signature is an HMAC-SHA256 over {timestamp}.{raw_body} using your subscription secret.
Node.js / TypeScript:
import crypto from "node:crypto";
function verifyOriznSignature(rawBody, header, secret) {
// Header format: "t=<timestamp>,v1=<hex_hmac>"
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("="))
);
const timestamp = parts.t;
const signature = parts.v1;
// Reject anything older than 5 minutes to block replay attacks.
const ageSeconds = Date.now() / 1000 - Number(timestamp);
if (ageSeconds > 300) throw new Error("stale_webhook");
const signedPayload = timestamp + "." + rawBody;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// Constant-time compare avoids timing-attack leaks.
if (
!crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex")
)
) {
throw new Error("invalid_signature");
}
}Python:
import hmac, hashlib, time
def verify_orizn_signature(raw_body: bytes, header: str, secret: str) -> None:
parts = dict(p.split("=") for p in header.split(","))
timestamp, signature = parts["t"], parts["v1"]
if time.time() - int(timestamp) > 300:
raise ValueError("stale_webhook")
signed_payload = f"{timestamp}.{raw_body.decode()}".encode()
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("invalid_signature")Always verify before parsing. If signature verification fails, return401immediately and do not act on the payload. The official SDKs do this for you when you use theirconstructEventhelper.
Retries & replays
If your endpoint returns non-2xx or times out (10 seconds), Orizn retries with exponential backoff. The schedule is:
- Attempt 2 β 30 seconds later.
- Attempt 3 β 5 minutes later.
- Attempt 4 β 30 minutes later.
- Attempts 5β10 β every 6 hours.
After 10 failed attempts the event is marked as undelivered. You can replay it manually from the dashboard or via the API. All deliveries (successful or not) are retained for 30 days.
Events may be delivered out of order or more than once β design your handler to be idempotent. The event id is a safe dedupe key.
Testing locally
You don't need to deploy to test. The Orizn CLI ships a tunnel that forwards real (or synthetic) webhook deliveries to your localhost:
# 1. Forward Orizn webhooks to your local dev server.
orizn webhooks listen --forward-to localhost:3000/orizn-webhook
# 2. Fire a synthetic event without waiting for a real policy change.
orizn webhooks trigger visa.policy_changed --destination JPN --passport VNM