Last updated

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 is POST /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 events

Key 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_until says 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_id per 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

EnvironmentBase URL
Productionhttps://core.hubbyesim.com/api
Staginghttps://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 on GET /v1/me when your service starts. null means it is not set; booking items then still carry code and the Hubby-app links. Ask Hubby to set it.
  • package_strategy and force_destinationless: they change what a package specification may carry. Read them on GET /v1/me (section 4). Hubby never sets force_destinationless on 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 the destination you send.
  • Partner name: name on GET /v1/me. Your install instructions tell the traveler to call the line by this name followed by eSIM (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_options on 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 answers 401 on core.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.

HeaderValue
X-Api-KeyYour public key id
X-TimestampUnix time in seconds when you built the request
X-SignatureLowercase 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 string

Rules 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 401 and code: timestamp_out_of_window: check your clock.
  • A rejected key or signature, or a missing signing header, is 401 with {"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: 409 idempotency_key_in_use, with Retry-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: 422 idempotency_key_reused. Use a new key per request.
  • Only a success is stored. A 4xx or a 5xx releases 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 409 idempotency_key_spent and 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:

StatuscodeMeaning
400malformed_query_stringThe query string cannot be parsed
401unauthenticatedUnknown key, wrong signature, missing signing header, or inactive account
401timestamp_out_of_windowX-Timestamp is outside the window. Check your clock
404not_foundWrong 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.
405method_not_allowedThe path exists, the method does not
422validation_failedA field failed validation; see errors
429rate_limitedToo many requests. Wait the seconds in Retry-After

Codes of one operation, listed again with it in sections 4 to 6:

StatuscodeWhen
400idempotency_key_requiredIssue call without an Idempotency-Key
403booking_departed, booking_has_redeemed_promo_codes, booking_has_paid_packagesCancel refused by a business rule
404item_has_no_packageRead or terminate on an item with no package, also right after a terminate
409package_already_issued, booking_cancelled, item_not_queued, starter_cap_exceeded, provisioning_target_changed, package_queue_not_redeemableIssue refused
409idempotency_key_in_use, idempotency_key_spentIdempotency (above)
409package_activated, package_not_issued_over_api, package_not_terminableTerminate refused
409legacy_graph_not_importablePOST /v1/redirect-tokens: the traveler's record on Hubby's previous platform cannot be imported. Contact Hubby
422destination_required, customer_is_classic_holder, no_bundle_resolved, no_free_esim_available, idempotency_key_reusedIssue 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
502package_termination_refusedThe network provider refused the terminate. Nothing changed; retry later
503legacy_booking_unavailableA booking still on Hubby's previous platform could not be fetched. Retry later
503bridge_unavailablePOST /v1/redirect-tokens: Hubby's previous platform could not be reached to import the traveler. Nothing was created; retry after Retry-After
503install_instructions_unavailableThe 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"
}
FieldMeaning
idStable. Send it as bundle_id to book exactly this bundle.
destinationThe 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).
typeunlimited, data_limited or starter. Filter on unlimited if that is all you sell.
size_bytesData allowance, in binary units: 1GB is 1073741824. null on unlimited bundles.
duration_seconds, duration_daysValidity, 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_throttleOn 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_infoThe recommended retail price, EUR, major units. Kept for older integrations; prefer prices.
pricesEUR 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_atWhen 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"
    }
  ]
}
FieldRules
external_referenceYour 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_atOptional. 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").
localeThe 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_optionsOptional. 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_idA bundle id from the catalog. Resolves to exactly that product.
package_specifications[].destination, package_type, package_duration, sizeThe 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_idYour 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": "…"
  }
}
FieldMeaning
is_cancellabletrue while DELETE would succeed. Read it before showing a cancel button.
items[]Always present on every booking read, one item per package specification.
items[].stateThe lifecycle state, section 8. unavailable means the item cannot be issued now; cancel the booking or book again.
items[].statusDeprecated: an internal queue status. Read state.
items[].size_bytes, items[].duration_secondsWhat 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[].packagenull until issued, then the issued package (section 5).
items[].code, deep_link_url, upgrade_urlPresent 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_reference and depart_at again returns the existing booking with 201 and writes nothing. A retry without an Idempotency-Key is therefore also safe. The repeat answers 201 like the first call, with no Idempotent-Replayed header.
  • After you cancel that booking, the same external_reference and depart_at create a new booking.
  • Bookings are never merged. The same external_reference with another depart_at creates 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 fixes package_type, size and package_duration on every specification. You may omit them.
  • force_destinationless: when it is true, your bookings are made without a destination, and the traveler picks one when they redeem in the Hubby app. It is never true for 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 the destination you send. A specification with neither destination nor bundle_id is refused at booking: POST /v1/bookings answers 422 validation_failed on package_specifications.N.destination, and nothing is booked. Only an account with force_destinationless: true can book a specification without a destination; issuing such an item answers 422 destination_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/bookings and the package-specification updates answer 422 validation_failed on package_type. An update that moves a starter to a destination that already holds two is refused in the same way.
  • The issue call answers 409 starter_cap_exceeded, or 422 validation_failed on external_user_id when the item gets its traveler on that call (section 5).

