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 theesim_typefield — you can mix universal and classic specs in the same Booking.
How it differs from the universal-eSIM flow
| Aspect | Universal (esim_type: 'universal') | Classic (esim_type: 'classic') |
|---|---|---|
| ICCID lifecycle | One per traveler, reused across packages | One per claim (or topped-up onto a matching existing eSIM) |
| Identifier required on spec | external_user_id only | Either external_user_id or email |
| Redemption surface | Hubby WebView inside your app | Native onCalls invoked from your app |
| Trip context | departure_date always meaningful | departure_date may be omitted — server defaults to today |
| Grant expiry | None | expires_at on the spec; defaults to 90 days |
The flow
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_datedefaults to today when omitted (skeletal Booking for non-travel grants).expires_aton the spec defaults to today + 90 days when omitted.esim_typedefaults touniversalwhen omitted — set it explicitly toclassic.
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
external_user_id), re-submitting with the samebooking_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 onPOST /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:
| Code | Cause |
|---|---|
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
nullIMSI 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.
Related
- Universal Native Integration — for partners centralising on one ICCID per traveler.
- Hubby WebView — drop-in webview alternative to the Native Integration.
- Multi-destination bookings — fan-out semantics for array destinations apply to classic grants too.
- API reference: POST /bookings — full request schema.