Last updated

Webhooks Guide

Hubby sends a webhook to your endpoint when something happens in a traveller's eSIM lifecycle: a booking is created, an eSIM is installed, a package activates or crosses a data threshold. Use the events to update your records, notify the traveller or start your own workflows.

This page has five parts:

  1. Set-up: how an endpoint is created.
  2. Verification: how you prove that a delivery comes from Hubby.
  3. Delivery: answers, retries and deduplication.
  4. Catalogue: every event, when it fires and which channels send it.
  5. Bodies: the published contract, with the envelope, the headers and the body of every event.

Set-up

  • Hubby creates the endpoint for you. Send your Hubby contact the URL and the events you want for each environment (staging and production). Events are chosen per endpoint, booking.created included.
  • Hubby generates a 64-character signing secret for the endpoint. It is shown once, when the endpoint is created, and Hubby gives it to you. It cannot be read back later. Store it as a secret.
  • Every endpoint that Hubby creates is signed.
  • Optionally, Hubby can add a static header to every delivery, for example x-api-key or Authorization: Bearer …. You agree its name, value and prefix with Hubby. It is not your Partner API key, and it is not a verification mechanism.
  • Optionally, Hubby can shape the body for your endpoint: a flat body (flat_payload) or a payload template per event (payload_templates). Both change the body shape. See Envelope in Bodies. An endpoint of a Partner on the Native channel (partner_app) always receives the default signed envelope: Hubby sets neither shaping for it, and does not create it without a secret.

Verification

Verify the signature on every delivery. Do not rely on the static header.

  • x-hubby-timestamp: Unix time in seconds. Reject a delivery when it is too far from your clock (we recommend 5 minutes). This is your replay protection.
  • x-hubby-signature: one sha256= value. Compute the lowercase hex HMAC-SHA256 of {x-hubby-timestamp}.{raw body} with your secret, and compare it in constant time. A verifier that splits the header on , and accepts any matching entry is also correct.

Use the raw request bytes as received. Do not parse and re-serialise the JSON first: a change in key order or whitespace breaks the match.

With the @hubbyesim/sdk package, verifyWebhook({ secret, body, headers }) does both checks and returns the parsed event. It throws WebhookVerificationError with a reason when a check fails.

A secret stays valid until Hubby regenerates it. A regeneration is a hard cutover: from the moment the new secret is stored, 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. Each endpoint has its own secret.

Delivery

  • Deduplicate on event_id. It is in the body and in the x-hubby-event-id header. It stays the same across retries and replays. delivery_id (also x-hubby-delivery-id) identifies one delivery; quote it when you contact support.
  • Answer 2xx fast. The request times out after 10 seconds. Store the event and process it after you answer.
  • A 4xx other than 429 is final. Hubby does not retry it. Use 4xx only for a request you will never accept.
  • Everything else is retried: 5xx, 429, a timeout and a transport error. One delivery makes at most 12 attempts. The wait starts at 10 seconds and doubles up to 1 hour, about 3.4 hours in total.
  • Do not answer 3xx. A redirect is followed, up to five times, and a 301, 302 or 303 becomes a GET without the body. The final answer's status is the one that counts.
  • Order is not guaranteed. Treat each event as independent.
  • On request, Hubby replays a delivery it already attempted to your endpoint. The replay has a new delivery_id, x-hubby-timestamp and signature; the event_id and the body, the envelope timestamp included, are the first send's. A delivery that was never attempted to your endpoint, for example from a time it was disabled or not subscribed, cannot be replayed.

Catalogue

The channel is how your travellers use Hubby: Native (your own app over the Partner API, see Native eSIM Integration), the Hubby app, or the Hubby web app (also when you show it in a WebView). An endpoint receives only the events it subscribes to, and only from the channels that can produce them.

EventFires whenChannels
booking.createdA booking is created, also by a bulk import.All
booking.cancelledYou cancel a booking over the booking API.All
package_queue.claimedA traveller redeems your grant, or you issue it over the Partner API, and its package is provisioned.All
package.activatedThe provider activates the package and reports it to Hubby.All
package.usage.50_percent, package.usage.80_percent, package.usage.100_percentThe provider's data-percentage alert reports a value in a window (above 47 and below 55, above 75 and below 85, above 90 percent). Unlimited packages: against the fair-use cap. Nothing fires on elapsed time.All
package.first_usageThe package's used data reaches 10 MB. Seen up to about 15 minutes late.All
package.terminatedYou terminate a package you issued, before it activates.Native
esim.installed, esim.removedThe provider reports the eSIM profile installed on a device (enabled or not) for the first time, or deleted from it. Each fires once per eSIM.All
esim.location_changeThe eSIM attaches to a network 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.All
promo_code.redeemedA traveller claims a code.Hubby app, Hubby web app
package.purchasedA paid purchase is provisioned.Hubby app, Hubby web app
topup.completedA paid top-up is provisioned.Hubby app, Hubby web app
upgrade_offer.purchasedA traveller pays for an upgrade offer.Hubby app, Hubby web app
payment.refundedA payment is refunded.Hubby app, Hubby web app

booking.rescheduled is not sent as a webhook. A package that Hubby's support gives a traveller (an Operator grant) or ends for them (an Operator terminate) sends no event. The events in Legacy backend are not sent by the core Partner API.

Legacy backend