Language

The install instructions (section 6) take their language in this order:

  1. ?locale= on the instructions read;
  2. the locale of your latest booking that issued a package on the eSIM, live or terminated;
  3. your default_locale on GET /v1/me;
  4. 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:

codeWhy
booking_departeddepart_at is in the past. The moment is compared, not the date
booking_has_redeemed_promo_codesan 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_packagesa 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"
      }
    }
  }
}
FieldMeaning
esim.lpaThe activation string. Render it as a QR code for "install on another device".
esim.smdp_address, esim.matching_idThe same string split for manual entry in phone settings.
esim.install_links.iosOpen on the iPhone that will hold the eSIM. iOS opens its own installer.
esim.install_links.androidSame for Android.
esim.installed_atnull 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_untilWhen 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_atThe 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_id here if you did not at booking. An item with no traveler is refused with 422 validation_failed.
  • Retrying with the same Idempotency-Key replays this 201 byte for byte, with Idempotent-Replayed: true. Retry blind after any timeout.
  • A different key on an item that already has a package is 409 package_already_issued with a Location header 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 installed and esim.installed_at is 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 201 is stored under the key, so a 5xx answer is safe to retry with the same key: nothing was issued and nothing is issued twice.

Refused with:

StatuscodeWhy
400idempotency_key_requiredNo Idempotency-Key header
409package_already_issuedThe item already has a package. Location points at it
409booking_cancelledThe booking is cancelled
409item_not_queuedThe item is not queued. An unavailable item cannot be issued now
409starter_cap_exceededThe 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
409provisioning_target_changedThe item changed while the call ran, for example a cancel. Read the item and decide again
409package_queue_not_redeemableThe item can no longer be issued. Read the booking
409idempotency_key_in_useThe same key is still running. Retry after Retry-After
409idempotency_key_spentThe key issued a package you have since terminated. Use a new key
422validation_failedNo 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
422destination_requiredThe 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
422customer_is_classic_holderThe 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
422no_bundle_resolvedNo bundle answers the item's specification any more. Cancel the booking or book again
422no_free_esim_availableHubby has no eSIM in stock for a new traveler right now. Retry later
422idempotency_key_reusedThe 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
503legacy_booking_unavailableRetry 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 Content on success. The package is ended on the network at once. The item returns to queued, 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. A package.terminated webhook follows.
  • The terminate spends the Idempotency-Key that issued the package, for the life of the item. Issue again with a new key; the old one answers 409 idempotency_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 queued in the same way and the issue key is spent in the same way. No webhook event tells you of this end. Read the item's state before you issue again.
  • Optional ?reason= from changed_plans, installation_failed, not_received, other. Any other value is 422 validation_failed.
  • 409 package_activated once the package has activated on the network. After that the package is consumed and cannot be ended or refunded.
  • 409 package_not_issued_over_api for a package a traveler redeemed in the Hubby app; it is theirs. 409 package_not_terminable when the package has already ended.
  • 404 item_has_no_package when the item has no package, for example on a repeat of a terminate that succeeded.
  • 502 package_termination_refused when the network provider refused. Nothing changed; retry later.

Rate limits

Four kinds of call have a limit per Partner. These are the default limits:

CallDefault 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=true60 per minute, shared by the two calls
POST /v1/bookings/imports/presign-upload30 per minute
GET /v1/analytics/summary, /funnel, /timeseries and /breakdowns60 per minute, shared by the four calls
Every other call, including GET /v1/esims/{iccid} and GET /v1/esims/{iccid}/usage without refreshNo 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"
  }
}
  • destination is the package's destination, the same object a bundle carries. Use it to tell which destinations the traveler holds.
  • valid_until is when the package stops working; null until it activates. expires_at is the activation deadline, not the end (section 5).
  • data_used_bytes is 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 with fair_use_throttle.throttle_after_bytes, which applies per day.
  • data_remaining_bytes is 0 on a package that has ended (expired or terminated), whatever was unused. It is null on an unlimited package, or when the allowance is not known.
  • booking_id is Hubby's booking id, not your external_reference; the webhooks send your reference under that name (section 7), so do not join the two on it. It is null for 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=true and a usage alert from the network (50, 80 and 100 percent) update the stored figures, so a stored figure can be days old. ?refresh=true reads 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 send refresh=true when 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:

