Hubby API (2.1.0)

Legacy API (api.hubbyesim.com). This is the legacy API reference. For the core Partner API, read the Partner API 5.x reference and the Native eSIM Integration guide. The webhook details below apply to the legacy API only; core webhooks follow the webhooks page.

Welcome to the Hubby API documentation.| Welcome to the Hubby API documentation. This API enables partners to seamlessly integrate eSIM booking and management capabilities into their applications.

Key Features:

  • Create and manage eSIM bookings for your customers
  • Access our global package catalog with country-specific offerings
  • Track booking statuses and package activations
  • Support for multiple package types (starter, data-limited, unlimited)

Package Types

The API supports four types of packages:

  • Starter packages: Hybrid packages that are both data and time limited. They provide a small data allowance within a short time period (default: 2 days). Perfect for first-time users and trials.

  • Data-limited packages: Traditional packages with a specific data allowance that expires after a certain period (default: 365 days). This is the primary package type and the default for most use cases.

  • Unlimited packages: Packages that provide unrestricted data usage for a specified duration. Subject to fair use policy. Ideal for heavy data users and digital nomads.

  • Time-limited packages (deprecated): Packages that provide a fixed data allowance for a specific duration with full-speed access. They expire when either the data limit or time limit is reached. This package type is deprecated and should not be used for new integrations — use a data-limited package with a short package_duration instead.

Note: Top-ups are always data-limited packages, regardless of the original package type.

Authentication: All API requests must include the following headers:

  • x-api-key: Your public API key
  • x-timestamp: Current Unix timestamp in milliseconds
  • x-signature: HMAC-SHA256 signature

The HMAC signature must be generated for each request using:

  1. Concatenate: timestamp + HTTP method + request path + query string (if any) The request path is the full path after the domain and must include the /api prefix. Example: "1678901234GET/api/bookings"
  2. Generate HMAC-SHA256 using your secret key
  3. Convert to hex string

Note: Swagger UI cannot be used to test the API directly as each request requires a unique HMAC signature. Please implement the authentication in your client application.

Example Node.js Implementation:

const cryptoJs = require('crypto-js');

// Configuration values that would normally come from environment
const secretKey = "YOUR_API_SECRET";
const publicKey = "YOUR_API_KEY";

// Generate headers for an API request.
// `url` is the full request URL, e.g. "https://api.hubbyesim.com/api/bookings?perPage=10"
function generateApiHeaders(method, url) {
    // Timestamp is in milliseconds e.g. 1715558400000
    const timestamp = Math.floor(Date.now()).toString();

    const parsed = new URL(url);

    // The signed path is everything after the domain, INCLUDING the /api
    // prefix, plus the query string if present.
    const pathWithQuery = parsed.pathname + parsed.search;

    // Sample payload: 1715558400000GET/api/bookings?perPage=10
    const payload = timestamp + method + pathWithQuery;

    // Generate the HMAC signature
    const signature = cryptoJs.HmacSHA256(payload, secretKey).toString(cryptoJs.enc.Hex);

    return {
        'x-timestamp': timestamp,
        'x-signature': signature,
        'x-api-key': publicKey,
        'Accept': 'application/json'
    };
}

Webhook Delivery Authentication

When Hubby delivers a webhook to your endpoint, the request carries your API key in a header. By default this is x-api-key: <your-key>. If your endpoint expects the key under a different header, the header name is configurable per partner (for example Authorization) with an optional value prefix (for example Bearer , producing Authorization: Bearer <your-key>). The default remains x-api-key with no prefix — contact support@hubbyesim.com to change it.

Signature verification

When a signing secret is configured, every webhook is signed so you can verify it originated from Hubby and was not altered in transit. Each delivery carries these headers:

  • x-hubby-signature — one or more comma-separated sha256=<hex> values. Verify by recomputing HMAC-SHA256(secret, "{timestamp}.{body}"), hex-encoding the result, and accepting the request if it matches any entry in the list. Signatures are lowercase hex. Treat this as a list from day one: today we send a single signature, but the list form lets us add a second signature during a future secret rotation without any change on your side.
  • x-hubby-timestamp — the signing timestamp, in epoch seconds. This is the {timestamp} in the signed string above.

The signed string is the timestamp, a literal ., then the exact raw request body as received. Verify against the raw bytes — do not re-serialize the JSON first, as key reordering or whitespace changes will break the match.

Reject the request if now - x-hubby-timestamp exceeds your tolerance window (we recommend ~5 minutes). This is your protection against replayed requests.

