Webhooks Guide
Overview
Webhooks notify your endpoint about events in a traveler's eSIM lifecycle — data usage thresholds crossed, eSIMs installed or removed, packages activated, bookings approaching departure. Use them to trigger push notifications, update your CRM, or drive automated workflows.
Each event is queued and delivered shortly after it occurs (typically within seconds), with automatic retries if your endpoint is briefly unavailable — see Retry Policy. Delivery is asynchronous and not guaranteed to be instant or ordered, so treat each event as independent and deduplicate on event_id.
Note: Webhook events are identical for both WebView and Native API integrations. The same events fire regardless of how the traveler interacts with their eSIM.
Setup
Contact support@hubbyesim.com to configure your webhook endpoint. You can register one URL per environment (staging and production).
Authentication
By default, every webhook request includes an x-api-key header carrying your configured API key. Verify this header matches your key before processing the payload.
If your endpoint expects the key under a different header, Hubby can deliver it under a custom header name (for example, Authorization) with an optional value prefix (for example, Bearer , producing Authorization: Bearer <your-key>). Contact support@hubbyesim.com to configure the header name and prefix for your endpoint — the default remains x-api-key with no prefix.
Signature verification
When a signing secret is configured, every webhook is signed so you can verify it came from Hubby and was not altered in transit. Each delivery carries:
x-hubby-signature— one or more comma-separatedsha256=<hex>values. Verify by recomputingHMAC-SHA256(secret, "{timestamp}.{body}"), hex-encoding the result, and accepting the request if it matches any entry in the list. Signatures are lowercase hex. Treat it as a list from day one — today we send a single signature, but the list form lets us add a second one during a future secret rotation without any change on your side.x-hubby-timestamp— the signing timestamp, in epoch seconds. This is the{timestamp}in the string above.
The signed string is the timestamp, a literal ., then the exact raw request body as received. Verify against the raw bytes — don't re-serialize the JSON first, as key reordering or whitespace changes will break the match. Reject the request if now - x-hubby-timestamp exceeds your tolerance window (we recommend ~5 minutes); this is your replay protection.
A secret stays valid until it is regenerated — there is no scheduled expiry. Regeneration is a hard cutover, so update your stored secret at the same moment. You receive separate secrets for staging and production.
Idempotency & event identity
Two identifiers accompany every delivery, both as headers and inside the JSON body (in case reading custom headers is awkward in your stack):
x-hubby-event-id/event_id— a stable identifier for the logical event. It stays identical across retries and across any replay of the same event, so deduplicate on it: processing the sameevent_idmore than once should be a no-op on your side.x-hubby-delivery-id/delivery_id— identifies a single delivery. Quote it when contacting support about a specific delivery. A replay carries the sameevent_idbut a newdelivery_id.
Payload Format
All webhook payloads share a common envelope:
{
"event": "event.name",
"timestamp": "2026-07-15T14:30:00Z",
"data": { ... },
"event_id": "esim.installed:abc123",
"delivery_id": "dlv_2f1c8e9a-7b3d-4a52-9c10-1e6b2f0a4d77"
}| Field | Type | Description |
|---|---|---|
event | string | Event type identifier |
timestamp | string | ISO 8601 UTC timestamp of when the event occurred. Distinct from the x-hubby-timestamp signing header (epoch seconds). |
data | object | Event-specific payload |
event_id | string | Stable identifier for the logical event (also the x-hubby-event-id header). Identical across retries and replays — deduplicate on it. |
delivery_id | string | Identifier for this single delivery (also the x-hubby-delivery-id header). A replay carries the same event_id but a new delivery_id. |
Note: The per-event examples below show
event,timestamp, anddatafor readability; every real delivery also includesevent_idanddelivery_idat the top level as shown above.
Retry Policy
- A delivery is considered successful when your endpoint returns any 2xx status.
- If your endpoint returns 5xx or 429, times out, or is unreachable, Hubby retries with exponential backoff over a few hours (up to 12 attempts total).
- A 4xx (other than 429) is treated as a permanent rejection of the content and is not retried. Return 2xx for "received" and reserve 4xx for genuinely malformed requests.
- Every delivery is recorded, and any past event can be replayed to you on request. A replay carries the same
event_id(so your dedup keeps it safe) with a fresh signature and timestamp. - Deliveries are independent and ordering is not guaranteed; rely on
event_idfor deduplication rather than on arrival order.
Note: Design your webhook handler to be idempotent. The same event may be delivered more than once (retries, or a replay), always with the same
event_id.
Events
Package Usage
These events are emitted per package, not per eSIM. If a traveler has multiple packages on their universal eSIM, each package emits its own usage events independently. The package_id field identifies which package triggered the event.
For data-limited packages, events fire when data consumption crosses a threshold. For unlimited packages (and the deprecated time-limited type), the same events fire when the corresponding percentage of the package's duration has elapsed (e.g., package.usage.50_percent fires when half the validity period has passed).
Use these to send push notifications prompting top-ups before the package runs out.
package.usage.50_percent
{
"event": "package.usage.50_percent",
"timestamp": "2026-07-18T16:45:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"package_id": "pkg_xyz",
"package_queue_uuid": "b29bd4d7-f058-497d-bc7b-ada1c4fad0dd",
"promo_code_id": null,
"destination": "GR",
"size": "1GB",
"package_type": "data-limited",
"used_bytes": 536870912,
"remaining_bytes": 536870912,
"usage_percent": 50
}
}package.usage.80_percent
{
"event": "package.usage.80_percent",
"timestamp": "2026-07-20T11:30:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"package_id": "pkg_xyz",
"package_queue_uuid": "b29bd4d7-f058-497d-bc7b-ada1c4fad0dd",
"promo_code_id": null,
"destination": "GR",
"size": "1GB",
"package_type": "data-limited",
"used_bytes": 858993459,
"remaining_bytes": 214748365,
"usage_percent": 80
}
}package.usage.100_percent
{
"event": "package.usage.100_percent",
"timestamp": "2026-07-16T09:12:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"package_id": "pkg_xyz",
"package_queue_uuid": "b29bd4d7-f058-497d-bc7b-ada1c4fad0dd",
"promo_code_id": null,
"destination": "GR",
"size": "1GB",
"package_type": "data-limited",
"used_bytes": 1073741824,
"remaining_bytes": 0,
"usage_percent": 100
}
}Duration-based package example
For unlimited packages (and the deprecated time-limited type), the same events fire based on duration elapsed instead of data consumed:
{
"event": "package.usage.80_percent",
"timestamp": "2026-08-10T16:00:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"package_id": "pkg_time_xyz",
"package_queue_uuid": "b29bd4d7-f058-497d-bc7b-ada1c4fad0dd",
"promo_code_id": null,
"destination": "GR",
"package_type": "unlimited",
"duration_days": 30,
"elapsed_days": 24,
"remaining_days": 6,
"usage_percent": 80
}
}Tip: The 80% event is the most effective trigger for top-up conversion. A well-timed push notification at this point ("Running low on data in Greece — top up now" or "Your package expires in 6 days") drives significant revenue.
eSIM Status
Triggered when the eSIM's installation state changes on the traveler's device.
esim.installed
The traveler has successfully installed the eSIM on their device.
{
"event": "esim.installed",
"timestamp": "2026-07-14T18:20:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"iccid": "8901234567890123456"
}
}esim.removed
The traveler has removed the eSIM from their device.
{
"event": "esim.removed",
"timestamp": "2026-08-01T10:05:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"iccid": "8901234567890123456"
}
}Note: A removed eSIM can be re-installed. This event does not mean the traveler has lost access permanently.
Package Lifecycle
Triggered when a package changes state.
package.activated
A package has been activated and the traveler can now use data. This typically occurs when the traveler arrives at the destination and connects to a local network.
{
"event": "package.activated",
"timestamp": "2026-07-15T16:00:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"package_id": "pkg_xyz",
"package_queue_uuid": "b29bd4d7-f058-497d-bc7b-ada1c4fad0dd",
"promo_code_id": null,
"destination": "GR",
"size": "1GB",
"activated_at": "2026-07-15T16:00:00Z",
"expires_at": "2027-07-15T16:00:00Z"
}
}Purchase
package.purchased
Triggered when an attributed traveler buys a new eSIM — the first purchase for a given destination — through the Hubby app or web app. Every later purchase of more data for a destination the traveler already has fires topup.completed instead, so you receive exactly one event per purchase: package.purchased for the first, topup.completed for each subsequent one.
{
"event": "package.purchased",
"timestamp": "2026-07-12T09:30:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": null,
"iccid": "8901234567890123456",
"payment_id": "pi_3OabcdEfGhIjKlMn",
"amount": 3000,
"currency": "eur",
"promo_code_id": "SUMMER2026GR",
"package_queue_uuid": "b29bd4d7-f058-497d-bc7b-ada1c4fad0dd"
}
}| Field | Type | Description |
|---|---|---|
external_user_id | string | null | Your identifier for the traveler |
booking_id | string | null | The booking this eSIM originated from (promo-code-delivered eSIMs); null for webview shop purchases |
iccid | string | The newly purchased eSIM ICCID |
payment_id | string | Unique identifier for this payment (matches payment_id on topup.completed) |
amount | number | Amount charged, in the smallest currency unit (e.g. cents) |
currency | string | ISO 4217 currency code |
promo_code_id | string | null | Original promo code that started the partner attribution, when one exists |
package_queue_uuid | string | null | For web-app purchases, the handle that reappears as package_queue_uuid on package.activated and package.usage.* — store it to join the purchase to the rest of the lifecycle. null for native-app purchases (use iccid to link). |
Note:
package_idis not present on this event — the provisioned package does not exist yet at payment time. Capture it frompackage.activated(correlate viapackage_queue_uuid).
Promo Code
promo_code.redeemed
Triggered when a traveler successfully redeems a promo code.
{
"event": "promo_code.redeemed",
"timestamp": "2026-07-10T12:00:00Z",
"data": {
"promocode": "SUMMER2026GR",
"booking_id": "booking_abc",
"redeemed_at": "2026-07-10T12:00:00Z",
"redeemed_by": "user@example.com"
}
}Top-Up
topup.completed
Triggered when a traveler successfully completes a top-up payment.
{
"event": "topup.completed",
"timestamp": "2026-07-22T10:15:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"iccid": "8901234567890123456",
"payment_id": "payment_xyz",
"amount": 1200,
"currency": "EUR",
"promo_code_id": null,
"package_id": "pkg_xyz",
"destination": "GR",
"size": "1GB"
}
}| Field | Type | Description |
|---|---|---|
external_user_id | string | null | Your identifier for the traveler |
booking_id | string | null | The booking associated with this eSIM |
iccid | string | The eSIM ICCID |
payment_id | string | Unique identifier for this payment |
amount | number | Amount charged, in the smallest currency unit (e.g. cents) |
currency | string | ISO 4217 currency code (e.g. EUR) |
promo_code_id | string | null | Promo code applied to this top-up, or null |
package_id | string | null | Identifier of the package the top-up added to the eSIM, or null if it cannot be resolved |
destination | string | null | ISO country code of the topped-up package, or null for legacy packages without a stored destination |
size | string | null | Data allowance of the topped-up package (e.g. 1GB), or null when unknown |
Classic eSIM Grants
classic_package_queue.claimed
Fires when a traveler claims a classic-eSIM grant via the native app. Distinct from topup.completed: no payment is involved here, and the is_top_up flag only indicates whether the package was attached to an existing matching eSIM — it does not imply a billing event.
{
"event": "classic_package_queue.claimed",
"timestamp": "2026-07-18T14:00:00Z",
"data": {
"queue_id": "fe17e0ef-0cd3-4c2f-8e27-8cbaca0bde29",
"booking_id": "iVlU7xgTCUq0537I9GpH",
"esim_iccid": "8901234567890123456",
"user_id": "YMQsyXIUet1q2L1Z76TJwoO7QQqP",
"partner_id": "partner-1",
"is_top_up": false
}
}| Field | Type | Description |
|---|---|---|
queue_id | string | Stable identifier for the claimed package queue. Matches the uuid in BookingResponse.package_queues[]. |
booking_id | string | null | The originating booking. Present for grants created via POST /bookings. |
esim_iccid | string | ICCID the package was activated on — a fresh one, or an existing one matched on destination + IMSI |
user_id | string | Hubby's user identifier (Firebase uid) |
partner_id | string | Your partner identifier |
is_top_up | boolean | true when attached to an existing matching eSIM; false when a fresh ICCID was provisioned. Informational only. |
Booking Events
Triggered based on the booking's departure date and time. These are powered by the departure_date field you provide when creating a booking. Including a full datetime with timezone (e.g., 2026-07-15T14:30:00+02:00) enables precisely timed events.
booking.within_cutoff
The booking's departure date is now within the configured cutoff window (default: 7 days). Use this to trigger installation reminders — this is the optimal time for travelers to install their eSIM while still at home on Wi-Fi.
{
"event": "booking.within_cutoff",
"timestamp": "2026-07-08T08:00:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"departure_date": "2026-07-15T14:30:00+02:00",
"days_until_departure": 7,
"esim_installed": false
}
}Tip: The
esim_installedfield tells you whether the traveler has already installed their eSIM. Iffalse, this is the moment to send a push notification with installation instructions. Iftrue, you can skip it or send a "You're all set" confirmation instead.
booking.about_to_depart
The booking's departure is imminent (default: 2 hours before the time in departure_date). This is the last-chance reminder for travelers who haven't installed their eSIM yet.
{
"event": "booking.about_to_depart",
"timestamp": "2026-07-15T12:30:00Z",
"data": {
"external_user_id": "partner_user_456",
"booking_id": "booking_abc",
"departure_date": "2026-07-15T14:30:00+02:00",
"hours_until_departure": 2,
"esim_installed": false
}
}Note: This event requires a full datetime with timezone in
departure_date(e.g.,2026-07-15T14:30:00+02:00). If only a date was provided (e.g.,2026-07-15), this event cannot fire because the exact departure time is unknown.
Recommended Communication Strategy
Map webhook events to traveler-facing messages for maximum conversion:
| Event | Suggested action | Channel |
|---|---|---|
booking.within_cutoff (eSIM not installed) | "Your trip is in 7 days — install your eSIM now while you're on Wi-Fi" | Push notification, email |
booking.about_to_depart (eSIM not installed) | "Departing in 2 hours — install your eSIM before you board" | Push notification |
esim.installed | "You're all set! Your eSIM is ready for Greece" | Push notification |
package.activated | "Connected! You're using data in Greece" | Push notification (optional) |
package.usage.50_percent | "You've used half your data in Greece" or "Your package is halfway through its validity" | Push notification (optional) |
package.usage.80_percent | "Running low on data — top up now to stay connected" or "Your package expires in 6 days" | Push notification |
package.usage.100_percent | "You've used all your data — renew to stay connected" or "Your package validity has ended" | Push notification (optional) |
topup.completed | "Top-up successful! Your new package is ready to use" | Push notification, email (optional) |
esim.removed | "eSIM removed — you can re-install anytime from the eSIM section" | Push notification (optional) |
Event Reference
| Event | Description |
|---|---|
package.usage.50_percent | 50% of package data consumed or duration elapsed |
package.usage.80_percent | 80% of package data consumed or duration elapsed |
package.usage.100_percent | 100% of package data consumed (data-limited) or full package duration elapsed (time-limited) |
esim.installed | eSIM installed on the traveler's device |
esim.removed | eSIM removed from the traveler's device |
package.activated | Package activated and data is usable |
promo_code.redeemed | Promo code redeemed by a traveler |
package.purchased | Traveler purchased a new eSIM (first purchase for a destination) |
topup.completed | Traveler completed a top-up payment |
| classic_package_queue.claimed | Traveler claimed a classic-eSIM grant via the native app | | booking.within_cutoff | Booking is within the departure cutoff window | | booking.about_to_depart | Departure is imminent (requires time + timezone in departure_date) |
Related
- The Hubby WebView — full WebView flow including
departure_date - Authentication guide — verify webhook signatures