ShowFrom
Titledestination.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 leftOtherwise 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_bytes counts every package in the entry, ended ones of the same trip included, and is always used_bytes + remaining_bytes. Data an ended package did not use counts as used.
  • package_ids names the packages the entry adds up: the same ids as in GET /v1/esims/{iccid}.
  • An empty data means nothing on the eSIM can still be used. Offer a new booking.
  • synced_at is when the figures were last read from the network. ?refresh=true reads them now. It refreshes the same readout as GET /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 in fair_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_until is 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": { "…": "…" } }
    ]
  }
}
FieldMeaning
localeThe 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_labelThe 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_tapThe 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_codeSteps 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.
manualSteps for typing the SM-DP+ address and matching id by hand. Localized on every phone family, like the other sections.
on_arrivalThe 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.
apnOptional 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[].textLocalized, except the English qr_code section above. Tap targets are marked with markdown bold, **Activate**, and nothing else; render or strip it.
clip_urlA short screen recording of the whole section on that phone family, in that language. null when none exists.
steps[].screenshot_urlAn 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 }
  }
}
FieldMeaning
phone_brand, phone_modelThe values you sent, as you sent them.
statussupported: 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_compatibletrue only when status is supported. false for unsupported and for unknown.
noteA 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.
deviceThe 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 5G is a different device. A model code (for example SM-S926B) does not match and answers unknown. model is 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, prefer EuiccManager.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 verifyWebhook or the rule above. That is the only check. A static header such as x-api-key is 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 4xx is final. Switch your verifier the moment you receive the new secret; if you need time to switch, answer 5xx while you do, so that the delivery is retried.
  • Deduplicate on event_id; retries repeat it.
  • Answer 2xx within 10 seconds. A 4xx other than 429 is final and is not retried. A 5xx, a 429, 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 answer 3xx: a redirect is followed, and a 301, 302 or 303 becomes a GET without 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: true and data.test: true in the body. Real traffic has neither. A Test event also has fake TEST-… ids, an event_id of {event}:test-… and real milliseconds in its timestamp.
  • 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.activated or 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"):

EventWhenUse it to
booking.createdAfter POST /v1/bookingsConfirm the reservation
booking.cancelledAfter DELETE /v1/bookings/{id}Close the order
package_queue.claimedAfter the issue call provisioned the package. Once per item: a re-issue after a terminate sends no new oneMark "eSIM issued" on the first issue; after a re-issue, use the issue answer
esim.installedThe profile landed on a phoneMark "installed", stop reminding
package.activatedThe provider activated the package and reported it to Hubby. Hubby does not observe the network attach itselfMark "active", disable withdrawal
package.first_usageThe 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_percentThe 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.terminatedAfter DELETE …/packageMark "terminated"
esim.removedThe profile was deleted from the phone. Once per eSIM, like esim.installedSupport
esim.location_changeThe 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.created and booking.cancelled send you each item as {id} alone: the code and uuid keys other channels receive are absent on a Native account.
  • destination on the package events, and destinations[] and packages[].destination on booking.created, carry the country's ISO 3166-1 alpha-3 code (NLD) or the region's code (EU, XCA). A region line is listed in booking.created like a country line. destinations is an array of these strings; destination_details carries the same destinations as objects, with the code in iso3.
  • expires_at is the provider's activation deadline, the same value /v1 returns: 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 is null before activation.
  • booking_id is your external_reference on every event, or null when the booking has none. On package_queue.claimed, booking_reference is the reference of the booking the eSIM was claimed with.
  • package_queue_uuid (and queue_id, item_id) is the booking item's items[].id. package_id is 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.installed and esim.removed, booking_id is 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 by queue_id (the item's id) and package_id.
  • Package events name their own booking: on package.activated, package.first_usage, package.usage.* and package.terminated, booking_id is 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_type is spelled data-limited on the package events (package.activated, package.usage.*, package.first_usage) and data_limited on package_queue.claimed. time-limited is 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}.

stateRead onMeaningYou can
queueditemReserved, not issuedcancel the booking, issue
unavailableitemCannot be issued now: no bundle answers the specification, or a payment is pendingcancel the booking, or book again
redeemeditem, packageIssued, 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 terminateterminate, show install screen
installeditem, packageThe eSIM is on a phone (esim.installed)terminate
activateditem, packageLive on the network, validity running. A package that used up its data stays activated until the provider ends it: read data_remaining_bytes for thatnothing to undo
expireditem, packageThe validity elapsed, or the provider ended the package after it activatedbook again (top-up)
terminatedpackage, rarely itemEnded 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 appthe 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
cancelleditemThe 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

QuestionAnswer
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 refusal code and the others. Handle a value you do not know, for example with a default branch, and never fail on it. Request enums stay closed: the server rejects a value it does not know.
  • A minor can change what a /v1 call 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 /v1 call that works today.
  • Every other breaking change ships as a new URL major. /v2 sits beside /v1, announced in advance. /v1 is then kept indefinitely, frozen: bug and security fixes only, with new features on /v2.