A secret stays valid until it is regenerated; there is no scheduled expiry. Regeneration is a hard cutover — update your stored secret at the same time. You receive separate secrets for staging and production.

Idempotency & event identity

Two identifiers accompany every delivery, both as headers and inside the JSON body (in case reading custom headers is awkward in your stack):

  • x-hubby-event-id / event_id — a stable identifier for the logical event. It stays identical across retries and across any replay of the same event, so deduplicate on it: processing the same event_id more than once should be a no-op on your side.
  • x-hubby-delivery-id / delivery_id — identifies a single delivery. Quote it when contacting support about a specific delivery. A replay of an event carries the same event_id but a new delivery_id.

Delivery, retries & replay

  • A delivery is considered successful when your endpoint returns any 2xx status.
  • If your endpoint returns 5xx or 429, times out, or is unreachable, Hubby retries with exponential backoff over a few hours (up to 12 attempts total).
  • A 4xx (other than 429) is treated as a permanent rejection of the content and is not retried. Return 2xx for "received" and reserve 4xx for genuinely malformed requests.
  • Every delivery is recorded and any past event can be replayed to you on request. A replay carries the same event_id (so your dedup keeps it safe) with a fresh signature and timestamp.
  • Deliveries are independent and ordering is not guaranteed; rely on event_id for deduplication rather than on arrival order.

Need Help?

  • Technical Support: support@hubbyesim.com
Download OpenAPI description
Languages
Servers
Production server
https://api.hubbyesim.com/api/
Staging server
https://api-staging.hubby.dev/api/

Booking

Operations

Destination

Operations

PromoCode

Operations

eSIM

Operations

WebView

Operations

Create a redirect token

Request

Generates a short-lived redirect token that the partner app uses to open the Hubby WebView. The token is valid for 5 minutes and is single-use.

When to call: Every time the traveler opens the eSIM section in your app. Do not cache the returned token — generate a fresh token on every open, on app resume, and if the 5-minute window expires.

Authentication: HMAC-SHA256 (same as all partner endpoints).

Bodyapplication/jsonrequired
Any of:

Identify the traveler with either external_user_id or email. When both are supplied, external_user_id takes precedence.

external_user_idstringrequired

The partner's own user identifier — your internal ID for this traveler. This is not Hubby's user_id from the booking API. Use this for travelers created via the WebView / universal flow.

Example: "partner_user_456"
emailstring(email)

Alternative to external_user_id for partners without a stable per-user identifier (e.g. classic-grant / email-keyed bookings). Resolves the traveler by email, scoped to your Partner. If the resolved user has no external_user_id yet, Hubby assigns one so subsequent calls remain stable.

Example: "traveller@example.com"
redirect_tokenstring(uuid)

Optional. If you pre-generate the token, pass it here. Otherwise Hubby generates one.

Example: "b3864ab6-c13b-42b5-9f69-935eb9c34aba"
curl -i -X POST \
  https://api.hubbyesim.com/api/redirect-tokens/create \
  -H 'Content-Type: application/json' \
  -d '{
    "external_user_id": "partner_user_456",
    "email": "traveller@example.com",
    "redirect_token": "b3864ab6-c13b-42b5-9f69-935eb9c34aba"
  }'

Responses

Redirect token created successfully

Bodyapplication/json
successboolean
Example: true
dataobject
Response
application/json
{ "success": true, "data": { "redirect_token": "1b80dfe9-0202-4151-a26f-ceac52ca3b34", "expires_in": 300 } }

Exchange redirect token for session JWT

Request

Exchanges a redirect token for a short-lived session JWT. The JWT is used as a Bearer token for all subsequent WebView API calls.

Called automatically by the Hubby WebView — partners do not call this endpoint directly. Documented for transparency and to aid debugging of WebView network traffic.

Authentication: None (the redirect token itself is the credential).

Bodyapplication/jsonrequired
redirect_tokenstring(uuid)required

The redirect token returned by /redirect-tokens/create.

Example: "1b80dfe9-0202-4151-a26f-ceac52ca3b34"
external_user_idstringrequired

The partner's own user identifier. Must match the external_user_id used when creating the redirect token.

Example: "123"
curl -i -X POST \
  https://api.hubbyesim.com/api/webapp/auth/exchange \
  -H 'Content-Type: application/json' \
  -d '{
    "redirect_token": "1b80dfe9-0202-4151-a26f-ceac52ca3b34",
    "external_user_id": "123"
  }'

Responses

Token exchanged successfully

Bodyapplication/json
successboolean
Example: true
dataobject
Response
application/json
{ "success": true, "data": { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 1209600 } }

