Last updated

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-separated sha256=<hex> values. Verify by recomputing HMAC-SHA256(secret, "{timestamp}.{body}"), hex-encoding the result, and accepting the request if it matches any entry in the list. Signatures are lowercase hex. Treat 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 same event_id more than once should be a no-op on your side.
  • x-hubby-delivery-id / delivery_id — identifies a single delivery. Quote it when contacting support about a specific delivery. A replay carries the same event_id but a new delivery_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"
}
FieldTypeDescription
eventstringEvent type identifier
timestampstringISO 8601 UTC timestamp of when the event occurred. Distinct from the x-hubby-timestamp signing header (epoch seconds).
dataobjectEvent-specific payload
event_idstringStable identifier for the logical event (also the x-hubby-event-id header). Identical across retries and replays — deduplicate on it.
delivery_idstringIdentifier 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, and data for readability; every real delivery also includes event_id and delivery_id at 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_id for 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"
  }
}
FieldTypeDescription
external_user_idstring | nullYour identifier for the traveler
booking_idstring | nullThe booking this eSIM originated from (promo-code-delivered eSIMs); null for webview shop purchases
iccidstringThe newly purchased eSIM ICCID
payment_idstringUnique identifier for this payment (matches payment_id on topup.completed)
amountnumberAmount charged, in the smallest currency unit (e.g. cents)
currencystringISO 4217 currency code
promo_code_idstring | nullOriginal promo code that started the partner attribution, when one exists
package_queue_uuidstring | nullFor 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_id is not present on this event — the provisioned package does not exist yet at payment time. Capture it from package.activated (correlate via package_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"
  }
}
FieldTypeDescription
external_user_idstring | nullYour identifier for the traveler
booking_idstring | nullThe booking associated with this eSIM
iccidstringThe eSIM ICCID
payment_idstringUnique identifier for this payment
amountnumberAmount charged, in the smallest currency unit (e.g. cents)
currencystringISO 4217 currency code (e.g. EUR)
promo_code_idstring | nullPromo code applied to this top-up, or null
package_idstring | nullIdentifier of the package the top-up added to the eSIM, or null if it cannot be resolved
destinationstring | nullISO country code of the topped-up package, or null for legacy packages without a stored destination
sizestring | nullData 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
  }
}
FieldTypeDescription
queue_idstringStable identifier for the claimed package queue. Matches the uuid in BookingResponse.package_queues[].
booking_idstring | nullThe originating booking. Present for grants created via POST /bookings.
esim_iccidstringICCID the package was activated on — a fresh one, or an existing one matched on destination + IMSI
user_idstringHubby's user identifier (Firebase uid)
partner_idstringYour partner identifier
is_top_upbooleantrue 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_installed field tells you whether the traveler has already installed their eSIM. If false, this is the moment to send a push notification with installation instructions. If true, 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.


Map webhook events to traveler-facing messages for maximum conversion:

EventSuggested actionChannel
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

EventDescription
package.usage.50_percent50% of package data consumed or duration elapsed
package.usage.80_percent80% of package data consumed or duration elapsed
package.usage.100_percent100% of package data consumed (data-limited) or full package duration elapsed (time-limited)
esim.installedeSIM installed on the traveler's device
esim.removedeSIM removed from the traveler's device
package.activatedPackage activated and data is usable
promo_code.redeemedPromo code redeemed by a traveler
package.purchasedTraveler purchased a new eSIM (first purchase for a destination)
topup.completedTraveler 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) |