Sign in →
Embed Widgets1 min read

Embed Widgets: Events

Full postMessage catalog, SSE pushed events, origin allowlist, and the webhook-vs-postMessage authoritative rule. Listen to widget lifecycle events from the parent page.

Updated 2026-06-15Suggest edits
Docs Embed Widgets Events

Listening from the parent page#

Every widget emits typed postMessage events you can listen to from the parent page. The SDK exposes a typed subscription helper via AforoEmbed.on(type, handler), or you can use the raw window.addEventListener('message') pattern.

subscribe.ts
// Strongly-typed subscription via the SDK
window.aforoEmbed?.on(
  'aforo.subscribe.checkout_requested',
  (payload) => {
    console.log('Customer clicked subscribe', payload);
    // payload = {
    //   offeringId: string,
    //   planName: string,
    //   priceCents: number,
    //   currency: string,
    //   billingCycle: 'monthly' | 'yearly',
    //   ratePlanId?: string,
    // }
  },
);

⚠ Webhook is authoritative#

WARNING
postMessage events fired into your page are advisory only. Always validate state changes via Aforo's signed webhooks before mutating your own database. Treating a postMessage as a source of truth is the most common integration bug.

Why? An attacker who controls a browser tab can forge postMessage events to your page. If you provision access based on aforo.subscription.created alone, an attacker can spoof the event and trick your app into granting access without payment.

Use postMessage events to update local UI state (e.g. show a success modal, navigate to a thank-you page, refresh a counter). Use signed webhooks to update persistent state (e.g. provision API keys, send welcome emails, write to your database). The webhook is your source of truth.

Outbound event catalog#

Every event follows the envelope shape { type: 'aforo.<widget>.<event>', payload: {…}, version: '1', emittedAt: '…', source: '<widget>' }. Payloads never contain PII (no email, no JWT, no customer ID — only the IDs you passed into the widget).

Lifecycle (every widget)

EventWhen
aforo.<widget>.readyWidget mounted and first paint complete (fires exactly once per mount)
aforo.<widget>.errorAny error path. Typed code, no PII. Includes { code, message, retryable? }

Widget-specific events

WidgetEventTrigger
PricingCardaforo.pricing-card.cta_clickedCustomer clicked a plan CTA
SubscribeButtonaforo.subscribe-button.disabledCustomer already has an active subscription
SubscribeButtonaforo.subscribe.checkout_requestedCustomer clicked Subscribe (fires BEFORE redirect)
SubscribeButtonaforo.subscription.createdCheckout completed (advisory — webhook is authoritative)
InvoiceListaforo.invoice-list.invoice.downloadedCustomer clicked Download PDF
InvoiceListaforo.invoice-list.pay_requestedCustomer clicked Pay Now (fires BEFORE BFF call)
InvoiceListaforo.invoice-list.invoice.expandedCustomer expanded a row to see line items
InvoiceListaforo.invoice-list.filter_changedCustomer flipped a status filter chip
InvoiceListaforo.invoice-list.search_changedCustomer typed in search (payload contains only queryLength, not the query)
InvoiceListaforo.invoice-list.invoice.paidSSE-pushed: an invoice was marked paid
CheckoutFlowaforo.checkout.step_changedCustomer advanced phases (bootstrapping → details → payment → confirming → completed)
CheckoutFlowaforo.checkout.customer_details_submittedCustomer submitted the billing details form (payload: booleans only, NO raw values)
CheckoutFlowaforo.checkout.payment_initiatedPayment provider invoked
CheckoutFlowaforo.checkout.payment_completedPayment provider returned success
CheckoutFlowaforo.checkout.confirmedCart confirmed server-side, subscription / invoice marked paid
CheckoutFlowaforo.checkout.cancelledCustomer cancelled via the cancel button
CheckoutFlowaforo.checkout.expired30-minute cart TTL elapsed

Inbound events the SDK accepts#

Some widgets accept events from the parent page. The SDK enforces an inbound allowlist — events outside this set are silently ignored.

  • aforo:checkout-completed — tell a SubscribeButton in event-only mode that the parent finished checkout.
  • aforo:checkout-cancelled — tell a SubscribeButton the parent cancelled the checkout.
  • aforo:invoice-paid — tell an InvoiceList to refresh after the parent processes a payment outside the widget.
  • aforo:refresh-requested — generic refresh signal for any widget that supports manual reload.
  • aforo:payment-challenge-completed — tell a CheckoutFlow that a 3DS challenge completed in a popup window.
parent.js
// After your own checkout flow completes, tell the invoice list to refresh:
const iframe = document.querySelector('iframe[src*="embed.aforo.ai"]');
iframe?.contentWindow?.postMessage(
  { type: 'aforo:invoice-paid', payload: { invoiceId: 'inv_abc123' } },
  'https://embed.aforo.ai',
);

SSE pushed events#

Widgets that need real-time updates open a Server-Sent-Events stream when they mount. Aforo pushes lifecycle events scoped to the (tenant, customer) pair as they happen — no polling required.

  • aforo.invoice.created — a new invoice was finalized
  • aforo.invoice.paid — an invoice transitioned to PAID
  • aforo.subscription.status_changed — a subscription transitioned to a new lifecycle state
  • aforo.payment.failed — a payment attempt failed (during checkout or dunning)
  • aforo.usage.threshold_breached — usage crossed a configured threshold

The connection auto-reconnects with exponential backoff (capped at 60s) if dropped. Multiple widgets on the same page share a single connection per tenant. When the last widget unmounts, the connection stays open for 30 seconds in case a new widget mounts — avoiding an unnecessary disconnect / reconnect cycle.

Origin allowlist#

The SDK validates the origin of every inbound postMessage event against the embed key's registered allowed_domains. Outbound events use window.parent.postMessage(payload, parentOrigin) where parentOrigin is the resolved parent origin — never '*'.

PRO TIP
When listening to events from the parent page, always check event.origin === 'https://embed.aforo.ai' before processing. The browser will not enforce this for you on the receiving side.

See the for the full inbound allowlist + 100ms dedup window + parent-origin validation rules.