Set marketing/tracking consent

Request

Sets or revokes the authenticated traveler's marketing and/or tracking consent. Either field may be omitted to leave it untouched; at least one must be present.

Setting a field to true records the current time as its _given_at timestamp, but only if it is not already set — re-sending true while already consented is a no-op and preserves the original consent date. Setting a field to false always clears its timestamp to null; a later true then records a fresh consent date.

Authentication: Bearer JWT from /webapp/auth/exchange.

Bodyapplication/jsonrequired
marketing_consentboolean

When true, records marketing_consent_given_at if unset. When false, clears it to null. Omit to leave untouched.

tracking_consentboolean

When true, records tracking_consent_given_at if unset. When false, clears it to null. Omit to leave untouched.

curl -i -X POST \
  https://api.hubbyesim.com/api/webapp/me/consent \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'Content-Type: application/json' \
  -d '{
    "marketing_consent": true,
    "tracking_consent": true
  }'

Responses

Consent updated (or already in the requested state)

Bodyapplication/json
successboolean
Example: true
dataobject

The full updated user document (snake_case).

Response
application/json
{ "success": true, "data": {} }

Get user dashboard

Request

Returns the authenticated traveler's dashboard data: active packages, data usage, unclaimed packages, and available actions.

Called automatically by the Hubby WebView — partners do not call this endpoint directly. Documented for transparency and to aid debugging of WebView network traffic.

Authentication: Bearer JWT from /webapp/auth/exchange.

curl -i -X GET \
  https://api.hubbyesim.com/api/webapp/me/dashboard \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>'

Responses

Dashboard data

Bodyapplication/json
successboolean
Example: true
dataobject

Dashboard payload including active packages, usage, and unclaimed packages. Structure may evolve — the WebView renders this automatically.

Response
application/json
{ "success": true, "data": { "unclaimed_packages": [ … ], "active_packages": [ … ], "expired_packages": [ … ] } }

Get Help Centre context

Request

Returns the traveler's real-time context for the in-app Help Centre: device, eSIM state, packages (webapp single-eSIM model), trip destination, and flags. The help web app maps this to its decision engine to route the traveler to the most likely resolution.

Reads live data (refreshes data_left from the provider). ?destination=<ISO3> overrides the current destination (else the eSIM's), ?surface= names which Surface the Help Centre is being shown on, and ?iccid= names the eSIM the traveler means.

Which eSIM is described. The one ?iccid= names, when it belongs to the traveler; otherwise the traveler's universal eSIM, else the most recently assigned live classic eSIM. An ICCID that is not the traveler's, one that does not exist, or none at all all fall back the same way — a bad parameter never blanks the page. esim.iccid on the answer names the eSIM that was described, whichever rule chose it.

A cut-over partner's universal eSIM (cut_over on the partner) is described from its in-use package — the running eSIM package, else the one attached and waiting to start: data_left, total_data and the trip destination come from that in-use package rather than from the eSIM's own counters, which read 0 for an eSIM that has never been activated. The eSIM's status is still its own. A classic eSIM, and a partner that is not cut over, are unchanged.

Called automatically by the Hubby Help WebView — partners do not call this directly.

Authentication: Bearer token — either the webapp JWT from /webapp/auth/exchange or a native Firebase ID token. Both Surfaces open the same Help Centre, so the endpoint accepts either credential. A traveler who is not signed in sends a visitor pass from /help/visitor instead.

A visitor (not signed in) has no account, so nothing is read: every field is null except source (as named), packages ([]), esim (null), booking ({ exists: false, waiting_destinations: [] } — signed out, they have no eSIM here), flags ({ has_topups: false, open_conversation }) and open_conversation_id, which still brings them back to a conversation they left open. Their intercom is null.

intercom — the identity the app logs Intercom's Messenger SDK in under (dual mode, ADR 0056; a 3.0.1 app). user_id is the backend's own contact key and must not be derived by the app: the Firebase uid for a normal native traveler, a different string for some partner-created users. user_hash is Intercom's identity verification (HMAC-SHA256 of user_id) under the secret of the platform the request came from — Intercom issues one per platform — picked by the request's User-Agent (iPhone, iPad or iPod is iOS, Android is Android) and by the account's phone_os when the User-Agent names no phone. Present only once the backend has that platform's secret configured; set it on the SDK before logging in when present, and log in without a hash when absent. The Help Centre page passes the whole object to the app unchanged on both Messenger-mode bridge calls.

Query
destinationstring

ISO-3166 alpha-3 code of the traveler's current destination (overrides the eSIM's).

