Pomelo (Wallet)
Nexa integrates with Pomelo as the primary issuer for virtual prepaid cards distributed to disrupted passengers. Cards cover meal allowance, sundries, and (per policy) incidentals during the disruption — a far better passenger experience than paper meal vouchers, with full transaction visibility and tight control over spend rules.
What we use it for
| Capability | Pomelo API | Nexa adapter |
|---|---|---|
| Card issuance | Cards API | PomeloVirtualWalletProvider.issue |
| Scheduled load (drops) | Cards API + scheduled jobs | PomeloVirtualWalletProvider.scheduleLoad |
| Card cancellation | Cards API | PomeloVirtualWalletProvider.cancel |
| PCI iframe (PAN/CVV reveal) | Pomelo Hosted Iframe | PomeloPciIframeService |
| Authorization webhooks | Webhook | PomeloWebhookController |
| Capture / settlement webhooks | Webhook | PomeloWebhookController |
| Reversal / refund webhooks | Webhook | PomeloWebhookController |
Architecture
Funding model
Cards live inside an affinity group — a card product the airline has pre-funded with Pomelo. Per-card loads are accounting moves inside that pool; Nexa does not call any "load funds from external bank account" API.
The airline pre-funds the affinity group with Pomelo on its own schedule. The platform's wallet domain tracks per-card balance against the pool and surfaces low-balance alerts before the pool runs dry.
Authentication
Two auth surfaces:
- Outbound (Nexa → Pomelo): OAuth2 client credentials. Tokens cached until
exp - 60s. - Inbound (Pomelo → Nexa, webhooks): HMAC-SHA256 signature on every webhook. Nexa validates before processing.
Credentials are tenant-managed under nexa/<tenant>/vendor/pomelo/. Each tenant has its own affinity group with separate funding.
Card lifecycle
Cards are issued one per (caseUrn, groupId) tuple — the lead passenger holds the card; siblings/family on the same PNR share it. The URN scheme keeps issuance idempotent: replaying an issue command finds the existing card and returns the same URN, no duplicates.
Funding strategies
The policy declares one of two strategies per tier:
Single-load
The full daily allowance is loaded at issue time. Simple, predictable. Used for short stays and lower-tier passengers.
Scheduled drops
The allowance is split across the day:
- 25% at issue (issue → breakfast)
- 35% at noon (lunch)
- 40% at 18:00 (dinner)
Scheduled drops are implemented as durable repeatable jobs keyed by the card URN. The first drop fires immediately at issue; subsequent drops fire on the wall clock. The job survives a redeploy or process replacement — the schedule is persistent.
Why drops? Two reasons:
- Passengers who decline the offer or no-show shouldn't be holding a fully-loaded card.
- It's a stronger UX signal: the passenger sees the card top up at meal times, reinforcing the airline's care during the disruption.
Two-phase balance model
Every card carries three balances:
| Balance | Meaning |
|---|---|
initialBalance | Frozen at issue — the maximum the card was ever authorized to hold. |
currentBalance | Settled balance — what the card actually has in cleared funds. |
availableBalance | Settled minus reserved holds — what the passenger can spend right now. |
A $50 card with a $15 authorization in flight has:
initialBalance: $50currentBalance: $50(settlement hasn't completed)availableBalance: $35
If the authorization captures, currentBalance drops to $35 and availableBalance stays at $35. If the authorization expires, availableBalance returns to $50.
Webhook flow
Pomelo POSTs every card event to the per-tenant wallet deployable's webhook ingress. Each event:
POST /v1/wallet/webhooks/pomelo HTTP/1.1
Content-Type: application/json
X-Pomelo-Signature: <hmac>
X-Pomelo-Event-Id: <id>
{
"type": "AUTHORIZATION",
"card_id": "card-9912",
"amount": { "value": 1500, "currency": "USD" }, // cents
"merchant": { ... },
"occurred_at": "2026-05-13T19:42:01Z"
}
Nexa:
- Validates the HMAC signature.
- Resolves the card URN from the Pomelo
card_id. - Applies the balance change atomically.
- Emits the canonical
wallet.card-txevent on the workflow bus for downstream consumers (audit, analytics, partner webhooks). - Returns
200with a signed body Pomelo verifies before considering the event acknowledged.
If a webhook arrives twice (network retry), the X-Pomelo-Event-Id header keys the idempotency check — duplicates are acknowledged + dropped.
Reconciliation
Webhooks are at-least-once and occasionally drop. A daily reconciliation job compares Nexa's local card view to Pomelo's authoritative record:
- Balance drift: alert + auto-correct from Pomelo's source.
- Missing transactions: backfill from Pomelo's transaction history.
- Status drift (e.g., a card Nexa shows as
LOADEDbut Pomelo showsCANCELLED): alert ops; auto-correct the local record.
The reconciliation runs as a durable repeatable job on a 24-hour cadence per tenant.
PCI boundary
Nexa never sees the PAN, CVV, or expiration. The reveal flow is:
- Passenger taps "Show card details" in the PWA.
- The PWA calls
GET /v1/me/wallet/reveal. - Nexa mints a short-lived JWT scoped to the card URN and returns a Pomelo iframe URL.
- The PWA renders Pomelo's iframe; the iframe fetches the PAN/CVV directly from Pomelo's PCI environment.
- The passenger sees the card details; Nexa was never in the PCI scope.
This is the entire compliance boundary. Nexa is not a PCI environment, and the audit scope is dramatically smaller as a result.
Hold / capture / reversal
Pomelo handles authorization decisions in real time at the network speed. Nexa is not in that path — we record the result asynchronously via the webhook. The WalletService does not approve or decline transactions.
If a hold expires without capture (typical 7 days, vendor-configurable), Pomelo emits a HOLD_RELEASED webhook and Nexa's available balance increases. There is no separate timer in Nexa for this — Pomelo is the source of truth for hold expiration.
Cancellation
Operators can cancel a card via the operations console (POST /v1/wallet/cards/{urn}/cancel). Reasons: end of contingency (case closed), fraud detection, lost card. Cancellation:
- Calls Pomelo to mark the card cancelled.
- Returns any unused balance to the affinity group.
- Records the cancellation in audit.
- Emits
wallet.card-cancelledfor downstream consumers.
Cancellation is irreversible — re-issuing a new card is a fresh issue against a new URN.
Failure modes
| Failure | Classification | Action |
|---|---|---|
| Pomelo 5xx during issue | Transient | Retry with backoff (5 attempts, base 3 s). |
| Pomelo 401 / 403 during issue | Permanent — credentials wrong | Alert ops; pause issuance for the tenant; do not retry. |
| Affinity group out of funds | Permanent | Surface to operator with "Wallet pool exhausted" — operator can either (a) cancel the wallet leg of the saga and book without the card, or (b) escalate to ops to refund the pool. |
| Webhook signature invalid | Reject | 401 to Pomelo; the event is logged but not applied. |
| Webhook for unknown card | Reject | 404 to Pomelo; reconciliation will catch the drift if it's real. |
Onboarding checklist
- Tenant signs Pomelo agreement + opens affinity group.
- Pomelo provides OAuth2 client credentials + webhook HMAC secret.
- Credentials in
nexa/<tenant>/vendor/pomelo/. - Webhook URL registered with Pomelo (per-tenant).
- Mock-mode flipped to
live. - First end-to-end card issue + auth + capture validated against sandbox.
- Production validation with an operator-issued test card.
Compliance & data handling
Pomelo is a data sub-processor under Nexa's tenant DPAs. PCI data is not in scope for Nexa (the iframe boundary). Per-card data Nexa stores: card URN, balance, transaction history (merchant, amount, time — no PAN). Cardholder data Nexa stores: lead-passenger name, contact (email/phone), tier — same data Nexa has from the case manifest.