Native eSIM Integration
Build the eSIM step into your own product. Your backend talks to the Partner API; your app or site shows the install screen; the traveler never needs a Hubby account or app. This page is the complete flow, Partner API 5.20.0.
Note: The Hubby WebView guide describes the legacy API on
api.hubbyesim.com. On core, the call that signs a traveler in to the Hubby web app isPOST /v1/redirect-tokens(section 4). This integration is for partners whose clients do not want a third-party surface in front of the traveler, or who already run their own contract and support screens.
1. Integration at a glance
Partner backend Hubby
──────────────────────────────────────────────────────────────────────────
1 GET /v1/bundles (daily) ─────▶ full catalog, both prices
2 POST /v1/bookings ─────▶ booking reserved, items queued
3 DELETE /v1/bookings/{id} ─────▶ free cancel while is_cancellable
4 POST .../items/{item}/package ─────▶ eSIM issued: ICCID, LPA string,
one-tap install links
5 GET /v1/esims/{iccid}/instructions ─────▶ install steps per OS, in the
traveler's language (optional)
6 show install screen to the traveler
7 DELETE .../items/{item}/package ─────▶ terminate before activation,
item back to queued
8 GET /v1/esims/{iccid} ─────▶ status of every package
GET /v1/esims/{iccid}/usage ─────▶ data and days left, one entry
per destination
9 webhooks ◀───── installed, activated, usage,
booking eventsKey facts for your product design:
- Booking and issuing are separate steps. A booking reserves. Issuing (step 4) creates the eSIM and returns the install material. You choose when to issue: at sale, or closer to departure.
- Validity starts at first use, not at issue. The package stays inactive on the network until the provider activates it. Hubby learns of the activation from the provider; it does not observe the network attach itself. An issued eSIM may be installed up to 365 days after issue. Once the package activates,
valid_untilsays when it stops working. - The traveler never needs a Hubby account or app. You show the install material in your own interface. The traveler installs with one tap (iOS, Android) or by scanning a QR code you render from the LPA string. If you do not want to write install copy, fetch ready-made step-by-step instructions per phone family in the traveler's language, with screen recordings.
- One traveler, one eSIM, forever. Send a stable
external_user_idper traveler. The second and every later booking for that id attaches to the eSIM already on their phone. That is also how a top-up works: book again for the same traveler, then issue. There is no separate top-up call. A new package for a destination where the traveler still has a live package waits its turn: it activates when the running one is drained or expires. A package for a different destination activates independently, when the provider activates it there. When a country package and a region package both cover where the traveler is, the network chooses which one carries the traffic; Hubby sets no order. A top-up may be of the other type: an unlimited package on a data entry, or a data package on an unlimited one. It waits its turn in the same way, and it opens a second usage entry for that destination (section 6). - A top-up is for a destination the traveler already holds. The Hubby apps offer a top-up only for such a destination; any other destination is a new purchase from your shop. The API does not enforce this, so your app must. Take the destination from a usage entry (section 6).
- You are billed per booking under your commercial agreement. The traveler never pays Hubby.
2. Base URL, authentication, common headers
Base URL
| Environment | Base URL |
|---|---|
| Production | https://core.hubbyesim.com/api |
| Staging | https://core-staging.hubbyesim.com/api |
All paths below are relative to the base URL, so GET /v1/destinations is https://core.hubbyesim.com/api/v1/destinations.
Every operation, field and refusal is in the Partner API 5.x (core) reference. That page is generated from the API this guide describes. The API Reference page describes the legacy API on api.hubbyesim.com.
Set up by Hubby for your account
Hubby sets these for your account. The API cannot change them; ask your Hubby contact.
- API keys: your key id and secret for each environment you use.
- End-customer channel: a Native account has
end_customer_channel: partner_app. Check it onGET /v1/mewhen your service starts.nullmeans it is not set; booking items then still carrycodeand the Hubby-app links. Ask Hubby to set it. package_strategyandforce_destinationless: they change what a package specification may carry. Read them onGET /v1/me(section 4). Hubby never setsforce_destinationlesson a Native account: a package issued over this API has no redeem step in which the traveler could pick a destination, so every package specification you book carries thedestinationyou send.- Partner name:
nameonGET /v1/me. Your install instructions tell the traveler to call the line by this name followed byeSIM(section 6,esim_label); nothing sets it on the phone for them. A visual identity has no effect on this label. - Traveler emails: Hubby emails a traveler only when your agreement turns on Hubby's emails for your account.
communication_optionson a booking does not turn them on or off. - Webhook endpoint: Hubby creates it and its signing secret (section 7).
Authentication: HMAC-SHA256 v1
Warning: This is the core signing scheme, and it is the only one these endpoints accept. The Authentication Guide and the signing example at the top of the API reference describe the legacy scheme of
api.hubbyesim.com(millisecond timestamp,timestamp + method + path). A request signed that way answers401oncore.hubbyesim.com. Use the samples on this page.
Use the key and secret that Hubby issues for core. If you keep your legacy integration, keep calling api.hubbyesim.com with your legacy keys: for a Partner that Hubby has cut over to core, the legacy backend forwards those signed calls to core unchanged. Admin keys and Hubby's own keys are not forwarded. To call core directly, use the keys that Hubby creates for you on core.
Every request carries three headers.
| Header | Value |
|---|---|
X-Api-Key | Your public key id |
X-Timestamp | Unix time in seconds when you built the request |
X-Signature | Lowercase hex HMAC-SHA256 of the canonical string, keyed with your secret |
The canonical string is six lines joined with \n, no trailing newline:
HUBBY-HMAC-SHA256-v1
{METHOD} uppercase, e.g. POST
{host} e.g. core.hubbyesim.com; with :port when the URL
names a non-default port, e.g. localhost:8000
{request URI} full path as sent, prefix and query string included,
e.g. /api/v1/bookings?cursor=abc
{X-Timestamp}
{sha256 of the raw body} lowercase hex; for a request with no body, the
sha256 of the empty stringRules that cost integrators time:
- Sign the path as sent, including
/api. Do not sign a relative path. - Sign the exact bytes you send. A GET has an empty body; do not send
[]or{}on a GET. - The timestamp must be within 300 seconds in the past and 5 seconds in the future of our clock. Outside that window the request is rejected with
401andcode: timestamp_out_of_window: check your clock. - A rejected key or signature, or a missing signing header, is
401with{"message": "Unauthenticated.", "code": "unauthenticated"}.
Pseudo-code:
ts = unix_seconds_now()
bodyHash = sha256_hex(rawBody or "")
canonical = "HUBBY-HMAC-SHA256-v1\n" + METHOD + "\n" + host + "\n" + requestUri + "\n" + ts + "\n" + bodyHash
signature = hmac_sha256_hex(secret, canonical)
headers = { "X-Api-Key": key, "X-Timestamp": ts, "X-Signature": signature,
"Content-Type": "application/json", "Accept": "application/json" }Node.js (no dependencies):
const crypto = require('crypto');
// url: the full request URL, e.g. "https://core.hubbyesim.com/api/v1/bookings?per_page=10"
// body: the exact string you will send, or "" for a request without a body
function coreHeaders(method, url, body = '') {
const { host, pathname, search } = new URL(url);
const timestamp = Math.floor(Date.now() / 1000).toString(); // seconds, not milliseconds
const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
const canonical = [
'HUBBY-HMAC-SHA256-v1',
method.toUpperCase(),
host,
pathname + search,
timestamp,
bodyHash,
].join('\n');
const signature = crypto
.createHmac('sha256', process.env.HUBBY_API_SECRET)
.update(canonical, 'utf8')
.digest('hex');
return {
'X-Api-Key': process.env.HUBBY_API_KEY,
'X-Timestamp': timestamp,
'X-Signature': signature,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
const body = JSON.stringify({ external_user_id: 'partner-user-88213' });
const url = 'https://core.hubbyesim.com/api/v1/bookings/PARTNER-LOC-4471/items/01J9X3K2M4N5P6Q7R8S9T0V1W2/package';
const response = await fetch(url, {
method: 'POST',
headers: { ...coreHeaders('POST', url, body), 'Idempotency-Key': crypto.randomUUID() },
body,
});Python (standard library only):
import hashlib, hmac, os, time
from urllib.parse import urlsplit
def core_headers(method: str, url: str, body: bytes = b"") -> dict:
parts = urlsplit(url)
request_uri = parts.path + (f"?{parts.query}" if parts.query else "")
timestamp = str(int(time.time())) # seconds, not milliseconds
body_hash = hashlib.sha256(body).hexdigest()
canonical = "\n".join([
"HUBBY-HMAC-SHA256-v1",
method.upper(),
parts.netloc,
request_uri,
timestamp,
body_hash,
])
signature = hmac.new(
os.environ["HUBBY_API_SECRET"].encode(), canonical.encode(), hashlib.sha256
).hexdigest()
return {
"X-Api-Key": os.environ["HUBBY_API_KEY"],
"X-Timestamp": timestamp,
"X-Signature": signature,
"Content-Type": "application/json",
"Accept": "application/json",
}Both samples sign the body bytes they send. Build the JSON string once, hash that string, and send that same string.
The official SDK, @hubbyesim/sdk 2.7.0 on npm, does this for you, with retries, idempotency keys and webhook verification. It speaks Partner API 5.20.0. Build one Hubby object from your key and secret; it has one method per operation, named as in this guide: getBundles, createBooking, issuePackage, terminatePackage, getEsim, getEsimUsage, getEsimInstructions, getCompatibleDevices, checkDeviceCompatibility and the rest. A refusal throws a HubbyApiError with status and code.
Idempotency
Idempotency-Key: <uuid> on a write. The key counts on the booking writes (create, presign upload, package-specification updates, reschedule, cancel: POST, PATCH and DELETE on /v1/bookings and /v1/bookings/{id}…), on the issue call, on POST /v1/redirect-tokens and on POST /v1/device-compatibility. Every GET ignores it: nothing is stored and nothing is replayed, so page 2 of a list is page 2 whatever key you send. The terminate call (DELETE …/items/{item}/package) and the eSIM reads ignore it too. We replay the stored response for the same key for 24 hours, so a retry after a timeout is safe. A replayed answer carries the header Idempotent-Replayed: true; the first answer does not. The header is optional on POST /v1/bookings and required on the issue call (section 5).
- A replay needs the same request, byte for byte: the same method, path and body. Build the body once and store it with the key, or rebuild it from the same stored values in the same order.
- A second request with the same key while the first is still running:
409idempotency_key_in_use, withRetry-After. Retry with the same key. If the first attempt succeeded, you get its stored answer. If it was refused, nothing was stored and the key is free again, so your retry runs the request again. Do not mint a new key here; that is a second booking or a second issue. - The same key with a different request:
422idempotency_key_reused. Use a new key per request. - Only a success is stored. A
4xxor a5xxreleases the key, so a retry with the same key runs the request again instead of replaying the refusal. - A terminate spends the key that issued the package, for the life of the item. Sending that key again answers
409idempotency_key_spentand issues nothing, also long after the 24 hours. Do not retry it; issue again with a new key.
Tracing
Every response of a matched route carries X-Hubby-Call-Id. Quote it in any support question; it names the exact request on our side. A 404 for a wrong path, a 405 and a 400 malformed_query_string are answered before routing and carry none.
Errors
Every refusal (4xx) answers {"message": "…", "code": "…"}. Branch on code, never on message: the message is for people and may change. A 422 also carries errors, the messages keyed by field, for example package_specifications.0.size.
Codes you can meet on any call:
| Status | code | Meaning |
|---|---|---|
| 400 | malformed_query_string | The query string cannot be parsed |
| 401 | unauthenticated | Unknown key, wrong signature, missing signing header, or inactive account |
| 401 | timestamp_out_of_window | X-Timestamp is outside the window. Check your clock |
| 404 | not_found | Wrong path, or the booking, item, destination or eSIM does not exist or is not yours. The two are not distinguished. The message is always Not found. |
| 405 | method_not_allowed | The path exists, the method does not |
| 422 | validation_failed | A field failed validation; see errors |
| 429 | rate_limited | Too many requests. Wait the seconds in Retry-After |
Codes of one operation, listed again with it in sections 4 to 6:
| Status | code | When |
|---|---|---|
| 400 | idempotency_key_required | Issue call without an Idempotency-Key |
| 403 | booking_departed, booking_has_redeemed_promo_codes, booking_has_paid_packages | Cancel refused by a business rule |
| 404 | item_has_no_package | Read or terminate on an item with no package, also right after a terminate |
| 409 | package_already_issued, booking_cancelled, item_not_queued, starter_cap_exceeded, provisioning_target_changed, package_queue_not_redeemable | Issue refused |
| 409 | idempotency_key_in_use, idempotency_key_spent | Idempotency (above) |
| 409 | package_activated, package_not_issued_over_api, package_not_terminable | Terminate refused |
| 409 | legacy_graph_not_importable | POST /v1/redirect-tokens: the traveler's record on Hubby's previous platform cannot be imported. Contact Hubby |
| 422 | destination_required, customer_is_classic_holder, no_bundle_resolved, no_free_esim_available, idempotency_key_reused | Issue refused |
| 500 | — | Issue: the network provider failed to create the package, or another issue for the same traveler is still running. Nothing was issued and the key was not stored: retry with the same key |
| 502 | package_termination_refused | The network provider refused the terminate. Nothing changed; retry later |
| 503 | legacy_booking_unavailable | A booking still on Hubby's previous platform could not be fetched. Retry later |
| 503 | bridge_unavailable | POST /v1/redirect-tokens: Hubby's previous platform could not be reached to import the traveler. Nothing was created; retry after Retry-After |
| 503 | install_instructions_unavailable | The installation instructions cannot be read right now. Retry later |
New codes may be added in a minor release. Treat an unknown code like the status it comes with.
Destination identifiers
Wherever a path takes {destination}, send the destination's id (a ULID), its ISO 3166-1 alpha-2 or alpha-3 code (ES, ESP), a region's code (EU), or its English name (Spain), in any letter case. A translated name does not resolve here. Only a destination you can sell resolves: when your account has a destination list, a destination outside it answers 404 not_found, like an unknown value.
3. Catalog
GET /v1/destinations
Every destination you can sell, as two lists: countries and regions. When your account has a destination list, only the destinations on it are listed, the same set your bookings accept; otherwise every active destination. The answer is not wrapped in data. Optional filter ?iso3=ESP, a comma-separated list of ISO alpha-3 codes; a region matches it only by its own iso3 code (XCA, XRC, XME).
{
"countries": [
{
"id": "01J…",
"name": "Spain",
"type": "country",
"iso2": "ES",
"iso3": "ESP",
"iso3s": [],
"i18n_name": { "en": "Spain", "de": "Spanien", "es": "España", "nl": "Spanje", "…": "…" }
}
],
"regions": [
{
"id": "01J…",
"name": "Europe",
"type": "region",
"iso2": "EU",
"iso3": null,
"iso3s": ["AUT", "BEL", "…"],
"slug": "europe",
"i18n_name": { "…": "…" },
"country_names": { "AUT": "Austria", "BEL": "Belgium", "…": "…" }
}
]
}A region's iso3s lists every country it covers; a country's iso3s is empty. country_names gives the member countries' names, keyed by ISO alpha-3 code.
Region codes. A country has its ISO 3166-1 codes in iso2 and iso3. A region has one code, in iso2 (for example EU, XA, XN, XS, OC) or in iso3 (for example XCA, XRC, XME); the other field is null. Read iso2, and iso3 when iso2 is null. A booking item's destination (section 4) is this same code, so every item maps back to a destination in this list. Do not hard-code the region codes: list them from GET /v1/destinations.
GET /v1/destinations/{destination}
One destination with its bundles embedded under bundles. {destination} is the id, an ISO code or the English name (see Destination identifiers). It carries the same iso2, iso3 and slug as the list. Do not use the fields marked "Internal" in the API reference (tier, is_active, sort_order, click_count, created_at, updated_at, created_by, updated_by): they are not for Partner use.
GET /v1/destinations/{destination}/bundles
Only the bundles of one destination, same bundle shape and same ETag behavior as below. Use it for the screen a traveler opens. Like GET /v1/bundles, it answers only sellable bundles.
GET /v1/bundles
The full catalog in one call: every bundle of every destination, with both prices. It answers only sellable bundles: active, visible and with a retail price on your price list. When your account has a destination list, only the bundles of the destinations on it, the same set your bookings accept. A bundle that is not in the answer cannot be booked by bundle_id.
Use it for a daily sync, not per screen. The response carries a weak ETag. Keep it with your copy of the catalog, and send it back as If-None-Match on the next sync. When nothing changed, the answer is 304 Not Modified with no body: keep your copy. Otherwise the answer is 200 with the full catalog and a new ETag: replace your copy. Any change to the body, a price change included, changes the tag.
With the SDK, getBundles({ etag }) does this for you. Pass the etag of your last sync (null the first time). An unchanged catalog resolves to { notModified: true, etag }; a changed one resolves to the body plus its new etag.
The bundle
{
"id": "01J…",
"destination": { "id": "01J…", "name": "Spain", "type": "country", "iso2": "ES", "iso3": "ESP" },
"type": "unlimited",
"size_bytes": null,
"duration_seconds": 604800,
"duration_days": 7,
"fair_use_throttle": {
"throttle_after_bytes": 3221225472,
"throttled_kbps": 512,
"resets": "daily"
},
"pricing_info": {
"final_price": 19.00,
"original_price": 19.00,
"currency": "EUR",
"currency_symbol": "€",
"discount_applied": false,
"discount_percentage": 0
},
"prices": {
"retail": { "amount": 1900, "currency": "EUR" },
"net": { "amount": 1140, "currency": "EUR" }
},
"updated_at": "2026-10-01T09:12:00.000000Z"
}| Field | Meaning |
|---|---|
id | Stable. Send it as bundle_id to book exactly this bundle. |
destination | The destination the bundle is sold for: id, English name, type (country or region), iso2, iso3. A region has its code in iso2 or iso3 (see "Region codes" under GET /v1/destinations). The same object as on an eSIM package and a usage entry (section 6). |
type | unlimited, data_limited or starter. Filter on unlimited if that is all you sell. |
size_bytes | Data allowance, in binary units: 1GB is 1073741824. null on unlimited bundles. |
duration_seconds, duration_days | Validity, counted from first use. Unlimited bundles are listed once for every day count that has a price for you, each with its own id. |
fair_use_throttle | On unlimited bundles only: after throttle_after_bytes in one day the speed drops to throttled_kbps until the day resets. Show this to the traveler before purchase. null otherwise. |
pricing_info | The recommended retail price, EUR, major units. Kept for older integrations; prefer prices. |
prices | EUR cents. retail is the recommended retail price. net is your price under your agreement, the amount we invoice; null when your price list gives the bundle a retail price but no net price. A bundle with net: null is still listed and you can book it; your agreement with Hubby sets what you pay for it. |
updated_at | When the bundle itself last changed. A price change does not move it: compare prices, or rely on the catalog ETag. |
The answer also carries label, provider, package_template, partner, traffic_policy, is_active, is_visible, priority, deleted_at, created_by and updated_by. They are internal: do not build on them or show them. Build your own product name from type, size_bytes and duration_days.
Descriptions, benefits, coverage copy and the terms and conditions are the same for every unlimited bundle. They are delivered as a content pack with a terms URL, not through the API.
4. Bookings
POST /v1/bookings
{
"external_reference": "PARTNER-LOC-4471",
"depart_at": "2026-11-02T00:00:00Z",
"return_at": "2026-11-09T00:00:00Z",
"locale": "es",
"email": "booker@example.com",
"first_name": "Ana",
"last_name": "García",
"package_specifications": [
{
"bundle_id": "01J…",
"external_user_id": "partner-user-88213"
}
]
}| Field | Rules |
|---|---|
external_reference | Your locator. You can later fetch, update and cancel by it instead of our id. Unique per Partner and depart_at; see "The booking reference" below. |
depart_at, return_at | Optional. ISO 8601. Cancellation is refused after depart_at. Without depart_at, is_cancellable has no time limit, and the external_reference rule keys on the empty date (see "The booking reference"). |
locale | The booking's language as a BCP 47 tag, used for any Hubby-side text. es, es-ES and es_es are all accepted and stored as one normalised tag. Up to 10 characters: a longer value is refused with 422. A value Hubby cannot read as a language is stored as null with no error, and your default_locale (GET /v1/me) then fills it. |
email, first_name, last_name, phone, … | The booker's contact. Optional. |
communication_options | Optional. Hubby stores and returns it. It does not control Hubby's messages (see "Set up by Hubby for your account", section 2). |
package_specifications[] | One per eSIM to issue. Minimum one. |
package_specifications[].bundle_id | A bundle id from the catalog. Resolves to exactly that product. |
package_specifications[].destination, package_type, package_duration, size | The older way to specify: destination, type (data-limited or data_limited, starter, unlimited), days or size ("3GB"). Still accepted. destination is an ISO alpha-2 or alpha-3 code, a region's code, or the English name, in any letter case, and must match exactly: a translated name, a typo or any other value answers 422, as does a destination outside your destination list. destination and package_type are required unless bundle_id is sent. size is required for data_limited and starter; package_duration (whole days) is required for unlimited and starter. Both type spellings are accepted; responses always spell it data_limited. The item keeps the size and days you asked for; Hubby picks the closest bundle of that type at issue, so read the issued package for the bundle. A package_type the destination does not sell at all is refused at booking with 422. Use bundle_id to be exact. |
package_specifications[].external_user_id | Your stable id for the traveler. May also be given later, on the issue call. Required by the time you issue: your booking items carry no code, so nothing else can claim them, and the issue call answers 422 on external_user_id for an item that names no traveler. Send the same id again to add a package to the traveler's eSIM. |
Response 201. A retry with the same Idempotency-Key replays it, with Idempotent-Replayed: true.
{
"data": {
"id": "01J…",
"external_reference": "PARTNER-LOC-4471",
"depart_at": "2026-11-02T00:00:00.000000Z",
"return_at": "2026-11-09T00:00:00.000000Z",
"cancelled_at": null,
"is_cancellable": true,
"items": [
{
"id": "01J…",
"state": "queued",
"external_user_id": "partner-user-88213",
"destination": "ES",
"package_type": "unlimited",
"size_bytes": null,
"duration_seconds": 604800,
"bundle_id": "01J…",
"package": null
}
],
"created_at": "…",
"updated_at": "…"
}
}| Field | Meaning |
|---|---|
is_cancellable | true while DELETE would succeed. Read it before showing a cancel button. |
items[] | Always present on every booking read, one item per package specification. |
items[].state | The lifecycle state, section 8. unavailable means the item cannot be issued now; cancel the booking or book again. |
items[].status | Deprecated: an internal queue status. Read state. |
items[].size_bytes, items[].duration_seconds | What you asked for, not the bundle's own figures. The bundle is chosen at issue; read its bundle_id and size from the issued package (section 5). |
items[].package | null until issued, then the issued package (section 5). |
items[].code, deep_link_url, upgrade_url | Present for Partners whose travelers use the Hubby app. Absent for a Partner on the own-app channel, in the booking.created and booking.cancelled webhooks too. Ignore them. |
The booking reference
external_reference is unique per Partner and depart_at among your live bookings. A cancelled booking and a booking brought over from the previous backend do not count.
- The same
external_referenceanddepart_atagain returns the existing booking with201and writes nothing. A retry without anIdempotency-Keyis therefore also safe. The repeat answers201like the first call, with noIdempotent-Replayedheader. - After you cancel that booking, the same
external_referenceanddepart_atcreate a new booking. - Bookings are never merged. The same
external_referencewith anotherdepart_atcreates a second booking. - A read by reference (
GET /v1/bookings/{id}) answers the newest booking that carries it. To read all of them, filter the list:GET /v1/bookings?external_reference=….
Your Partner settings: GET /v1/me
GET /v1/me answers your Partner and its settings. Read it before you build the booking form. Two settings change what a package specification may carry:
package_strategy: when it is set, it fixespackage_type,sizeandpackage_durationon every specification. You may omit them.force_destinationless: when it istrue, your bookings are made without a destination, and the traveler picks one when they redeem in the Hubby app. It is nevertruefor a Native account (end_customer_channel: partner_app): a package issued over this API has no redeem step, so Hubby refuses the combination. Your bookings always carry thedestinationyou send. A specification with neitherdestinationnorbundle_idis refused at booking:POST /v1/bookingsanswers422validation_failedonpackage_specifications.N.destination, and nothing is booked. Only an account withforce_destinationless: truecan book a specification without a destination; issuing such an item answers422destination_required.
Under either setting, a specification that names a bundle_id is refused with 422. Specify by dimensions instead.
Booking for a partner below you
partner on POST /v1/bookings names one of your descendant partners by its slug, and the whole booking is then that partner's: its catalog, price list, visual identities and travelers. Only the named partner's own key can read, change, cancel or issue that booking and read its eSIMs: your key answers 404 on every one of those calls after the 201. Its webhooks go to the named partner's endpoint, not to yours. Key inheritance (scopes_descendants on your key) covers the analytics calls only.
Starter
A Starter (type: starter) is a Hubby-app entry product: small data and few days. It opens the Hubby-app top-up offer. Book it on Native only when your agreement includes it.
A traveler can have at most two Starter packages per destination from you. These count: starters not yet issued, starters issued (also when they were later cancelled, or upgraded to another package type), and starters earlier in the same request. A starter cancelled before it was issued does not count. A starter for another destination, or from another Partner, does not count.
A third starter for one destination is refused:
POST /v1/bookingsand the package-specification updates answer422validation_failedonpackage_type. An update that moves a starter to a destination that already holds two is refused in the same way.- The issue call answers
409starter_cap_exceeded, or422validation_failedonexternal_user_idwhen the item gets its traveler on that call (section 5).
Language
The install instructions (section 6) take their language in this order:
?locale=on the instructions read;- the
localeof your latest booking that issued a package on the eSIM, live or terminated; - your
default_localeonGET /v1/me; en-US.
The copy exists in English, German, Spanish and Swedish. A language with no translation falls back by prefix (es-ES, then es), then to English. The eSIM label does not depend on the language (section 6, esim_label).
The booking locale accepts short and underscore forms, up to 10 characters (see the table above). It can also name a language outside the list below, for example fi or ja; the instructions follow it when copy exists in that language. The ?locale= query is stricter: it takes only one of these 32 tags, the reference's SupportedLocale, and answers 422 for any other value: en-US, en-EU, en-GB, en-CA, nl-NL, de-DE, fr-FR, fr-CA, it-IT, es-ES, cs-CZ, pl-PL, pt-PT, fr-BE, nl-BE, de-AT, de-CH, fr-CH, it-CH, sv-SE, sk-SK, de-BE, en-AU, da-DK, ko-KR, hu-HU, no-NO, pt-BR, en-NZ, zh-CN, ru-RU, ro-RO.
GET /v1/bookings/{id} and GET /v1/bookings
{id} is our id or your external_reference. The list is cursor-paginated and filterable. With ?format=csv the answer is still JSON: every booking that departs from departure_from to departure_to (both then required), in departure order, up to 5000 bookings in one answer instead of one page. pagination.hasNextPage is then true when more than 5000 matched: send a shorter date range.
PATCH /v1/bookings/{id}
Reschedule: {"depart_at": "…"}. Also used to edit booker fields. If Hubby sends pre-departure emails for your account, they follow the new dates.
DELETE /v1/bookings/{id}
Cancels the whole booking, free of charge, and removes the traveler's personal data from it. 200 with {"message": "Booking cancelled successfully."}. A repeat DELETE of a cancelled booking answers 200 and changes nothing.
Refused with 403 and a code when:
code | Why |
|---|---|
booking_departed | depart_at is in the past. The moment is compared, not the date |
booking_has_redeemed_promo_codes | an item holds an issued package that is not terminated. "Promo code" is the previous backend's name for an item's code, kept so existing integrations still match. Terminate the package first (section 5); that is possible only before it activates. Once a package has activated, or when an item was brought over from the previous backend already redeemed, the booking can no longer be cancelled |
booking_has_paid_packages | a traveler paid for a package on this booking: a payment still pending and a paid upgrade of an item count, a top-up bought on the eSIM does not. Cannot occur on the own-app channel, where the traveler never pays Hubby. |
is_cancellable on every booking read tells you before you call. A cancelled booking answers false.
Cancellation is per booking, not per item. A group of four is one booking if nobody may drop out alone, or four bookings if they may.
POST /v1/redirect-tokens
A short-lived, single-use token that signs one of your travelers in to the Hubby web app, by your external_user_id; no booking is needed. This is the core counterpart of the legacy Hubby WebView flow. Body: {"external_user_id": "partner-user-88213"}. Answer: {"data": {"redirect_token": "…", "expires_in": 300}}. The token lives expires_in seconds and is consumed by the first sign-in; mint a new one each time the traveler opens the web app.
The traveler is found, imported or created on this call: a traveler you have not named before is created now. For a Partner that moved over from the previous platform, Hubby first asks that platform for the traveler and imports their eSIMs, packages and bookings when it holds them; only a traveler it does not hold is created fresh. While the previous platform cannot answer, the call answers 503 bridge_unavailable with Retry-After, and nothing is created. A record that cannot be imported answers 409 legacy_graph_not_importable: retrying gives the same answer; contact Hubby. A missing or malformed external_user_id is 422 validation_failed.
5. Issuing the eSIM
Issuing creates the package on the network and, for a traveler with no Hubby eSIM yet, assigns one. The response holds everything the traveler needs to install.
POST /v1/bookings/{booking}/items/{item}/package
Headers: Idempotency-Key required.
Body, optional unless the item has no traveler yet:
{ "external_user_id": "partner-user-88213" }Response 201:
{
"data": {
"id": "01J…",
"state": "redeemed",
"bundle_id": "01J…",
"issued_at": "2026-10-13T14:02:11.000000Z",
"installed_at": null,
"activated_at": null,
"expires_at": "2027-10-13T14:02:11.000000Z",
"valid_until": null,
"terminated_at": null,
"esim": {
"iccid": "8944…",
"installed_at": null,
"lpa": "LPA:1$rsp.example.net$ABCD-EFGH-IJKL",
"smdp_address": "rsp.example.net",
"matching_id": "ABCD-EFGH-IJKL",
"install_links": {
"ios": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.net%24ABCD-EFGH-IJKL",
"android": "https://esimsetup.android.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.net%24ABCD-EFGH-IJKL"
}
}
}
}| Field | Meaning |
|---|---|
esim.lpa | The activation string. Render it as a QR code for "install on another device". |
esim.smdp_address, esim.matching_id | The same string split for manual entry in phone settings. |
esim.install_links.ios | Open on the iPhone that will hold the eSIM. iOS opens its own installer. |
esim.install_links.android | Same for Android. |
esim.installed_at | null until the eSIM profile is on a phone. Set for a repeat traveler: the eSIM is already installed, so skip the install screen and show that the new package is ready. |
valid_until | When the package stops working: activation plus its validity. null until the package activates, because the clock starts at first use. Show "starts on first use" while it is null. |
expires_at | The activation deadline: the latest moment the package can still be activated, set by the network provider at issue. It is not when the package ends; a package with a few days of validity can typically still be activated for about a year. Use valid_until for the end. |
Rules:
- The item must name a traveler. Send
external_user_idhere if you did not at booking. An item with no traveler is refused with422validation_failed. - Retrying with the same
Idempotency-Keyreplays this201byte for byte, withIdempotent-Replayed: true. Retry blind after any timeout. - A different key on an item that already has a package is
409package_already_issuedwith aLocationheader pointing at the GET below. Nothing is issued twice. - A traveler who already has a Hubby eSIM gets the new package on the same eSIM: the ICCID repeats. Once that eSIM is on the phone, the answer is
installedandesim.installed_atis set; skip the install screen. - An issued eSIM may be installed for 365 days. The package activates when the provider activates it, and only then does its validity start.
- Issuing calls the network provider inline. The call has a rate limit per Partner (Rate limits).
- Only a
201is stored under the key, so a5xxanswer is safe to retry with the same key: nothing was issued and nothing is issued twice.
Refused with:
| Status | code | Why |
|---|---|---|
| 400 | idempotency_key_required | No Idempotency-Key header |
| 409 | package_already_issued | The item already has a package. Location points at it |
| 409 | booking_cancelled | The booking is cancelled |
| 409 | item_not_queued | The item is not queued. An unavailable item cannot be issued now |
| 409 | starter_cap_exceeded | The traveler already has two Starter packages for this destination from you, counting those since cancelled or upgraded (section 4, "Starter"). When the item gets its traveler on this call, the same cap answers 422 validation_failed on external_user_id instead |
| 409 | provisioning_target_changed | The item changed while the call ran, for example a cancel. Read the item and decide again |
| 409 | package_queue_not_redeemable | The item can no longer be issued. Read the booking |
| 409 | idempotency_key_in_use | The same key is still running. Retry after Retry-After |
| 409 | idempotency_key_spent | The key issued a package you have since terminated. Use a new key |
| 422 | validation_failed | No traveler on the item and no external_user_id sent, or a different one sent. Also the Starter limit, when the item gets its traveler on this call |
| 422 | destination_required | The item was booked without a destination, which only a traveler who redeems in the Hubby app can pick. Only an item booked while your account had force_destinationless: true, or booked for you by Hubby, has no destination: a specification you send with neither destination nor bundle_id is refused at booking with validation_failed. Cancel the booking and book again with a destination on each package specification |
| 422 | customer_is_classic_holder | The traveler holds a Classic eSIM, which cannot take a package issued here, and no other eSIM. This happens only for a traveler who moved over from the previous Hubby platform with a single-destination eSIM; a traveler you created here never holds one. Contact Hubby with the traveler's external_user_id. The item stays queued: issue it again, with a new key, once Hubby has corrected the eSIM |
| 422 | no_bundle_resolved | No bundle answers the item's specification any more. Cancel the booking or book again |
| 422 | no_free_esim_available | Hubby has no eSIM in stock for a new traveler right now. Retry later |
| 422 | idempotency_key_reused | The key was already used for a different request |
| 500 | — | The network provider failed to create the package, or another issue for the same traveler is still running. Nothing was issued and the key was not stored: retry with the same key |
| 503 | legacy_booking_unavailable | Retry later |
GET /v1/bookings/{booking}/items/{item}/package
Reads the issued package back, same body as above with the current state and timestamps. An item with no package, also right after a terminate, answers 404 item_has_no_package: read the item's state from the booking instead.
DELETE /v1/bookings/{booking}/items/{item}/package — terminate
Ends an issued package that the traveler has not yet used. Use it for a withdrawal, a changed plan, or "the traveler never received the QR code" before re-issuing.
204 No Contenton success. The package is ended on the network at once. The item returns toqueued, so you can issue again or cancel the booking. No charge is raised for a terminated package. The traveler keeps their Hubby eSIM; a new issue puts the new package on it. Apackage.terminatedwebhook follows.- The terminate spends the
Idempotency-Keythat issued the package, for the life of the item. Issue again with a new key; the old one answers409idempotency_key_spent, also long after the 24-hour replay window. - When Hubby or the network provider ends a package you issued before it activated, the item returns to
queuedin the same way and the issue key is spent in the same way. No webhook event tells you of this end. Read the item'sstatebefore you issue again. - Optional
?reason=fromchanged_plans,installation_failed,not_received,other. Any other value is422validation_failed. 409package_activatedonce the package has activated on the network. After that the package is consumed and cannot be ended or refunded.409package_not_issued_over_apifor a package a traveler redeemed in the Hubby app; it is theirs.409package_not_terminablewhen the package has already ended.404item_has_no_packagewhen the item has no package, for example on a repeat of a terminate that succeeded.502package_termination_refusedwhen the network provider refused. Nothing changed; retry later.
Rate limits
Four kinds of call have a limit per Partner. These are the default limits:
| Call | Default limit, per Partner |
|---|---|
POST /v1/bookings/{booking}/items/{item}/package (issue) | 30 per minute |
GET /v1/esims/{iccid}?refresh=true and GET /v1/esims/{iccid}/usage?refresh=true | 60 per minute, shared by the two calls |
POST /v1/bookings/imports/presign-upload | 30 per minute |
GET /v1/analytics/summary, /funnel, /timeseries and /breakdowns | 60 per minute, shared by the four calls |
Every other call, including GET /v1/esims/{iccid} and GET /v1/esims/{iccid}/usage without refresh | No limit |
The presign upload and the analytics calls also have a limit per IP address: 60 and 600 per minute.
A call over its limit answers 429 rate_limited. Wait the seconds in Retry-After, then send it again. A replay of an issue with the same Idempotency-Key does not count against the limit.
6. eSIM status, usage and installation instructions
GET /v1/esims/{iccid}
The traveler's eSIM with every package on it, oldest first, ended ones included. Only eSIMs of your own travelers resolve; any other ICCID is 404 not_found. For a Partner that moved over from the previous platform, the eSIM can also hold packages you did not issue, for example ones carried over from the traveler's previous eSIM; no webhook names them.
{
"data": {
"iccid": "8944…",
"external_user_id": "partner-user-88213",
"installed_at": "2026-10-20T08:11:00.000000Z",
"packages": [
{
"id": "01J…",
"booking_id": "01J…",
"state": "activated",
"bundle_id": "01J…",
"destination": { "id": "01J…", "name": "Spain", "type": "country", "iso2": "ES", "iso3": "ESP" },
"activated_at": "2026-11-02T13:40:00.000000Z",
"expires_at": "2027-10-13T14:02:11.000000Z",
"valid_until": "2026-11-09T13:40:00.000000Z",
"data_used_bytes": 1395864371,
"data_remaining_bytes": 1825361101
}
],
"refreshed_at": "2026-11-03T09:00:00.000000Z"
}
}destinationis the package's destination, the same object a bundle carries. Use it to tell which destinations the traveler holds.valid_untilis when the package stops working;nulluntil it activates.expires_atis the activation deadline, not the end (section 5).data_used_bytesis the total data used since activation, from Hubby's last readout of the network. It is not the use of the current day, and the API gives no daily figure: on an unlimited package, do not compare it withfair_use_throttle.throttle_after_bytes, which applies per day.data_remaining_bytesis0on a package that has ended (expiredorterminated), whatever was unused. It isnullon an unlimited package, or when the allowance is not known.booking_idis Hubby's bookingid, not yourexternal_reference; the webhooks send your reference under that name (section 7), so do not join the two on it. It isnullfor a package the traveler bought in the Hubby app, and for a package redeemed with a promo code that belongs to no booking.- The figures are as old as
refreshed_at. Hubby reads a package in the background only until its first use; after that only your?refresh=trueand a usage alert from the network (50, 80 and 100 percent) update the stored figures, so a stored figure can be days old.?refresh=truereads the network now and has a rate limit; a read without it has none, so poll it to watch for the install and the first use, and sendrefresh=truewhen the traveler opens a "my data" screen (Rate limits).
GET /v1/esims/{iccid}/usage
What the traveler can still use on the eSIM, one entry per destination, newest first. These are the figures the Hubby apps show for the same eSIM. Use this call for the "my data" screen; use GET /v1/esims/{iccid} for the state of each package.
{
"data": [
{
"destination": { "id": "01J…", "name": "Spain", "type": "country", "iso2": "ES", "iso3": "ESP" },
"is_unlimited": false,
"total_bytes": 6442450944,
"used_bytes": 1395864371,
"remaining_bytes": 5046586573,
"valid_until": "2026-11-09T13:40:00.000000Z",
"remaining_seconds": 518400,
"package_ids": ["01J…", "01J…"],
"synced_at": "2026-11-03T09:00:00.000000Z"
},
{
"destination": { "id": "01J…", "name": "Europe", "type": "region", "iso2": "EU", "iso3": null },
"is_unlimited": true,
"total_bytes": null,
"used_bytes": 0,
"remaining_bytes": null,
"valid_until": null,
"remaining_seconds": 604800,
"package_ids": ["01J…"],
"synced_at": "2026-11-03T09:00:00.000000Z"
}
]
}Show one card per entry:
| Show | From |
|---|---|
| Title | destination.name |
| "N days left" | remaining_seconds, rounded up to days. It is set even before the package starts: a package that has not been used yet counts its whole validity. |
until <date> | valid_until, only when it is set. null means that no package in the entry is running yet: every live package in it still waits to start. An entry with a running package always has a date. null does not mean nothing was used: the entry also counts its packages that have already ended, and a package of the other type for the same destination can be running while this entry waits for it. |
| "Used up" | remaining_bytes === 0, even when days are left. A top-up adds to it. |
| Data left | Otherwise remaining_bytes of total_bytes. Draw a gauge from the two. |
| "Unlimited" | is_unlimited: total_bytes and remaining_bytes are null, so show no totals. used_bytes is still set. |
const DAY = 86400;
const cards = usage.data.map((entry) => {
const daysLeft = Math.ceil(entry.remaining_seconds / DAY);
return {
title: entry.destination.name,
data: entry.is_unlimited ? 'Unlimited'
: entry.remaining_bytes === 0 ? 'Used up'
: `${formatBytes(entry.remaining_bytes)} of ${formatBytes(entry.total_bytes)} left`,
time: entry.valid_until === null
? `${daysLeft} days left`
: `${daysLeft} days left, until ${formatDate(entry.valid_until)}`,
};
});How entries are formed:
- Every package for the same destination adds up into one entry, so two Spain packages are one Spain card. Packages the traveler bought in the Hubby app count too.
- A destination can have two entries: one for its data packages and one for its unlimited packages. A country and a region that covers it are two entries.
total_bytescounts every package in the entry, ended ones of the same trip included, and is alwaysused_bytes + remaining_bytes. Data an ended package did not use counts as used.package_idsnames the packages the entry adds up: the sameids as inGET /v1/esims/{iccid}.- An empty
datameans nothing on the eSIM can still be used. Offer a new booking. synced_atis when the figures were last read from the network.?refresh=truereads them now. It refreshes the same readout asGET /v1/esims/{iccid}?refresh=true, so one refresh on either call updates both reads, and the two share one rate limit (Rate limits).- On an unlimited package, the
package.usage.*percentages count against the package's provisioned fair-use ceiling, not against the daily throttle infair_use_throttle. The ceiling is large and differs by package. A usage alert on an unlimited package is therefore not a top-up prompt.
To offer a top-up on a card, list the plans of destination.id with GET /v1/destinations/{destination}/bundles, book one for the same external_user_id by bundle_id, and issue it.
Why the top-up rule is in your app, not in the API:
- The API attaches any package to the traveler's current eSIM, for any destination. It does not refuse a package for a destination the traveler does not hold.
- "Top up only a destination the traveler holds" is a product rule of the Hubby apps: they offer a top-up only on a destination the traveler already holds. To give your travelers the same behavior, offer top-ups only on these cards.
- Packages for one destination run one after the other, whatever their type: a new package waits until the one before it is used up or ends. That is why an entry's
valid_untilis the end of the running package plus the whole validity of every package waiting after it, and why a data package and an unlimited package for the same destination are two entries that each count their own packages. Section 1 gives the order.
GET /v1/esims/{iccid}/instructions
Step-by-step installation instructions for this eSIM, per phone, in the traveler's language, with the eSIM's own activation material filled in. The copy is the guided setup of the Hubby web app, with its screen recordings and screenshots, so you can show instructions without writing or maintaining them yourself. One section is not from that setup: qr_code on every phone family. It is Hubby's own generic copy, in English in every language, with no clip and no screenshots.
It answers for every eSIM that GET /v1/esims/{iccid} answers for, also after you terminated its packages or cancelled their booking.
Query: locale (optional, one of the 32 supported tags such as de-DE; any other value is 422 validation_failed). The language order is in section 4, "Language". The copy exists in English, German, Spanish and Swedish. Copy in any other language is English, and locale still echoes the locale you asked for.
{
"data": {
"iccid": "8944…",
"locale": "de-DE",
"esim_label": "Partner eSIM",
"ios": [
{
"family": "iphone",
"one_tap": {
"title": "eSIM aktivieren",
"install_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.net%24ABCD-EFGH-IJKL",
"clip_url": "https://cdn.hubbyesim.com/native/v4/iphone/de/clips/auto_install.mp4",
"steps": [
{ "text": "Tippen Sie im Dialog auf **Aktivieren**", "screenshot_url": null },
{ "text": "Tippen Sie auf **Weiter**, sobald die eSIM bereit ist", "screenshot_url": null },
{ "text": "Wählen Sie **Im Ausland**", "screenshot_url": null },
{ "text": "Wählen Sie **Nur Daten**", "screenshot_url": null },
{ "text": "Wählen Sie **Reise-eSIM und aktuelle SIM**", "screenshot_url": null },
{ "text": "Überspringen Sie den **Datensparmodus**", "screenshot_url": null }
]
},
"qr_code": {
"title": "Install with a QR code",
"lpa": "LPA:1$rsp.example.net$ABCD-EFGH-IJKL",
"clip_url": null,
"steps": [
{ "text": "On the iPhone that will hold the eSIM, open **Settings**, then **Mobile Service**, then **Add eSIM**", "screenshot_url": null },
{ "text": "Tap **Use QR Code** and scan the code shown on another screen", "screenshot_url": null },
{ "text": "Tap **Continue** and follow the steps on screen", "screenshot_url": null }
]
},
"manual": {
"title": "eSIM manuell installieren",
"smdp_address": "rsp.example.net",
"matching_id": "ABCD-EFGH-IJKL",
"clip_url": "…",
"steps": [ "…" ]
},
"on_arrival": [
{
"id": "rename",
"title": "eSIM umbenennen",
"clip_url": "https://cdn.hubbyesim.com/native/v4/iphone/de/clips/rename.mp4",
"steps": [
{ "text": "Tippen Sie in den Einstellungen auf **Mobilfunk**", "screenshot_url": "…" },
{ "text": "Geben Sie „**Partner eSIM**“ ein", "screenshot_url": "…" }
]
},
{ "id": "turn_on", "title": "eSIM einschalten", "clip_url": "…/turnon.mp4", "steps": [ "…" ] },
{ "id": "roaming", "title": "Datenroaming aktivieren", "clip_url": "…/roaming.mp4", "steps": [ "…" ] },
{ "id": "mobile_data", "title": "eSIM für mobile Daten wählen", "clip_url": "…/connecting.mp4", "steps": [ "…" ] }
],
"apn": null
}
],
"android": [
{
"family": "pixel",
"one_tap": { "…": "…" },
"qr_code": { "…": "…" },
"manual": { "…": "…" },
"on_arrival": [ "…" ],
"apn": {
"apn": "globaldata",
"title": "APN einrichten",
"clip_url": "…",
"steps": [ "…" ]
}
},
{ "family": "samsung", "one_tap": { "…": "…" }, "qr_code": { "…": "…" }, "manual": { "…": "…" }, "on_arrival": [ "…" ], "apn": { "…": "…" } },
{ "family": "xiaomi", "one_tap": { "install_link": null, "…": "…" }, "qr_code": { "…": "…" }, "manual": { "…": "…" }, "on_arrival": [ "…" ], "apn": { "…": "…" } }
]
}
}| Field | Meaning |
|---|---|
locale | The language the instructions were asked in, by the order in section 4, "Language". A booking's locale is echoed as stored, so it can be a short tag such as es. The copy exists in English, German, Spanish and Swedish; in any other language it is English, and this field still echoes the locale asked for. |
esim_label | The name the instructions tell the traveler to give the line: your Partner name (name on GET /v1/me) followed by eSIM. The steps already use it wherever the traveler types or selects it; nothing sets it on the phone for them. A visual identity has no effect on it. |
ios[], android[] | One entry per phone family: iphone; pixel, samsung, xiaomi. Find a family by family, not by its position in the list. For an Android phone you cannot place, use the samsung entry. |
one_tap | The link to open on the device itself, and what the OS asks afterwards. On Xiaomi, install_link is null: that phone has no one-tap install, and the steps are the manual steps. Show the manual section there. |
qr_code | Steps for a second device, plus the LPA string to render as a QR code. English on every phone family, whatever the language; clip_url and every screenshot_url are null. |
manual | Steps for typing the SM-DP+ address and matching id by hand. Localized on every phone family, like the other sections. |
on_arrival | The sections for the destination, each id once: rename the line, turn_on the line, enable roaming, pick the line for mobile_data. Show them in the order of the list. The order can be different for each family (on Samsung and Xiaomi, mobile_data comes before roaming), so find a section by its id, not by its position. |
apn | Optional help for an eSIM that has signal but no internet: add the access point name by hand. apn is the value to type (the steps already name it; you can also show it with a copy button), with title, clip_url and steps like a section. Show it only in that case, for example on a "No internet?" screen, not in the main install flow: many eSIMs set the APN automatically. null on the iPhone. |
title, steps[].text | Localized, except the English qr_code section above. Tap targets are marked with markdown bold, **Activate**, and nothing else; render or strip it. |
clip_url | A short screen recording of the whole section on that phone family, in that language. null when none exists. |
steps[].screenshot_url | An SVG of the screen for that step, in that language. null when none exists. |
Media is served from a public CDN and may be hot-linked or copied. Copy and media are maintained by Hubby and may change between calls without a version bump; do not cache them longer than a week. 503 install_instructions_unavailable means the instructions cannot be read right now; retry later.
Device compatibility
Hubby keeps its own Device compatibility list of phone models. Use it to tell the traveler before they buy whether their phone model supports eSIM.
GET /v1/device-compatibility lists the Supported devices on that list, ordered by brand, then name. The list is not paginated. Optional query ?brand=Samsung answers one brand only; case, spaces, punctuation and accents do not matter. A brand that is not on the list answers an empty devices list.
{
"data": {
"devices": [
{ "brand": "Samsung", "name": "Galaxy S24+", "model": "SM-S926B", "year": 2024, "note": null }
]
}
}POST /v1/device-compatibility checks one device. Body: {"phone_brand": "Samsung", "phone_model": "Galaxy S24+"}, both required. Send the brand and the name the traveler picked from the list.
{
"data": {
"phone_brand": "Samsung",
"phone_model": "Galaxy S24+",
"is_compatible": true,
"status": "supported",
"note": null,
"device": { "brand": "Samsung", "name": "Galaxy S24+", "model": "SM-S926B", "year": 2024 }
}
}| Field | Meaning |
|---|---|
phone_brand, phone_model | The values you sent, as you sent them. |
status | supported: the model is on the list as eSIM-capable. unsupported: the model is on the list as not eSIM-capable. unknown: the model is not on the list, or Hubby has no answer for it. Like every response enum, the list can grow. |
is_compatible | true only when status is supported. false for unsupported and for unknown. |
note | A free-text caveat for the model, for example "US: eSIM only". Regional limits are here. Show it to the traveler as it is. null when the list has no note, and always null for unknown. |
device | The device on the list that matched: brand and name as Hubby spells them, model and year (both nullable). null for unknown. |
Rules:
- The match is on brand + device name only. Case, spaces and punctuation do not matter, but a variant such as
5Gis a different device. A model code (for exampleSM-S926B) does not match and answersunknown.modelis for display only: one code can name different phones. - A value that is not on the list is not refused: it answers
unknown. - The answer is about the phone model, not about the traveler's phone. A supported model can still be carrier-locked, or a regional variant without eSIM. Before checkout, ask the traveler to dial
*#06#: a phone that shows an EID has eSIM. In an Android app, preferEuiccManager.isEnabled()on the device itself.
7. Webhooks
Hubby creates your endpoint: send your Hubby contact the URL and the events you want. Hubby generates a signing secret for the endpoint and gives it to you once; it cannot be read back. Every endpoint Hubby creates is signed. A Native account's endpoint always receives the default envelope below: Hubby sets no flat body and no payload template for it, and does not create it without a secret. Each delivery is a POST with this envelope:
{
"event": "package.activated",
"timestamp": "2026-11-02T13:40:00.000Z",
"data": { "…": "event body" },
"event_id": "package.activated:01J…",
"delivery_id": "01J…"
}Headers: x-hubby-timestamp (unix seconds), x-hubby-signature (sha256= + hex HMAC-SHA256 of {x-hubby-timestamp}.{raw body} with your secret), x-hubby-event-id, x-hubby-delivery-id. Hubby sends one signature per delivery; a verifier that splits the header on , and accepts any matching entry is also correct.
- Verify the signature, on the raw body, with the SDK's
verifyWebhookor the rule above. That is the only check. A static header such asx-api-keyis optional, set by Hubby only when you ask for one, and is not a verification mechanism. - A secret change is a hard cutover. From the moment Hubby stores the new secret, every delivery, retry and replay is signed with the new secret alone, and a delivery your verifier refuses with a
4xxis final. Switch your verifier the moment you receive the new secret; if you need time to switch, answer5xxwhile you do, so that the delivery is retried. - Deduplicate on
event_id; retries repeat it. - Answer
2xxwithin 10 seconds. A4xxother than429is final and is not retried. A5xx, a429, a timeout or a transport error is retried: at most 12 attempts, the wait starting at 10 seconds and doubling up to 1 hour (about 3.4 hours in total). Do not answer3xx: a redirect is followed, and a301,302or303becomes aGETwithout the body. Each attempt is signed again with the time it is sent, so a 300-second timestamp tolerance holds for retries. - Test events. Ask your Hubby contact to send a Test event to your endpoint. It is a real, signed delivery with two markers: the header
x-hubby-synthetic: trueanddata.test: truein the body. Real traffic has neither. A Test event also has fakeTEST-…ids, anevent_idof{event}:test-…and real milliseconds in itstimestamp. - Staging has no simulator. Staging issues real eSIMs from a test inventory, and the events come from the real provider. To see
esim.installed,package.activatedor a usage event, install the eSIM on a phone and use it.
Events a Native integration can receive (the channel column of the catalogue says "All" or "Native"):
| Event | When | Use it to |
|---|---|---|
booking.created | After POST /v1/bookings | Confirm the reservation |
booking.cancelled | After DELETE /v1/bookings/{id} | Close the order |
package_queue.claimed | After the issue call provisioned the package. Once per item: a re-issue after a terminate sends no new one | Mark "eSIM issued" on the first issue; after a re-issue, use the issue answer |
esim.installed | The profile landed on a phone | Mark "installed", stop reminding |
package.activated | The provider activated the package and reported it to Hubby. Hubby does not observe the network attach itself | Mark "active", disable withdrawal |
package.first_usage | The package's used data reached 10 MB. Hubby reads usage every 15 minutes, so it can arrive up to about 15 minutes late | — |
package.usage.50_percent, …80_percent, …100_percent | The provider's data alert reports a value in a window: above 47 and below 55 percent (50_percent), above 75 and below 85 (80_percent), above 90 (100_percent). An alert outside every window sends nothing, so an earlier event can be missing. The byte counts come from that alert, or from the last stored reading when the alert carries none. On an unlimited package the percentage is of the fair-use ceiling (section 6, GET /v1/esims/{iccid}/usage). Nothing fires on elapsed time. | Offer a top-up on a data package |
package.terminated | After DELETE …/package | Mark "terminated" |
esim.removed | The profile was deleted from the phone. Once per eSIM, like esim.installed | Support |
esim.location_change | The eSIM attached in another country. Hubby reads the provider's hourly location feed every 15 minutes, so expect 15 to 75 minutes of latency, more when a delivery is retried | — |
promo_code.redeemed, package.purchased, topup.completed, upgrade_offer.purchased and payment.refunded come only from the Hubby app and web app, so a Native integration never receives them. booking.rescheduled is not sent as a webhook. A package that Hubby's support gives your traveler (an Operator grant) or ends for them (an Operator terminate) sends no event at all.
Field meanings to know:
booking.createdandbooking.cancelledsend you each item as{id}alone: thecodeanduuidkeys other channels receive are absent on a Native account.destinationon the package events, anddestinations[]andpackages[].destinationonbooking.created, carry the country's ISO 3166-1 alpha-3 code (NLD) or the region's code (EU,XCA). A region line is listed inbooking.createdlike a country line.destinationsis an array of these strings;destination_detailscarries the same destinations as objects, with the code iniso3.expires_atis the provider's activation deadline, the same value/v1returns: the last moment the package can start. It is not the end of the package's validity.remaining_days(usage events) is the days of validity left after activation: from today to activation plus the package's duration, never below 0. It isnullbefore activation.booking_idis yourexternal_referenceon every event, ornullwhen the booking has none. Onpackage_queue.claimed,booking_referenceis the reference of the booking the eSIM was claimed with.package_queue_uuid(andqueue_id,item_id) is the booking item'sitems[].id.package_idis an opaque id: store it, do not parse it.- An id in a webhook is the same string, in the same letter case, as on the API. Most ids are lower case, but some are upper case (for example the id of an eSIM from Hubby's stock, or of a booking item that Hubby imported). Compare and look up ids exactly as you received them; do not change their case.
- On
esim.installedandesim.removed,booking_idis the reference of the booking the eSIM was first claimed with, not of a later package on it. Correlate these events to a booking item byqueue_id(the item'sid) andpackage_id. - Package events name their own booking: on
package.activated,package.first_usage,package.usage.*andpackage.terminated,booking_idis the reference of the booking that bought that package, so a top-up's events carry the top-up's booking, not the first one. package_typeis spelleddata-limitedon the package events (package.activated,package.usage.*,package.first_usage) anddata_limitedonpackage_queue.claimed.time-limitedis never sent.
The body of each event is on the Webhooks page.
8. Lifecycle
state on a booking item, and on the package inside it. An item answers its package's state once it has one; before that it is queued, unavailable, redeemed or cancelled. A package is redeemed, installed, activated, expired or terminated. An item's status is deprecated; read state.
The item reads are the booking calls (POST, GET and PATCH on /v1/bookings…). The package reads are POST and GET …/items/{item}/package and packages in GET /v1/esims/{iccid}.
state | Read on | Meaning | You can |
|---|---|---|---|
queued | item | Reserved, not issued | cancel the booking, issue |
unavailable | item | Cannot be issued now: no bundle answers the specification, or a payment is pending | cancel the booking, or book again |
redeemed | item, package | Issued, install material available. On an item with package: null: already spent, or brought over spent from the previous backend; there is nothing to install and nothing to terminate | terminate, show install screen |
installed | item, package | The eSIM is on a phone (esim.installed) | terminate |
activated | item, package | Live on the network, validity running. A package that used up its data stays activated until the provider ends it: read data_remaining_bytes for that | nothing to undo |
expired | item, package | The validity elapsed, or the provider ended the package after it activated | book again (top-up) |
terminated | package, rarely item | Ended before activation: by your terminate, by Hubby, or by the provider. A package that never activated keeps redeemed or installed past its expires_at; it does not move to expired. An item reads terminated only when the package showed data use before Hubby recorded its activation, or when the traveler redeemed the item in the Hubby app | the item answers queued at once, with package: null, and the issue key is spent; issue again with a new key. An item that reads terminated stays issued: show it like expired |
cancelled | item | The booking was cancelled, or its payment was rolled back | — |
Transitions are driven by your calls, by Hubby, by provider events and by an activated package's validity elapsing; nothing else moves on a timer.
This list can grow. Show a neutral status for a state you do not know, and do not fail (section 10).
9. Frequently asked questions
| Question | Answer |
|---|---|
| Where do we get the static information: countries, names, terms, benefits, coverage? | Countries and regions with names in every language: GET /v1/destinations. Products: GET /v1/bundles. Coverage of a region: its iso3s. Terms, benefits and descriptions: the content pack, one set for all unlimited products, with a terms URL. |
| How do we get the price? | Every bundle carries prices.retail and prices.net. Net is what we invoice you. |
| Can we book, activate and cancel via API, with no app? | Yes. Book (section 4), issue (section 5), cancel (section 4). The traveler installs from your screen with the one-tap link or a QR you render. |
| Can we get the cancellation terms via API? | is_cancellable on every booking read says whether a cancel will succeed now. The rules behind it are in section 4. |
| Unlimited by number of days? | Yes. Unlimited bundles exist for every day count that has a price for you. Pick the bundle whose duration_seconds matches the trip and send its bundle_id. |
| When should we issue? | At sale or closer to departure; validity starts at first use either way. Issue before the traveler leaves, so they install on Wi-Fi at home. |
| Can a returning customer reuse the eSIM? | Yes, by sending the same external_user_id. |
| How does a top-up work? | Book again for the same external_user_id, then issue. The package lands on the eSIM already on the phone. Offer it only for a destination the traveler already holds, from a usage entry (section 6); the API does not enforce this. |
| Can we check whether the traveler's phone supports eSIM? | Partly. GET /v1/device-compatibility lists the phone models on Hubby's list, and POST /v1/device-compatibility answers for one model (section 6, "Device compatibility"). The answer is about the model, not the traveler's phone: also ask the traveler to dial *#06# and look for an EID. |
| How do we show data and days left? | GET /v1/esims/{iccid}/usage, one card per entry (section 6). |
| Do API keys expire? | Keys for this API (core, /v1): no, unless Hubby sets an expiry date on the key. A key without an expiry works until Hubby revokes it. Keys for the legacy API on api.hubbyesim.com follow the Authentication guide. |
10. Versioning
This guide describes Partner API 5.20.0. A new minor is additive, except for the changes its entry in the changelog names: read that entry before you upgrade. The OpenAPI document (the Partner API 5.x (core) reference) is published with each release, and @hubbyesim/sdk on npm is built from the same document; SDK 2.7.0 speaks Partner API 5.20.0.
- Response enums are open. A minor release may add a value to any enum in a response:
state,status,source,package_type,type, a refusalcodeand the others. Handle a value you do not know, for example with adefaultbranch, and never fail on it. Request enums stay closed: the server rejects a value it does not know. - A minor can change what a
/v1call answers, but only as its changelog entry says. Each minor's entry in the changelog lists its non-additive changes under "Not additive": after each one, a request that succeeded before can be refused, or a value you already read can change. A change that is not on that list does not break a/v1call that works today. - Every other breaking change ships as a new URL major.
/v2sits beside/v1, announced in advance./v1is then kept indefinitely, frozen: bug and security fixes only, with new features on/v2.