iccidstring<= 32 characters

The eSIM the traveler means — the ICCID the app appends to the Help Centre URL when Help is opened from an eSIM's own screens (install, set-up and activate, and the "Not using this eSIM?" sheet), and the Help Centre passes on. The eSIM is described only when it belongs to the traveler; a stranger's ICCID, an unknown one, a blank or a repeated parameter is treated as none named, and the default rule applies. Never a 4xx.

Example: iccid=8944000000000000001
surfacestring

Which Surface the Help Centre is being shown on — the native app's webview, or a browser.

Named by the caller, never derived from the credential. The Capacitor wrapper authenticates with an ordinary Firebase ID token, so a wrapper-hosted Help Centre is indistinguishable from the native app at the token; deriving would rank a webapp traveler against the native journey. The claimed Surface and the authentication path are recorded side by side in the backend's logs.

Omitted, empty or unrecognised → source comes back null. An unrecognised value is normalised, not rejected: a mistyped parameter must not cost a traveler their help. Matching is case-insensitive and trims surrounding whitespace.

Enum"native""webapp"
curl -i -X GET \
  'https://api.hubbyesim.com/api/help/context?destination=string&iccid=8944000000000000001&surface=native' \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>'

Responses

Help context

Bodyapplication/json
successboolean
Example: true
dataobject

HelpContext (snake_case). Every field nullable; the engine degrades per missing field.

Response
application/json
{ "success": true, "data": { "source": "native", "partner_id": "string", "partner": { … }, "locale": "string", "first_name": "string", "device": { … }, "trip": { … }, "esim": { … }, "packages": [ … ], "booking": { … }, "open_conversation_id": "string", "flags": { … }, "intercom": { … } } }

Start a Help Centre support chat (Intercom)

Request

Opens an Intercom conversation for the traveler, carrying the help-app context AND server-side support fields the app never sees (partner, promocode, iccid, hubby_user_id — read from the user/eSIM). The agent/Fin skips discovery.

The traveler's help context (from /help/context) plus the stuck-step info is sent as the body; support fields are attached server-side.

One open conversation per traveler. If the traveler already has an open conversation (open in Intercom and written in within the last 7 days — the same rule as open_conversation_id on /help/context), no second one is created: the existing id comes back with reused: true, the new context is added to that conversation as an internal note, its custom attributes are refreshed, and the contact is refreshed too — its latest_esim_* attributes to the eSIM asked about now, with the durable attributes, name and email as the escalation has them.

open_conversation: false — the app opens Intercom's Messenger itself (dual mode, ADR 0056; a 3.0.1 app). The reuse rule above runs with one difference: it looks for the conversation that blocks the Messenger's composer — state open or snoozed, of any age, with no seven-day window (ADR 0051 amended 2026-10-05) — rather than for an open conversation in the sense of /help/context. Intercom refuses a new conversation beside either state, and the page opens the Messenger's home or its composer by the same rule. Otherwise, instead of creating a conversation, the backend writes the context onto the traveler's Intercom contact (help_context, help_path, entry_state, step_stuck, locale, help_context_at, beside the durable attributes; retried without the six if the workspace has not created them), keeps it, and answers { ok: true, contact_id }. When Intercom's webhook reports the conversation the traveler then starts (conversation.user.created, within 15 minutes of this call), the backend posts the kept context into it as an internal note, once. A visitor under the flag touches Intercom not at all and is answered { ok: true, contact_id: null }. Any other value, or the flag absent, is today's behaviour — an old client never sends it — with one addition: before opening the conversation itself, the backend keeps a bodiless context so the webhook recognises the conversation as already described.

A conversation the traveler starts in the Messenger on their own (its composer, a push), with no call to this endpoint behind it, is described after the fact: when the webhook reports it (conversation.user.created) and no kept context claims it, the backend resolves the traveler from the contact's external id, describes their eSIM by the Help Centre's default rule, and writes the same contact attributes (the latest_esim_* rows included), an internal note whose first line says the conversation started from the Messenger, and the conversation attributes. Never for a visitor. Best-effort, and once per conversation: the description is claimed on the conversation's own record before anything is posted, so a redelivered webhook describes nothing twice.

