Webhooks
Webhooks come in two flavors at Nexa:
- Inbound — vendor systems (Pomelo, Amadeus, Hotelbeds, AeroAPI) calling Nexa to notify of state changes.
- Outbound — Nexa calling registered partner endpoints to deliver platform events (case lifecycle, disruption detected, voucher issued).
Both use the same patterns: HMAC-SHA256 signing, idempotency keys, exponential retry, and replay endpoints.
Outbound webhooks (Nexa → partner)
Partners with *.events.subscribe scopes register a URL via Nexa Customer Success. Nexa delivers events to that URL with the following contract.
Request shape
POST https://your-system.example/nexa-webhook HTTP/1.1
Content-Type: application/json
User-Agent: Nexa-Webhook/1.0
X-Nexa-Signature: sha256=<base64-hmac>
X-Nexa-Signature-Timestamp: 1746489600
X-Nexa-Event-Type: case.resolved
X-Nexa-Event-Urn: urn:event:e-7f8e1
X-Nexa-Tenant: urn:airline:latam
X-Nexa-Correlation: urn:correlation:abc123
{
"type": "case.resolved",
"occurred_at": "2026-05-13T20:11:00Z",
"tenant": "urn:airline:latam",
"case_urn": "urn:case:c-7f8e1",
"data": { … }
}
Signature verification
The signature is HMAC-SHA256(timestamp + "." + body, secret) base64-encoded. Verify in your endpoint:
import hmac, hashlib, base64, time
def verify(headers, body_bytes, secret):
sig_header = headers["x-nexa-signature"] # "sha256=<base64>"
timestamp = int(headers["x-nexa-signature-timestamp"])
if abs(time.time() - timestamp) > 300: # reject > 5 min skew
raise ValueError("stale signature")
payload = f"{timestamp}.".encode() + body_bytes
expected = base64.b64encode(
hmac.new(secret, payload, hashlib.sha256).digest()
).decode()
expected_header = f"sha256={expected}"
if not hmac.compare_digest(sig_header, expected_header):
raise ValueError("bad signature")
The 5-minute timestamp window prevents replay attacks. The HMAC is computed over timestamp + "." + body (not just body) so a replayed body with a fresh timestamp doesn't pass.
Idempotency
X-Nexa-Event-Urn is the idempotency key. Partners must dedupe — the same event may be delivered more than once during retries. The event URN is stable across all delivery attempts.
Retries
Failed deliveries (non-2xx response, connection error, timeout > 10s) retry with exponential backoff up to 24 hours:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | +1 min |
| 3 | +5 min |
| 4 | +30 min |
| 5 | +2 h |
| 6 | +6 h |
| 7 | +24 h |
After attempt 7, the subscription is paused and the partner contact receives an in-band notification. Re-enabling requires a successful test delivery via POST /partner/v1/webhooks/test.
Replay
Partners can request a replay of any event within 7 days:
GET /partner/v1/events/replay?since=urn:event:e-1234&until=urn:event:e-1240
Authorization: Bearer <token>
Replay does not re-trigger the platform side effect — it just re-pushes the event payload to the registered webhook URL.
Outbound event types
| Event type | When | Scope required |
|---|---|---|
disruption.detected | flight-predictor flags a high-confidence disruption (probability > policy threshold) | disruptions.events.subscribe |
case.opened | A case is provisioned (auto or manual) | cases.events.subscribe |
case.amended | Manifest updated (added passengers, corrected next-flight) | cases.events.subscribe |
subcase.processing | Operator submitted compensation; saga running | cases.events.subscribe |
subcase.offer-ready | Saga succeeded; passenger has been notified | cases.events.subscribe |
subcase.resolved | Passenger accepted | cases.events.subscribe |
subcase.declined | Passenger declined; saga rolling back | cases.events.subscribe |
subcase.failed | Vendor permanent failure; manual review queued | cases.events.subscribe |
subcase.compensation-failed | Saga rollback failed; manual reconciliation required | cases.events.subscribe |
case.resolved | All sub-cases reached a terminal state | cases.events.subscribe |
voucher.issued | Voucher generated and dispatched | vouchers.read (subscribe gating uses the same scope) |
wallet.card-issued | Virtual prepaid card issued | wallet.events.subscribe |
wallet.card-tx | Wallet authorization, settlement, reversal | wallet.events.subscribe |
Every event payload includes tenant, case_urn (where applicable), correlation_urn, and occurred_at. The data block carries event-specific fields.
Inbound webhooks (vendor → Nexa)
Several Nexa vendors deliver state changes via inbound webhook. These are configured at vendor onboarding by Nexa engineering, not by customers.
Pomelo (wallet)
Pomelo delivers transaction-state changes (auth, capture, reversal) via HMAC-signed webhooks to the per-tenant wallet deployable's webhook ingress. Each event carries the issued-card URN and is reconciled against Nexa's local card record. Authorization events update the held balance; capture events finalize the transaction; reversals release the hold.
Amadeus / Hotelbeds (booking)
Amadeus and Hotelbeds do not natively support outbound webhooks for booking state changes — these vendors operate on a request-response model. Where vendor-side state changes asynchronously (e.g., a hotel cancels a confirmed booking), Nexa polls the vendor's reconciliation API on a configurable interval (5–60 minutes per vendor) and emits internal events that map to outbound subcase.compensation-failed or voucher.amended events.
AeroAPI (flight predictor)
AeroAPI streams flight-state changes via a long-poll API. The flight-predictor worker consumes the stream continuously, normalizes flight events, and publishes them on the platform domain.flights topic. Disruptions detected here trigger the outbound disruption.detected event.
Twilio / SendGrid / WhatsApp Business (notifications)
Inbound delivery-status webhooks (delivered, bounced, replied, opted-out) are received by the per-tenant notifications deployable and reconciled against the per-passenger send log. Bounce/unsubscribe events update the passenger's contact preferences in the tenant's airline adapter cache.
AeroAPI delivery & retry
Inbound webhooks are received via the partner-api ingress for vendor-shape webhooks; signature validation happens at the proxy edge. Bad signatures return 401; vendor retries kick in. Nexa's own retry behavior on the read side (when polling rather than receiving) is governed by the per-vendor token bucket — see Integrations.
Webhook security checklist
Before going live with a webhook integration, confirm:
- Signature verification runs on every request. Reject unsigned or bad-signature requests with
401. - Timestamp window is enforced. Reject signatures more than 5 minutes old.
- Idempotency dedupe uses
X-Nexa-Event-Urn(or the vendor-side equivalent). The same event may be delivered more than once. - Endpoint returns 2xx fast. Don't synchronously process the event in the webhook handler — enqueue it and 200 immediately. Webhook timeout is 10s; queue-based handling is the safe pattern.
- TLS only. HTTP webhooks are not delivered.
- Secret rotation is wired up. Both the OAuth client secret and the HMAC webhook secret rotate every 90 days; the dual-key window is 14 days.
- Allowlist Nexa egress IPs in your firewall if you require IP-level allowlisting. The current list is published at
https://docs.nexa.ai/static/egress-ips.txt.
Where to next
- Authentication
- Partner API — full set of partner endpoints including webhook subscription management.
- Integrations — what Nexa connects to on the vendor side.