A Partner that still integrates with the legacy API receives its webhooks from the legacy backend. That backend also sends these events, which the core Partner API does not send:

  • booking.within_cutoff and booking.about_to_depart: departure reminders.
  • classic_package_queue.claimed: a classic-eSIM grant claimed in the Hubby app. The core name is package_queue.claimed.
  • The package.usage.* events on elapsed duration for an unlimited package.

Bodies

The text below is Hubby's published webhook contract.

Partner Webhook Contract

What your webhook endpoint receives from Hubby: which events exist, how they are delivered and signed, and the fields of each body. There is no machine-readable schema for webhook events; this page is their description.

Every Partner receives the same bodies. Each event is described below as Hubby sends it today. If you integrated with Hubby's legacy webhooks, read If you used the legacy webhooks for what changed.


Events and channels

Which End-customer channel can produce each event. The channel is the surface your travellers use: native_app (the Hubby app), webapp (the Hubby web app, also when you show it in a WebView) and partner_app (your own app over the Partner API: Native (eSIM integration)). "All" means the event does not depend on the channel. An endpoint receives an event only when it subscribes to it; every event below, booking.created included, is chosen per endpoint.

EventFires whenChannels
booking.createdA booking is created, also by a bulk import.All
booking.cancelledYou cancel a booking over the booking API.All
package_queue.claimedA traveller redeems your grant, or you issue it over the Partner API, and its Package is provisioned.All
package.activatedThe provider activates the Package and reports it to Hubby.All
package.usage.50_percent, package.usage.80_percent, package.usage.100_percentThe provider's data-percentage alert crosses a threshold (see the package.usage.* section).All
package.first_usageThe Package's used data reaches 10 MB. Seen up to about 15 minutes late.All
package.terminatedYou terminate a Package you issued, before activation.partner_app
esim.installed, esim.removedThe provider reports the eSIM profile installed on a device (enabled or not) for the first time, or deleted.All
esim.location_changeThe eSIM attaches to a network in another country.All
promo_code.redeemedA traveller claims a code in a Hubby app.native_app, webapp
package.purchasedA paid purchase is provisioned.native_app, webapp
topup.completedA paid top-up is provisioned.native_app, webapp
upgrade_offer.purchasedAn upgrade offer is paid on the upgrade-offer page.native_app, webapp
payment.refundedStripe reports a refund on a payment.native_app, webapp

On partner_app, your travellers buy nothing from Hubby and claim no code, so the five events in the last rows never reach you: your items carry no code and no upgrade link. For the same reason booking.created and booking.cancelled send you each package-queue item as {id} alone: the code and uuid keys other channels receive are absent (see Booking fields). A change of booking dates (booking.rescheduled) is not a webhook event: no endpoint can subscribe to it. A package that Hubby's support gives your traveller free of charge (an Operator grant) or ends for them (an Operator terminate) sends no event at all: not package_queue.claimed, not package.terminated, nothing.

Set-up

  • Hubby creates the endpoint for you: the URL, the events it receives, and its optional shaping (see Envelope).
  • Hubby generates a 64-character signing secret for every endpoint it creates, and gives it to you once, when the endpoint is created. It is stored encrypted and cannot be read back.
  • On request, Hubby regenerates the secret. A secret change is a hard cutover: from the moment the new secret is stored, every delivery, every retry and every 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.
  • Every endpoint Hubby creates is signed. Only an endpoint carried over from the legacy webhook settings can lack a secret, and its deliveries then carry no x-hubby-signature.
  • Hubby can also set a static header, for a receiver that wants one. It is optional. Its name (x-api-key when none is chosen), its value and an optional prefix are set per endpoint. It is not your API key, and it is not a verification mechanism: verify the signature.
  • On request, Hubby sends a Test event to your endpoint: a real delivery of a sample body, marked as a test (see Headers and signature). A Test event differs from live traffic in four ways: its ids are fake TEST-… strings, its event_id is {event}:test-{random}, its envelope timestamp carries real milliseconds, and with a Flat body the test: true marker is a top-level key.

Delivery and retries

  • Answer fast with a 2xx. The request times out after 10 seconds. Process the event after you answer.
  • A 4xx other than 429 is final. The delivery is not retried.
  • Anything else is retried: a 5xx, a 429, a timeout or a transport error. One delivery makes at most 12 attempts. The wait between attempts starts at 10 seconds and doubles to 1 hour (10 s, 20 s, 40 s, … 2560 s, 3600 s, 3600 s), about 3.4 hours in total.
  • Do not answer 3xx. A redirect is followed, up to five times, and a 301, 302 or 303 is followed as a GET without the body. The status of the final answer is the one that counts.
  • On request, Hubby replays a delivery it already attempted to your endpoint. The replay has a new delivery_id, x-hubby-timestamp and signature; event_id and the body, the envelope timestamp included, are the first send's.
  • Every attempt is signed again with the time it is sent, so a 300-second timestamp tolerance holds for retries too.
  • Deduplicate on event_id. A retry repeats event_id and delivery_id. Order is not guaranteed.

Envelope