The context note is one fact per line, on every path that posts one. The first line, which an inbox previews, is Traveller is stuck at "<stuck_at>", plus (before the trip) or (at the destination) when the path says which; without stuck_at it falls back to the step or path id. Then Device, eSIM with its status, provider and ICCID, Type (data limited, time limited, starter, unlimited, or Universal · <type>), Destination, Data left, Days left (every type but data limited, and only for a package that is running: one waiting to start has no days left), Promo code and Partner (both from the described eSIM's own document, the partner falling back to the account's), then what the traveler corrected and typed. For a visitor (not signed in) the first line ends . Not signed in, no account details. and the note has no Partner line. A fact the backend does not hold has no line. The step and path ids are not lines; they stay as the step_stuck and help_path conversation attributes. The contact attribute promocode is the described eSIM's own promo code. The contact also carries the described eSIM as the latest_esim_* attributes support reads their reports from, under the keys and in the shapes the 2.x app wrote: latest_esim_iccid, latest_esim_status, latest_esim_imsi, latest_esim_partner (the partner's document path, companies/hubby/partners/<id>), latest_esim_country (the catalogue's name), latest_esim_provider, latest_esim_activated_at, latest_esim_time_assigned, latest_esim_promo_code, latest_esim_package_count and latest_esim_package_1..3 (<total bytes> - <status>). Written on every escalation for a signed-in traveler, a reused conversation included, and never for a visitor. Every row is always sent: a fact the described eSIM lacks, or every fact for a traveler known to have no eSIM, is cleared (null, where the 2.x app wrote the string none), so the rows describe one eSIM, and only the one the traveler has now. When the eSIM could not be read, no row is sent and the contact keeps what it had: a failed read is not "no eSIM".

The language the traveler picked (language). When sent, the conversation opens with the traveler's pick as their own first message (e.g. 🇳🇱 Nederlands) and the context goes to the agent as an internal note the traveler never sees. The contact's language_override is set to it before the conversation is created, so Intercom's routing rules can read it; it is also set as the conversation attribute support_language, in its own call. Without it — or with a value support does not work in — the context opens the conversation, as before.

A visitor (not signed in), authenticated with a visitor pass, reaches a person as an Intercom lead: no account is read, no contact attributes are sent, and no external_id (Intercom keeps none for a lead). Their lead from an earlier conversation is reused. The context note says they are not signed in. Rate limited per visitor (5 an hour → 429).

Authentication: Bearer token — either the webapp JWT from /webapp/auth/exchange, a native Firebase ID token, or a visitor pass from /help/visitor.

Requires: INTERCOM_ACCESS_TOKEN configured, and the custom attributes pre-created in Intercom (see helpChat/README.md).

Bodyapplication/json
entry_statestring or null
pathstring or null

The path the traveler was on when they asked for a person — a guided flow (e.g. connect_triage), or one of the screens the Help Centre resolves to instead of a flow (esim_shop, human_handoff). One value names a topic rather than a screen: esim_conversion, sent when the traveler asked about switching to the Universal eSIM; the conversation opens with that reason instead of a stuck step. Temporary, until every user has been migrated.

surfacestring or null
Enum"native""webapp"
variantstring or null
step_stuckstring or null

The step id the traveler got stuck on

stuck_atstring or null<= 200 characters

Where the traveler was when they asked, in the words their screen used, in English whatever language they read Help in: the start-screen section and the step's title (said once when they are the same), or the screen they asked from (the Help Centre start screen, the handoff card's line). The first line of the context note. Optional: without it, or with a value that is not a short string, the note names the step or path id instead.

Example: "Set up your eSIM › Rename your eSIM"
steps_shownArray of strings
localestring or null

The language the traveler reads the Help Centre in — the one the app is set to (?locale=), then the account's. The conversation is opened in it: it becomes the Intercom conversation's locale attribute, over the account's saved language, which nothing updates when the traveler switches the app to another language.

Only a language tag is taken (de, pt-BR, zh-Hans); anything else, or none, falls back to the account's.

Example: "de"
languagestring or null

The language the traveler picked to be helped in, from the support team's languages (the old Intercom Messenger's list, in its order). The Help Centre's own locale codes where it has the language; no is Norwegian and zh Chinese.

  • The conversation opens with the pick as the traveler's own message — the language named in itself behind its flag (🇳🇱 Nederlands) — and the context goes to the agent as an internal note.
  • The Intercom contact's language_override is set to it before the conversation exists (nb for no, zh-CN for zh), so a routing rule on the contact's language sees it. Best-effort: a code Intercom refuses is logged and costs only the routing.
  • The conversation attribute support_language is set to it, apart from the other attributes, so the attribute not yet existing in Intercom cannot take the others down with it.

Any other value is ignored, as if none were sent.

