Webhooks
So your integration learns an application was approved without polling us. An airline that has to ask will ask badly — usually every fifteen seconds, usually with a cron job nobody owns.
curl -X POST https://api.haavisa.com/v2/webhook-endpoints \
-H "Authorization: Bearer $HAAVISA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/hooks/haavisa",
"events": ["application.approved", "application.rejected"]
}'The response contains a secret, shown once. Store it — a receiver that cannot verify the signature cannot trust the payload, and the payload says whether someone can board a plane.
Verifying a delivery
Every delivery carries three headers and a JSON body:
X-Haavisa-Signature: t=1759411200,v1=6f2a...
X-Haavisa-Event: application.approved
X-Haavisa-Delivery: 7b178d67-6829-4685-9ea5-f40c9d4194e9import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, header, body, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
// Freshness. An authentic signature from last year is still an attack.
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest();
const presented = Buffer.from(parts.v1 ?? '', 'hex');
return presented.length === expected.length && timingSafeEqual(presented, expected);
}import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = float(parts.get("t", "nan"))
if timestamp != timestamp: # NaN
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{int(timestamp)}.".encode() + body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Deliveries repeat. Deduplicate.
Delivery is at-least-once, not exactly-once. Retries mean you will occasionally receive the same event twice, and a receiver that processes it twice has approved a visa twice.
if (await seen(req.headers['x-haavisa-delivery'])) {
return res.status(200).json({ received: true, duplicate: true });
}
await markSeen(req.headers['x-haavisa-delivery']);Answer 2xx as soon as you have persisted the event. Anything else is treated as a failure and retried. Do the slow work — calling your own systems, emailing a customer — after responding, or a slow handler becomes a retry storm.
Events
GET /v2/webhook-events returns the current list, so nothing has to hardcode a guess. Subscribing to an event that does not exist is a 400, not a silent no-op.
application.created·application.submittedapplication.submitted_to_authority— we filed it with the government. Distinct fromapplication.submitted, which is the applicant pressing submit in our wizard; this one carries the authority's own reference number.application.under_review·application.approved·application.rejectedapplication.documents_requested·application.document_uploaded(per file, and it means the applicant says the upload finished — validation is separate)payment.authorized·payment.captured·payment.failed·payment.refundedrule.added·rule.changed·rule.retired— the eligibility rules themselves changed, delivered to every subscribed endpoint rather than to one tenant, because an amendment to a country's schedule affects all of them.
Retries
A non-2xx response, a timeout, or an unreachable host schedules a retry with exponential backoff — six attempts over roughly half an hour (the delay doubles from one minute). After fifteen consecutive failures the endpoint is switched off.
That is deliberate. A URL that has been dead for a week will not recover on the hundredth attempt, and the attempts cost a delivery budget that working endpoints need. The endpoint records why it was disabled, and re-enabling it clears the failure count so one more failure does not immediately switch it off again.
When you think we did not send something
GET /v2/webhook-endpoints/:id/deliveries returns what we sent, when, and what your server replied. It exists because “did you actually send it” is the first question in every webhook dispute, and the answer should be reproducible rather than remembered.
Rotating the secret
Related
- Authentication — the key you register an endpoint with.
- API reference — every webhook operation.
- Errors and rate limits — the envelope every failure uses.