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.

register an endpoint
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:

headers
X-Haavisa-Signature: t=1759411200,v1=6f2a...
X-Haavisa-Event: application.approved
X-Haavisa-Delivery: 7b178d67-6829-4685-9ea5-f40c9d4194e9
Node
import { 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);
}
Python
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.

dedupe on the delivery id
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.

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