Enum"en""nl""de""cs""it""es""fr""pl""sk""ko"
Example: "nl"
device_modelstring or null<= 60 characters

Visitors only. The phone model the Help Centre shows the traveler: remembered on the phone from their last signed-in visit, or read from the browser (Android only — iOS never names its model). Absent when neither is known. Goes into the context note. Trimmed; anything longer than 60 characters, or not a string, is ignored. Ignored for a signed-in traveler — their phone is the account's record.

Example: "iPhone 13"
iccidstring or null<= 32 characters

The eSIM the conversation is about — esim.iccid as /help/context answered it, sent back so the note and the contact attributes name the eSIM the traveler was looking at. The same rule as on the context: described when it belongs to the traveler, else the default rule (the universal eSIM, else the most recently assigned live classic one). Omit it when the context named none.

Example: "8944000000000000001"
conversation_idstring or null

The traveler's open conversation, named when they asked for a person while in it: the Help Centre raises that chat at once and sends the new context here, so it reaches the agent in it as an internal note (the reuse path below). Only that conversation, and only while it is the traveler's open one — otherwise the answer is 409 and nothing is created, so a page that still thinks a closed chat is open never opens a second conversation behind the traveler's back. Omit it to open (or be handed back) a conversation as usual.

Example: "215468832132"
open_conversationboolean or null

Send false only when the app opens Intercom's Messenger itself (dual mode, ADR 0056; a 3.0.1 app). The backend then opens no conversation: the context goes onto the traveler's Intercom contact and is kept for the internal note the webhook posts into the conversation the traveler starts in the Messenger. Strictly false; true, any other value, or the field absent is today's behaviour. Omit it from the Help Centre's own chat.

Example: false
capturedobject or null

Anything the traveler typed into a form step (e.g. booking_ref, email), appended to the conversation body so the agent skips the screenshot loop

Example: {"booking_ref":"ABC123"}
correctionsobject or null

What the traveler told the Help Centre it had wrong about them, keyed by the fact. The Help Centre asserts four facts at the top of every screen (phone, where they are, departure, eSIM state); three of them are correctable, and all three are asked at once in a single sheet. The correction is scoped to that Help session and nothing is written back to the account, so the conversation is where it becomes durable.

Sent as a disagreement, never as a fact — the context attached server-side (phone_os, device_model) still reports what the platform holds. "Traveler says Samsung, our record says iPhone" is worth more to an agent than either alone. Omit the field entirely when the traveler corrected nothing; an empty object reads as "they were asked and said nothing".

Keys: device (iphone | samsung | pixel | xiaomi | other), location (not_there_yet | in_destination | trip_over), esim (not_installed | not_connected | working). Reaches Intercom as the traveller_says conversation attribute.

device names a phone brand rather than a platform because the brand is what decides which instructions and artwork exist — the Samsung, Pixel and Xiaomi Settings paths differ from each other and from Apple's. other means generic Android.

Example: {"device":"samsung"}
curl -i -X POST \
  https://api.hubbyesim.com/api/help/escalate \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'Content-Type: application/json' \
  -d '{
    "entry_state": "string",
    "path": "string",
    "surface": "native",
    "variant": "string",
    "step_stuck": "string",
    "stuck_at": "Set up your eSIM › Rename your eSIM",
    "steps_shown": [
      "string"
    ],
    "locale": "de",
    "language": "nl",
    "device_model": "iPhone 13",
    "iccid": "8944000000000000001",
    "conversation_id": "215468832132",
    "open_conversation": false,
    "captured": {
      "booking_ref": "ABC123"
    },
    "corrections": {
      "device": "samsung"
    }
  }'

Responses

Conversation created, or the traveler's open conversation handed back — or, with open_conversation: false and no open conversation, the context on the contact and nothing created

Bodyapplication/json
successboolean
Example: true
dataA conversation (object) or No conversation (the app opens the Messenger) (object)
One of:

Created, or the traveler's open one handed back. Also the answer under open_conversation: false while a conversation is open (the reuse path).

Response
application/json
{ "success": true, "data": { "conversation_id": "string", "contact_id": "string", "reused": true } }

List a traveler's Help Centre support conversations

Request

Every support conversation the traveler has had through the Help Centre, whatever its state, the most recently written in first — the Help Centre's Chat History screen (ADR 0052). Each entry is a summary: the state, when it was last written in, and the newest message. The thread itself is read with /help/chat once a conversation is opened.

Authentication: Bearer token — either the webapp JWT from /webapp/auth/exchange or a native Firebase ID token. Both Surfaces open the same Help Centre, so the endpoint accepts either credential.

