Skip to main content

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

CapabilityPomelo APINexa adapter
Card issuanceCards APIPomeloVirtualWalletProvider.issue
Scheduled load (drops)Cards API + scheduled jobsPomeloVirtualWalletProvider.scheduleLoad
Card cancellationCards APIPomeloVirtualWalletProvider.cancel
PCI iframe (PAN/CVV reveal)Pomelo Hosted IframePomeloPciIframeService
Authorization webhooksWebhookPomeloWebhookController
Capture / settlement webhooksWebhookPomeloWebhookController
Reversal / refund webhooksWebhookPomeloWebhookController

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:

BalanceMeaning
initialBalanceFrozen at issue — the maximum the card was ever authorized to hold.
currentBalanceSettled balance — what the card actually has in cleared funds.
availableBalanceSettled minus reserved holds — what the passenger can spend right now.

A $50 card with a $15 authorization in flight has:

  • initialBalance: $50
  • currentBalance: $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:

  1. Validates the HMAC signature.
  2. Resolves the card URN from the Pomelo card_id.
  3. Applies the balance change atomically.
  4. Emits the canonical wallet.card-tx event on the workflow bus for downstream consumers (audit, analytics, partner webhooks).
  5. Returns 200 with 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 LOADED but Pomelo shows CANCELLED): 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:

  1. Passenger taps "Show card details" in the PWA.
  2. The PWA calls GET /v1/me/wallet/reveal.
  3. Nexa mints a short-lived JWT scoped to the card URN and returns a Pomelo iframe URL.
  4. The PWA renders Pomelo's iframe; the iframe fetches the PAN/CVV directly from Pomelo's PCI environment.
  5. 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:

  1. Calls Pomelo to mark the card cancelled.
  2. Returns any unused balance to the affinity group.
  3. Records the cancellation in audit.
  4. Emits wallet.card-cancelled for downstream consumers.

Cancellation is irreversible — re-issuing a new card is a fresh issue against a new URN.

Failure modes

FailureClassificationAction
Pomelo 5xx during issueTransientRetry with backoff (5 attempts, base 3 s).
Pomelo 401 / 403 during issuePermanent — credentials wrongAlert ops; pause issuance for the tenant; do not retry.
Affinity group out of fundsPermanentSurface 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 invalidReject401 to Pomelo; the event is logged but not applied.
Webhook for unknown cardReject404 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.

Was this helpful?