{
  "event": "package.activated",
  "timestamp": "2026-07-15T16:00:00.123Z",
  "data": { "…": "the event body, below" },
  "event_id": "package.activated:01K…",
  "delivery_id": "01K…"
}
KeyMeaning
eventThe contract event name.
timestampISO 8601 UTC with milliseconds: the moment the event occurred. It is stored to the second, so the milliseconds are always .000 (2026-07-15T16:00:00.000Z), a Test event excepted (see Set-up).
dataThe event body.
event_id{event}:{entity id}. Stable across retries of one event, so you can deduplicate on it. Treat the whole value as opaque, except that the part before the first : is the event name (the same value as event).
delivery_idIdentifies the delivery. Opaque.

Two per-endpoint settings change this shape. Hubby sets both on request, and both run on the body described here, template first:

  • payload_templates (a Payload template per event): the template is a map of output key to value. A value that is exactly {{name}} moves the field name out of data into the output key; any other value is a constant. Every field of data that no template value names is kept. The key order is fixed: the kept fields first, in their own order, then the template's keys in the template's order; a kept field that is also a template key stays in its original position and takes the template's value. A variable naming a field the event does not carry is null.
  • flat_payload (a Flat body): the endpoint receives the data object alone (after its template, if any), with event_id and delivery_id appended to it. There is no event key and no timestamp key: tell the events apart by x-hubby-event-id, or by event_id, whose part before the first : is the event name.

An endpoint with neither receives the bodies below. An endpoint of a Partner on the partner_app channel always receives the bodies below, signed: Hubby does not set a flat body or a template for it, and does not create it without a secret.

Headers and signature

Every delivery is a POST with Content-Type: application/json.

HeaderSentValue
x-hubby-signaturealways, on every endpoint Hubby creates (see Set-up)One signature: sha256= followed by the lowercase hex HMAC-SHA256, keyed with the endpoint's current secret, of {x-hubby-timestamp}.{raw body}.
x-hubby-timestampalwaysUnix time in seconds when this attempt was sent. It is the timestamp the signature covers.
x-hubby-event-idalwaysThe same value as event_id in the body.
x-hubby-delivery-idalwaysThe same value as delivery_id in the body.
x-hubby-syntheticonly on a Test eventtrue. The header is absent, not false, on real traffic. A Test event's body also carries data.test: true.
x-api-key, or the header name configured for the endpointonly when a static header is setThe configured value, after the configured prefix if there is one, with one space between them. Not your API key and not a check: verify the signature.

To verify: read x-hubby-timestamp and the raw request body exactly as received, compute sha256= + hex HMAC-SHA256 of {timestamp}.{body} with the secret, and compare it, in constant time, with x-hubby-signature. Hubby sends one signature per delivery; a verifier that splits the header on , and accepts any matching entry is also correct.

Verify the signature over the bytes received, not over a re-serialised body: the order of keys inside data is not guaranteed. (A Payload template fixes the order of its own output.)

Conventions

For the package and eSIM events (package.activated, topup.completed, package.purchased, package.usage.*, package.first_usage, esim.installed, esim.removed, esim.location_change; the first two points also hold for booking.created):

  • A field Hubby cannot resolve is an explicit null, never absent.
  • Fields that a table marks Additional, and fields that no table lists, may appear. Ignore keys you do not read.
  • size is the whole number of GiB, rounded: round(bytes / 2^30) + "GB" (1.5 GiB → "2GB", 1.49 GiB → "1GB", 500 MiB → "0GB"). For an unlimited Package it is the fair-use ceiling, not the size sold; duration_days is the field that tells unlimited Packages apart.
  • package_type is data-limited, starter or unlimited on these events. The other events send data_limited. The deprecated time-limited is never sent, on any event.
  • expires_at is the provider's activation deadline: the last moment the Package can start. It is the same value the Partner API returns as expires_at. It is not activation plus the time allowance.
  • destination is the Package's destination code: ISO 3166-1 alpha-3, uppercase, for a country (NLD); the region code for a region or global destination (EU, XCA), as the legacy webhooks sent it. It is the same value the Partner API's legacy routes publish as the destination id.

For every event:

  • booking_id is your booking reference (external_reference on /api/v1), or null when there is none. No event sends Hubby's own booking id under that name. Each event's table says which booking it names.
  • "The booking the eSIM was claimed with" is the booking of the package-queue item behind the eSIM's first Package, when that item was your grant (from a booking or a promo code) and not a purchase, a Switch or an Operator grant. "The code the eSIM was claimed with" is that item's code.
  • package_queue_uuid and queue_id both carry a package-queue item's id: items[].id on /api/v1. It is the value to correlate on and is NOT the key of that item's upgrade-offer page. See Two identifiers per package-queue item. One exception: for an item migrated from the legacy platform, package_queue_uuid is the item's id on the legacy platform, which is the id you received for it there. queue_id and items[].id stay Hubby's id for the same item.
  • package_id is an opaque id. Do not parse it.
  • An id is the same string in a webhook and on /api/v1, in the same letter case. Most ids are lowercase ULIDs, but some are uppercase: 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.

package.activated

The provider activated the Package and reported it to Hubby. The event is sent when the provider activates the Package; Hubby does not observe the network attach itself.

