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:
- Set-up: how an endpoint is created.
- Verification: how you prove that a delivery comes from Hubby.
- Delivery: answers, retries and deduplication.
- Catalogue: every event, when it fires and which channels send it.
- 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.createdincluded. - 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-keyorAuthorization: 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: onesha256=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 thex-hubby-event-idheader. It stays the same across retries and replays.delivery_id(alsox-hubby-delivery-id) identifies one delivery; quote it when you contact support. - Answer
2xxfast. The request times out after 10 seconds. Store the event and process it after you answer. - A
4xxother than429is final. Hubby does not retry it. Use4xxonly 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 a301,302or303becomes aGETwithout 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-timestampand signature; theevent_idand the body, the envelopetimestampincluded, 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.
| Event | Fires when | Channels |
|---|---|---|
booking.created | A booking is created, also by a bulk import. | All |
booking.cancelled | You cancel a booking over the booking API. | All |
package_queue.claimed | A traveller redeems your grant, or you issue it over the Partner API, and its package is provisioned. | All |
package.activated | The provider activates the package and reports it to Hubby. | All |
package.usage.50_percent, package.usage.80_percent, package.usage.100_percent | The 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_usage | The package's used data reaches 10 MB. Seen up to about 15 minutes late. | All |
package.terminated | You terminate a package you issued, before it activates. | Native |
esim.installed, esim.removed | The 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_change | The 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.redeemed | A traveller claims a code. | Hubby app, Hubby web app |
package.purchased | A paid purchase is provisioned. | Hubby app, Hubby web app |
topup.completed | A paid top-up is provisioned. | Hubby app, Hubby web app |
upgrade_offer.purchased | A traveller pays for an upgrade offer. | Hubby app, Hubby web app |
payment.refunded | A 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_cutoffandbooking.about_to_depart: departure reminders.classic_package_queue.claimed: a classic-eSIM grant claimed in the Hubby app. The core name ispackage_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.
| Event | Fires when | Channels |
|---|---|---|
booking.created | A booking is created, also by a bulk import. | All |
booking.cancelled | You cancel a booking over the booking API. | All |
package_queue.claimed | A traveller redeems your grant, or you issue it over the Partner API, and its Package is provisioned. | All |
package.activated | The provider activates the Package and reports it to Hubby. | All |
package.usage.50_percent, package.usage.80_percent, package.usage.100_percent | The provider's data-percentage alert crosses a threshold (see the package.usage.* section). | All |
package.first_usage | The Package's used data reaches 10 MB. Seen up to about 15 minutes late. | All |
package.terminated | You terminate a Package you issued, before activation. | partner_app |
esim.installed, esim.removed | The provider reports the eSIM profile installed on a device (enabled or not) for the first time, or deleted. | All |
esim.location_change | The eSIM attaches to a network in another country. | All |
promo_code.redeemed | A traveller claims a code in a Hubby app. | native_app, webapp |
package.purchased | A paid purchase is provisioned. | native_app, webapp |
topup.completed | A paid top-up is provisioned. | native_app, webapp |
upgrade_offer.purchased | An upgrade offer is paid on the upgrade-offer page. | native_app, webapp |
payment.refunded | Stripe 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
4xxis final. Switch your verifier the moment you receive the new secret. If you need time to switch, answer5xxwhile you do, so that the delivery is retried. - 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-keywhen 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, itsevent_idis{event}:test-{random}, its envelopetimestampcarries real milliseconds, and with a Flat body thetest: truemarker 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
4xxother than429is final. The delivery is not retried. - Anything else is retried: a
5xx, a429, 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 a301,302or303is followed as aGETwithout 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-timestampand signature;event_idand the body, the envelopetimestampincluded, 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 repeatsevent_idanddelivery_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…"
}| Key | Meaning |
|---|---|
event | The contract event name. |
timestamp | ISO 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). |
data | The 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_id | Identifies 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 fieldnameout ofdatainto the output key; any other value is a constant. Every field ofdatathat 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 isnull.flat_payload(a Flat body): the endpoint receives thedataobject alone (after its template, if any), withevent_idanddelivery_idappended to it. There is noeventkey and notimestampkey: tell the events apart byx-hubby-event-id, or byevent_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.
| Header | Sent | Value |
|---|---|---|
x-hubby-signature | always, 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-timestamp | always | Unix time in seconds when this attempt was sent. It is the timestamp the signature covers. |
x-hubby-event-id | always | The same value as event_id in the body. |
x-hubby-delivery-id | always | The same value as delivery_id in the body. |
x-hubby-synthetic | only on a Test event | true. 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 endpoint | only when a static header is set | The 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.
sizeis 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_daysis the field that tells unlimited Packages apart.package_typeisdata-limited,starterorunlimitedon these events. The other events senddata_limited. The deprecatedtime-limitedis never sent, on any event.expires_atis the provider's activation deadline: the last moment the Package can start. It is the same value the Partner API returns asexpires_at. It is not activation plus the time allowance.destinationis 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 destinationid.
For every event:
booking_idis your booking reference (external_referenceon/api/v1), ornullwhen 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_uuidandqueue_idboth carry a package-queue item's id:items[].idon/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_uuidis the item's id on the legacy platform, which is the id you received for it there.queue_idanditems[].idstay Hubby's id for the same item.package_idis 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.
| Field | Type | Meaning |
|---|---|---|
iccid | string | The eSIM the Package is on. |
package_id | string | The Package. Opaque. |
package_queue_uuid | string|null | The package-queue item that provisioned the Package. |
external_user_id | string|null | The traveller's id in your own system. |
booking_id | string|null | Your 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_id | string|null | The code the eSIM was claimed with. |
destination | string|null | See Conventions: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA). |
size | string|null | See Conventions. |
package_type | string|null | See Conventions. |
duration_days | integer|null | The Package's time allowance in days, rounded. |
activated_at | string|null | ISO 8601 UTC, milliseconds. |
expires_at | string|null | The provider's activation deadline, as on the Partner API (see Conventions). ISO 8601 UTC, milliseconds. |
elapsed_days | integer|null | Calendar days since activation: normally 0 on this event. Additional. |
remaining_days | integer|null | Calendar 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_id | Additional. |
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.
| Field | Type | Meaning |
|---|---|---|
iccid | string | The eSIM the Package was on. The traveller keeps it. |
external_user_id | string|null | The traveller's id in your own system. |
booking_id | string|null | Your booking reference: external_reference on /v1/bookings. |
booking_reference | string|null | The same value as booking_id, under the Partner API's name. |
item_id | string | The booking item: items[].id on /v1/bookings. |
package_id | string | The Package that was ended: id of the issued package. Opaque. |
reason | string|null | The reason the terminate call sent: changed_plans, installation_failed, not_received or other; null when it sent none. |
terminated_at | string | When 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.
| Field | Type | Meaning |
|---|---|---|
iccid | string | The eSIM the top-up landed on. |
package_id | string | The Package the top-up provisioned. |
payment_id | string | Hubby's payment record. Never null. See below. |
payment_intent_id | string|null | The payment processor's id (Stripe pi_…). |
amount | integer|null | What the customer was charged, in minor units of currency. |
currency | string|null | ISO 4217, lowercase. |
external_user_id | string|null | The traveller's id in your own system. |
booking_id | string|null | Your booking reference of the booking the eSIM was claimed with. |
promo_code_id | string|null | The code the eSIM was claimed with — not a code on the top-up. |
destination | string|null | See Conventions: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA). |
size | string|null | See Conventions. |
package_type | string|null | See Conventions. |
duration_days | integer|null | The 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_code | Additional. 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.
| Field | Type | Meaning |
|---|---|---|
iccid | string | The eSIM the purchase was provisioned onto. |
payment_id | string|null | The payment processor's id (Stripe pi_…). |
amount | integer|null | What the customer was charged, in minor units of currency. |
currency | string|null | ISO 4217, lowercase. |
package_queue_uuid | string | The package-queue item the sale created. The same value package.activated sends. |
external_user_id | string|null | The traveller's id in your own system. |
booking_id | string|null | Your booking reference of the booking the eSIM was claimed with. null when the purchase's redeem claimed the eSIM. |
promo_code_id | string|null | The 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_id | Additional. 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.
| Field | Type | Meaning |
|---|---|---|
iccid | string | The eSIM the Package is on. |
package_id | string | The Package. Opaque. |
package_queue_uuid | string|null | The package-queue item that provisioned the Package. |
external_user_id | string|null | The traveller's id in your own system. |
booking_id | string|null | Your booking reference of the booking that bought the Package, as on package.activated. |
promo_code_id | string|null | The code the eSIM was claimed with. |
destination | string|null | See Conventions: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA). |
size | string|null | See Conventions. |
package_type | string|null | See Conventions. |
duration_days | integer|null | The Package's time allowance in days, rounded. |
usage_percent | integer | The percentage the provider reported, truncated. On 100_percent it can be any value above 90, including above 100. |
used_bytes | integer | Data used. |
remaining_bytes | integer|null | Data left, never below 0. null when the total is unknown. |
elapsed_days | integer|null | Calendar days since activation. null before activation. |
remaining_days | integer|null | Calendar 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_id | Additional. 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.
| Field | Type | Meaning |
|---|---|---|
iccid | string | The eSIM the Package is on. |
external_user_id | string|null | The traveller's id in your own system. |
booking_id | string|null | Your booking reference of the booking that bought the Package, as on package.activated. |
package_id | string | The Package. Opaque. |
package_queue_uuid | string|null | The package-queue item that provisioned the Package. |
promo_code_id | string|null | The code the eSIM was claimed with. |
destination | string|null | See Conventions: ISO 3166-1 alpha-3 for a country (NLD), the region code for a region or global destination (EU, XCA). |
size | string|null | See Conventions. |
package_type | string|null | See Conventions. |
duration_days | integer|null | The Package's time allowance in days, rounded. |
size_bytes | integer|null | The total data, in bytes. |
used_bytes | integer | Data used when the threshold was observed. |
first_used_at | string | When 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.
| Field | Type | Meaning |
|---|---|---|
iccid | string | The eSIM. |
external_user_id | string|null | The traveller's id in your own system. |
booking_id | string|null | Your 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_id | string|null | The code the eSIM was claimed with. null in the same cases as booking_id. |
esim_id, package_id, queue_id | Additional. 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}.
| Field | Type | Meaning |
|---|---|---|
iccid | string | The eSIM. |
from_country | string|null | ISO 3166-1 alpha-3 of the country the eSIM was last seen in. null on the first attach ever observed for the ICCID. |
to_country | string | ISO 3166-1 alpha-3 of the country the eSIM attached in. |
first_attach | boolean | true exactly when from_country is null. |
at | string | When the network saw the location update. ISO 8601 UTC, milliseconds. Order by this field. |
mcc | string|null | Mobile country code of the network attached to. |
mnc | string|null | Mobile network code of the network attached to. |
result_code | string|null | The 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_type | string|null | The location-update type, as the operator reports it (GPRS, VOICE). |
batch_id | string | The feed file the change was read from. Opaque. |
external_reference | string | Additional: the provider's id for the location update, the same value event_id ends with. |
external_user_id | string|null | Additional: 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.
| Field | Type | Meaning |
|---|---|---|
payment_id | string|null | The payment processor's id (Stripe pi_…). Unlike on topup.completed, this is not Hubby's payment record. |
refund_id | string|null | The Stripe refund id, as Stripe sends it. Usually re_…, but not always (pyr_… also occurs). Do not parse the prefix. |
amount | number|null | This 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. |
currency | string|null | ISO 4217, lowercase. |
refund_type | string|null | full as soon as the refunded total reaches the charge, else partial. |
booking_id | string|null | Your booking reference of the booking the eSIM was claimed with. |
external_user_id | string|null | The traveller's id in your own system. |
package_id | string|null | The Package the refunded payment provisioned. null for a sale not yet redeemed and for an upgrade applied in place. |
package_queue_uuid | string|null | The package-queue item the refunded payment paid for. null for an upgrade applied in place. |
promo_code_id | string|null | The discount code the refunded checkout applied. Not the claim code that promo_code_id names on the other events. |
transaction_id, amount_minor | Additional: 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:
| Field | Type | Meaning |
|---|---|---|
external_user_id | string|null | The 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_id | string|null | Your booking reference. |
departure_date | string|null | ISO 8601 UTC, milliseconds. A date sent without a time is midnight UTC: 2026-10-02T00:00:00.000Z. |
return_date | string|null | The same format. |
status | string | PENDING 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. |
destinations | array of strings | The 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. |
packages | array | One {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_count | Additional. 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:
statusisCANCELLED, andbooking.cancelled_atis 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_itemandpackage_queue_itemsstill name the traveller's items, and onnative_appandwebapptheir codes, so an endpoint can match them to whatbooking.createdsent; those codes no longer redeem. Onpartner_appeach item is{id}, as onbooking.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.
destinationis 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).nullonly when the Package has no destination.- Timestamps inside
dataare ISO 8601 UTC with six fractional digits. A timestamp read from a stored date ends in.000000Z;redeemed_atcarries real microseconds. The envelopetimestampis different: it has milliseconds on every event (see Envelope). external_user_idis the traveller's id in your own system. It is the one key that can be absent rather thannull: it is left out when the event has no customer. Onlyupgrade_offer.purchasedcan reach you without one.
Package fields
Sent by package_queue.claimed.
| Field | Type | Meaning |
|---|---|---|
package_id | string | The Package. |
external_reference | string|null | The connectivity provider's id for the Package. |
queue_id | string|null | The package-queue item that provisioned the Package. |
bundle_id | string|null | The catalogue Bundle the Package was provisioned from. |
esim_iccid | string | The eSIM the Package is on. |
booking_id | string|null | Your 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_code | string|null | The code of the package-queue item. |
promo_code_id | string|null | The code the eSIM was claimed with. It differs from promo_code for a Package that a top-up provisioned. |
booking_reference | string|null | Your 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). |
destination | string|null | See the rules above. |
destination_name | string|null | The destination's English name. |
package_type | string|null | data_limited, starter or unlimited. |
size_bytes | integer|null | Total data, in bytes. For an unlimited Package, the fair-use ceiling. |
size | string|null | size_bytes as a label. |
duration_days | integer|null | The Package's time allowance in days, rounded. |
activated_at | string|null | When the Package became active. |
expires_at | string|null | The provider's activation deadline, as on the Partner API (see Conventions). |
user_id | string|null | Hubby's customer id. |
partner_id | string|null | Hubby'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.
| Field | Type | Meaning |
|---|---|---|
booking.id | string | Hubby's booking id. |
booking.external_reference | string|null | Your booking reference. |
booking.brand_slug | string|null | The brand the booking is shown under; null for your default. |
booking.departure_date | string|null | YYYY-MM-DD. |
booking.return_date | string|null | YYYY-MM-DD. |
booking.locale | string|null | For example en-GB. |
booking.partner | string | Your Partner slug (not an id). |
booking.cancelled_at | string|null | When the booking was cancelled. |
destination_details | array | One 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_departure | integer|null | Whole days from today (UTC) to departure, never below 0. null when there is no departure date. |
first_package_queue_item | object | {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_items | array | Every such item, the same shape. |
package_queue_items_count | integer | How many there are. |
esim_installed | boolean | Whether the traveller's current eSIM is installed. |
external_user_id | string|null | See 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:
| Value | Where you read it | What it is for |
|---|---|---|
| The item's id | first_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/v1 | Correlation. This is the value that joins a booking response to the event stream. |
| The item's offer key | first_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 key | The 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_uuidis 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, notitems[].id(see Conventions).promo_codes[].uuidon the booking API is the id, not the offer key. To build an upgrade link, readoffer_uuid, or simply send theupgrade_urlthe 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 two links on a booking item
The booking API returns two ready-made links per item. Neither is sent on any webhook event; both are read off a booking response.
| Field | What it opens | Null when |
|---|---|---|
upgrade_url | The 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_url | The 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.
| Field | Type | Meaning |
|---|---|---|
promocode | string|null | The code claimed. |
queue_id | string | The package-queue item. |
booking_id | string|null | Your booking reference of the claimed item: external_reference on /api/v1. null for a code with no booking. |
redeemed_at | string | When it was claimed. |
redeemed_by | string|null | The claimant's email address. Personal data. |
external_user_id | string|null | See 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:
| Field | Type | Meaning |
|---|---|---|
is_top_up | boolean | Whether the traveller already had a current eSIM. |
external_user_id | string|null | See 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.
| Field | Type | Meaning |
|---|---|---|
queue_id | string|null | The upgraded item, or the top-up's new item. |
transaction_id | string | Hubby's payment record. |
booking_id | string|null | Your 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_id | string|null | The Stripe PaymentIntent id (pi_…). |
amount | integer | The price in EUR cents, whatever currency the customer paid in. |
currency | string | Always "EUR", uppercase. |
iccid | string|null | The traveller's current eSIM for a top-up; null for an upgrade. |
package_type | string|null | data_limited, starter or unlimited. |
destination | string|null | See the rules above. |
destination_name | string|null | The destination's English name. |
size_bytes | integer|null | Data, in bytes. |
size | string|null | size_bytes as a label. |
duration_days | integer|null | Days. |
promo_code | string|null | The code of the package-queue item. |
discount_code | string|null | The discount code applied to the price. |
external_user_id | string|null | See 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
| Event | On the legacy webhooks | Note |
|---|---|---|
package.activated, topup.completed, promo_code.redeemed, esim.installed, esim.removed, package.usage.*, payment.refunded, package.purchased, booking.created, esim.location_change | same name | Legacy key set, with the differences below. |
package_queue.claimed | classic_package_queue.claimed | Renamed. 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_depart | Not 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_idis always your booking reference, ornull. 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
timestampis the moment the event occurred, not the moment it was queued.event_idanddelivery_idids 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 asBearer key, notBearerkey. - Key order inside
datacan differ. Verify over the bytes received. package.activatedandpackage.usage.*:booking_idis 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_bytesisnull, not0, when the total is unknown.elapsed_dayscounts calendar days, not whole 24-hour periods.remaining_dayscounts calendar days to the end of the validity; the legacy value wasduration_days − elapsed_daysin 24-hour periods, so the two can differ by one day.esim.installedis sent when the provider reports the profile installed on a device, enabled or not; not when the profile starts to download.esim.location_changeis sent only for an eSIM that has a traveller, and not when the change is read more than 24 hours late.event_idis the same value as on the legacy webhooks for the same update.topup.completed:payment_idis always Hubby's payment record; the Stripe id is always onpayment_intent_id. The event is sent when the Package exists, not at payment.package.purchased: sent when the purchase is redeemed, always with theiccidof the eSIM the Package went onto and thepackage_queue_uuid.payment.refunded:refund_typeisfullas soon as the refunded total reaches the charge;amountis 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:destinationvalues follow the Conventions rule (ISO 3166-1 alpha-3 for a country, the region code for a region), andsizeis 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[].uuidon the booking API's legacy routes is the item's id. On the legacy API it was the offer key; readoffer_uuidinstead.deep_link_urlon booking responses is the same link as on the legacy API, with the same shape.