Event catalog
Every async state change in heliumOS emits a webhook event. This
catalog enumerates the event types you can subscribe to; payload
shapes are documented in the API reference under the
Events tag. The authoritative, always-current list is served at
GET /v1/reference/event_types, and webhook endpoint
enabled_events is validated against it.
Shape
Every event carries:
{
"id": "evt_...",
"type": "sim.activated",
"created_at": "2026-04-24T15:00:00Z",
"livemode": false,
"data": { "object": { ... } },
"idempotency_key": "..."
}
Subscriber events
| Type | Fires when |
|---|---|
subscriber.created | A subscriber record is created, whether through POST /v1/subscribers or the migration import (import-originated events carry data.imported: true) |
subscriber.locked | A subscriber's SIM-swap / port-out protection lock is turned on (locked_status becomes user_initiated_lock) |
subscriber.unlocked | A subscriber's SIM-swap / port-out protection lock is turned off (locked_status becomes unlocked) |
subscriber.updated | Any field on the subscriber record changes. previous_attributes carries the prior value of each changed field (contact fields redacted, as below). A change with nothing new in it emits nothing. A lock or payment-block change also emits its own event, so you never have to diff for one |
subscriber.deleted | A subscriber with no billing history is deleted through DELETE /v1/subscribers/{id}. Carries the full subscriber snapshot, since once the row is gone the event is the only record of it |
subscriber.redacted | A right-to-erasure request was carried out: the person's personal data was scrubbed. Distinct from subscriber.deleted — the record and its invoices and payment history stay, only the personal data is gone. data names the scopes and the fields cleared |
subscriber.payment_updates_blocked | The subscriber is barred from adding a new card for a period, after a card decline that looked fraudulent. The object carries payment_updates_blocked_until and payment_updates_blocked_reason. Fires again if the block is renewed with a fresh window |
subscriber.payment_updates_unblocked | That block is lifted early, before its window runs out |
address.updated | A subscriber's billing, shipping or e911 address is created or changed. An e911 change has already been accepted by the carrier by the time this fires |
Subscriber event payloads are always PII-redacted: email, phone, and
date_of_birth are null and the object carries pii_redacted: true.
Read the subscriber back through GET /v1/subscribers/{id} (subject to
your role's PII permissions) when you need the contact fields.
Subscription events
| Type | Fires when |
|---|---|
subscription.created | A subscription record is created, whether through POST /v1/subscriptions (solo or group) or the migration import (import-originated events carry data.imported: true) |
subscription.updated | A status or pending-plan-change reconciliation with no dedicated event below, such as a scheduled downgrade being set or cleared |
subscription.plan_switched | A plan change applies (an upgrade immediately, a scheduled downgrade at renewal) |
subscription.line_added | A member joins a group subscription. Fires alongside subscription.updated, one per line, and names the line and the member on it |
subscription.line_removed | A member leaves a group subscription. Fires alongside subscription.updated, one per line. reason says why: requested (removed through the API or dashboard), scheduled (a departure booked for the end of the paid term landed), port_out (the member ported their number to another carrier), or null when the cause was not recorded |
subscription.line.removal_scheduled | A member's departure is booked for the end of a term the payer already paid for. Carries departs_at; the departure itself arrives later as subscription.line_removed |
subscription.line.removal_canceled | The payer takes back a booked departure, and the member stays |
subscription.scheduled_change_failed | A booked change — a member's own downgrade, or one line of a whole-group billing-period switch — could not be applied at renewal and never will be. data.scheduled_change.reason says why. Without it your app would keep showing the pending change after it quietly stopped being pending |
subscription.paused | Billing is put on hold — moves to paused, and a void collection pause appears on the object. Service is not interrupted; the SIMs keep working |
subscription.resumed | Billing restarts — moves from paused back to active and the collection pause is cleared |
subscription.cancelled | Moves to cancelled |
subscription.terminated | Moves to terminated |
subscription.past_due | Payment is overdue and the subscription moves to past_due |
subscription.unpaid | Payment retries are exhausted and collection has stopped |
subscription.recovered | A past_due subscription recovers to active after a successful payment |
subscription.addon_added | An add-on is attached to the subscription |
subscription.addon_removed | An add-on is removed from the subscription |
subscription.addon_expired | A time-bounded add-on reaches the end of its term |
subscription.renewal_upcoming | A prepaid multi-month term (3, 6 or 12 months) is about to renew for another term in one charge. Carries a subscription_renewal object — the net amount, the renewal date, the card it will land on, and one entry per seat on a group — so you can warn the payer and offer them a switch to monthly. The lead time is yours to set (renewal_reminder_lead_days, renewal_reminder_lead_days_annual on the operator). Monthly subscriptions never fire it; their pre-renewal notice is invoice.upcoming |
SIM events
The eSIM install code rides
sim.provisioned,sim.activatedandsim.swapped— but only for an endpoint that subscribes to them on purpose. Theactivation_code(LPA) string installs the subscriber's profile onto whatever device scans it, so it is delivered only whereenabled_eventsnames the type directly (sim.*, or the exact type). An endpoint left on the default catch-all*still receives every one of these events; theactivation_codefield is simply absent from the body.Building an eSIM flow? Subscribe with
sim.*rather than*. Wiring up an analytics sink or a general-purpose connector?*is the right choice, and it keeps install credentials out of it.
| Type | Fires when |
|---|---|
sim.provisioned | A SIM record is created and provisioned |
sim.imported | An existing SIM is imported (migration) |
sim.activation_started | Activation is requested from the carrier |
sim.activated | Carrier confirms activation |
sim.activation_failed | Carrier rejects activation |
sim.activation_resolution_required | Activation stalled and needs operator resolution |
sim.suspended | Moves to suspended |
sim.restored | Moves from suspended back to active |
sim.deactivated | Terminal deactivation |
sim.cleared | A SIM is released back to unassigned stock — an operator clear, or a card freed after a port-in was abandoned. data.state says what kind of stock it became: deactivated for a line that was torn down, provisioned for a never-activated card returned to the pool. The object carries the SIM's metadata; when a departing member's line is being held so they can still move their number, metadata.carrier_held_until is when that hold ends. previous_attributes carries what the card was released from: subscriber_id, subscription_id, subscription_line_id (the line it served, null if it sat on none) and msisdn |
sim.linked | Unassigned stock is bound to a subscriber, on its own or as the first half of a number move. Carries the link target: on a group line that is the member, not the payer |
sim.synced | A fresh read from the carrier is recorded on the SIM. Fires on every sync, including ones that change nothing, alongside any sim.suspended or sim.deactivated the sync found |
sim.swapped | A new SIM takes over the MSISDN |
sim.reassigned | The SIM's placement moves — a different subscription, owner, or holder line (an explicit reassign, or a leftover SIM reused by a re-subscribe). The object carries the new placement (subscriber_id = the payer, holder_subscriber_id = the member who uses the line, subscription_id, subscription_line_id, external_subscriber_id, and subscription_status — the target subscription's own status when the SIM moved); previous_attributes carries the old subscription_id, subscriber_id, subscription_line_id, and subscription_status. Mirroring the end user? Read holder_subscriber_id, not subscriber_id — the latter is the payer here but the link target on sim.linked. Re-pointing a mirrored subscription? Only when previous_attributes.subscription_status is cancelled, and take the new status from subscription_status rather than assuming active — the target can be past_due, and a pending_activation target that the move activates announces that on its own subscription event. |
sim.products_updated | The SIM's provisioned products (SLOs) change |
sim.scam_block_changed | The line's scam blocking is switched on or off. Carries scam_block_enabled. Only available on plans that include scam protection |
sim.video_quality_changed | The line's video streaming quality changes because its plan sets video_step_down_after_mb: lowered to 480p when the line's data use this cycle reaches it, raised back at the next cycle or when a data boost lifts it. Carries video_quality, previous_video_quality and video_stepped_down |
sim.wps_changed | The line's Wireless Priority Service code is changed. Carries the new wps |
sim.msisdn_changed | The SIM's MSISDN changes |
sim.boosted | A data boost is applied |
sim.boost_expired | A data boost reaches the end of its window |
sim.esim_profile_downloaded | An eSIM install profile is issued for the SIM. Carries the SIM and profile_expires_at, never the profile itself. Repeated downloads for one line are worth watching: that is what a hijacked-device install looks like |
sim.boost_reduced | Part of an accumulated data boost is taken back — a refunded or detached purchase reversed on a line still holding others. Carries the new boost_mb and the boost_mb_removed; the whole-grant reversal stays sim.boost_expired |
sim.throttled | The SIM is throttled after crossing a usage cap |
sim.throttle_cleared | Throttling is lifted (cycle rollover or manual clear) |
sim.port_out_pin_generated | A port-out PIN is generated for the SIM |
sim.port_out_pin_cleared | The SIM's port-out PIN is cleared |
SIM inventory events
The pool of unassigned cards an operator loads and activations draw from.
| Type | Fires when |
|---|---|
sim_inventory.imported | A batch of cards is loaded into the pool. One event per import, not one per card |
sim_inventory.claimed | A card is drawn from the pool for a new SIM |
sim_inventory.released | A claimed card goes back into the pool because its SIM's port-in was cancelled or failed at the carrier |
sim_inventory.quarantined | A card is taken out of circulation and will not be drawn again. Carries quarantine_reason |
sim_inventory.low_stock | A claim leaves exactly the low-stock threshold of available cards for that carrier connection and card type. Carries available and threshold. It fires on the crossing only: a pool loaded already below the threshold never fires it |
Port events
| Type | Fires when |
|---|---|
port.created | A port-in request record is created |
port.submitted | The port-in request is accepted by heliumOS |
port.in_progress | The carrier has the port-in in flight |
port.resolution_required | The port-in stalled and needs operator or subscriber input. status_reason carries the carrier's own message (e.g. 6B-T-Mobile Number Transfer PIN is required or incorrect) and status_reason_code its message code (6B) — branch on the code, show the text |
port.resolution_expiring | A stalled port-in is nearing the carrier's deadline (~30 days from submission) after which the transfer is cancelled and the line switched off. Fires once per submission, around day 21. Carries the same status_reason / status_reason_code, plus submitted_at and days_since_submitted |
port.completed | The number transfer completed |
port.failed | The port-in failed |
port.cancelled | The port-in was cancelled |
port.out_validation_requested | The gaining carrier requested port-out validation |
port.out_completed | A port-out finalized and the line left heliumOS |
Catalog events
The plans and addons an operator sells. Emitted in the same transaction as the catalog write, and the payload is the full public object — an integration mirroring the catalog can upsert straight from the event rather than re-reading it, and hears a price change the moment it is made.
| Type | Fires when |
|---|---|
plan.created | A plan is added to the catalog |
plan.updated | A plan's details or price change |
plan.deleted | A plan is withdrawn from sale. Subscriptions already on it are unaffected |
addon.created | An addon is added to the catalog |
addon.updated | An addon's details or price change |
addon.deleted | An addon is withdrawn from sale. Addons already attached to a subscription are unaffected |
Usage events
| Type | Fires when |
|---|---|
usage.data_threshold_crossed | A configured data-usage threshold is crossed |
usage.voice_threshold_crossed | A configured voice-usage threshold is crossed |
usage.sms_threshold_crossed | A configured SMS-usage threshold is crossed |
usage.intl_voice_threshold_crossed | The carrier reports the line is running out of, or has used up, its international calling allowance |
usage.intl_roaming_threshold_crossed | The carrier reports the line is running out of, or has used up, its international roaming allowance |
usage.intl_sms_threshold_crossed | The carrier reports the line is running out of, or has used up, its international texting allowance |
usage.tier_changed | The SIM moves between throttle tiers |
usage.cycle_rolled_over | A billing cycle rolls over and counters reset |
Billing events
| Type | Fires when |
|---|---|
invoice.upcoming | A pre-renewal notice three days ahead of the next billing cycle (a preview invoice object, status: "upcoming", no id yet). See the note below — it fires for the subscriptions heliumOS bills itself: every sandbox subscription, and the live ones that have moved onto its billing engine |
invoice.created | An invoice is created |
invoice.finalized | An invoice is finalized and ready to collect |
invoice.document_ready | The invoice's PDF has been rendered and can be linked to. Fires once per stored document, moments after invoice.finalized — see the note below |
invoice.paid | An invoice is paid |
invoice.payment_failed | A payment attempt on the invoice fails |
invoice.voided | An invoice is voided |
invoice.marked_uncollectible | heliumOS gives up collecting on a finalized invoice. The debt stays on the books — this is not the same as voiding it — but the collection ladder stops. Fires whether the write-off happens on heliumOS's own billing engine or is mirrored from the payment processor |
payment.succeeded | A payment succeeds |
payment.failed | A payment fails |
payment.refunded | A payment is refunded |
payment_method.attached | A payment method is attached to a subscriber |
payment_method.updated | A payment method changes for one of two unrelated reasons. It becomes the subscriber's default (previous_attributes carries is_default: false) — the subscriber's own card in their wallet, and its on-file detail, are unchanged. Or its on-file detail (brand, last four digits, expiry) changes — either the card network pushed a replacement PAN or expiry onto the same credential, or the detail was edited directly (previous_attributes carries one or more of card_brand, card_last4, card_exp_month, card_exp_year, holding each field's prior value). Check which keys previous_attributes carries to tell the two apart; the object itself always reflects the payment method's current state either way |
payment_method.detached | A payment method is detached from a subscriber |
balance.credited | An operator issues a subscriber a positive account credit — a goodwill credit, a service credit — through POST /v1/subscribers/{id}/balance_credits. Distinct from discount.credit_applied below; the two are never emitted for the same money, so a reconciliation cannot double-count |
discount.credit_applied | A recurring discount credit lands on an invoice heliumOS issued itself |
Which subscriptions get an invoice.upcoming
Every subscription is billed by one of two things: heliumOS's own billing engine, or the payment processor behind it. That is what decides whether you get a pre-renewal notice.
- Sandbox subscriptions are billed by heliumOS, always. They get an
invoice.upcomingthree days before the cycle turns over, and the amount on it is the renewal's own arithmetic — every seat on a family plan, the add-ons falling due on this cycle, and any credit carried over from a mid-cycle change. It is a pre-tax figure: tax is assessed when the invoice is actually raised. - Live subscriptions move onto heliumOS's billing engine in stages, a subscription at a time rather than a whole environment at once, so one live environment can hold both kinds. One that has moved gets the same notice as a sandbox subscription. One the payment processor still bills gets none, because the processor, not heliumOS, is the only thing that can say what its next bill will be. Nothing about the payload changes when a subscription moves.
If you want a heads-up on a live subscription today, poll
GET /v1/subscriptions/{id}/renewal, which answers the same question on demand.
Emailing the bill: wait for invoice.document_ready
heliumOS does not send email or SMS to your subscribers — you do, on your own channels, with your own wording. Two fields on the invoice are what you send them:
hosted_invoice_url— a page your customer can open with no login, safe to put in an email or an SMS.document_url— the PDF of the same bill, on the same link.
Both are signed and valid for 90 days. Read them fresh from the API when you
send rather than storing them, and your links are always current; a link that has
aged out answers 410 with a plain page telling the reader to ask you for a new
one.
The catch is timing. invoice.finalized fires the instant the bill is frozen,
and the PDF is rendered moments later — so an invoice fetched on
invoice.finalized can still carry document_url: null. invoice.document_ready
is the event that says the document exists. Send on that one, and both fields
are populated on the invoice object it carries.
Invoices issued before heliumOS began rendering documents have no
document_url, and their hosted_invoice_url is the payment provider's own
page — which expires on the provider's schedule, not ours.
Dispute events
A cardholder disputed a charge with their bank — a chargeback.
| Type | Fires when |
|---|---|
dispute.opened | A cardholder disputes a charge with their bank |
dispute.updated | The dispute's status or reason changes, including an inquiry being reclassified as a fraud chargeback |
dispute.closed | The bank reaches its final verdict, won or lost |
pSIM order events
| Type | Fires when |
|---|---|
psim_order.paid | A paid pSIM order is placed (the card is charged) |
psim_order.created | A free pSIM order is placed (no charge) |
psim_order.refunded | A pSIM order's charge is refunded (or a paid order is canceled) |
psim_order.canceled | A free pSIM order is canceled |
The recipient email and shipping_address are redacted in the event store
and injected into the outbound webhook body at delivery time, so your
fulfilment backend receives a complete order.
SMS events
| Type | Fires when |
|---|---|
sms.received | A subscriber texts your short code. Payload: the inbound_message. heliumOS does not answer it yet |
CPNI step-up events
When a support agent needs to view a subscriber's PII or change their account, heliumOS issues a one-time verification code and asks you to deliver it to the subscriber over your own channel (SMS, app push, email). The subscriber reads it back to the agent, who enters it to unlock access for a window.
| Type | Fires when |
|---|---|
cpni.verification.requested | An agent started a step-up challenge, carries the one-time code |
cpni.verification.succeeded | The agent entered the correct code |
cpni.verification.failed | The challenge locked out after too many attempts |
cpni.escalation.requested | An agent asked a supervisor for access without the subscriber's code, for when the customer can't be reached. Carries the agent's reason |
cpni.escalation.approved | A supervisor approved the request, and the agent has access for a limited window. This is the event to tell the subscriber their account was opened without their verification |
cpni.escalation.denied | A supervisor turned the request down |
The cpni.escalation.* events carry no personal data and no code.
cpni.verification.requested is the only event whose payload
contains a secret: data.object.code. Deliver it to the subscriber and
treat it like a password: don't log it, and don't store it. The code is
short-lived (operator-configured, default 10 minutes) and is never
returned by the GET /events API; it rides the signed webhook only.
{
"type": "cpni.verification.requested",
"data": {
"object": {
"object": "cpni_verification",
"id": "cpniv_01HGZX...",
"subscriber_id": "sbr_01HGZX...",
"agent": { "user_id": "...", "email": "agent@operator.example" },
"code": "482913",
"expires_at": "2026-06-08T17:10:00Z",
"grant_duration_seconds": 1800
}
}
}
Team hand-offs: submitted_by
data.object.agent on every cpni.verification.* event is the agent who
requested the challenge. When your operator shares verified access across
the support team, a teammate can take over the chat and enter the code the
subscriber reads back. On cpni.verification.succeeded and
cpni.verification.failed, a submitted_by object then names the agent who
entered the code (or, on a failure, the wrong codes). It is present only when
that agent differs from agent; when the requester entered the code
themselves, the field is omitted.
{
"type": "cpni.verification.succeeded",
"data": {
"object": {
"object": "cpni_verification",
"id": "cpniv_01HGZX...",
"status": "verified",
"agent": { "user_id": "...", "email": "first-agent@operator.example" },
"submitted_by": { "user_id": "...", "email": "second-agent@operator.example" }
}
}
}
The dashboard's verification log records who entered the correct code as Completed by, including when that was the requesting agent.
Referral events
Only fire when the referrals product is switched on for your operator.
| Type | Fires when |
|---|---|
referral.created | A signup was attributed to somebody's referral code. No money has moved yet — this is the event to congratulate the referrer on |
referral.reward.earned | A reward step resolved to an amount. data.object is the reward, carrying referral_id, step_index, recipient, amount_cents and the invoice that triggered it. The amount is frozen here and never re-read from the offer |
referral.reward.fulfilled | The credit landed on the recipient's account. Carries credit_ref. balance.credited also fires from the credit primitive with kind: "referral" — the two describe the same money from opposite ends, so drive your accounting from one of them, not both |
referral.reward.forfeited | A step will never be earned, with reason: the referee cancelled, the invoice that would have counted was refunded, the referrer stopped being a customer, a cap was reached, or the referral was voided |
referral.completed | Every reward step on a referral has a final outcome |
referral.voided | An operator reversed a mistaken attribution, with the reason recorded |
Payloads carry public ids and no personal data about either party. Changes to a referral offer are operator configuration and are audited in the dashboard rather than broadcast. See the Referrals guide.
Carrier connection events
| Type | Fires when |
|---|---|
carrier_connection.verified | A scheduled verify call succeeded |
carrier_connection.auth_failed | A verify call failed authentication |
carrier_connection.credentials_expired | The connection's carrier credentials expired |
Payment connection events
Fire only when a scheduled check finds the connection's status has changed;
previous_attributes carries the old status.
| Type | Fires when |
|---|---|
payment_connection.verified | The payment processor connection is working again (status becomes active) |
payment_connection.auth_failed | The processor rejected the connection's credentials |
payment_connection.credentials_expired | The connection's processor credentials expired |
Fraud screening events
Fraud screening is enabled per operator. These events only fire for an operator it has been turned on for; ask your heliumOS contact to enable it.
| Type | Fires when |
|---|---|
fraud.evaluated | A signup, account change or POST /v1/fraud/decisions request was screened. Carries the decision, score, reasons and mode, and the screened subject — which includes the email address and phone number, so treat the payload as personal data |
Webhook endpoint events
| Type | Fires when |
|---|---|
webhook_endpoint.test | You send a test event to an endpoint from the dashboard. Carries only the endpoint's id |
Event types are additive: new types may appear without a major
version bump. Handlers should ignore unknown type values.