FieldTypeMeaning
iccidstringThe eSIM the Package is on.
package_idstringThe Package. Opaque.
package_queue_uuidstring|nullThe package-queue item that provisioned the Package.
external_user_idstring|nullThe traveller's id in your own system.
booking_idstring|nullYour booking reference of the booking that bought the Package: the booking of its package-queue item. For a top-up, that is the top-up's booking, not the booking the eSIM was claimed with. null when that booking has no reference, or the item has no booking.
promo_code_idstring|nullThe code the eSIM was claimed with.
destinationstring|nullSee Conventions: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA).
sizestring|nullSee Conventions.
package_typestring|nullSee Conventions.
duration_daysinteger|nullThe Package's time allowance in days, rounded.
activated_atstring|nullISO 8601 UTC, milliseconds.
expires_atstring|nullThe provider's activation deadline, as on the Partner API (see Conventions). ISO 8601 UTC, milliseconds.
elapsed_daysinteger|nullCalendar days since activation: normally 0 on this event. Additional.
remaining_daysinteger|nullCalendar days of validity left: from today to activation plus the time allowance, so normally the whole time allowance on this event. An activation Hubby learns of on a later day gives a larger elapsed_days and a smaller remaining_days. null for a Package with no time allowance. It does not count to expires_at. Additional.
size_bytes, bundle_id, external_reference, destination_name, user_id, partner_idAdditional.

package.terminated

You ended a Package you issued over the Partner API (DELETE /v1/bookings/{booking}/items/{item}/package), before Package activation. The Package is ended on the network, nothing is charged, and the booking item is queued again: it can be issued again or its booking cancelled. The traveller keeps their eSIM. event_id entity: the Package, so a later issue and terminate of the same item is a new event. Sent once per terminate; a refused terminate sends nothing. A terminate done by Hubby, or an end by the network provider, is not sent.

The body uses the Partner API's names, except booking_id, which means what it means on every other event.

FieldTypeMeaning
iccidstringThe eSIM the Package was on. The traveller keeps it.
external_user_idstring|nullThe traveller's id in your own system.
booking_idstring|nullYour booking reference: external_reference on /v1/bookings.
booking_referencestring|nullThe same value as booking_id, under the Partner API's name.
item_idstringThe booking item: items[].id on /v1/bookings.
package_idstringThe Package that was ended: id of the issued package. Opaque.
reasonstring|nullThe reason the terminate call sent: changed_plans, installation_failed, not_received or other; null when it sent none.
terminated_atstringWhen the Package was ended. ISO 8601 UTC, six fractional digits.

topup.completed

A paid top-up was provisioned onto an eSIM. It is sent once the Package exists, not when the payment settles.

FieldTypeMeaning
iccidstringThe eSIM the top-up landed on.
package_idstringThe Package the top-up provisioned.
payment_idstringHubby's payment record. Never null. See below.
payment_intent_idstring|nullThe payment processor's id (Stripe pi_…).
amountinteger|nullWhat the customer was charged, in minor units of currency.
currencystring|nullISO 4217, lowercase.
external_user_idstring|nullThe traveller's id in your own system.
booking_idstring|nullYour booking reference of the booking the eSIM was claimed with.
promo_code_idstring|nullThe code the eSIM was claimed with — not a code on the top-up.
destinationstring|nullSee Conventions: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA).
sizestring|nullSee Conventions.
package_typestring|nullSee Conventions.
duration_daysinteger|nullThe Package's time allowance in days, rounded.
queue_id, amount_eur, size_bytes, bundle_id, external_reference, destination_name, activated_at, expires_at, user_id, partner_id, discount_codeAdditional. amount_eur is the price in EUR cents, whatever currency the customer paid in. queue_id is the top-up's package-queue item.

payment_id

payment_id is Hubby's own payment record, because it is never null: a sale settled through a Checkout Session has no intent id. To key on the Stripe id, read payment_intent_id.

package.purchased

A paid purchase was provisioned onto an eSIM: the traveller's universal eSIM, or one claimed for them if they had none. It is sent once the Package exists, when the traveller redeems the purchase, not when the payment settles.

FieldTypeMeaning
iccidstringThe eSIM the purchase was provisioned onto.
payment_idstring|nullThe payment processor's id (Stripe pi_…).
amountinteger|nullWhat the customer was charged, in minor units of currency.
currencystring|nullISO 4217, lowercase.
package_queue_uuidstringThe package-queue item the sale created. The same value package.activated sends.
external_user_idstring|nullThe traveller's id in your own system.
booking_idstring|nullYour booking reference of the booking the eSIM was claimed with. null when the purchase's redeem claimed the eSIM.
promo_code_idstring|nullThe code the eSIM was claimed with. null when the purchase's redeem claimed the eSIM.
package_id, destination, size, package_type, duration_days, transaction_id, amount_eur, discount_code, activated_at, expires_at, size_bytes, bundle_id, external_reference, destination_name, user_id, partner_idAdditional. size and package_type follow Conventions. transaction_id is Hubby's payment record. amount_eur is the price in EUR cents, whatever currency the customer paid in.

package.usage.50_percent, package.usage.80_percent, package.usage.100_percent

The connectivity provider sent a data-percentage alert for a Package, and the percentage it reported falls in a window: above 47 and below 55 percent (50_percent), above 75 and below 85 percent (80_percent), or above 90 percent (100_percent). An alert outside every window sends nothing. Each fires at most once per Package. event_id entity: the Package.

The provider's alert is the only trigger. For an unlimited Package the percentage is of the fair-use cap (the size it carries). Nothing fires on elapsed time: a Package that runs out of days sends no usage event.