Only the caller's own conversations. The list is served from the Firestore mirror kept by the Intercom webhook, queried by the caller's stable key; no Intercom call is made. At most 50 conversations are returned. A conversation the mirror has never recorded a message time for (one written before that field was kept and untouched since) is omitted.

state reflects what the mirror last learned from Intercom: open, closed or snoozed, or null for a mirror written before the state was kept. A traveler may write into a closed conversation through /help/chat/reply; Intercom reopens it.

curl -i -X GET \
  https://api.hubbyesim.com/api/help/conversations \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>'

Responses

The traveler's conversations, newest activity first

Bodyapplication/json
successboolean
Example: true
dataobject
Response
application/json
{ "success": true, "data": { "conversations": [ … ] } }

Read a Help Centre support conversation

Request

Returns the messages of an Intercom conversation the traveler opened via /help/escalate. This is the polling endpoint the live-chat view reads.

Authentication: Bearer token — either the webapp JWT from /webapp/auth/exchange or a native Firebase ID token. Both Surfaces open the same Help Centre, so the endpoint accepts either credential.

Ownership is enforced on every request. The conversation's owning Intercom contact must match the caller; another traveler's conversation answers 403, never its contents.

Reads are served from a Firestore mirror kept fresh by the Intercom webhook, so polling costs no Intercom call on the fast path. Past HELP_MIRROR_MAX_AGE_SECONDS (default 120) the conversation is re-verified against Intercom even on a mirror hit, so a lagging or unconfigured webhook self-heals rather than pinning stale messages.

The state rides beside the messages. state is what the mirror last learned from Intercom (or what the re-verification just saw): the Help Centre's chat sheet polls this endpoint, and a conversation an agent closes while the sheet is up is how the entry screen's Your current chat row comes down (ADR 0052 §2).

Query
conversation_idstringrequired

The conversation id returned by /help/escalate.

curl -i -X GET \
  'https://api.hubbyesim.com/api/help/chat?conversation_id=string' \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>'

Responses

The conversation's messages

Bodyapplication/json
successboolean
Example: true
dataobject(HelpChatThread)

A Help Centre support conversation and the messages on it, as /help/chat and /help/chat/reply both return it. Messages are plain text — the HTML of the Intercom part body is stripped — with their photos and files in attachments; parts carrying neither are omitted.

Response
application/json
{ "success": true, "data": { "conversation_id": "string", "state": "open", "closed_at": 1790169580, "messages": [ … ] } }

Post a traveler reply to a Help Centre conversation

Request

Posts the traveler's message to an Intercom conversation opened via /help/escalate, as the traveler's own contact, and returns the updated thread.

Authentication: Bearer token — either the webapp JWT from /webapp/auth/exchange or a native Firebase ID token. Both Surfaces open the same Help Centre, so the endpoint accepts either credential.

Ownership is verified before anything is written. The conversation is fetched and checked against the caller first, so a reply to another traveler's conversation answers 403 without posting. A visitor's conversation belongs to an Intercom lead, which carries no external_id; its ownership is the one the backend recorded when the visitor opened it (and moved to the account by /help/visitor/claim).

The mirror is written through with the updated thread, so the traveler's own reply appears on the next poll of /help/chat without waiting for the webhook.

A reply to a closed conversation reopens it. The state is not checked here: Intercom reopens a closed conversation when its contact writes in it, so a traveler continuing a past conversation from the Chat History screen (/help/conversations) picks it up where it stopped rather than starting a new one (ADR 0052).

A photo (image) goes with the words, or alone. It is checked before Intercom is asked anything, stored in the project's Storage bucket under help-chat/<conversation_id>/ only once ownership is verified, and posted to Intercom as a signed read link (attachment_urls, valid 30 days). A photo with no words is posted with the body 📷, which Intercom requires; the messages this endpoint and /help/chat answer show it with an empty body and the photo in attachments. A photo that cannot be stored posts nothing (500).

Bodyapplication/jsonrequired
conversation_idstringrequired

The conversation id returned by /help/escalate.

textstring<= 2000 characters

The traveler's message. Trimmed; at most 2000 characters. Required when there is no image; with one, it is the photo's caption.

imageobject

A photo from the traveler. The Help Centre scales it down on the phone before sending.

curl -i -X POST \
  https://api.hubbyesim.com/api/help/chat/reply \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'Content-Type: application/json' \
  -d '{
    "conversation_id": "string",
    "text": "string",
    "image": {
      "data": "string",
      "content_type": "image/jpeg",
      "name": "string"
    }
  }'

