Protocol & security
@klappay/one runs in a merchant's own page — a completely different threat model than a package that only ever touches a trusted backend's own secrets. Everything below is this package's own CLAUDE.md non-negotiable invariants, written out in full — not defaults you can configure away.
What this package never has access to
- A private key. The payer's own wallet app signs everything — that happens entirely inside
one-id(a different origin, a different repo). This package never proposes or builds a transaction itself. - A session token. The payer's session lives in
one-id's own cookie, on Klap's origin — this package never reads or stores anything from that popup/iframe beyond the publicPaymentResultit relays viapostMessage.
If you ever find yourself reaching for a private key or a session token inside application code that imports @klappay/one, that's a sign the design needs to change, not that this package needs a new escape hatch.
The postMessage wire format
The same contract one-js and one-id both implement — canonical here, mirrored by hand on the klap-one side (web/src/lib/bridge.ts), since there's no shared package between the two repos to enforce it:
type BridgeMessage =
| { type: 'klappay:ready'; requestId: string }
| { type: 'klappay:resize'; requestId: string; height: number }
| { type: 'klappay:pending'; requestId: string }
| { type: 'klappay:confirming'; requestId: string; txHash: string; network: string }
| { type: 'klappay:success'; requestId: string; result: PaymentResult }
| { type: 'klappay:error'; requestId: string; error: { code: string; message: string } }
| { type: 'klappay:cancel'; requestId: string }
| { type: 'klappay:reconnecting'; requestId: string; state: 'started' | 'recovered' | 'failed' }klappay:pending is sent right before one-id asks the wallet to sign/send — before it has responded at all, so there's no txHash or any other payload yet. It exists purely so an integrator can persist "a payment attempt is underway" ahead of a possible reload/close; onPending is not a terminal outcome and doesn't re-enable the button the way onSuccess/onError/onCancel do.
klappay:confirming is sent once the wallet has responded and a transaction was actually sent — txHash/network are both real and verifiable at this point, even though Core hasn't confirmed the payment yet. Like onPending, it isn't a terminal outcome. Unlike onPending, an integrator can safely treat this one as authoritative enough to persist and auto-resume a "confirming" UI from — a txHash can be checked against Core independently of whatever happens to this checkout afterward (reload, tab close, even the popup/iframe itself disappearing), which isn't true yet at the onPending stage.
klappay:success is sent once Klap Core has confirmed the charge as paid by this transaction. result.walletVerified says whether Klap One could also tie that payment to the payer's own wallet: Core reports the transaction's signer, the senders of the paying token transfers, and the ERC-4337 account whose user operation paid, and Klap One looks for the payer's wallet in any of them. That covers wallets that sign their own transaction, EIP-7702 wallets behind a gas-sponsoring relayer (MetaMask smart accounts) and ERC-4337 accounts. When none of them names the wallet (a relayer paying through a swap, or a swap from native ETH via a relayer, whose value moves through calls that leave no log) the payment is still a success, sent with walletVerified: false, never a klappay:error: telling a payer who paid that the payment failed would push them to pay twice. An older one-id omits the field.
klappay:reconnecting is sent when one-id detects, after the payer returns from backgrounding the tab/app (switching to their wallet app to approve, then coming back), that its WalletConnect relay connection may have dropped — state: 'started' while it retries (relayer.restartTransport()), then 'recovered' or 'failed'. Like onPending/onConfirming, never terminal — onCancel/onError are unaffected either way, and a 'failed' state still leaves one-id's own in-checkout retry affordance and Cancel button as the way out. Sent on both rendering modes, not just the iframe one: a popup tab is exposed to the same OS-level backgrounding as an iframe is, since the browser freezes an entire backgrounded tab's frame tree together (popup or iframe alike), not the frame specifically — see iframe vs. popup.
klappay:cancel only ever means a deliberate in-page Cancel click — this package's own onCancel config callback receives a second possible reason, 'closed', that one-id never sends: core/klappay-one.ts synthesizes it locally when a popup disappears (closed via the browser's own UI) with no bridge message at all, since there was no chance for one-id to report anything. A caller that wants to distinguish "the payer explicitly backed out" from "we genuinely don't know what happened" should branch on this reason, not treat every cancel the same.
chargeId/requestId/locale/returnOrigintravel in the popup/ iframe URL itself (/id/?chargeId=X&requestId=Y&locale=en&returnOrigin=https://merchant.com) — none of these values are secret, and putting them in the URL avoids a race a bidirectional handshake would otherwise have ("did my configpostMessagearrive before the page was ready?").postMessageonly carries the return path,one-id→one-js.requestIdis generated fresh bycore/klappay-one.ts(crypto.randomUUID(), not a secret) on everyopen()call — it's what lets a single page with more than one button/charge tell whichopen()call a given response belongs to.- Every message the checkout sends targets
returnOriginspecifically as itspostMessagetargetOrigin(never'*') — defense in depth, since nothing in these messages is actually secret (a publictxHash, a public wallet address, an error code), but there's still no reason to use a wildcard target when the real one is already known.
core/bridge.ts validates every message before trusting it
This is the specific invariant this repository's own test suite exists to protect (src/core/bridge.test.ts):
function onMessage(event: MessageEvent): void {
if (event.origin !== klapOneOrigin) return
if (!isBridgeMessage(event.data) || event.data.requestId !== requestId) return
// ... only now is event.data trusted
}Two checks, both required, in this order:
event.originmust match the configured Klap origin exactly. A message from any other origin — including a malicious page that somehow gets a reference to your window — is silently dropped.event.data.requestIdmust match the specificopen()call in flight. Even a genuine message from the correct origin, but for a different checkout (a stale popup left open, or a second button on the same page), is silently dropped rather than partially trusted.
A message failing either check is dropped with no error surfaced and no partial trust extended — it's simply as if the message never arrived, not a degraded "trust some of it" path. isBridgeMessage also validates every field a given type actually carries (a state on klappay:reconnecting really is one of the three known literals, a result on klappay:success really has every PaymentResult field, etc.) — not just the type/ requestId envelope every variant shares.
What this defends against, precisely: a genuinely separate page or frame reaching into your checkout — a different tab, a stale popup, another site entirely. It does not, and structurally cannot, defend against a script running in the same JS realm as this package — a compromised (or malicious) third-party <script> tag on an otherwise-honest merchant page. That script shares window with one-js and can dispatch its own synthetic MessageEvent with any origin/requestId/payload it likes; no client-side check can distinguish that from the real thing, because there's nothing left to check once the attacker's code and this package's code are the same trust domain. This is exactly why onSuccess is documented above as a UX signal only, never proof of payment — the actual defense against that scenario is server-side (Core's webhook), not anything this package's bridge validation can provide.
The popup/iframe content is never white-labeled
variant/size only ever style the button this package renders — one-id's own content inside the popup/iframe is 100% Klap's, always, on every integration, with no merchant branding/logo anywhere in it. This isn't a missing feature; it's deliberate, the same reasoning Apple Pay and Google Pay apply to their own buttons: a payer needs to recognize "this is Klap asking me for approval" regardless of which merchant embedded the button. See Styling for the actual customization surface.
onSuccess is a UX signal, never proof of payment
The same principle Stripe applies to its own webhook: never release a product because the browser said "it worked." The tab can close mid-flow, the network can drop the confirmation, and a callback is trivially easy for an integrator to misuse as if it were authoritative. onSuccess exists for UX — show a "Paid!" screen, redirect to a thank-you page — while real fulfillment (releasing the product, marking an order paid in your own database) has to come from Klap Core's own charge.confirmed webhook, the same webhook system Core uses for everything else.
// WRONG — releases the product based only on the client-side callback
onSuccess: (result) => releaseProductDirectly(result.txHash)
// RIGHT — onSuccess only drives UX; your backend releases the product
// once it receives Klap Core's charge.confirmed webhook
onSuccess: (result) => {
showThankYouScreen(result.txHash)
router.push(`/order/awaiting-confirmation?tx=${result.txHash}`)
}See @klappay/node's webhook verification docs for constructing and verifying that event server-side.
No eval/Function over anything from the network
The button's markup/CSS is a static template compiled into this package's build (ui/klappay-button.ts's #css(), core/iframe.ts's css()) — never HTML built from an API response. There's no code path in this package that interprets a string from a network response as executable code or markup.
Mirrored, not shared
core/bridge.ts's BridgeMessage type mirrors klap-one's own web/src/lib/bridge.ts by hand — there's no shared package between the two repositories enforcing the two stay in sync, since one-js ships to npm/a public CDN and klap-one's backend code never should. Any change to the wire format has to land in both places deliberately.
See it running
Every app in Examples creates its Charge server-side with @klappay/node, never exposes KLAP_API_KEY to the browser, and only ever uses onSuccess to update the UI — the same invariants described above, applied end to end in a real, running app.