FieldTypeMeaning
iccidstringThe eSIM the Package is on.
package_idstringThe Package. Opaque.
package_queue_uuidstring|nullThe package-queue item that provisioned the Package.
external_user_idstring|nullThe traveller's id in your own system.
booking_idstring|nullYour booking reference of the booking that bought the Package, as on package.activated.
promo_code_idstring|nullThe code the eSIM was claimed with.
destinationstring|nullSee Conventions: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA).
sizestring|nullSee Conventions.
package_typestring|nullSee Conventions.
duration_daysinteger|nullThe Package's time allowance in days, rounded.
usage_percentintegerThe percentage the provider reported, truncated. On 100_percent it can be any value above 90, including above 100.
used_bytesintegerData used.
remaining_bytesinteger|nullData left, never below 0. null when the total is unknown.
elapsed_daysinteger|nullCalendar days since activation. null before activation.
remaining_daysinteger|nullCalendar days of validity left: from today to activation plus the time allowance, never below 0. null before activation, and for a Package with no time allowance. It does not count to expires_at.
activated_at, expires_at, size_bytes, bundle_id, external_reference, destination_name, user_id, partner_idAdditional. activated_at and expires_at are ISO 8601 UTC, milliseconds; expires_at is the activation deadline (see Conventions).

package.first_usage

A Package's used data reaches 10 MB (10 × 1024 × 1024 bytes). Hubby reads usage from the provider every 15 minutes, so the event can arrive up to about 15 minutes late; first_used_at is when Hubby observed it. Fires once per Package. event_id entity: the Package. The fields it shares with package.activated have the same meanings.

FieldTypeMeaning
iccidstringThe eSIM the Package is on.
external_user_idstring|nullThe traveller's id in your own system.
booking_idstring|nullYour booking reference of the booking that bought the Package, as on package.activated.
package_idstringThe Package. Opaque.
package_queue_uuidstring|nullThe package-queue item that provisioned the Package.
promo_code_idstring|nullThe code the eSIM was claimed with.
destinationstring|nullSee Conventions: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA).
sizestring|nullSee Conventions.
package_typestring|nullSee Conventions.
duration_daysinteger|nullThe Package's time allowance in days, rounded.
size_bytesinteger|nullThe total data, in bytes.
used_bytesintegerData used when the threshold was observed.
first_used_atstringWhen Hubby observed it. ISO 8601 UTC, milliseconds. The envelope timestamp is the same moment, cut to the second (.000Z).

esim.installed, esim.removed

esim.installed: the provider reported the eSIM profile installed on a device, enabled or not, for the first time. Fires once per eSIM.

esim.removed: the provider reported that an installed eSIM was deleted from its device. Fires once per eSIM, like esim.installed: a second removal of the same eSIM is not sent.

event_id entity: the eSIM, for both.

An eSIM outlives its bookings, so booking_id here is your reference of the booking the eSIM was first claimed with, not the booking of a later Package on it. To tie the event to a booking item, correlate on queue_id (the item's id, items[].id on /api/v1) and package_id.

FieldTypeMeaning
iccidstringThe eSIM.
external_user_idstring|nullThe traveller's id in your own system.
booking_idstring|nullYour reference of the booking the eSIM was first claimed with. null for an eSIM that was bought, not claimed, and for an imported eSIM whose first Package has no package-queue item.
promo_code_idstring|nullThe code the eSIM was claimed with. null in the same cases as booking_id.
esim_id, package_id, queue_idAdditional. package_id is the eSIM's newest Package that came from a booking, else its newest Package; queue_id is that Package's package-queue item.

esim.location_change

Triggered when one of your travelers' eSIMs attaches to a mobile network in a different country than it was last seen in — detected from the network operator's hourly location-update feed, which Hubby reads every 15 minutes, so expect 15 to 75 minutes of latency, more when a delivery is retried, and occasional out-of-order delivery (order by at). from_country: null means this is the first attach ever observed for the ICCID. Deduplicate on event_id.

It is sent only for an eSIM that has a traveller. A change read more than 24 hours after at is not sent.

event_id entity: the provider's location update, so event_id reads esim.location_change:{update id}.

FieldTypeMeaning
iccidstringThe eSIM.
from_countrystring|nullISO 3166-1 alpha-3 of the country the eSIM was last seen in. null on the first attach ever observed for the ICCID.
to_countrystringISO 3166-1 alpha-3 of the country the eSIM attached in.
first_attachbooleantrue exactly when from_country is null.
atstringWhen the network saw the location update. ISO 8601 UTC, milliseconds. Order by this field.
mccstring|nullMobile country code of the network attached to.
mncstring|nullMobile network code of the network attached to.
result_codestring|nullThe network's result, as the operator reports it: CA (accepted), RJ (rejected), PI, SO. Every result is sent: the country is known whether or not the attach was accepted.
lu_typestring|nullThe location-update type, as the operator reports it (GPRS, VOICE).
batch_idstringThe feed file the change was read from. Opaque.
external_referencestringAdditional: the provider's id for the location update, the same value event_id ends with.
external_user_idstring|nullAdditional: the traveller's id in your own system.

payment.refunded

Stripe reported a refund on a payment made with Hubby. One event per Stripe refund, so a second partial refund is a second event. It is sent also when the refunded sale was never redeemed; package_id is then null.

FieldTypeMeaning
payment_idstring|nullThe payment processor's id (Stripe pi_…). Unlike on topup.completed, this is not Hubby's payment record.
refund_idstring|nullThe Stripe refund id, as Stripe sends it. Usually re_…, but not always (pyr_… also occurs). Do not parse the prefix.
amountnumber|nullThis refund's amount, not the running total, in major units of currency (12.5 for €12.50; a zero-decimal currency such as JPY is not divided). A whole amount is an integer (30). null when Stripe did not include the refund.
currencystring|nullISO 4217, lowercase.
refund_typestring|nullfull as soon as the refunded total reaches the charge, else partial.
booking_idstring|nullYour booking reference of the booking the eSIM was claimed with.
external_user_idstring|nullThe traveller's id in your own system.
package_idstring|nullThe Package the refunded payment provisioned. null for a sale not yet redeemed and for an upgrade applied in place.
package_queue_uuidstring|nullThe package-queue item the refunded payment paid for. null for an upgrade applied in place.
promo_code_idstring|nullThe discount code the refunded checkout applied. Not the claim code that promo_code_id names on the other events.
transaction_id, amount_minorAdditional: Hubby's payment record, and amount in minor units as Stripe reported it.

booking.created

A booking was created, including by a bulk import. event_id entity: the booking, so an endpoint receives one delivery per booking, however many travellers it names.

The body is the fields below, then the Booking fields:

FieldTypeMeaning
external_user_idstring|nullThe id, in your own system, of the traveller on the booking's first package line that has one. null when no line names one.
booking_idstring|nullYour booking reference.
departure_datestring|nullISO 8601 UTC, milliseconds. A date sent without a time is midnight UTC: 2026-10-02T00:00:00.000Z.
return_datestring|nullThe same format.
statusstringPENDING on booking.created, CANCELLED on booking.cancelled. Only the webhooks send it: the /v1 Booking has no status. It names the event that sent the body, not the booking's current state.
destinationsarray of stringsThe destination code of the packages, each once, in package order: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA), as the legacy webhooks sent them.
packagesarrayOne {destination, size, package_type, package_duration} per package line on the whole booking, in the order they were created, not deduplicated: two identical packages are two entries. size is the size as a booking specifies it, rebuilt from bytes: GB for a whole number of GiB, else MB ("500MB", "1GB"; 1.5 GiB is "1536MB"), not the Conventions rounding. package_type follows Conventions. package_duration is whole days, or null. destination is the destination code (see destinations). A line with no destination at all is left out of packages and destinations; a region line is listed.
booking, days_until_departure, destination_details, esim_installed, first_package_queue_item, package_queue_items, package_queue_items_countAdditional. As in Booking fields. destination_details names the same destinations as destinations, as objects.