Responses

Reply posted; the updated thread is returned

Bodyapplication/json
successboolean
Example: true
dataobject(HelpChatThread)

A Help Centre support conversation and the messages on it, as /help/chat and /help/chat/reply both return it. Messages are plain text — the HTML of the Intercom part body is stripped — with their photos and files in attachments; parts carrying neither are omitted.

Response
application/json
{ "success": true, "data": { "conversation_id": "string", "state": "open", "closed_at": 1790169580, "messages": [ … ] } }

Issue a Help Centre visitor pass (not signed in)

Request

Issues the visitor pass a traveler who is not signed in uses to reach a person through the Help Centre — /help/context, /help/escalate, /help/conversations, /help/chat and /help/chat/reply accept it as a Bearer token. There is no account behind a visitor: the pass carries a random visitor id and is kept on the phone by the Help Centre.

Renewal. Send a still-valid pass as Authorization: Bearer <pass> and the same visitor gets a fresh one (90 days), so their conversations stay theirs. No pass, or an expired, forged or unreadable one, gets a new visitor.

The only unauthenticated Help Centre route. Gated instead on:

  • App Check — the X-Firebase-AppCheck header. In monitor mode (the default) a token that is sent is verified and the outcome logged, and every caller is served; with HELP_VISITOR_APP_CHECK=enforce a missing or invalid token answers 401.
  • A per-IP rate limit — 20 passes an hour → 429. The address is the one a proxy of ours wrote into X-Forwarded-For (counted from the right, HELP_TRUSTED_PROXY_HOPS), never an entry the caller sent.

Called automatically by the Hubby Help WebView — partners do not call this directly.

Headers
X-Firebase-AppCheckstring

The native app's App Check token, handed to the Help Centre over the bridge. Required when HELP_VISITOR_APP_CHECK=enforce.

Authorizationstring

The visitor's current pass, to renew it for the same visitor.

Example: Bearer eyJhbGciOiJIUzI1NiIs...
curl -i -X POST \
  https://api.hubbyesim.com/api/help/visitor \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIs...' \
  -H 'X-Firebase-AppCheck: string'

Responses

A visitor pass

Bodyapplication/json
successboolean
Example: true
dataobject
Response
application/json
{ "success": true, "data": { "visitor_token": "string", "expires_at": 0 } }

Take over a visitor's Help Centre conversations after signing in

Request

A traveler who chatted while signed out and has now signed in hands over their visitor pass; every conversation that visitor had is filed under their account, so it shows in their Chat History and they can keep writing in it.

Then, best-effort: the visitor's Intercom lead is merged into the account's Intercom contact, when the account has one, so the agent sees one person. A failed or impossible merge is logged and the claim still succeeds.

A pass is accepted up to 30 days past its expiry. Idempotent: a second claim finds nothing left and answers claimed: 0.

Authentication: a signed-in traveler's Bearer token (webapp JWT or native Firebase ID token). A visitor pass is refused with 403.

Bodyapplication/jsonrequired
visitor_tokenstringrequired

The visitor pass from /help/visitor.

curl -i -X POST \
  https://api.hubbyesim.com/api/help/visitor/claim \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'Content-Type: application/json' \
  -d '{
    "visitor_token": "string"
  }'

Responses

The visitor's conversations were filed under the account

Bodyapplication/json
successboolean
Example: true
dataobject
Response
application/json
{ "success": true, "data": { "claimed": 0 } }

Refresh a user's universal eSIM

Request

Assigns a fresh universal eSIM to a user identified by external_user_id. If the user already has a universal eSIM, it is replaced with a new one. If the user does not yet have one, a new eSIM is provisioned and assigned.

Use this endpoint to pre-provision an eSIM for a traveler before they open the WebView, or to replace a problematic eSIM with a fresh one.

Authentication: Bearer JWT from /webapp/auth/exchange.

Bodyapplication/jsonrequired
external_user_idstringrequired

The partner's own user identifier. Must match the external_user_id used during authentication.

Example: "partner_user_456"
curl -i -X POST \
  https://api.hubbyesim.com/api/webapp/refresh-esim \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'Content-Type: application/json' \
  -d '{
    "external_user_id": "partner_user_456"
  }'

Responses

Universal eSIM assigned successfully

Bodyapplication/json
successboolean
Example: true
dataobject
Response
application/json
{ "success": true, "data": { "iccid": "8901234567890123456", "qr": "LPA:1$smdp.example.com$ACTIVATION-CODE", "status": "RELEASED" } }

Webhooks

Webhooks