Last updated

Classic eSIM Grants

Classic eSIM grants let your backend pre-stage a single-IMSI eSIM for a User, who claims it from inside your native app. Unlike the universal-eSIM Native Integration — which centralises a traveler's data on one ICCID and queues packages onto it — a classic grant provisions a fresh ICCID (or tops up onto a matching existing one) per claim. It's the right fit for use cases that don't share the universal-eSIM mental model:

  • Loyalty rewards — "thank you for flying with us, here's 1 GB of Spain data".
  • Refund / retention grants — issued case-by-case without trip context.
  • Sponsorship-style perks — one-off eSIMs without recurring travel data needs.

Already using the Native eSIM Integration? Classic grants coexist with the universal flow on the same Booking endpoint and the same Partner credentials. The choice is per package_specifications[] entry via the esim_type field — you can mix universal and classic specs in the same Booking.


How it differs from the universal-eSIM flow

AspectUniversal (esim_type: 'universal')Classic (esim_type: 'classic')
ICCID lifecycleOne per traveler, reused across packagesOne per claim (or topped-up onto a matching existing eSIM)
Identifier required on specexternal_user_id onlyEither external_user_id or email
Redemption surfaceHubby WebView inside your appNative onCalls invoked from your app
Trip contextdeparture_date always meaningfuldeparture_date may be omitted — server defaults to today
Grant expiryNoneexpires_at on the spec; defaults to 90 days

The flow

Native App(Hubby SDK)Hubby APIPartner BackendNative App(Hubby SDK)Hubby APIPartner BackendPOST /api/bookings{ esim_type: 'classic', email or external_user_id, ... }{ package_queues: [{ uuid, esim_type, ... }], booking }FCM push (queue_id, booking_id) — fire-and-forgetlist_classic_package_queues (onCall)[{ id, package_specification, expires_at }, ...]set_classic_package_queue_destination{ queue_id, iso3 } (onCall, optional)queue updatedclaim_classic_package_queue { queue_id } (onCall){ queue, esim_iccid, esim_packages, is_top_up }

Step 1 — Create a Booking (server-side)

Same POST /api/bookings you use for traditional bookings, with one classic-tagged spec:

curl --request POST \
  'https://api.hubbyesim.com/api/bookings' \
  --header 'x-api-key: YOUR_API_KEY' \
  --header 'x-timestamp: 1779359116000' \
  --header 'x-signature: YOUR_HMAC_SIGNATURE' \
  --header 'Content-Type: application/json' \
  --data '{
    "email": "traveler@example.com",
    "package_specifications": [
      {
        "esim_type": "classic",
        "email": "traveler@example.com",
        "destination": "ESP",
        "size": "1GB",
        "package_type": "data-limited"
      }
    ]
  }'

Notable defaults applied server-side when a spec is classic:

  • departure_date defaults to today when omitted (skeletal Booking for non-travel grants).
  • expires_at on the spec defaults to today + 90 days when omitted.
  • esim_type defaults to universal when omitted — set it explicitly to classic.

Response:

{
  "success": true,
  "data": {
    "id": "iVlU7xgTCUq0537I9GpH",
    "package_queues": [
      {
        "uuid": "fe17e0ef-0cd3-4c2f-8e27-8cbaca0bde29",
        "destination": "Spain",
        "iso3": "ESP",
        "esim_type": "classic"
      }
    ]
  }
}

Persist the package_queues[].uuid if you want to correlate with webhook events later.

Re-submitting is idempotent. Because classic grants are user-keyed (by per-spec email or external_user_id), re-submitting with the same booking_id, departure_date, and partner merges any new grants into the existing booking and skips ones already queued for that user — so retries are safe and you can add a second grant later. See Re-submitting a booking on POST /bookings.


Step 2 — User claims via the native app

Three Firebase v2 callable functions, exposed through the Hubby SDK. All require a Firebase-authenticated User (the App Check enforcement is on).

list_classic_package_queues

Returns the calling User's unclaimed, unexpired classic queues.

// Response shape
{
  "queues": [
    {
      "id": "fe17e0ef-0cd3-4c2f-8e27-8cbaca0bde29",
      "package_specification": {
        "esim_type": "classic",
        "size": "1GB",
        "iso3": "ESP",
        "destination": "Spain",
        "package_type": "data-limited"
      },
      "expires_at": "2026-08-19T10:22:54.528Z",
      "redeemed_at": null
    }
  ]
}

set_classic_package_queue_destination (only when iso3 is unset)

Use when the partner created the grant without picking a destination — the User selects one in your UI, then you call this endpoint. Validates the iso3 against the active-Destinations list (per ADR 0002), idempotent overwrite is supported.

// Request
{ "queue_id": "fe17e0ef-0cd3-4c2f-8e27-8cbaca0bde29", "iso3": "ESP" }

Error codes:

CodeCause
invalid-argument (400)iso3 could not be resolved, or the destination is inactive, or the queue is not classic-typed
not-found (404)Queue doesn't exist or doesn't belong to the calling User
failed-precondition (409)Queue has already been claimed
failed-precondition (410)Queue has expired (expires_at past)

claim_classic_package_queue

Provisions a new classic eSIM or attaches the new package to an existing matching one (same Destination + same IMSI on the destination's package template). The match check is automatic — the response's is_top_up flag tells you which path ran.

// Request
{ "queue_id": "fe17e0ef-0cd3-4c2f-8e27-8cbaca0bde29" }

// Response shape
{
  "queue": { /* updated queue, redeemed_at set, esim populated */ },
  "esim_iccid": "8901234567890123456",
  "esim_packages": [ /* dashboard-visible packages on the eSIM */ ],
  "is_top_up": false
}

Same error codes as set_destination, plus invalid-argument (400) when the queue has no destination set yet (iso3 is null).


Why some claims become top-ups

When a User has an existing non-archived classic eSIM whose imsi matches the IMSI on the destination's package template and whose provider matches the bundle's provider, the claim attaches the new EsimPackage to that existing eSIM instead of provisioning a new one. The User keeps the same physical SIM profile on their device; only an additional package is added.

This is not a top-up in the glossary sense — that term is reserved for Stripe-paid additions. The topup.completed partner webhook does not fire on a classic-grant claim. The is_top_up flag in the response is purely informational so your UI can phrase the success screen appropriately ("eSIM ready" vs "Package added to your existing eSIM").

Matching rules:

  • Most-recently-assigned candidate wins when multiple match (by time_assigned).
  • Provider mismatch disqualifies the match (different IMSI plan space).
  • A null IMSI on the destination's template skips matching entirely — the claim always provisions a new eSIM.

Webhooks

Classic grants emit the same booking-level webhooks as other flows (booking.created, booking.updated). The package_queues[].uuid in the booking response is the stable identifier for correlation.

On claim, the classic_package_queue.claimed webhook fires (opt-in via webhook_settings.events.classic_package_queue_claimed). The payload carries queue_id, booking_id, esim_iccid, user_id, partner_id, and an is_top_up flag indicating whether the eSIM was freshly provisioned or attached to an existing matching one. See the API reference for the full schema.

The topup.completed webhook does not fire for classic-grant claims — that event is reserved for Stripe-paid additions.