booking.cancelled

You cancelled a booking through the booking API (DELETE /v1/bookings/{id}, or the legacy DELETE /bookings/{id} and DELETE /bookings/{id}/byExternalBookingId): its trip contact was anonymised and every package still queued on it was cancelled, so none of its codes can be redeemed any more. event_id entity: the booking, so an endpoint receives one delivery per booking. Sent once: cancelling a booking that is already cancelled succeeds and sends nothing, and a refused cancellation sends nothing.

The body is booking.created's, field for field, read from the booking after the cancellation, so one parser reads both:

  • status is CANCELLED, and booking.cancelled_at is when the booking was cancelled.
  • No field carries the trip contact: the cancellation is recorded after the anonymisation, and no booking event ever carries it.
  • first_package_queue_item and package_queue_items still name the traveller's items, and on native_app and webapp their codes, so an endpoint can match them to what booking.created sent; those codes no longer redeem. On partner_app each item is {id}, as on booking.created.

The other events

These events use their own field names, apart from booking_id, which is your booking reference on each of them (see Conventions). The Conventions for size and package_type do not apply here: these events send size as a label ("3GB", "1.50GB", "500MB", "0 Bytes", binary units) and package_type as data_limited, starter or unlimited.

Rules that hold for all of them:

  • Ids are 26-character ULIDs unless the table says otherwise. Their letter case follows the rule for every event above.
  • destination is the Package's destination code: ISO 3166-1 alpha-3, uppercase, for a country (NLD); for a region or global destination the region code the legacy webhooks sent (EU, XCA). null only when the Package has no destination.
  • Timestamps inside data are ISO 8601 UTC with six fractional digits. A timestamp read from a stored date ends in .000000Z; redeemed_at carries real microseconds. The envelope timestamp is different: it has milliseconds on every event (see Envelope).
  • external_user_id is the traveller's id in your own system. It is the one key that can be absent rather than null: it is left out when the event has no customer. Only upgrade_offer.purchased can reach you without one.

Package fields

Sent by package_queue.claimed.

FieldTypeMeaning
package_idstringThe Package.
external_referencestring|nullThe connectivity provider's id for the Package.
queue_idstring|nullThe package-queue item that provisioned the Package.
bundle_idstring|nullThe catalogue Bundle the Package was provisioned from.
esim_iccidstringThe eSIM the Package is on.
booking_idstring|nullYour reference of the booking of the claimed package-queue item: external_reference on /api/v1. null when that booking has none, or the item has no booking.
promo_codestring|nullThe code of the package-queue item.
promo_code_idstring|nullThe code the eSIM was claimed with. It differs from promo_code for a Package that a top-up provisioned.
booking_referencestring|nullYour reference of the booking the eSIM was claimed with. It differs from booking_id when the claim put the Package on an eSIM the traveller already had (is_top_up).
destinationstring|nullSee the rules above.
destination_namestring|nullThe destination's English name.
package_typestring|nulldata_limited, starter or unlimited.
size_bytesinteger|nullTotal data, in bytes. For an unlimited Package, the fair-use ceiling.
sizestring|nullsize_bytes as a label.
duration_daysinteger|nullThe Package's time allowance in days, rounded.
activated_atstring|nullWhen the Package became active.
expires_atstring|nullThe provider's activation deadline, as on the Partner API (see Conventions).
user_idstring|nullHubby's customer id.
partner_idstring|nullHubby's id for your Partner account.

