Embedded Checkout

Parent Controls

Gate buyer interaction with the embedded checkout from the parent page. Useful when your storefront has fields that must be filled (shipping address, terms acceptance, plan selector) before the buyer should be able to submit the embedded payment.

What this is and isn't
Is: a way for the parent to render an overlay over the entire embed area that swallows clicks until the parent says otherwise. The overlay is light-grey + cursor not-allowed so the buyer sees the form behind but can't interact with it.
Isn't: a way to validate the buyer's data from the parent. Field-level validity is up to your form. The parent signals "ready" or "not ready" — Throttle just enforces the gate.

The submitDisabled prop

Both <PaymentEmbed /> and <CheckoutEmbed /> in @usethrottle/checkout-react accept a controlled submitDisabled?: boolean prop. When it flips to true, the SDK posts a parent → iframe command and the iframe renders a full-area overlay. When it flips back to false, the overlay disappears.

tsx
'use client';
import { useState } from 'react';
import { PaymentEmbed } from '@usethrottle/checkout-react';
import { useForm } from 'react-hook-form';

export function CheckoutPage({ sessionId }: { sessionId: string }) {
  const { register, watch } = useForm({ mode: 'onChange' });
  const email = watch('email');
  const name = watch('name');
  const formValid = !!email && !!name;

  return (
    <>
      <form>
        <input {...register('name', { required: true })} placeholder="Full name" />
        <input {...register('email', { required: true })} placeholder="Email" type="email" />
      </form>

      <PaymentEmbed
        sessionId={sessionId}
        parentOrigin="https://shop.example.com"
        submitDisabled={!formValid}
        onSucceeded={({ orderId }) => router.push(`/orders/${orderId}`)}
      />
    </>
  );
}

Defaults

  • Omitting submitDisabled entirely keeps the embed enabled (backward compatible). The SDK never posts a parent command in this case.
  • submitDisabled={false} and omitting the prop are equivalent at runtime, but passing false explicitly opts your embed into the parent-control protocol — useful when your form starts valid (e.g., everything pre-filled).
  • The first parent command is sent after the iframe posts throttle.ready. You don't need to wait for this in your component code — the SDK buffers and re-sends.

Composes with collect-flag auto-disable

The iframe also auto-disables the Pay button until every field required by the session's collect flags (shipping address, billing address) is complete. Parent-set submitDisabled composes additively with that validation gate: the button stays disabled while either the parent says "not ready" or the iframe's own validation hasn't passed.

Practically: you don't need to mirror the iframe's address validation in your parent form. Only gate on parent-controlled state (terms checkbox, plan selection, out-of-band eligibility checks). Throttle handles the rest.

Without React

The SDK is a thin wrapper over postMessage. Vanilla JS, Vue, Svelte, Angular — anything that mounts an iframe can post the same shape.

js
// Direct postMessage when not using @usethrottle/checkout-react.
// Useful for vanilla JS storefronts or non-React frameworks.

const iframe = document.querySelector('#throttle-checkout');
const throttleOrigin = 'https://checkout.usethrottle.dev';

function setSubmitDisabled(disabled) {
  iframe.contentWindow.postMessage(
    {
      source: 'throttle-parent',
      version: 1,
      type: 'set-submit-disabled',
      disabled,
    },
    throttleOrigin,
  );
}

// Wire to your form's validity state:
form.addEventListener('input', () => {
  setSubmitDisabled(!form.checkValidity());
});

Message contract

The parent → iframe channel uses source: 'throttle-parent' to disambiguate from the iframe → parent events (which use source: 'throttle'). The iframe ignores anything that doesn't match.

json
{
  "source": "throttle-parent",   // distinguishes parent commands from
                                  // throttle's own iframe → parent events
  "version": 1,                   // protocol version; bumps are additive
  "type": "set-submit-disabled",
  "disabled": true | false
}

Future commands will extend the same envelope; the protocol is additive.

Security

  • The iframe verifies event.origin matches the merchant-allowed parent origin before reading the message. A different page can't steal control.
  • Setting disabled: false from an attacker doesn't bypass anything — it simply removes the overlay. The buyer still has to actually submit the form, and Throttle still has its own server-side validation on the /complete route.
  • The overlay is cosmetic, not a security boundary. It blocks the buyer's clicks but doesn't prevent scripts. Treat submitDisabled as a UX gate, not a server-side enforcement primitive.

What does it actually do inside the iframe?

The iframe's EmbedShell listens for the parent command and renders a positioned overlay (semi-transparent white + cursor not-allowed) covering the embed content when disabled. The overlay sits at z-index 50 so it covers both Throttle's native UI and any nested provider iframe (the card form). The buyer sees what they've typed but can't click anything.

We chose "overlay over the iframe" instead of "disable the submit button only" because the provider embed renders its own submit button inside a nested iframe we don't control. Greying the entire interaction surface gives the same UX outcome with stable semantics.

Common uses

  • Address before pay. Your parent page renders a shipping address form. Pass submitDisabled={!addressValid}.
  • Terms acceptance. A "I agree to terms" checkbox above the embed. Pass submitDisabled={!checked}.
  • Plan selection. Buyer must pick a plan before paying. Pass submitDisabled={!selectedPlan}.
  • Async eligibility. You're waiting on a backend check (e.g., calling subscriptions/eligibility-check). Disable until the result returns.

Next