Booking fields

Sent by booking.created and booking.cancelled, after their own fields. Their own destinations is an array of strings (see booking.created); the objects for the same destinations are in destination_details.

FieldTypeMeaning
booking.idstringHubby's booking id.
booking.external_referencestring|nullYour booking reference.
booking.brand_slugstring|nullThe brand the booking is shown under; null for your default.
booking.departure_datestring|nullYYYY-MM-DD.
booking.return_datestring|nullYYYY-MM-DD.
booking.localestring|nullFor example en-GB.
booking.partnerstringYour Partner slug (not an id).
booking.cancelled_atstring|nullWhen the booking was cancelled.
destination_detailsarrayOne object per destination: iso3 (string|null: the destination code, ISO 3166-1 alpha-3 for a country and the region code for a region, under this key name for both), name (English), translated_name (in the booking's locale when a translation exists, else English).
days_until_departureinteger|nullWhole days from today (UTC) to departure, never below 0. null when there is no departure date.
first_package_queue_itemobject{id, code, uuid} of the traveller's first package-queue item on the booking. id and uuid are two different identifiers, not two spellings of one — see Two identifiers per package-queue item below. On the partner_app channel the object is {id}: code and uuid are absent, not null.
package_queue_itemsarrayEvery such item, the same shape.
package_queue_items_countintegerHow many there are.
esim_installedbooleanWhether the traveller's current eSIM is installed.
external_user_idstring|nullSee the rules above.

The three package_queue keys are absent, not null, for a traveller with no package-queue item on the booking.

Neither of the item's two ready-made links (upgrade_url, deep_link_url) is sent on this or any other event — they are booking API fields, described in The two links on a booking item. On native_app and webapp this event carries the uuid one of them is composed from, so an endpoint that wants the upgrade link without a second call can build it; the deep link needs the code, which this event also carries. On partner_app your travellers have neither a code to type nor an upgrade page to open, so the two bearer values are not sent: each item is {id}.

A booking event's event_id is the booking's, so an endpoint receives one delivery of each booking event per booking. When a booking has several travellers, that delivery describes the first traveller recorded; the others are not delivered.

Two identifiers per package-queue item

A package-queue item carries two identifiers that are both called some form of "uuid" somewhere in Hubby's APIs. They are different values and are never interchangeable:

ValueWhere you read itWhat it is for
The item's idfirst_package_queue_item.id and package_queue_items[].id here; package_queue_uuid on package.activated, package.purchased, package.usage.*, package.first_usage and payment.refunded; item_id on package.terminated; queue_id on the rest; promo_codes[].uuid and package_queues[].uuid on the booking API's legacy routes; items[].id on /api/v1Correlation. This is the value that joins a booking response to the event stream.
The item's offer keyfirst_package_queue_item.uuid and package_queue_items[].uuid here; offer_uuid on the booking API, both the legacy routes and /api/v1, except on partner_app, where items carry no offer keyThe traveller's upgrade-offer page, where they can buy a larger package before redeeming. Nothing else uses it, and it joins nothing.

Two consequences:

  • package_queue_uuid is the id, despite the name. It does not carry the offer key, on any event. The name is kept from the legacy webhooks. For an item migrated from the legacy platform it is the item's legacy id, not items[].id (see Conventions).
  • promo_codes[].uuid on the booking API is the id, not the offer key. To build an upgrade link, read offer_uuid, or simply send the upgrade_url the booking API returns ready-made.

The offer link itself is {public-pages base URL}/offer/{offer key}. You do not need to know that shape: every booking API response that carries an item carries upgrade_url beside offer_uuid, already composed, except on partner_app, where both are absent. The link is a bearer capability URL — whoever opens it can buy an upgrade for that package — so it belongs only in the traveller's hands, never anywhere public.

The booking API returns two ready-made links per item. Neither is sent on any webhook event; both are read off a booking response.

FieldWhat it opensNull when
upgrade_urlThe upgrade-offer page, where the traveller buys a larger package before redeeming. Composed from the offer key above.No public-pages deployment is configured for the environment.
deep_link_urlThe Hubby app, which applies the item's code automatically so the traveller never types it. On a device without the app it leads to the store; on desktop, to a download page.The item carries no code.

Both are meant to be used as given, not rebuilt. Their base URLs are deployment configuration and can move without notice; the fields cannot.

promo_code.redeemed

A traveller claimed a code. event_id entity: the package-queue item, so each redemption of a multi-use code is its own event. It goes to the claimant's Partner.

FieldTypeMeaning
promocodestring|nullThe code claimed.
queue_idstringThe package-queue item.
booking_idstring|nullYour booking reference of the claimed item: external_reference on /api/v1. null for a code with no booking.
redeemed_atstringWhen it was claimed.
redeemed_bystring|nullThe claimant's email address. Personal data.
external_user_idstring|nullSee the rules above.

package_queue.claimed

A traveller redeemed your grant, or you issued it over the Partner API, and its Package was provisioned. The grant is a package-queue item from a booking, or one redeemed through a promo code. Sent once per item: a re-issue after a terminate carries the same event_id, so it is not delivered again. A promo code with no booking fires it too, with booking_id null. A purchase, a paid top-up, a Switch or an Operator grant does not fire it. event_id entity: the package-queue item. activated_at is usually null at this point; expires_at is set. Body: Package fields and:

FieldTypeMeaning
is_top_upbooleanWhether the traveller already had a current eSIM.
external_user_idstring|nullSee the rules above.

upgrade_offer.purchased

An upgrade offer was paid, either as an upgrade of an existing package-queue item or as a top-up. event_id entity: the upgraded item, or the top-up's new item. It is sent when the payment settles: an upgrade provisions nothing, so it has no Package to wait for. The Package description is what was sold, not what was provisioned; for an upgrade, the Package description, booking_id and promo_code describe the item before the upgrade.

FieldTypeMeaning
queue_idstring|nullThe upgraded item, or the top-up's new item.
transaction_idstringHubby's payment record.
booking_idstring|nullYour booking reference of the upgraded item's booking: external_reference on /api/v1. Set when the upgraded item came from your booking; null for a top-up, and for an item whose booking has no reference or that has no booking.
payment_idstring|nullThe Stripe PaymentIntent id (pi_…).
amountintegerThe price in EUR cents, whatever currency the customer paid in.
currencystringAlways "EUR", uppercase.
iccidstring|nullThe traveller's current eSIM for a top-up; null for an upgrade.
package_typestring|nulldata_limited, starter or unlimited.
destinationstring|nullSee the rules above.
destination_namestring|nullThe destination's English name.
size_bytesinteger|nullData, in bytes.
sizestring|nullsize_bytes as a label.
duration_daysinteger|nullDays.
promo_codestring|nullThe code of the package-queue item.
discount_codestring|nullThe discount code applied to the price.
external_user_idstring|nullSee the rules above.

If you used the legacy webhooks

The envelope, the headers and the signature scheme are the legacy webhooks', so your verification keeps working with the secret you already hold. The event names and the bodies differ as below.

Names

EventOn the legacy webhooksNote
package.activated, topup.completed, promo_code.redeemed, esim.installed, esim.removed, package.usage.*, payment.refunded, package.purchased, booking.created, esim.location_changesame nameLegacy key set, with the differences below.
package_queue.claimedclassic_package_queue.claimedRenamed. Same keys, plus the Package fields. A legacy subscription to the old name becomes a subscription to the new one.
—booking.within_cutoff, booking.about_to_departNot sent. Time a departure reminder from booking.created's departure_date.
package.first_usage, package.terminated, booking.cancelled, upgrade_offer.purchased—New.

The other classic_* events are not sent.

Values that differ

  • booking_id is always your booking reference, or null. On the legacy webhooks it could be a Hubby document id or a Checkout Session id.
  • Fields marked Additional were not on the legacy webhooks. Ignore the keys you do not read.
  • Envelope timestamp is the moment the event occurred, not the moment it was queued. event_id and delivery_id ids have a new form; they never collide with legacy ids.
  • Static header prefix: one space is put between the prefix and the value. A prefix stored as "Bearer" arrives as Bearer key, not Bearerkey.
  • Key order inside data can differ. Verify over the bytes received.
  • package.activated and package.usage.*: booking_id is the booking that bought the Package (the booking of its own item). The legacy value was the promo's booking, or else the booking of the Package's own item; the two differ when the promo's booking is not the Package's own.
  • package.usage.*: fired only by the provider's data alert, never on elapsed time, and the 50 percent event fires on its own window, whatever the template. The byte counts are from the alert that fires the event, or from the last stored reading when the alert carries none. remaining_bytes is null, not 0, when the total is unknown. elapsed_days counts calendar days, not whole 24-hour periods. remaining_days counts calendar days to the end of the validity; the legacy value was duration_days − elapsed_days in 24-hour periods, so the two can differ by one day.
  • esim.installed is sent when the provider reports the profile installed on a device, enabled or not; not when the profile starts to download.
  • esim.location_change is sent only for an eSIM that has a traveller, and not when the change is read more than 24 hours late. event_id is the same value as on the legacy webhooks for the same update.
  • topup.completed: payment_id is always Hubby's payment record; the Stripe id is always on payment_intent_id. The event is sent when the Package exists, not at payment.
  • package.purchased: sent when the purchase is redeemed, always with the iccid of the eSIM the Package went onto and the package_queue_uuid.
  • payment.refunded: refund_type is full as soon as the refunded total reaches the charge; amount is divided by the currency's own factor, not always by 100; the event is sent even when no eSIM is found for the payment.
  • booking.created: destination values follow the Conventions rule (ISO 3166-1 alpha-3 for a country, the region code for a region), and size is rebuilt from bytes ("1024MB" comes back as "1GB", and 1.5 GiB as "1536MB").
  • promo_code.redeemed: every redemption of a multi-use code is sent, not only the first.
  • promo_codes[].uuid on the booking API's legacy routes is the item's id. On the legacy API it was the offer key; read offer_uuid instead.
  • deep_link_url on booking responses is the same link as on the legacy API, with the same shape.