The store connectivity book Glossary

PayAssist · store connectivity · 18 October 2026

How any store can connect to PayAssist through one standard protocol

This book starts from zero: what a card payment is, why networks lose answers, and how keys and signatures prove who is talking. Then it builds a Stripe-style protocol any store can use, checks what an earlier multi-agent "arena" proposed, and follows one synthetic WooCommerce order through success and thirty other situations, most of which go wrong.

Status: design study; the proposed protocol is not built Reader: a new developer, or a curious high-school student Reading time: about 3 hours, or 40 minutes for the figures alone
Store PayAssist + bank charge $38.50 A approved, answer arrives B declined, answer arrives C approved, answer lost ? A and B are easy to handle.Most of this book is about C.
One request, three endings. In C the money moved and nobody nearby knows it yet.

Chapter 0

How to read this book

Every idea appears first as a picture with numbered steps. The text under it is a caption. Labels tell you whether a statement describes code that exists today, a proposal, or an example.

Figure 0.1Today only WooCommerce can connect; the proposal gives every store the same contract

Today as observed in the local checkouts on 18 October 2026

Today: the WooCommerce plugin, PayAssist Backend, the account node, the buyer and the provider, with seven numbered steps. WooCommerce storePayAssist plugin (PHP) PayAssist Backendrouting referee Only the plugin speaks thisdialect: one shared secret,one callback URL per store Buyer's browsermodal or full-page redirect Account nodeone account, own IP ProviderStripe, Square, PayPal... 1payment-request 2token + node URL 3open pay page 4card typed into the provider's fields 5provider API 6signed result 7signed /status: paid

Proposed a design, not built; hostnames and paths are placeholders

Proposed: many kinds of stores call one public API; the routing core picks an account and either an account node or the Connect service; an event engine signs events for each store and offers a pull feed. 1 every store creates a checkout session with its own key and an Idempotency-Key WooCommerce plugin Next.js app Node + React shop Anything that speaks HTTPS Buyer's browserhosted, modal or element PayAssist public API v1 keys per store, scoped Idempotency-Key on writes sessions, payments, refunds one OpenAPI contract dated versions Event engine event row commits with change signed delivery per endpoint retries up to 3 days pull feed for missed events Routing coresame referee picks the account Accountnodesstandard providers,including stripe Connectservicestripe_connect,platform key here two separate paths, no automatic fallback Providerscard data stays here 2 3card typed into the provider's fields 4signed events

Swipe sideways to see the whole figure.

Today: (1) the plugin signs a request with a secret it shares with Backend, (2) gets a 12-minute checkout token and the node's address, (3) the buyer opens the account node's page, (4) types the card into the provider's own fields, (5) the node calls the provider from its own IP, (6) reports a signed result, and (7) Backend sends a signed callback and the plugin marks the order paid. Proposed: (1) any store creates a checkout session with its own key, (2) the same routing core picks the account and the execution path, (3) the card still goes only to the provider, and (4) an event engine signs each event for each store endpoint and keeps a feed for anything a store missed. The labels in the proposed half are explained later: idempotency keys in Chapter 3, the OpenAPI contract and dated versions in Chapter 7, signed events in Chapter 10.

The labels

Payment systems attract confident claims. This book marks every important statement with one of these labels so you always know how much weight it can carry. Press at any time to see them again.

LabelWhat it means hereExample in this book
TodaySeen in PayAssist code or records on 18 October 2026.The plugin's 24 callback attempts, the last about 18 hours after the first.
ProposedA recommendation from the arena agents or engineering review.A 72-hour webhook retry window.
MeasuredA recorded measurement, usually one sample.Staging Connect checkout: 9.19 s from popup to ready form after the 17 October patch.
IllustrativeExample values and code. They teach the shape only.POST /v1/checkout_sessions, order #1042, $38.50.
Five more labels you will meet less often
LabelWhat it means hereExample in this book
Owner-confirmedA recorded decision by the PayAssist owner. It binds the design.stripe and stripe_connect stay separate, with no automatic fallback.
This bookA judgment made while writing this book, separate from the arena's history.Use the published Standard Webhooks signature format.
Synthetic testA test against simulated inputs. It proves the model, not production.The SDK sketch's 10 passing tests.
UnverifiedNobody has checked it yet.Mastercard's service-provider thresholds.
CorrectedAn earlier claim that went too far, with safe wording."Zero PCI" becomes "card data stays out of PayAssist's servers".

The colors

BuyerStorePayAssistAccount nodeConnect serviceProviderCard issuer

Seven actors appear again and again, and each keeps one color in every diagram and in the text: the buyer in a browser, the store and its server, PayAssist's API and core, an account node that holds one provider account's credentials, the central Connect service for Stripe Connect, the payment provider such as Stripe or Square, and the buyer's card issuer, which is the bank that issued the card. Green, amber and red only ever mean good, uncertain and bad outcomes.

The moving diagrams

Sequence diagrams show messages moving between actors over time, top to bottom. Each one has the same controls: Play, Pause, Next, Back and Reset, plus Show all to see every step at once. Click a diagram and use the arrow keys if you prefer the keyboard. Every diagram also has a plain list of its steps under "Read every step as text". Try this small one now. It is the most important idea in the whole book.

The fix for that double charge is one header and one database table. Chapter 3 explains it, and most of the book is about doing that kind of thing everywhere money or orders change.

The route through the book

Figure 0.3Five parts, from first principles to a WooCommerce plugin
I Foundationschapters 1 to 5 payments, networks,trust, the browser II The protocolchapters 6 to 12 objects, API, keys,events, store side III Decisionschapters 13 and 14 the arena, the stack,what to defer IV Building itchapters 15 to 17 minimum release, gates,migration V WooCommercechapters 18 to 21 one store, every goodand bad scenario
If you only have twenty minutes, read chapter 3, chapter 6's state explorer, chapter 15 and the scenario explorer in chapter 20. Appendices hold the glossary, the decision ledger, corrected claims, the proposed API, the failure matrix and every source.

What this book promises and what it does not

It explains the major ways a payment connection succeeds and fails, with the rule that handles each one. It does not claim to list every possible failure. Payment systems sit on banks, card networks, browsers, hosting companies and people, and each of those can surprise you. Appendix G lists the areas this book deliberately leaves out, such as tax, subscriptions, payouts and fraud scoring.

Synthetic data only

The store "Larkspur Tea", order #1042, every key, id and signature in this book are invented for teaching. Hostnames end in .example, a name reserved for documentation, because the real hostnames are an open decision.

Part I

Foundations

Before designing anything, you need five ideas: why a standard helps, how a card payment really moves, why networks make honest programs disagree, how two computers prove who they are, and what a browser lets one website do to another. Each chapter builds one of them from scratch.

Chapter 1

Why one standard protocol

Today PayAssist has one kind of customer integration, a WordPress plugin with its own private rules. The owner wants any store to connect the way developers already connect to Stripe. This chapter explains what that means and what it does not.

Figure 1.1A private dialect needs a new adapter for every platform; a published standard needs one document
Today: one private dialect PayAssistplugin routes only WooCommerce Next.js Node + React custom signature no way in Proposed: one published contract PayAssist API v1resources, keys, idempotency,events, versions WooCommerce Next.js Node + React Django, Rails... each store uses a small SDK, or plain HTTPS,against the same documented contract
Left: the current request and callback format exists only inside the PayAssist WooCommerce plugin, so a Next.js or Node shop has no supported way to connect. Right: a published contract turns every new platform into a small client of the same rules instead of a new integration project.

What the owner asked for

On 18 October 2026 the owner asked for a fixed standard, mostly inspired by Stripe, that lets WordPress, Next.js and Node with React stores connect through standard protocols and flows. Their focus was the core logic of a system that stays flexible, maintainable and scalable Owner-confirmed. A few minutes later they added that they wanted the best design even if it meant replacing parts of the current setup. Those two messages, paraphrased here, are the whole brief. They contain no traffic numbers, no compliance level and no deadline, which matters later when this book separates real requirements from numbers that agents invented.

What "like Stripe" means, and what it does not

Stripe is famous among developers for its integration contract, not for any single technology. You can describe that contract in six parts, and Figure 1.2 stacks them in the order a request meets them.

Figure 1.2The layers of a payment protocol, from the store's code down to the money
Store codecheckout page, order database, fulfillment 6 · SDKs and browser piecesPHP, Node, Python clients · PayAssist.js · hosted page, modal, embedded element The public contract 1 · Resources: checkout sessions, payments, attempts, refunds, disputes, events 2 · Authentication: keys that say which store, which mode, which permissions 3 · Idempotency: every write can be retried without doing it twice 4 · Events: signed pushes plus a feed you can read back · 5 · Dated versions PayAssist corerouting referee, business state, event log, reconciliation Money executionaccount nodes, Connect service, providers, banks This is whatthe standarddefines Mostly staysrouting, nodes, Connect,signed node contract
The six numbered parts are the "Stripe-like" contract. The bottom two layers are PayAssist's existing engine. The proposal mostly rewrites the store-facing layers and leaves the routing core, the per-account nodes and the separate Connect service in place.

"Like Stripe" therefore does not mean copying Stripe's internal systems, and it cannot mean offering everything Stripe offers. PayAssist differs from Stripe in two ways that shape every chapter:

  • PayAssist routes; it does not process cards itself. Each checkout is sent to one of many provider accounts, such as a Stripe, Square or PayPal account. Each standard provider account has its own account node, a small server with its own IP address and that account's credentials; Stripe Connect calls come from a central Connect service instead. Card numbers go from the buyer's browser straight to the provider.
  • Providers differ. A Stripe account can do things a Square account cannot, and the reverse. Any feature that depends on the provider, such as styling the card fields or saving a card for next time, works only as far as each provider allows.

What stays and what changes

Part of the systemTodayIn the proposal
Routing refereeToday Backend picks the account for each checkout.Unchanged. Routing never appears in the public API.
Account nodesToday One node per provider account, own IP, calls the provider.Kept for the first release This book. The locked principle covers standard providers: one account, one node, one dedicated IP Owner-confirmed. The owner has said dedicated nodes are not a mandatory constraint for the Connect target.
Stripe ConnectToday A central Connect service owns the platform key; built and deployed on staging, not production.Unchanged and separate. Owner-confirmed stripe and stripe_connect never fall back to each other.
How a store authenticatesOne shared secret per store signs requests in both directions.Separate keys per store and mode, with scopes; a separate signing secret per webhook endpoint.
How a store starts a paymentA Woo-shaped "payment request" for one order.A documented checkout session resource with an idempotency key.
How a store learns the resultOne signed callback URL per store, six event kinds, 24 attempts over about 18 hours.Signed events to any number of endpoints, retries for up to 3 days, and a feed to read missed events.
Who can integrateThe PayAssist WooCommerce plugin.Any server that speaks HTTPS, with SDKs for PHP and Node first.

Who the protocol is for

The protocol's users are developers: a store owner who writes code, an agency that builds shops, or PayAssist's own plugin team. Treat the protocol as a product with its own documentation, test mode, error messages and change log. A developer should be able to build a correct integration from the documentation alone, without asking PayAssist how a field behaves. That standard is higher than "the plugin works", and it is the reason Part II spends so long on edge cases.

Check yourself

Why can't PayAssist promise that a merchant can style every card field exactly as they like?

Show the answer

The card fields belong to the provider and run on the provider's own web page inside an iframe. PayAssist can only pass the styling options each provider accepts. Chapter 9 shows how the design reports which styles a provider ignored instead of hiding it.

Chapter 2

How a card payment works

A card payment is two journeys. Messages travel in seconds and decide whether the payment is approved. Money travels over days and can be pulled back weeks later. Most bugs in payment software come from mixing up the two.

Figure 2.1Who takes part in one card payment, and which way messages and money move
Approval messages: seconds "may I charge $38.50 to this card?" "approved" or "declined" comes back the same way Buyertypes the cardin a browser Storethe merchant'sshop and orders PayAssistpicks the account,tracks the state Account nodecalls the providerfor one account ProviderStripe, Square...also the acquirer Card networkVisa, Mastercardroutes the request Card issuerthe buyer's bank,says yes or no Money: days, and reversible settlement through the network payout to the merchant's bank account, minus fees PayAssist never holds the cardnumber in either journey
The top lines are the approval conversation, which finishes in seconds. The bottom line is the money, which settles over days and can still move backwards through refunds and disputes. PayAssist sits in the message path to choose the account and keep score, and it never handles the card number. The acquirer is the bank that accepts card payments for a merchant and pays them out; providers such as Stripe act as one or work with one.

The approval conversation

When a buyer clicks Pay, the provider sends an request through the to the , which is the buyer's bank. The issuer checks the balance and its fraud signals and answers yes or no. That round trip usually takes a second or two. A yes reserves the money on the buyer's card. A then turns the reservation into a real charge, either immediately or later, for example when a shop ships a backordered item.

A no is called a decline. Some declines are final, such as a stolen card. Others invite a retry, such as insufficient funds after payday. Either way, a decline is a definite answer. Chapter 3 shows why "no answer at all" is a different case, one that must never be treated like a decline.

The bank's identity check: 3-D Secure

Sometimes the issuer wants proof that the real cardholder is paying. , usually shortened to 3DS, pauses the payment and shows the buyer a check from their bank, such as approving in a banking app. In the European Economic Area the law requires this for most online card payments; the rule is called strong customer authentication, from Article 97 of the EU's second Payment Services Directive. The issuer decides whether to ask; neither the store nor PayAssist can force a payment through without it S53 S56. Passing 3DS usually shifts fraud liability to the issuer, but Stripe's own documentation says that shift is not guaranteed.

For a payment system, 3DS matters because it creates a long pause in the middle of a payment. The buyer can close the tab, lose signal on a train, or take ten minutes to find their phone. During that pause the payment is neither paid nor failed.

The money's journey, and how it comes back

Figure 2.2One payment lives for weeks, not seconds
0 s seconds minutes days weeks to months 1click Pay 2authorizedor 3DS pause 3events reachthe store 4captured,settled, paid out 5refundsany time later 6disputescan reverse it all The checkout window lives here. Everythingafter it must arrive as events, not as page loads.
(1) The click starts it. (2) Approval takes seconds, unless 3DS adds a pause. (3) The store hears about the result through events within seconds to minutes. (4) Capture, settlement and payout take days. (5) Refunds and (6) disputes, also called chargebacks, can arrive weeks or months later, long after the buyer's browser is gone. The scale is stretched so each stage is visible.

A sends money back at the merchant's request. A is the buyer asking their bank to take it back, which the card network then decides with deadlines for evidence. Neither one changes the fact that the original payment succeeded. Good systems record them as separate objects with their own lifecycle, which is exactly what Chapter 6 does.

Where PayAssist fits

PayAssist's job is to pick which provider account takes each payment and to keep the official record of what happened. Today that works like this Today:

  • Backend, the PayAssist server that acts as referee, chooses an account for each checkout based on availability, currency, limits and current volume.
  • Each standard provider account has its own account node with a dedicated IP address. The node talks to that provider using that account's keys.
  • Stripe Connect is a separate path. A central Connect service holds the platform key and makes the Stripe calls; the existing node proxy only relays the checkout. The owner requires the two Stripe paths to stay separate, with no automatic fallback from one to the other Owner-confirmed.
  • The buyer types the card into the provider's own fields. PayAssist and the store never see the card number. That is a locked principle of the system Owner-confirmed.

This book's proposal keeps all of that. What changes is how a store talks to PayAssist: how it asks for a checkout, proves who it is, and learns what happened.

Check yourself

A buyer's bank approves a $38.50 charge, and three weeks later the buyer disputes it and wins. Did the original payment "fail"?

Show the answer

No. The payment succeeded and the money moved. The dispute is a second, separate event that moved money back. A system that rewrites the payment's status to "failed" loses the history a merchant needs for accounting and for answering the dispute.

Chapter 3

Networks lose answers

Two honest programs can disagree about what happened. A network can lose, delay, repeat or reorder a message, and a computer can stop between any two steps. Four tools turn that into something you can reason about: idempotency keys, durable records, events written with the change, and reconciliation.

Figure 3.1Every request ends in one of three ways, and the third one is the dangerous one
Caller"charge $38.50" A · "It worked"You know the result. Record it and move on. B · "It did not work" (a decline, a validation error)You know nothing happened. Fixing the input and trying again is safe. C · Silence: a timeout, a reset connection, an error page from a server in betweenThe charge may have happened or not. Neither "retry blindly" nor "give up" is safe.
Outcomes A and B are easy. Outcome C is where double charges, lost orders and angry buyers come from. Distributed systems engineers call it the unknown outcome, and this book calls the state it leaves behind unknown.

Chapter 0's first moving diagram showed what happens when a program treats silence as failure: it sends the charge again and the buyer pays twice. The opposite mistake is just as bad. A program that treats silence as success ships goods that were never paid for. The only honest answer to silence is "I don't know yet, and I will find out."

Tool 1: name the intention, not the attempt to send it

An is a unique name for one intended action, such as "create the checkout for order 1042, revision 1". The caller makes it once, stores it, and sends it with every retry of that action. The server remembers each key with its result. When the same key arrives again, the server returns the saved result instead of doing the work twice. Think of a numbered ticket at a deli counter: if you hand over ticket 47 twice, the kitchen does not make two sandwiches.

Rules are numbered across the whole book, so this chapter jumps between them; Appendix E lists all eighteen.

Rule R1 · One request, one key

Every request that creates or changes money or orders carries an idempotency key. The server stores the key, a fingerprint of the request (a short hash that changes if any field changes), and the response in the same database transaction as the change itself: a group of database writes that either all happen or none do. The same key with the same request returns the saved response. The same key with a different request is refused, because that is a client bug.

Notice "in the same database transaction". If the server saved the charge but crashed before saving the key, the retry would find no key and charge again. Storing them together makes "did it happen" and "what did we answer" a single fact. Chapter 7 turns this into a full protocol with status codes, and its lab lets you try to break it.

Tool 2: assume a crash between any two lines

Figure 3.3Five places a server can stop while handling one payment, and what each one leaves behind
Receiverequest + key Save attemptkey, attempt, commit Call providermoney may move Save resultstate + event, commit Reply 1 2 3 4 5 the reply itself gets lost Red circles: the process can die here, or the machine can lose power, or a deploy can restart it.
The table below says what each crash point leaves behind and which mechanism recovers it. Every later design decision in this book exists to handle one of these five points.
Crash pointWhat is true afterwardsWhat recovers it
1 · before savingNothing happened, so there is no record and no charge.The caller retries with the same key; the server starts fresh.
2 · after saving, before the provider callAn attempt exists that never reached the provider.The retry finds the attempt and resumes it. A reconciler asks the provider, which says it has never seen it.
3 · after the provider call, before saving the resultMoney may have moved, and only the provider knows.Retries reuse the same provider idempotency key (rule R5); the reconciler reads the provider (rule R18).
4 · after saving, before replyingThe result is recorded but nobody was told.The caller's retry gets the saved response (R1); events tell everyone else (R7).
5 · reply lost on the networkSame as point 4 from the caller's side.Same as point 4.
Rule R4 · Unknown is not failure

A timeout, a reset connection or a gateway error from the provider means "unknown". The attempt is marked unknown, the buyer sees a waiting screen, and a reconciler finds out what happened. Nothing may retry an unknown charge with a new key or on another account.

Tool 3: write the news in the same breath as the change

Once a payment succeeds, several parties need to hear about it: the store, the dashboard, accounting. The tempting code updates the database and then sends a message. That is two writes to two systems, and a crash between them leaves them disagreeing forever.

Figure 3.4Two separate writes can disagree; an outbox row commits with the change
Fragile: two writes 1 · UPDATE payment = paid 2 · crash here 3 · send message (never runs) Database says paid.The store never hears. Durable: one transaction plus an outbox BEGIN1 · UPDATE payment = paid2 · INSERT outbox eventCOMMIT, both or neither Senderreads the outbox Receiverdedupes by event id 3 · deliver, maybe twice A crash before COMMIT loses both;a crash after keeps both. The senderretries until delivered, so receiversmust ignore duplicates.
This is the transactional outbox pattern. Its weakness is the reverse of the fragile version: the sender may deliver an event more than once, so every receiver needs to recognize a repeat S67. A duplicate is easy to ignore. A missing event is hard to notice.
Rule R7 · State and event commit together

A change to a payment and the event that announces it are written in one database transaction. A separate sender delivers events afterwards and may retry.

Rule R8 · Deliver at least once, apply once

Events are delivered until the receiver confirms, so duplicates happen. Every receiver records each event id it has applied, in the same transaction as the change the event causes, and ignores an id it has seen.

This pair is the honest version of "exactly once". Networks cannot promise that a message arrives exactly once; a system can promise that each message's effect happens once, by delivering at least once and deduplicating on arrival. When someone claims exactly-once delivery, ask where the deduplication happens.

Tool 4: when nobody answers, go and ask

Some unknowns never resolve themselves. The provider's webhook may be lost, a node may restart, a browser may close. A is a scheduled job that lists everything still unresolved and asks the source of truth, usually the provider, what really happened. It runs often at first and less often later, and it raises an alarm when something stays unresolved too long.

Figure 3.5The reconciliation loop turns "eventually" into a deadline you can monitor
1 · Listunknown, processing, 3DS 2 · Askread the provider object 3 · Applysame rules as live traffic 4 · Announceevents to the store 5 · repeat: soon after the attempt, then less often, until the outcome is final 6 · still unknown past the limit? alert a person
"Eventually consistent" is only useful with a number attached. Today the node code on the staging server keeps re-reading unanswered provider reads for 23 hours and undecided payments for 7 days before marking them unresolved for a person Today. The proposal keeps that idea and shows its age in the dashboard.
Rule R18 · Reconcile on a clock

Every attempt that is unknown, processing or waiting on 3DS is re-read from the provider on a schedule until it is final. Past a stated age it becomes an alert. A daily job also compares PayAssist's records with the provider's reports.

One more trap: clocks

It is tempting to sort events by their timestamps. Different machines' clocks drift by milliseconds or seconds, and some systems record time to the second only. Stripe's documentation warns that its event timestamps have one-second resolution and must not be used to order events S19. The fix is a version number on each object that goes up by one with every change. Chapter 10 uses it.

Check yourself

A store's checkout code calls PayAssist, waits 10 seconds, gets no answer and shows the buyer "Payment failed, please try again". What is wrong, and what should it show?

Show the answer

Silence is outcome C, not B. The payment may have gone through, so inviting a second attempt risks a double charge. It should show a waiting state ("we're confirming your payment"), retry the same request with the same idempotency key, and rely on the event or a server-side read to learn the truth.

Chapter 4

Proving who is talking

On the internet anyone can send any request to any address. Before PayAssist creates a checkout or a store marks an order paid, each side needs proof of who sent the message and that nobody changed it on the way. Three tools do that: secret keys, signatures and short-lived capabilities.

Names are public, secrets are not

A website login has a username, which other people may know, and a password, which only you know. API keys follow the same split. A is like a username: it identifies the store and is safe inside a web page. A is like a password: whoever holds it can act as the store, so it lives only on the store's server. A is a secret key with a list of allowed actions, such as "create checkout sessions and read events, nothing else". Stripe now recommends restricted keys over all-powerful secret keys for new integrations S29.

A key travels in the Authorization header of an HTTPS request. HTTPS encrypts the connection and proves the server's identity, so the key cannot be read on the way. The server looks up which store the key belongs to and in which , test or live. That lookup, and nothing in the request body, decides whose data the request may touch.

A signature proves who wrote a message and that nobody changed it

Figure 4.1How an HMAC signature works: both sides hold the same secret, only the stamp travels
Sender (PayAssist) Receiver (store) message{"amount":3850,...} shared secret (never sent) HMAC-SHA256a one-way mixer signaturev1,K3m9... 1 message + signaturetravel together; the secret does not 2 Recomputewith its own secret copy 3 · compare inconstant timeequal: acceptdifferent: reject A signature proves who wrote the message and that it is unchanged. It does not hide the message: anyone who intercepts it can read it.
(1) The sender mixes the message with a secret both sides know, using HMAC-SHA256, and gets a short signature. (2) The message and signature travel together; the secret never does. (3) The receiver repeats the calculation with its own copy of the secret and compares the results. Change one byte of the message and the result is completely different, so the receiver rejects it.

An signature guarantees two things: the message came from someone who holds the secret, and the bytes are exactly what that party signed. It guarantees nothing about secrecy. PayAssist's current checkout token is a good example Today: it is signed, so nobody can change the amount, but it is not encrypted, and its claims include a snapshot of the customer's shipping details that anyone holding the URL can decode. The code comments acknowledge this. A design that treats "signed" as "private" will leak data.

Two details decide whether signature checking works:

  • Check the exact bytes that arrived. If a program parses the JSON and turns it back into text before checking, the spacing or key order can change, and a genuine message fails. Worse, a program that checks the parsed version can be fooled by two messages that parse the same way. Chapter 10's lab shows the first problem live.
  • Compare in constant time. A normal string comparison stops at the first different character, so an attacker can measure response times to guess a valid signature one character at a time. Signature libraries compare every byte regardless.

Replays: a genuine message sent again

An attacker who records a genuine signed message can send it again later. The signature still matches, because the message is unchanged. Two defenses work together. A timestamp inside the signed content lets the receiver reject anything older than a few minutes; Stripe's libraries default to five S20. Within those minutes, the receiver's record of event ids already applied (rule R8) turns a replay into a harmless duplicate. Neither defense works alone.

Short-lived capabilities for the browser

The buyer's browser needs to start a payment for one checkout, but it must never hold a key that can act for the whole store. The answer is a : an unguessable string that grants a few actions on one object for a short time, such as "show and pay checkout cs_test_8Lk until 15:30". Stripe calls its version a client_secret. If it leaks, the damage is limited to finishing that one checkout, which the buyer was allowed to do anyway.

Figure 4.2The credentials in the proposal, from least to most powerful
more power, more damage if leaked Checkout capabilitybrowser · one sessionminutes, then useless Publishable keybrowser · names the storeloads PayAssist.js only Restricted keyplugin or jobonly its listed scopes Webhook secretstore server · one endpointverifies events only Secret keystore server onlyeverything, one mode
Give each component the weakest credential that lets it do its job. The WooCommerce plugin needs a restricted key, not the all-powerful one. The webhook secret is separate from every API key, so a store that leaks it cannot be impersonated toward PayAssist, and the reverse.

Tenants: one store must never see another

PayAssist serves many stores from one system. Each store is a , and the most important security property is that no request ever reads or changes another tenant's data. The simple, strict way: the key decides the tenant and the mode, and every database query includes both, so an id copied from another store's data finds nothing. The scenario "Cross-store access attempt" in Chapter 20 steps through it, including why the answer should be 404 rather than 403: a 403 would confirm that the other store's object exists.

Rule R14 · The credential decides the tenant

Store and mode come from the authenticated key, never from the request body or URL. Every query filters on both. A request for another tenant's object gets 404. Test keys never see live data, and live keys never see test data.

QuestionToday TodayProposed Proposed
How does the store prove itself to PayAssist?An HMAC signature over the request body, header payassist-signature: t=…,v1=…, with a 300-second window.A restricted or secret key in the Authorization header over HTTPS.
How does PayAssist find the right secret?It reads store_id from the not-yet-verified body, loads that store's secret, then verifies the exact bytes. The ledger review found this is identity lookup, not a bypass.It looks the key up by a keyed hash in one indexed read, before reading any part of the body.
How does the store verify PayAssist's callbacks?With the same per-store secret used for its own requests.With a separate secret per webhook endpoint, rotatable with an overlap.
What can the browser hold?A signed 12-minute checkout token, readable, carrying amount and customer snapshot.A short-lived capability for one session, opaque, carrying no personal data.

The current design is not broken. It is a reasonable private link between one plugin and one backend. It stops scaling when there are many kinds of clients, many endpoints per store and keys that need different permissions. Chapter 8 designs the full key lifecycle: creation, scopes, storage, rotation and what to do when a key leaks.

Check yourself

A developer suggests putting the store's secret key in a Next.js environment variable named NEXT_PUBLIC_PAYASSIST_KEY so the checkout page can create sessions directly. What goes wrong?

Show the answer

Next.js copies NEXT_PUBLIC_ variables into the JavaScript sent to every visitor, so anyone can read the key and act as the store, including refunding orders. Sessions must be created on the server. The browser only ever receives the checkout capability for its own session.

Chapter 5

The browser is a shared room

A checkout page runs code from many sources at once: the store, its plugins, analytics tags, PayAssist and the provider. The browser keeps them apart by origin. This chapter shows where those walls are, how the pieces talk through them, and what the card-data rules say about it.

Origins are the walls

Browsers group pages by : the scheme, host and port together, such as https://larkspur-tea.example. The lets a page read and change everything from its own origin and almost nothing from another. An places a page from another origin inside your page like a window in a wall: you can see it, but your scripts cannot reach through to read what is typed there.

Figure 5.1Three nested origins on one checkout page; press a button to see what each one can reach
Store page · https://larkspur-tea.example theme, cart, other plugins, analytics tags order summary $38.50 PayAssist payment page today: the account node's domain; later maybe one stable origin status: "Checking..." Provider card fields 4242 4242 4242 4242 12/29 123 the only place the card number exists injected scripthides the real frame,draws fake fields,sends what you typeto someone else Provider serversreceive the card number card data PayAssist serverssee tokens and results only status Store serverprices the cart, gets events

Press a button above. Each wall is an origin boundary; scripts can only reach what is inside their own rectangle.

How the walls let messages through

Frames on different origins talk with : one window posts a message to another, and the receiver gets an event saying which origin sent it. Two rules from the browser makers make this safe S38. The sender names the exact origin it expects to receive the message instead of "*", which means anyone. The receiver checks event.origin and event.source before trusting anything in the message.

Today PayAssist's node pages post their status messages with the target "*", and the WooCommerce modal on the receiving side checks the frame's window, its exact origin and a source marker before acting Today. The messages carry only display hints such as "ready", "resize" and "payment status", and the order is never marked paid from them. An earlier version of the plugin's receipt page skipped the origin check; the staging source fixed it and has tests for forged messages.

A improves on this. After one checked handshake the page hands the frame a private port, and later messages travel only through that port, where other pages cannot listen or inject S39. MDN's own example sends the port with "*", which the handshake in Chapter 9 deliberately does not copy.

Rule R16 · Check every message, then use a private channel

Every cross-frame message is sent to an exact origin and checked for origin and source window on arrival. After the handshake, page and frame talk over a private MessageChannel port. Messages may carry a status the server observed, for the screen, but never card data and never the authority to mark an order paid.

Who may put your page in a frame

A page decides who may embed it with the rule of its Content Security Policy, sent as an HTTP header S41. A payment page should list only the stores allowed to show it, so a hostile site cannot wrap it in a deceptive layout. Today the node's /pay page sends frame-ancestors *, a deliberate choice in the code because one node serves many stores Today. A per-session list of allowed store origins is one of the proposal's browser changes. The referrer header cannot replace it: the embedding page can suppress or change what the frame sees there, so identity must come from a signed capability S42 S43.

What the card-data rules say

The card industry's security standard, , applies to anyone who stores, processes or transmits card numbers, and also to service providers who could affect their security without touching them S10. Merchants prove compliance with forms called self-assessment questionnaires. The lightest one for online shops, , applies when every element of the payment form comes from a compliant third-party provider.

Figure 5.2Who carries which card-data obligations in an embedded checkout
Merchant (the store) May qualify for SAQ A when everypayment-form element comes from acompliant provider. S2 The page that embeds the formstays in scope. S5 Since 31 March 2025: confirms thesite resists script attacks. S1 S3 The acquirer or card brand, notPayAssist, decides which form. S12 PayAssist A service provider that could affectcard security, so PCI DSS appliesto it. S10 Cannot use SAQ A, which is formerchants only. S4 Owes merchants written statementsof responsibility and evidence. S6 S11 Its page wraps the provider's frame:no PCI FAQ covers that nesting. Provider Receives and processes the cardnumber inside its own fields. Holds its own validated compliance;Stripe says its Checkout and Elementssupport merchant SAQ A when useddirectly. S17 That statement covers a merchantusing Stripe directly, not a thirdparty that wraps Stripe's frames.
Small labels such as S5 point to the PCI Security Standards Council and Stripe pages in Appendix F, all read on 18 October 2026. The amber line is an open question for a qualified assessor: PayAssist's payment page holds the provider's fields one level down, and no Council FAQ found addresses that arrangement.
Corrected claims about PCI

Summaries of the earlier arena said the design gives "zero PCI", "automatic SAQ A" and keeps PayAssist and merchants "completely out of PCI Level 1 scope" Corrected. The evidence says otherwise: the merchant's page stays in scope, the merchant must confirm script protection, the acquirer chooses the form, and PayAssist is a service provider with its own obligations. Safe wording: card numbers are typed into provider-hosted fields and never reach PayAssist's or the merchant's servers, which is designed to support SAQ A eligibility and must be confirmed with the acquirer and an assessor.

The danger in Figure 5.1's last button is the reason the 2025 rule exists. The scenario below plays it out. The defenses all live on the store page or in the choice of display mode, which is why Chapter 9 keeps a full-page hosted redirect as the safest option for stores that cannot control their own scripts.

Rule R15 · Card data stays in provider fields

Card numbers are typed only into provider-hosted fields and sent only to the provider. PayAssist and the store handle tokens, ids and results. PayAssist does not build its own card field or card vault.

Cookies, Safari and the third-party question

A payment frame served from a node's domain is cross-origin to the store page and, because the two have different registrable domains, cross-site as well. Cookie rules care about the second: a frame's cookies are third-party when its site differs from the site in the address bar S98. Two subdomains of one store would be cross-origin but same-site. Safari has blocked third-party cookies by default since version 13.1 in March 2020, and it gives third-party frames separate, temporary storage S48 S49. A frame can ask for its cookies with the Storage Access API, but only after the buyer clicks inside it S51. Chrome, by contrast, announced in 2025 that it will keep letting users choose and will not remove third-party cookies by default S52.

The practical rule is simple: a payment frame must work with no cookies and no stored state of its own. It can receive everything it needs in a short-lived capability and talk to its servers directly. That does not make the whole checkout immune to tracking prevention. Provider fraud and device checks inside nested provider frames can still lose signals, wallets have their own rules, and return URLs can lose link decorations, so each provider needs real tests on Safari.

Corrected claim about Safari

Arena summaries promised "complete immunity" to Safari's tracking prevention Corrected. Safe wording: PayAssist's own frame needs no third-party cookie or persistent frame storage. Provider frames, wallets and storage behavior still need browser-specific testing.

Wallets need permission at every level

Apple Pay and Google Pay inside frames need the allow="payment" attribute on every iframe in the chain, from the store page down to the provider's frame S47. Safari has allowed Apple Pay in third-party iframes this way since version 17.0 S46; older Safari versions cannot be assumed. A nested design has one more link to forget than a single provider frame, and testing must cover it.

Check yourself

A store owner asks: "If my card form is in an iframe, I don't need to worry about PCI, right?" What do you tell them?

Show the answer

The iframe keeps the card number off their server, which is a big reduction. Their checkout page still matters, because a script on it can draw a fake form. Since March 2025 SAQ A merchants must confirm their site is protected from script attacks, and their acquirer decides which questionnaire applies.

Part II

The protocol

Seven chapters build the proposed standard from the inside out: the objects it talks about, the HTTP contract, the keys, the browser pieces, the events, what a store must do on its side, and how everything converges when messages go missing. Everything in this part is Proposed unless a label says otherwise.

Chapter 6

The objects and their states

A protocol is mostly a vocabulary. This chapter names the objects a store sees, draws how they relate, and lets you drive a payment through its states to see which moves the rules allow.

Figure 6.1The public objects and who owns each oneProposed
Store ordercart, prices, stocklives in the store Checkout sessioncs_…amount, return URL, expiry Paymentpay_…the money, its status Attemptpat_…one provider object ref 1 : 1 1 : n Refundre_… Disputedp_… 0 : n each Eventevt_…"this object changed" Webhook endpointwe_…URL + own signing secret API keystore, mode, scopessee chapter 8 sent to The store owns the order.PayAssist owns the session,payment and attempts.Routing owns which accountan attempt uses, and nevershows it in the public API.
A checkout session is the store's handle on one order revision. It owns exactly one payment, the money. A payment can need several attempts, each a provider object pinned to one provider account, but only one of them may be able to take money at any moment (rule R3). Refunds and disputes hang off the payment as separate objects. Every change produces an event, which goes to each of the store's webhook endpoints that subscribed to it. The cs_, pay_ and pat_ prefixes follow the arena proposals; any distinct prefixes would do.

Why a session sits on top of the payment

Stripe offers two styles: a low-level PaymentIntent that the developer drives step by step, and a higher-level Checkout Session that wraps one. The arena proposals from Claude Opus 5.5 and Codex GPT-6.1-Sol both made the session the default for PayAssist, with good reason Proposed. The store owns the cart and the return URL. PayAssist owns the decision about which account processes the money. A session gives the store one stable object while PayAssist manages the provider objects underneath it, including a second attempt on another account once the first can no longer take money. Today's code already has the pieces: a durable payment_requests table and a separate payment_attempts table whose id travels inside the signed checkout token Today. The proposal turns them into documented public objects instead of renaming them in a diagram.

Rule R2 · One live payment per order

An order has one live payment, for its newest revision, and at most one open session. A payment stops being live only when nothing more can be charged on it. A repeated create returns the open session if the terms match. It is refused if they differ. A session that expired unpaid can be followed by a fresh one for the same revision, with a new key, once its payment is canceled. The store raises the revision number whenever the cart changes. One admission record per order decides every create in turn: it refuses an older revision, and before accepting a newer one PayAssist cancels the old provider object, answering 409 if the provider cannot confirm the cancel.

The payment's states

A payment moves through five states. Stripe's PaymentIntent has a similar set S24, and the arena proposals kept the same shape. The important design choices are which moves are allowed and which are refused. Drive it yourself below: press the events in any order and watch which ones the rules reject.

Figure 6.2Payment state explorer for order #1042, $38.50Illustrative
requires_payment_methodwaiting for a card processingprovider deciding requires_actionbank check (3DS) succeededmoney captured cancelednothing can be charged Refunds and disputes never changethis status. They are separateobjects with their own events. paydeclinedold-tab success

    Try this: Pay, then "Provider: declined", then "Provider: charge succeeded", to see why a declined object still counts as able to take money. Or Pay, let the session expire, confirm the cancel, and start a new session.

    Three more rules finish the picture. The explorer enforces R2, R3 and R6; R5 lives in provider calls the explorer does not show.

    Rule R3 · One payable provider object per payment

    Only one provider object may be able to take money for a payment at any moment. An attempt whose outcome is unknown counts as able to. A decline does not always end the object. A declined Stripe PaymentIntent waits for another card S24, so the next card goes to the same object, or PayAssist cancels it and waits for Stripe to confirm before creating another S89. A declined Square payment ends in FAILED, which already retires it S90. Only a retired object makes room for a new one, and an unknown result never retires anything. While an object can take money, the payment stays pinned to its account.

    Rule R5 · Provider keys come from the attempt

    Every call to a provider for an attempt uses an idempotency key derived from the attempt id and the operation, such as pat_1:create (a hash of the two works as well), with the exact parameters saved on the attempt. A retry reuses both, so the provider can deduplicate, but only inside the provider's retention window: Stripe may drop a key once it is 24 hours old and then treats it as a new request S22. Past that window nothing re-sends an uncertain create; reconciliation reads the provider instead. The current nodes already pass the attempt id as the Stripe idempotency key and as PayPal's request id Today. They also carry a diagnosed, unfixed defect: standard /pay can re-select the basket under the same key, which changes the parameters behind it (E1 §4) Today. The new store protocol does not repair that; the standard path needs its own fix.

    Rule R6 · Real money wins

    A confirmed success beats an expired session and any cancel PayAssist has asked for but the provider has not confirmed. PayAssist never discards a success; it reports it, with the session marked expired if that happened first, and the store's late-payment policy decides between fulfilling and refunding. A payment is marked canceled only after the provider confirms its object can no longer take money, or when no object ever existed, so a later success for a canceled payment contradicts the provider's own answer and goes to a person. Today's reconciliation already lets success supersede an earlier cancel or failure Today.

    A decline is an answer; silence is not

    The arena's summaries described a "smart" feature: when account A declines or fails, retry automatically on account B under the same session. The underlying designs did not agree with that summary Corrected. Codex's design required an unresolved attempt to block any reroute and allowed a new attempt only after a definite decline; Astra's froze uncertain requests. That difference decides whether a buyer can be charged twice. A decline is a definite "no" for one card, but it does not always retire the provider object. A declined Stripe PaymentIntent goes back to requires_payment_method, and anyone holding its client secret, such as a forgotten second tab, can still confirm it with another card S24. A new object created beside it would leave two that can charge. So after a decline the next card goes to the same object, or the old one is canceled and the provider confirms it before a new one exists (rule R3). A timeout is worse: silence might hide a successful charge on account A.

    Even after a definite decline, sending the buyer's card to a different account is a policy question, not a technical shortcut. Card networks publish rules and fees for re-attempting declined transactions, fraud systems may treat rapid retries across merchants as card testing, and some declines mean "do not try again". This book recommends This book: a new attempt after a decline happens only when the buyer acts again, such as entering another card; which account it may use is decided by the routing policy with those rules in view; and an unknown outcome blocks every new attempt until reconciliation settles it.

    Saved cards wait

    Saving a card for next time sounds simple, but a saved card belongs to the provider account that saved it. A card saved on Stripe account A cannot be charged through Square account B. Inside one Stripe Connect platform, a card saved on the platform can be cloned to connected accounts for direct charges S32. Network tokens are tied to one card and one merchant S34. Moving stored cards between processors is a gated processor-to-processor migration S35, and Square and PayPal scope saved cards their own ways S36 S37. Saved cards therefore pin a buyer to an account, which fights the router. Claude Opus 5.5's arena design left them out of version 1 and this book agrees This book; Codex's design included a setup resource but not in its first slice. If a paying merchant needs saved cards, design them per provider, starting with Stripe Connect's cloning model.

    Check yourself

    In the explorer, why does "Buyer presses Pay" get refused after "Provider call times out", even though the screen shows no success?

    Show the answer

    The first attempt's outcome is unknown, so it counts as live under rule R3. A second attempt could charge the card a second time if the first one quietly succeeded. The buyer waits until the reconciler or a provider event settles the first attempt.

    Chapter 7

    The API contract

    The contract is the part developers touch every day: the shape of a request, the meaning of each status code, how versions change, and above all how a retry stays safe. Getting these boring details exactly right is what makes an integration feel trustworthy.

    Anatomy of one request

    Create a checkout sessionProposedIllustrative

    POST /v1/checkout_sessions HTTP/1.1                         ① method and path: create one session
    Host: api.payassist.example
    Authorization: Bearer pa_rk_test_51Ex…AMPLE                  ② restricted key: store + mode + scopes
    Idempotency-Key: wc-1042-r1-s1                               ③ names this intention (rule R1)
    PayAssist-Version: 2026-11-01                               ④ the contract version the caller expects
    Content-Type: application/json
    
    {
      "amount": 3850,                       ⑤ integer minor units: 3850 cents = $38.50
      "currency": "usd",
      "order_reference": "1042",            ⑥ the store's own order id
      "order_revision": 1,                        bumps when the cart changes (rule R2)
      "ui_mode": "modal",                     ⑦ hosted, modal or embedded
      "return_url": "https://larkspur-tea.example/checkout/order-received/1042",
      "metadata": { "woo_order_key": "wc_order_EXAMPLE" }
    }
    
    HTTP/1.1 201 Created                                             ⑧ created; a replay says so in a header
    Request-Id: req_7Hc…
    {
      "id": "cs_test_8Lk…", "object": "checkout_session", "status": "open",
      "payment": "pay_3Qd…", "client_secret": "cs_test_8Lk…_secret_…",
      "expires_at": 1792302600, "livemode": false, "version": 1
    }

    Every proposed request has the same parts. The key (②) decides whose data this is and what it may do. The idempotency key (③) makes the request safe to repeat. The version header (④) pins the meaning of every field, so PayAssist can change the contract later without breaking old integrations. Amounts are whole numbers of the currency's smallest unit (⑤), never decimals, which avoids rounding errors. The store's own order id and revision (⑥) bind the session to exactly one version of the cart. Every response carries a request id, which support can look up.

    Why plain HTTPS and JSON

    Both arena designs that addressed it, Codex GPT-6.1-Sol's and Claude Opus 5.5's, recommended REST over HTTPS with JSON for the public contract, described in one 3.1 document, and kept gRPC for internal paths that measure a need Proposed. The reasons are practical: browsers cannot speak gRPC without a translating proxy, PHP's gRPC extension is awkward on shared WordPress hosting, and every developer already has curl and Postman. The arena summaries described this as a unanimous vote; it was two independent recommendations that happened to agree Corrected.

    The OpenAPI document becomes the single source of truth. SDKs for PHP and Node are generated from it, with hand-written helpers for webhooks on top, and a check in continuous integration fails the build whenever the server's routes and the document disagree. Dated versions follow Stripe's model, where a header selects the version per request and each webhook endpoint has its own pinned version S28.

    Errors that tell the client what to do

    An error body should answer one question: what should the caller do now? The proposal uses the standard problem-details format from RFC 9457 S62 with a few extra fields: a stable code, the offending param, the request_id, and retryable. The status code family tells the SDK how to behave.

    StatusMeaningWhat the caller does
    200, 201Done, or a replay of a finished request.Use the result.
    400Malformed request, or a missing idempotency key on a write.Fix the code. Retrying unchanged will fail again.
    401, 403Bad key, or a key without the needed scope.Stop and alert a person.
    404No such object for this store and mode.Stop and check which id was sent; never probe other stores' ids.
    409 request_in_progressThe same idempotency key is still running.Wait briefly and retry with the same key and body.
    409 order_revision_conflictThis order revision already has a session with different terms.Send a new revision with a new key.
    422 idempotency_key_reusedThe key was used before with a different body.A client bug: fix key generation.
    429Rate limited.Wait for Retry-After, then retry with jitter.
    500, 502, 503, timeoutSomething failed inside, or the answer was lost.For writes this is outcome C, unknown: retry with the same key, never a new one.

    The idempotency protocol, step by step

    Figure 7.1What the server does with every write that carries a keyProposed
    Write arriveskey K, body Bstore + mode from the API key 1 · Insert row(store, mode, route, K)fingerprint = hash(validated B) 2a · New keydo the work and save the response in the SAMEtransaction · answer 201 2b · Seen, finished, same fingerprintreplay the saved status and body byte for bytewith Idempotent-Replayed: true 2c · Seen, still runninganswer 409 request_in_progress; never starta second copy of the work 2d · Seen, different fingerprintanswer 422 idempotency_key_reused;change nothing Replays are kept 7 days; the businessuniqueness of (order, revision) is keptfor the life of the payment.
    (1) The server inserts a row for the key, scoped to the store, mode and route, with a fingerprint of the validated request. (2a) A new key does the work and stores the response in the same transaction. (2b) A finished key with the same fingerprint replays the stored response. (2c) A key still running gets 409. (2d) A key reused for a different request gets 422 and nothing changes.

    The status codes follow the IETF's draft Idempotency-Key specification: 400 for a missing key, 409 while the first request is still running, 422 for a key reused with a different payload S59. That draft expired in April 2026 without becoming a standard S58, so the book cites it as a widely copied convention, not a rule. The arena proposals disagreed on details here: Claude Opus 5.5 kept rows for 24 hours, Codex GPT-6.1-Sol and Codex Astra kept replays for 7 days, and Codex used 409 where the others used 422 Proposed. This book picks the draft's codes, a 7-day replay window and permanent business uniqueness This book, because a store's retry queue can run longer than a day after an outage, and the cost of a longer window is only storage.

    The fingerprint should hash the validated request, meaning the fields after parsing and defaulting, together with the store, mode and route. Hashing the raw bytes would reject harmless differences such as key order; Codex Astra proposed the JSON Canonicalization Scheme of RFC 8785 for this, which is one reasonable choice S61.

    Figure 7.2Idempotency lab: try to create two checkouts for one orderIllustrative
    Responses, newest first
      Server state
      KeyRequestStateSaved response

      Things to try: send the same request twice; switch the amount but keep the key; switch to the other tab's key with the same amount; bump the revision and send with the old key.

      Lists, limits and the shape of time

      • Lists use cursors. GET /v1/events?after={cursor}&limit=100 returns the next page after a saved position, with a next_cursor to save once the page is applied. A cursor is only safe if nothing can appear behind it later, and a database can break that. Transaction A takes event number 100 and pauses; transaction B takes 101 and commits; a store reads 101 and saves its cursor; then A commits 100, behind the cursor, where the store will never look. So the feed does not use those numbers. One publisher hands out feed positions only to events that have already committed, in order, and every page stops at a fixed high-water mark This book. A cursor belongs to one store and mode.
      • Rate limits answer 429 with Retry-After. SDKs wait, then retry with jitter, keeping the same idempotency key.
      • Timestamps are Unix seconds in UTC. They describe when things happened; object versions, not timestamps, decide what is newer.

      The full list of proposed endpoints and events is in Appendix D, organized by resource the way Stripe's reference is.

      Check yourself

      A store's server sends create with key K, gets a 503, and its developer's code makes a new key and sends again. Which outcome table row did they ignore?

      Show the answer

      The last one. A 503 on a write is an unknown outcome. The first request might have created the session. Retrying with the same key K is always safe; a new key risks a second session, which rule R2 would then have to catch.

      Chapter 8

      Keys, scopes and tenants

      Keys get created, copied, leaked, rotated and forgotten. This chapter designs their whole life: what a key looks like, how PayAssist stores it, how a plugin gets one without anybody pasting secrets, and what happens on the day one leaks.

      What a key looks like

      Key formatsThis bookIllustrative

      pa_pk_test_4f8KQ2…            publishable: identifies a store in the browser
      pa_rk_live_9Zt1Lm…c7Qd         restricted: listed scopes only (the plugin's key)
      pa_sk_live_2Hx8Vb…k3Pw         secret: everything for one store and mode
      whsec_RXhhbXBsZU9ubHk…         webhook signing secret, one per endpoint
       └┬─┘└┬┘└┬─┘└───┬────┘└┬┘
        │   │  │      │      └ 32-bit checksum, so scanners can skip look-alikes
        │   │  │      └ at least 128 bits of randomness
        │   │  └ mode: test or live
        │   └ type: pk, rk or sk
        └ PayAssist's own prefix

      The arena proposals used Stripe's exact prefixes, pk_, sk_ and rk_ Proposed. This book recommends a PayAssist prefix in front of them This book. Many PayAssist merchants will already have Stripe keys in the same configuration files, and identical prefixes invite pasting the wrong one. Secret scanners also match on prefixes, and GitHub's partner program asks token issuers for distinctive patterns, high-entropy random parts and a checksum so leaks can be found with few false alarms S86. A key that looks exactly like Stripe's would be reported to the wrong company. Webhook secrets keep the whsec_ form because the Standard Webhooks libraries expect it (Chapter 10).

      How PayAssist stores them

      The two kinds of secret need opposite treatment. PayAssist only ever needs to compare an API key with what a store sends, so it stores a keyed hash of the key, like a password, and shows the key once at creation. PayAssist must use a webhook signing secret to sign every event, so it cannot store just a hash; it stores the secret encrypted, with the encryption key kept outside the database. Codex GPT-6.1-Sol's arena design spelled this out: a keyed hash for API keys, and envelope encryption for any secret an outbound call needs Proposed. Claude Opus 5.5's design stored API keys as SHA-256 hashes and gave each webhook endpoint its own secret, but never said how those secrets are stored. This book adopts Codex's explicit split This book.

      Figure 8.1The life of a key, with an overlap so rotation never breaks a checkoutProposed
      old key valid overlap: both validfor example 1 hour to 7 days new key valid 1created, shown oncestored as a keyed hash 2in uselast-used time recorded 3rollednew key issued 4store deploys new keyold key's last use stops 5old key expires 6 · leaked? revoke now,no overlap, then roll
      (1) A key is shown once and stored as a keyed hash. (2) PayAssist records when it was last used. (3) Rolling creates a new key while the old one stays valid for an overlap the merchant chooses; Stripe allows up to 7 days for API keys S29. (4) The store deploys the new key and the old key's last-used time stops moving. (5) The old key expires. (6) A leaked key is revoked immediately, accepting a short outage over a thief's access.

      Scopes: give each part the smallest key that works

      Who holds itKeyScopes
      WooCommerce pluginrestrictedcheckout_sessions:write checkout_sessions:read payments:read refunds:write events:read
      A store's reporting jobrestrictedpayments:read events:read
      An agency's admin toolrestricted, expiringwhatever the merchant approves, for a limited time
      A custom Node backendsecret or restrictedsecret keys are allowed but discouraged, as Stripe now does

      Scope checks run on the server for every request, including when an authorization result is cached. A key can also carry an expiry date and an optional list of allowed IP addresses.

      Connecting a plugin without pasting secrets

      Today a merchant pairs the WooCommerce plugin by entering settings that include a shared secret Today. Pasted secrets end up in chat messages, screenshots and support tickets. Claude Opus 5.5's arena design proposed a "Connect to PayAssist" button that mints the plugin's key and registers its webhook endpoint automatically; Codex GPT-6.1-Sol's proposed the OAuth authorization-code flow with PKCE for third-party apps Proposed. The flow below combines them.

      The day a key leaks

      Three properties limited that leak. The key had a recognizable format, so it was found quickly. Its scopes were the most it could do: it could create sessions, read events and issue refunds for one store in one mode, and nothing else. And revoking it took effect at once while the plugin received a new key. The overlap in Figure 8.1 is for planned rotation of a key nobody else has seen, never for an exposed one S88. Today a single per-store secret signs traffic in both directions, so rotating it means changing Backend and the plugin at the same moment Today.

      Test mode is a separate world

      Every key belongs to test or live mode, and so does every object. A test key can never read or change a live row; "livemode" is part of every unique index and every query. Test mode routes to provider sandboxes or to PayAssist's existing mock node, which boots the real node code with a fake provider Today. That separation is what lets merchants rehearse every scenario in Chapter 20 without moving real money.

      Check yourself

      Why must PayAssist store webhook signing secrets encrypted rather than hashed, when it hashes API keys?

      Show the answer

      PayAssist only compares API keys, so a hash is enough to check one. To sign events PayAssist needs the actual webhook secret every time it sends, and a hash cannot be turned back into the secret. Encryption keeps it unreadable in the database while still usable by the signing service.

      Chapter 9

      PayAssist.js and the payment frame

      The browser side has three jobs: show the provider's card fields inside the store's checkout, keep the buyer informed while money moves, and never claim a result the server has not confirmed. This chapter compares the ways to show the form, takes apart the frame-origin question, and draws the browser's state machine.

      Three ways to show the payment form

      Figure 9.1Hosted, modal and embedded checkouts trade control for exposure
      node-a.payassist.example PayAssist page provider card fields store page is gone Hosted redirect larkspur-tea.example/checkout PayAssist frame provider fields store page dimmed behind Modal larkspur-tea.example/checkout store's ownform andsummary PayAssistelement fields Embedded element least effort · least stylingstore scripts out of the card path today's WooCommerce popupstore page still in scope most control · most exposureneeds PayAssist.js and layout care
      The proposed session object carries a ui_mode that picks one of these three shells; the server side is identical. Today the staging WooCommerce plugin offers two of them, a full-page hosted redirect and a popup modal Today.
      Hosted redirectModalEmbedded element
      Store developer effortA redirect and a return page.A script tag and a button.PayAssist.js, layout space, state handling.
      Card-path exposure on the store pageThe card page is PayAssist's, not the store's. SAQ A's script criterion does not apply to redirect merchants S7, though the page that redirects still matters S5.The store page embeds the frame, so it stays in scope.Same as modal, with more store code nearby.
      StylingPayAssist's page, with the store's logo and colors.Appearance settings inside the frame.Appearance settings, honored as far as each provider allows.
      Wallets and 3DSSimplest: one top-level page.allow="payment" on each frame; 3DS inside the overlay.Same as modal, plus the page must give the frame a full-screen overlay for 3DS.
      Mobile in-app browsersWorks.Usually works.Wallets may not; fall back to hosted.

      Codex GPT-6.1-Sol's arena design made hosted checkout the default and the first slice; Claude Opus 5.5's listed all three modes from the start Proposed. This book recommends shipping hosted and modal first, because both already exist in the current plugin and node pages, and adding the embedded element after the protocol underneath is proven This book.

      Where should the payment page live? The frame-origin decision

      Today each account node serves its own payment page from its own domain, so a store's checkout frame comes from a different host depending on which account the router picked Today. Claude Opus 5.5 and Claude Sonnet 5.5 proposed moving every buyer-facing page to one stable PayAssist origin, with nodes still making provider calls from their dedicated IPs behind it Proposed.

      Figure 9.2Per-node origins today versus one stable origin: what each side gains and risks
      Today: one origin per node store checkout pay.node-a domain pay.node-b domain + a blocked or broken domain affects one account + already built and owner-confirmed for Connect - store CSP must allow every node domain - wallet domain registration per node - each new domain starts with no reputation Proposed: one stable origin store checkout pay.payassist.example node A node B + one CSP entry and one wallet domain for all stores + reputation and caching built once - a block or outage there stops every store at once - a new central edge in every checkout's path - changes the owner-confirmed node-domain pattern Decision D1 belongs to the owner. This book recommends keeping node origins for the first release and testing a stable origin later on a measured share of traffic, comparing form-reach rates by network before and after.
      A stable origin makes the store's setup simpler and concentrates risk in one place. Both sides have real costs. The owner confirmed today's customer-facing node-domain pattern for the Connect work Owner-confirmed. This book recommends keeping those origins at first and evaluating a stable origin separately This book; that recommendation is not an owner-approved target topology.
      Corrected claim about ISP blocking

      Arena summaries said a stable origin "solves" ISP security filters that blocked "up to 73% of checkout forms" Corrected. The real record is narrower. In a local investigation of one store from 5 September to 4 October 2026, 159 of 217 orders attributed to Charter/Spectrum networks (73%) showed no sign that the card form ever loaded, against 4 of 201 on AT&T (2%); Xfinity's checker confirmed a block. Form reach recovered on the existing domain about 18 hours after the owner filed unblock requests with Spectrum and Xfinity, a timing association rather than a controlled test Measured. The network study is E7; the recovery comes from the later investigation note, E12. Nobody ever deployed or tested a stable origin, and a single origin can be blocked too. The evidence supports monitoring form reach by network and having an unblock routine, not a claim that one hostname prevents blocks.

      The handshake

      Whichever origin serves the frame, page and frame should greet each other in a checked way before anything else happens. Today they exchange plain window messages, which the WooCommerce modal checks for origin and window Today. The proposal adds a nonce from the server and a private MessageChannel port, drawing on Claude Opus 5.5's and Codex GPT-6.1-Sol's designs. Claude Sonnet 5.5's code sketch had the origin checks and the port but no nonce, and its listener would stop after the first message even if that message was invalid. This book's review found both gaps by reading the sketch; nobody has written or tested a fix This book.

      Two details decide whether the handshake can work at all. First, frame-ancestors is a header on the frame's own response, and a meta tag cannot set it S41, so the node must choose it before sending the page. The frame's path carries the session id, which is not a secret, and the node looks up that session's store origins and sends them with Cache-Control: no-store; the capability stays in the fragment. Second, the nonce must reach both sides by different roads. The store's server receives frame_nonce when it creates the session and puts it in the page; the frame learns the same nonce from the node when it verifies the capability. The page sends init with the protocol version, session id and nonce when the iframe's load event fires, and repeats it until the frame answers hello with the same three values and one private port, so the frame never has to announce itself to an unknown parent. The frame starts listening before it verifies the capability and holds an early init until verification finishes This book.

      Reserving space so the page does not jump

      When a frame loads late and pushes the page down, buyers mis-tap and Google's layout-shift score (CLS) suffers. Claude Sonnet 5.5's design has the server suggest the form's likely height when it creates the session, so the store page can reserve that space before the frame arrives, and the frame then reports its real height. That is a sound technique. Its "CLS below 0.02" figure was labeled a target and never measured; summaries later presented it as a result Corrected. Wallet buttons that appear only on some devices will still shift a first visit.

      Styling as data, not code

      Merchants want the form to match their store. Letting them inject CSS into the payment frame would let them, or an attacker on their page, read keystrokes through clever selectors. Claude Sonnet 5.5's design accepts only a closed set of named settings, such as primary color and corner radius, which code inside the frame turns into CSS, and it reports which settings a provider ignored. Its compiler is real code. Rerun under Bun 1.4.0 on 18 October 2026, the shared compiler-and-reducer test file passed 10 tests with 41,509 assertions in 3.37 seconds; one appearance test generated 20,000 random style objects and checked them for forbidden characters Synthetic test. That shows the compiler rejects the tested strings under Bun. It does not test a browser, a provider's fields or font loading, and summaries that called it proof of a secure SDK overstated it Corrected.

      The browser never decides the outcome

      Figure 9.4The checkout's browser states, with "unknown" as a first-class stateProposed
      ready confirming challenge (3DS) processing unknown succeeded failed Pay network lost server answers only after the SERVER says failed: back to ready with a new attempt and a new key "unknown" nevershows a failuremessage or afresh card form
      A lost answer moves the checkout to unknown, where it waits and asks the server. Only a server-reported failure returns the buyer to ready with a new attempt and a new key. Claude Sonnet 5.5 built this as a small tested reducer. The book's evidence review reproduced one gap in it: reloading the page while the state was processing restarted at ready and minted a new key Synthetic test. The sketch has not been repaired. A fixed resume must treat every in-flight phase as unknown until the server answers, with a regression test for this exact reload.

      Popups, 3DS and wallets

      • 3DS needs the full screen. The frame asks the page for an overlay; the page makes the frame full-screen with a focus trap, and the reserved space keeps the page from jumping underneath. Stripe forbids sandboxing its 3DS iframe S53.
      • Popups must open inside the click. Safari blocks a window opened after an asynchronous wait, so PayPal-style flows open a blank window synchronously and fill it afterwards.
      • Wallets need permission at every frame. allow="payment" on each iframe in the chain, Safari 17 or later for Apple Pay in a cross-origin frame S46 S47.
      • In-app browsers get the hosted page. Wallets often fail inside app webviews, so PayAssist.js falls back to the hosted redirect there.

      The same protocol from a Next.js store

      Next.js App Router sketchIllustrativeProposed SDK names

      // app/checkout/actions.ts runs on the server only
      'use server';
      import { payassist } from '@/lib/payassist';        // new PayAssist(process.env.PAYASSIST_RK)
      export async function startCheckout() {
        const cart = await loadCartForCurrentShopper();  // authenticate and price on the server:
                                                         // server actions are public POST endpoints
        const s = await payassist.checkoutSessions.create(
          { amount: cart.totalMinor, currency: cart.currency, order_reference: cart.orderId,
            order_revision: cart.revision, ui_mode: 'embedded', return_url: cart.returnUrl },
          { idempotencyKey: `order-${cart.orderId}-r${cart.revision}` });
        return { clientSecret: s.client_secret };               // the only thing the browser gets
      }
      
      // app/api/payassist/webhook/route.ts
      export async function POST(req: Request) {
        const raw = await req.text();                          // exact bytes, before any JSON.parse
        const event = payassist.webhooks.verify(raw, req.headers, process.env.PAYASSIST_WHSEC);
        await db.transaction(async (tx) => {                  // inbox + order + jobs, one transaction
          if (!(await tx.inbox.insertIfNew(event.id))) return;  // duplicate: already applied
          await applyPaymentEvent(tx, event);                     // version check, mark paid, enqueue fulfillment
        });
        return new Response(null, { status: 200 });             // only after the commit
      }

      Three Next.js facts shape this sketch. Server Actions are reachable by any POST request, so each must authenticate and price the cart itself S82. Route handlers read the raw body with request.text() S83. And after(), which runs code after the response, is not durable: work scheduled there is lost if the process dies S84. Claude Sonnet 5.5's sketch answered the webhook first and fulfilled inside after(), which can lose a paid order Corrected. In Next.js 16 the request-interception file is called proxy.ts instead of middleware.ts and runs on Node.js only S85.

      Check yourself

      A buyer's phone loses signal right after they press Pay. When signal returns, what should the checkout show, and what must it not do?

      Show the answer

      It shows a waiting state, the unknown phase, and asks the server about the existing attempt using the same key. It must not show "payment failed" or a fresh card form, and it must not create a new attempt key, because the first charge may have gone through.

      Chapter 10

      Events and webhooks

      A store learns what happened to its payments through events: small signed messages that PayAssist pushes to the store's server, plus a feed the store can read back whenever pushes fail. This chapter follows one event from the database to the store and through every way the trip can go wrong.

      Figure 10.1From a committed change to a store that has applied it, in seven stepsProposed
      1 · Commitpayment = succeeded+ event row, one transaction 2 · Fan outone delivery row per(event, endpoint) 3 · Dispatchclaim due rows, sign now,limit per endpoint 4 · Egress proxyblocks private IPs, noredirects, 10 s timeout 5 · Store endpointverify bytes, insert event id,apply, then answer 200 6 · No 2xx? Reschedulewait a random time up tomin(6 h, 30 s × 2ⁿ), repeat 7 · After 72 hoursdelivery marked failed, endpoint flagged,merchant emailed; the event is not lost The store can always catch up: GET /v1/events?after={cursor} returns the same events for 30 days
      (1) The change and its event commit together, rule R7. (2) Each of the store's endpoints gets its own delivery row, so one broken endpoint never blocks another. (3) A dispatcher claims due rows, signs each send at send time and limits concurrent requests per endpoint. (4) All outgoing traffic passes a proxy that refuses private network addresses. (5) The store verifies, records and applies, then answers 200. (6) Anything else is rescheduled with a random, growing delay. (7) After 72 hours the delivery is marked failed and the merchant is told, while the event stays readable in the feed.

      What an event contains

      payment.succeededProposedIllustrative

      {
        "id": "evt_1QxL…",                  stable across every retry: the receiver's dedup key
        "type": "payment.succeeded",
        "created": 1792301845,
        "livemode": false,
        "api_version": "2026-11-01",         the version this copy is rendered in
        "data": {
          "object": {                          a snapshot of the object at this moment
            "id": "pay_3Qd…", "object": "payment", "status": "succeeded",
            "amount": 3850, "amount_received": 3850, "amount_refunded": 0, "currency": "usd",
            "checkout_session": "cs_test_8Lk…", "session_status": "complete",   expired if money moved after the session ended (R6)
            "order_reference": "1042", "order_revision": 1,
            "version": 4                         goes up by one with every change (rule R9)
          }
        }
      }

      The event carries a snapshot of the whole object, so a store can usually act without a follow-up call, and a version number, so it can tell old news from new. It carries no card data, no client secret and only the customer details a store needs. The proposed version 1 event list is short: checkout.session.completed, checkout.session.expired, payment.processing, payment.requires_action, payment.succeeded, payment.canceled, payment.declined, refund.created, refund.succeeded, refund.failed, dispute.created, dispute.updated and dispute.closed. Today the plugin receives six callback kinds: succeeded, refunded, partially refunded, and dispute opened, won and lost Today.

      PayAssist stores each event once, as a snapshot of the object at one version. Each webhook endpoint pins an API version, and each delivery row freezes the body rendered for that version when the delivery is created, so every retry and every manual replay sends the same bytes with a fresh signature. Changing an endpoint's version affects only deliveries created afterwards. The feed renders an event in the reading key's pinned version and says which one it used. A store that receives one event through both a webhook and the feed deduplicates by event id, never by comparing bytes This book.

      How long to keep trying

      A small WordPress site can be down for a weekend. The retry window decides whether its paid orders arrive late or never. Today Backend's WooCommerce callback queue makes 24 attempts and then marks the row dead; the waits between them add up to about 18 hours Today. Claude Opus 5.5's arena document claimed these attempts ended after 2 to 4 hours; that figure matches a different queue, the old Connect service's, not the WooCommerce queue Corrected. All three infrastructure designs in the arena proposed retrying for 72 hours, the same window Stripe uses for live webhooks S18, with different formulas Proposed. This book picks full jitter, the formula from AWS's backoff study: each wait is a random time between zero and min(6 hours, 30 seconds × 2attempt), with a hard deadline at 72 hours S66 This book. If every wait came out at its average, that gives 32 attempts before the deadline; real counts vary with the random draws. The randomness stops thousands of failed deliveries from retrying at the same instant when a popular host comes back.

      Figure 10.2When does a paid order reach a store that was offline?

      Red dots fail because the store is down, the large green dot is the first attempt after it recovers, and grey dots are later attempts that are no longer needed. The time axis is stretched so the early minutes are visible. Proposed dots show the average of each random wait. Today's schedule comes from Backend/src/services/outbox.ts Today.

      Rule R10 · Pull covers what push misses

      Every event is readable through a cursor feed for 30 days, the window Claude Opus 5.5 proposed and the one Stripe uses for its own event API S21. Store SDKs sync from a saved cursor on boot and every few minutes, through the same deduplicating code that handles pushes. The feed gives positions only to committed events, in order, so an event that commits late lands after a saved cursor instead of behind it (Chapter 7).

      Signing: use the published standard

      Every arena design signed webhooks the way Stripe does: a header with a timestamp and an HMAC of the timestamp plus the body Proposed. That works. This book recommends adopting the published Standard Webhooks specification instead This book. It uses three headers, webhook-id, webhook-timestamp and webhook-signature, signs id.timestamp.body, and lists several signatures separated by spaces during a key rotation S63 S64. Including the event id in the signed content stops anyone from changing the id to slip past deduplication, and existing open-source libraries already verify it in many languages. Today's plugin link uses its own payassist-signature: t=…,v1=… header with one shared secret Today.

      Figure 10.3Signature lab: sign an event, then try to fool the receiverIllustrative
      What PayAssist sends
      What the store's verifier sees

      The lab computes real HMAC-SHA256 signatures in your browser with example secrets, in the Standard Webhooks format. Try: switch the receiver to the new secret without rotation, then turn rotation on.

      Rule R11 · Verify the exact bytes first

      The receiver verifies the signature over the id, the timestamp and the raw body before parsing anything, compares in constant time, accepts any of the listed signatures during a rotation, and rejects timestamps more than 5 minutes away from its clock. Then it checks the event id against its inbox (rule R8).

      No promise about order

      Retries, parallel senders and network paths reorder messages. Stripe documents that it does not guarantee event order S19, and none of the arena designs could promise arrival order over HTTP either; Codex Astra's design came closest, with per-order delivery lanes that wait for each delivery before sending the next, at the cost of one slow delivery blocking that order's later events Proposed. Summaries that promised strict per-order or per-merchant ordering went beyond all four designs Corrected.

      Rule R9 · Versions, not arrival order

      Every object carries a version that increases with each change, and every event carries the object's version. Receivers ignore an event whose version is older than the one they stored, and fetch the object when unsure. Refunds and disputes are separate objects, so a version check never hides a second refund.

      Protecting PayAssist from the endpoints it calls

      A webhook URL is an address a merchant typed. A careless or malicious one could point at PayAssist's own internal network, a classic server-side request forgery. The proposal resolves the hostname once, checks every resulting address against private and reserved ranges, connects only to the checked address, refuses redirects, and repeats the checks in an egress proxy at the network layer S87. It also protects other stores from a slow one: each endpoint gets a small number of concurrent requests and a 10-second timeout, so a store on a struggling host fills its own queue, not everyone's. Codex Astra proposed deficit round-robin scheduling for fairness at very high volume; Chapter 14 explains when that becomes worth building.

      Check yourself

      Your store's webhook handler marks the order paid, then calls a shipping API that takes 25 seconds, then answers 200. What will PayAssist see, and what should change?

      Show the answer

      PayAssist times out after 10 seconds and retries, so the handler runs again and could ship twice. Record the event and the order change, enqueue the shipping as a job in the same transaction, answer 200 at once, and let a worker call the shipping API with its own idempotency key. Chapter 11 shows this pattern.

      Chapter 11

      What the store must do

      PayAssist can deliver every event and still lose an order if the store's own code is careless. The store side has one job: apply each payment fact exactly once, and make the outside work that follows (shipping, email, stock) survive crashes. This chapter shows the pattern every SDK quickstart should ship.

      Figure 11.1Three doors into one function, and the jobs that run after itProposed
      1 · Webhook routeverify, then call 2 · Thank-you pageserver-side read first 3 · Sync jobreads the event feed apply-payment(fact) BEGIN · lock this order · event id seen before? stop · matches what checkout saved? · newer version of this object? · one completion job per payment COMMIT 4 · Workerclaims a jobwith a lease,calls out withthe job's key,records result,retries onunknown jobs shipping email stock, ERP The database decides who wins a race, not timing. Outside calls happen only in the worker, never inside the transaction.
      (1) Webhooks, (2) the thank-you page and (3) the event-feed sync all call the same apply-payment function, with either a verified event or a fresh server-side read. Inside one transaction it locks the order, skips an event id it has already seen, checks that the payment matches what was saved when checkout started and that this object's version is newer, and writes one completion job per payment; the job marks the order paid. (4) A worker retries each durable job with a stable idempotency key for the downstream service and reconciles any send whose result it never saw. That key stops a repeat shipment only if the shipper deduplicates on it or can say what it already did; a local job key alone cannot promise it. Codex GPT-6.1-Sol's arena design described this merchant-side pattern and stated that limit explicitly Proposed.

      The shortcut that loses shipments

      Claude Opus 5.5's arena design proposed a compact version: insert a fulfillment row with ON CONFLICT DO NOTHING, and ship only if the insert created a row. It does stop two simultaneous webhooks from both shipping. It fails on a crash, because the row commits before the shipping call happens, and every retry then sees "already exists" and skips the work Corrected. Step through it, then through the fix.

      Rule R13 · Outside work runs as durable jobs

      The inbox row, the order change and the job rows for outside work commit in one transaction. A worker runs each job with a lease and a stable idempotency key toward the outside system. When the outcome of an outside call is unknown, the worker looks it up or retries with the same key; it never assumes. Jobs that keep failing show up as alerts.

      Some outside systems have no idempotency key, such as an old email gateway. The worker must then record "about to send" before sending and "sent" after, and on restart decide from those records and the outside system's own logs whether to send again. Some duplicates may remain possible; the job design makes them rare and visible instead of silent.

      The race between the buyer and the webhook

      A fast browser often reaches the thank-you page before the webhook arrives. If the page only reads the local order, the buyer sees "pending" for a payment that already succeeded. The fix is for the page's server code to ask PayAssist directly and feed the answer through the same apply-payment function. Stripe's fulfillment guide recommends the same thing: fulfill from both the webhook and the landing page, with a function that is safe to call repeatedly and concurrently S27.

      Rule R12 · The browser never decides "paid"

      Return URLs, query parameters, frame messages and client-side callbacks only change what the buyer sees. Today's owner-confirmed rule is narrower still: in WooCommerce, only the signed Backend-to-plugin /status callback marks an order paid Owner-confirmed. A signed pull on the thank-you page was built on 4 September 2026 and reverted at the owner's request the same day. This book proposes that the new protocol also accept an authenticated server read from PayAssist, applied through the same durable payment transaction as a webhook This book. That extension is a proposal; the owner has not decided it.

      Stock, holds and payments that arrive late

      Stock lives in the store; money moves at the provider. If a buyer is stuck in a bank check while the store's stock hold runs out, another buyer can take the last item, and the first payment then succeeds for an item that is gone. WooCommerce's "Hold stock" setting cancels unpaid pending orders after its number of minutes and releases their stock S78. Today that clock and PayAssist's 12-minute checkout window are unrelated Today.

      The proposal gives the store two controls and names a third for later. First, each session ends before the order's stock reservation does, with a margin; Chapter 18 shows how the plugin reads WooCommerce's two clocks and renews the reservation when a buyer comes back. Second, every store needs a written late-payment policy: fulfill if possible, otherwise refund automatically through the API with an idempotency key, and tell the buyer. Claude Opus 5.5's design offered a per-session late_payment_policy: auto_refund option for that case Proposed. A session's expiry stops new tries but cannot stop a payment already in flight, so the policy is needed even when the clocks are right. Third, for later: separate authorization and capture, where the bank approves now and the money moves only after the store confirms stock, would need a capture call, an authorized state and its own events, and version 1 has none of them This book.

      What nobody can promise

      Arena briefs asked for designs where inventory and payments "never desync". Codex GPT-6.1-Sol's answer was blunt and correct: across an independent merchant database and a processor, that is not an honest guarantee Corrected. What a good design can promise is narrower and more useful:

      • no duplicate local effects, because of the inbox and job keys;
      • explicit in-between states such as "paid but not fulfilled", instead of silent gaps;
      • detection within a stated time, through reconciliation and alerts;
      • a written, tested compensation for every mismatch, usually a refund or a manual review.

      The store-side checklist

      1. Read the raw request body and verify the signature before parsing (R11).
      2. Lock the order first, so a webhook, a page read and the feed take turns.
      3. Insert the event id into an inbox table; stop if it is already there (R8).
      4. Ignore a version older than the stored version of the same object (R9).
      5. Check mode, payment, order revision, amount and currency against what was saved when checkout started.
      6. Do all local changes and job inserts in the same transaction, and answer 200 only after it commits.
      7. Run outside work in a worker with leases and idempotency keys (R13).
      8. Sync from the event feed on boot and every few minutes (R10).
      9. On the thank-you page, read the payment server-side and apply it with the same checks and job as a webhook (R12).
      10. End each session before the order's stock reservation runs out, and write a late-payment policy (R6).
      11. Alert on paid-but-unfulfilled orders older than a set time.
      Check yourself

      Two copies of the same payment.succeeded event arrive at the same millisecond on two PHP workers. Walk through what stops a double shipment.

      Show the answer

      Both transactions try to insert the same event id into the inbox. The second waits for the first's row lock, then finds the id already there, changes nothing and answers 200. Only the first writes the completion job. That job passes a stable key to the shipper, which prevents a second shipment if the shipper deduplicates on it.

      Chapter 12

      Convergence: how everything ends up agreeing

      With retries, unknowns and late events, the store, PayAssist and the provider will sometimes disagree for a while. A trustworthy system makes those disagreements visible and bounds them: 72 hours of pushes, 30 days of feed, and reconciliation after that. Three loops do the work.

      Figure 12.1Three loops at three speeds bring the provider, PayAssist and the store back into agreementProposed
      Providerthe truth about money PayAssistthe truth about the checkout Storethe truth about the order 1 · attempt reconcilerminutes · unknown and stuck attempts 2 · events + feed syncseconds to days · pushes, retries, pulls 3 · daily ledger check against provider reports
      (1) The attempt reconciler asks the provider about every attempt that is unknown, processing or waiting on 3DS, often at first and then less often. (2) Event delivery and the store's feed sync carry PayAssist's facts to the store, for up to 72 hours by push and 30 days by pull. (3) A daily job compares PayAssist's records with the provider's reports and settlement files and raises any mismatch for a person.

      Loop 1: the attempt reconciler

      The reconciler is what makes rule R4 safe to follow. When an attempt goes silent, the buyer waits on a "checking" screen while the reconciler reads the provider. The scenario below is the dangerous case: the provider charged, and its answer was lost.

      Today the standard node already does a version of this. It starts a recovery read about 3 minutes after a payment page opens even if the browser never confirms, and the staging source keeps re-reading unanswered provider calls for 23 hours and undecided payments for up to 7 days before marking them unresolved for a person instead of inventing a cancel or decline Today. Claude Opus 5.5's design proposed reading every stuck attempt after 5 minutes and then every 15 minutes until the session's expiry plus 24 hours Proposed. The exact schedule matters less than three properties: it starts quickly, it never fabricates an outcome, and its age is visible.

      Loop 2: events and the feed

      Chapter 10 built this loop. Its bound is concrete: pushes for 72 hours, the feed for 30 days. A store offline for longer than 30 days has bigger problems and needs a manual reconciliation report, which the dashboard can export.

      Loop 3: the daily ledger check

      Webhooks can be wrong in ways nobody notices: a missed refund, a fee that differs from the quote, a dispute that never produced an event. The slow loop compares PayAssist's ledger with each provider's balance and settlement reports. Codex Astra proposed reconciling captures and refunds against provider records and settlement reports independently of webhook delivery, noting that a successful HTTP delivery is not settlement evidence Proposed. Running that loop once a day is this book's choice This book.

      What an operator should see

      ConditionWhy it mattersProposed alert
      Attempt unknown for too longMoney may have moved with nobody told.Warning at 15 minutes, page a person at 1 hour Illustrative
      Paid at PayAssist, not paid at the storeThe store missed events and is not syncing.Per store, once the 72-hour push window ends
      Paid but not fulfilledA store-side job is stuck or was skipped.Store dashboard, owned by the merchant
      Fulfilled but not paidThe store trusted a browser signal.Store dashboard; usually a code bug
      Amount or currency mismatchWrong order lookup or a tampered cart.Immediate; never auto-resolve
      Endpoint failingA store is offline or rejecting signatures.Email and dashboard banner after repeated failures
      Provider report mismatchA missed refund, dispute or fee difference.Daily report, reviewed by a person

      PayAssist already runs an OpenTelemetry pipeline into Loki, Prometheus and Grafana with correlated payment logs and alert rules Today, and the project's telemetry contract requires every new endpoint, worker and flow to follow its instrumentation standard. The new protocol's loops plug into that stack; they do not need a new monitoring system.

      "Eventually" with numbers

      Every promise of eventual agreement in this book has a stated bound: retries for 72 hours, the feed for 30 days, the reconciler until final with alerts by age, the ledger check daily. If someone proposes a new loop without a bound and an alert, it is not finished.

      Check yourself

      Why does the reconciler never mark a long-silent attempt as "declined" just to close it?

      Show the answer

      Silence is not evidence. If the charge succeeded at the provider, a fabricated decline would free the payment for a second attempt and could charge the buyer twice. An attempt that cannot be resolved automatically goes to a person, who checks the provider's dashboard.

      Part III

      Decisions

      On the morning of 18 October 2026 a coordinator agent asked several AI agents to design this system in competition, an "arena". This part reconstructs what happened from the thread records, separates what each agent proposed from what was later claimed about them, and explains the choices this book makes and why.

      Chapter 13

      What the arena argued, and what survived

      Four agents wrote four design documents in under twenty minutes. Nobody voted, nothing was adjudicated, and the "winners" in the chat summaries were the coordinating agent's own words. The proposals themselves were often careful; the summaries often were not.

      Figure 13.1The arena on 18 October 2026, minute by minute (UTC)From thread records
      00:00 00:10 00:20 00:30 00:40 CoordinatorGemini 3.8 Flash;GPT-6-Astra at 00:44 Owner CodexGPT-6.1-Sol ClaudeOpus 5.5 ClaudeSonnet 5.5 CodexGPT-6-Astra Gemini batch pre-arena answer "winners" "champions" book commissionedGPT-6-Astra "best in class" "more agents" asks if one doc is enough architecture, 12 sections platform, 6 sections, pushback SDK blueprint + 2 tested files distributed engine, 10 sections created, never ran; no answer recovered
      Bars show when each agent was working, taken from the thread metadata rather than from the coordinator's descriptions. Red marks are the coordinator's summaries, which named "winners" and "champions" with no vote or scoring behind them. The Gemini "research subagent" credited in those summaries never produced output; the coordinator itself ran three web searches whose results came mostly from secondary sites. The coordinator changed from Gemini 3.8 Flash to GPT-6-Astra before the book request, so every earlier arena summary was Gemini's.

      The briefs shaped the answers

      The owner's two messages asked for a Stripe-like standard and permission to replace legacy parts. They contained no traffic figures and no compliance target Owner-confirmed. The coordinator's briefs to the agents added several:

      AgentLines the coordinator added to its briefEffect
      Codex GPT-6.1-Sol, Claude Opus 5.5"handling 100k+ events/sec", "strict SAQ-A PCI compliance", "RIP OUT any legacy constraints", "pull zero punches"Codex sized a Kafka pipeline for 100,000 events per second as arithmetic; Opus pushed back that no measured event rate existed.
      Claude Sonnet 5.5"ZERO-PCI TOKENIZATION", "production-ready" Next.js ergonomics, "bulletproof" frontendAn SDK design that called itself SAQ A-compatible and tested two small modules.
      Codex GPT-6-Astra"millions of stores, 50,000+ orders/sec", "zero double-spends", ordering per order with "infinite horizontal scaling"A cell-based design with a capacity model; Astra itself said infinite scaling is not an implementable guarantee.

      So the famous numbers, 100,000 events and 50,000 orders per second, were inputs from the coordinator, not measurements or forecasts. Treat every capacity figure from the arena as illustrative arithmetic Illustrative.

      What each agent proposed

      Agent and documentMain proposalsMost useful contribution
      Codex GPT-6.1-Sol
      HTML/store-connectivity-architecture.html
      Public REST and OpenAPI; session around a payment intent with attempts and durable operations; scoped browser capabilities and OAuth for apps; provider-controlled card fields; outbox, CDC and Kafka at the stated scale; cursor recovery; unordered delivery with versions; merchant inbox and fulfillment outbox; unknown outcomes block rerouting; 7-day replay; hosted checkout first.Honesty about limits: "zero PCI" is inaccurate, a "100k/sec ready" diagram would be "architecture theater", and inventory and payments cannot be promised never to disagree.
      Claude Opus 5.5
      HTML/store-connectivity-platform-2026-10-18.html
      REST and OpenAPI; session, payment and attempt; publishable, secret and restricted keys with per-endpoint webhook secrets; no saved cards in version 1; a stable frame origin with a checked handshake; Postgres outbox first, Kafka only when measured; 72-hour retries and a 30-day pull feed; object versions; a 30-minute session; generated SDKs and a CLI.Pushback on the brief's scale and PCI language, and the pull feed that turns a lost webhook into a delay.
      Claude Sonnet 5.5
      HTML/payassist-js-sdk-blueprint-2026-10-18.html, sdk-blueprint/
      Provider-hosted fields inside a coordinating frame; a capability in the URL fragment and a private port; styling as a closed set of tokens; pinned frame versions; layout hints; a method registry; Next.js streaming and server actions; a headless WooCommerce adapter while keeping a classic gateway.The browser state machine with unknown as a real state, and two pieces of tested code.
      Codex GPT-6-Astra
      HTML/store-connectivity-engine-arena.html
      Independent cells; serializable financial storage that survives a region loss; reservations held while outcomes are unknown; JSON-canonical fingerprints with 7-day replay; outbox, change capture and Kafka per cell; per-order delivery lanes; fair scheduling and circuit breakers; failover targets and a benchmark plan.Precise failure-injection tests, and ledger reconciliation independent of webhook delivery.

      Where they disagreed

      QuestionThe positionsThis book's choice
      Message transportCodex: Postgres outbox plus change capture plus Kafka "for the stated sustained scale", though it allowed simpler polling at low volume. Opus: Postgres outbox now, Kafka when measured. Astra: Kafka per cell.Postgres outbox now behind an interface, with a measured trigger to move (Chapter 14).
      Event orderCodex and Opus: no arrival order, versions decide. Astra: per-order lanes that wait for each delivery.Versions; lanes deferred.
      After a declineOpus: a second attempt may use another account. Codex: only an explicit new attempt after a definite decline. Astra: uncertain requests freeze.Buyer-initiated new attempt after a definite decline; routing policy decides the account; unknown blocks.
      Idempotency detailsOpus: 409 running, 422 reused, rows kept 24 hours. Codex: 409 reused, 202 for pending, 7 days. Astra: 422 reused, 409 running, 7 days plus tombstones.Draft codes 409 and 422, 7-day replay, permanent business uniqueness.
      Retry formulaCodex: 5 s base, 6 h cap, 72 h. Opus: full jitter, 30 s base, 6 h cap, 72 h deadline; its estimate of about 20 attempts does not follow from that formula, because the count depends on the random waits and the deadline. Astra: 2 s base, 1 h cap.Full jitter, 30 s base, 6 h cap, 72 h deadline.
      Regions and cellsCodex: cells with synchronous replication across zones. Astra: three-region quorum, RPO 0 and RTO 120 s as targets. Opus: not needed at first.Deferred with explicit triggers.
      Frame originOpus and Sonnet: one stable origin. Codex: a hardened checkout origin, no ISP claim.Owner decision D1; keep node origins first.
      First buildCodex: hosted checkout with durable operations. Opus: events and keys first. Sonnet: the state machine in the existing modal. Astra: contracts and one cell.Chapter 15's minimum release combines Opus's order with Codex's hosted-first scope.

      What the records do not show

      • No vote, rubric or rebuttal round. Each agent ran once, independently. "Winners" and "champions" appear only in the coordinator's summaries.
      • No owner approval of any proposal. The open decisions in Opus's and Sonnet's documents remain open.
      • No research report from Gemini. The batch credited with comparing Stripe, Adyen and Shopify contains only a placeholder.
      • No benchmark. Every throughput, failover and layout-shift figure is a target or arithmetic.
      • No working integration. Two JavaScript modules were tested; the Next.js, host, frame and WooCommerce code are unexecuted sketches, and the review found real gaps in them.

      How this book decided

      Where the proposals differed, this book applied five tests in order This book:

      1. Correctness under the failure list. Does the choice keep every scenario in Chapter 20 safe for money and orders?
      2. Binding constraints. Does it respect the owner's decisions: separate Stripe paths with no fallback, no platform key at nodes, card data never on PayAssist's servers, the existing proxy for Connect transport?
      3. Evidence. Is it supported by code, a measurement or a primary source, or only by an agent's assertion?
      4. The smallest thing that works. Does it need new infrastructure before there is a measured reason?
      5. Reversibility. Can PayAssist change course later without breaking stores that already integrated?

      The full list of decisions, with each one's source and status, is the decision ledger in Appendix B. The claims that had to be corrected are collected in Appendix C. An independent evidence agent recovered the arena history and audited 103 claims for this book; its report is in the evidence folder listed in Appendix H.

      Check yourself

      A colleague says, "The arena proved Kafka can handle 50,000 orders per second for us." What is wrong with that sentence?

      Show the answer

      Three things. The 50,000 figure was a target the coordinator wrote into Astra's brief. Astra's numbers were arithmetic plus a plan for a future benchmark, not a measurement. And nothing measured PayAssist's actual traffic, so there is no evidence that PayAssist needs that capacity at all.

      Chapter 14

      The stack, and what to build later

      The recommended technology is mostly what PayAssist already runs. Bigger tools such as Kafka, change-data capture and cells solve real problems, but problems PayAssist has not measured yet. This chapter lists the stack, then draws a ladder with the measurement that justifies each step up.

      PieceChoiceWhy
      Public API serviceTypeScript on Bun, inside or beside BackendBackend already runs Bun with Express, Drizzle, postgres.js and zod Today; the team knows it, and the public API needs Backend's routing and business state.
      DatabasePostgreSQL, as todayTransactions make the idempotency row, the state change and the event row one fact (rules R1 and R7). Unique constraints decide races.
      Event deliveryOutbox tables plus a dispatcher worker that claims rows with FOR UPDATE SKIP LOCKEDNo new system to operate. PostgreSQL documents SKIP LOCKED for queue-like tables and warns against it for general reads S68. Backend's staging outbox already uses short leases and fenced settlement Today.
      ContractOpenAPI 3.1 document in the repositoryOne source for documentation, generated clients and a CI check that routes match the document.
      Server SDKsPHP (Composer) and Node first, Python laterPHP for WooCommerce, Node for Next.js and Node shops. Generated from the contract, with hand-written webhook helpers. Stainless or openapi-generator is open decision D3.
      Webhook signingStandard Webhooks v1A published format with existing verifier libraries (Chapter 10).
      BrowserToday's node pages for hosted and modal; PayAssist.js later for embeddedReuses working, tested pages first; the embedded element waits until the protocol is proven.
      WooCommerceThe existing plugin, rebuilt as a client of the PHP SDK, classic and Blocks checkout, HPOS-compatibleWooCommerce needs a gateway class for classic checkout and a separate Blocks integration S72 S73; order code must use WooCommerce's CRUD for HPOS S77.
      SecretsAPI keys as keyed hashes; signing secrets encrypted with a key held outside the databaseChapter 8.
      ObservabilityThe existing OpenTelemetry, Loki, Prometheus and Grafana stackAlready in production with correlated payment journeys and alerts Today.
      DeploymentCoolify, as todayNo change needed for the first release.

      Some candidates were considered and left out This book. Redis Streams would add a second store that can disagree with Postgres. Amazon SQS caps message delay at 15 minutes and would tie a Coolify-hosted system to AWS. Public gRPC and GraphQL make life harder for PHP hosts and browsers without a measured benefit. Claude Opus 5.5's arena comparison reached the same verdicts on Redis Streams and SQS Proposed.

      The complexity ladder

      Figure 14.1Four rungs of infrastructure, and the measured trigger for each step upThis book
      0 · Start hereone Postgres primary, outbox,polling dispatcher, endpoint limits 1 · Tune Postgrestime-partitioned deliveries,separate dispatcher workers,read replica for the feed 2 · Add a logchange capture (Debezium) intoKafka or Redpanda; fairscheduling across endpoints 3 · Cells, regionsindependent copies per group ofstores; quorum storage acrossregions; fenced failover move up when:tables outgrow cleanup, ordispatch lag misses its target move up when:a load test shows dispatch nearwhat one primary sustains, ormany consumers need replay move up when:data-residency law, a contractwith strict recovery targets, orone region's loss is unacceptable AWS reserves full cell designsfor strict recovery needs,such as RPO under 5 s and RTOunder 30 s (S71) cost: a cluster to run, schemaregistry, lag alerts; retries stillneed a delay store beside it cost: routing layer, moreinfrastructure, cross-regionlatency on every money write Each rung keeps the same data model and the samedispatcher interface, so climbing is a deploymentchange, not a protocol change for stores.
      Start on rung 0. Climb only when a measurement crosses the trigger written on the next rung. Codex GPT-6.1-Sol's design kept sharding and ownership keys in the data model from day one while saying expensive infrastructure should wait for measurements; this ladder follows that advice Proposed. Triggers are stated as measurements on purpose: nobody has measured PayAssist's event rate for this protocol yet.

      The three big ideas, in plain words

      • Change-data capture into Kafka. Instead of a worker polling the outbox table, a connector reads the database's own change log and copies each committed row into Kafka, a durable, ordered log many consumers can read at their own pace. It helps when one database cannot keep up with polling or many systems need the same stream. Consumers must still deduplicate, because the connector can repeat rows after a restart S69.
      • Fair scheduling (deficit round-robin). When thousands of endpoints compete for a limited pool of workers, a large merchant with a slow server could take most of them. Deficit round-robin gives each endpoint a turn and a small budget per round, like a teacher calling on every raised hand before anyone gets a second turn. With a few hundred stores, a per-endpoint concurrency limit and a timeout already give each store its own lane.
      • Cells. A cell is an independent copy of the whole system that serves a slice of the customers, so one bad deployment or overloaded database hurts only that slice S70. PayAssist's account nodes already have a cell-like shape for provider calls, but they share Backend and its database. Full cells multiply infrastructure and need a routing layer, and AWS's own guidance reserves them for strict recovery targets S71.
      What would justify climbing

      Before any step, run the load test Codex Astra described at a smaller scale: realistic payload sizes, skewed traffic where one merchant is much bigger than the rest, a slice of slow endpoints, a long outage of a large endpoint, and crashes injected at every commit boundary. Measure dispatcher lag, oldest pending delivery, database load and healthy-merchant latency. Climb when the numbers say so, and write down which number crossed which line.

      Check yourself

      A slow merchant server takes 9 seconds to answer every webhook. With a pool of 20 delivery workers and no per-endpoint limit, what happens to other merchants, and which rung-0 feature prevents it?

      Show the answer

      The slow merchant's backlog can occupy all 20 workers for 9 seconds each, delaying everyone else's events. A per-endpoint concurrency limit, for example 2 requests at a time, plus a 10-second timeout keeps the slow merchant in its own small lane while the other workers serve everyone else.

      Part IV

      Building it

      Three chapters turn the design into work: the smallest release that is useful, the evidence needed before calling it industry-grade, and a migration that never puts a live checkout on two paths at once. The owner has not approved any of it for implementation; it is the recommended shape of the work.

      Chapter 15

      The smallest release that works

      The first release should make today's WooCommerce stores more reliable before any new kind of store connects, and it should be complete enough that a Next.js shop could integrate from the documentation alone. This chapter draws the line between "in version 1" and "later".

      Figure 15.1The first release: new pieces in blue outline, reused pieces solidProposed
      WooCommerce plugin 2.0client of the PHP SDK Next.js example storeclient of the Node SDK PHP + Node SDKsgenerated from OpenAPI Public API v1keys, scopes, test and liveidempotency table (R1)sessions over payment requests (R2)payments, attempts, refunds (read/create)dated version header Event engineevents in the same transaction (R7)deliveries per endpoint, 72 h retriesStandard Webhooks signaturesfeed: /v1/events, 30 days (R10) Dashboard pageskeys, endpoints, deliveries, replay Routing core + tokensreused as they are today Nodespay pages forhosted + modal Connectunchanged,separate path Providers Grafana, Loki, Prometheusexisting observability
      Version 1 adds the public contract, the event engine, dashboard pages and two SDKs, and rebuilds the WooCommerce plugin on top of them. It reuses the routing core, the signed checkout token, the account nodes' payment pages for the hosted and modal modes, the separate Connect service and the monitoring stack.

      In version 1, and why

      In version 1Why it cannot wait
      Events with per-endpoint secrets, 72-hour retries and the 30-day feedIt fixes a real weakness first: today's callbacks stop after about 18 hours and there is no way to catch up. Claude Opus 5.5 put this phase first for the same reason.
      Keys with scopes, test and live modesEvery other feature depends on knowing which store and mode is calling.
      Checkout sessions, payments, attempts, refundsThe minimum vocabulary to start a payment, learn its result and give money back.
      Idempotency table and business uniqueness (R1, R2)Without them retries create duplicates; they are cheap to add now and costly to retrofit once stores depend on the API.
      Hosted and modal display, using today's node pagesBoth already work and are tested; they need a session-aware entry point, not a rewrite.
      OpenAPI document, PHP and Node SDKs with webhook helpersA standard nobody can call conveniently is not a standard.
      WooCommerce plugin 2.0 behind a per-store flagThe first real client, and the proof that the contract covers a full store.
      Dashboard pages for keys, endpoints, deliveries and replayMerchants and support need to see and fix deliveries without database access.
      Telemetry for every new endpoint and workerThe project's instrumentation standard requires it for anything new.

      Later, and the reason for waiting

      LaterWhat would trigger it
      Embedded element and PayAssist.jsVersion 1 stable in production; a merchant who needs in-page checkout; browser certification plan ready.
      One stable frame originAn owner decision on D1, then a measured trial.
      Disputes evidence submissionDispute events ship in version 1; submitting evidence through the API can follow.
      Saved cardsA paying merchant who needs them, designed per provider.
      Developer CLI with local webhook forwarding and test triggersAfter the API settles; Claude Opus 5.5 and Codex GPT-6.1-Sol both proposed it.
      Python SDK, more framework examplesDemand from integrators.
      Kafka, change capture, cells, regionsThe triggers on the Chapter 14 ladder.

      New tables in version 1

      Schema sketchProposedIllustrative

      api_keys            id, store_id, mode, kind (pk|rk|sk), prefix, keyed_hash, scopes[], expires_at, last_used_at, revoked_at
      idempotency_keys    store_id, mode, route, key, fingerprint, state (running|done), status, response, created_at
                          unique (store_id, mode, route, key); replay rows kept 7 days
      order_admissions    store_id, mode, order_reference, highest_revision, live_payment_id
                          unique (store_id, mode, order_reference): every create for an order passes this row in turn (rule R2)
      payments            id, store_id, mode, order_reference, order_revision, terms_digest, amount, currency,
                          status, amount_received, version, payment_request_id
                          unique (store_id, mode, order_reference, order_revision) where status <> 'canceled': a second guard
      checkout_sessions   id, payment_id, ui_mode, status, expires_at, return_url, frame_nonce, version
                          unique (payment_id) where status = 'open'
      attempts            today's payment_attempts rows, plus provider_object_state, frozen create parameters,
                          first_sent_at and the provider's key-retention deadline (rules R3, R5)
      events              id, store_id, mode, type, object_type, object_id, object_version, payload, created_at,
                          feed_position (set by the single publisher after commit: rule R10)
                          written in the same transaction as the change: rule R7
      webhook_endpoints   id, store_id, mode, url, enabled_types[], api_version, secrets (encrypted, with expiry), status
      webhook_deliveries  id, event_id, endpoint_id, api_version, body (frozen when created), attempt, next_attempt_at,
                          lease_until, last_status, state
                          unique (event_id, endpoint_id); partitioned by day
      underneath: payment_requests, payment_attempts, checkout tokens, routing and accounting tables

      Two of those lines hide open work. Today Backend allows one payment request per store and WooCommerce order Today, while the proposal allows a new payment per order revision; how revisions map onto that rule is a design decision for step 2. And reusing today's attempts means reusing their keys, including the diagnosed /pay defect that can change a create's parameters under the same key (E1 §4). Safe retries need the parameters frozen on the attempt as well as a stable key, so gate 1 in Chapter 16 checks that repeated loads of one attempt send identical parameters.

      The order of work

      Figure 15.2Four steps, each one shippable on its ownProposed
      1 · Eventsevent table, deliveries,72 h retries, feed,endpoint secrets; alsoserves today's plugin 2 · The APIkeys and scopes,idempotency, sessions,OpenAPI, PHP andNode SDKs 3 · Plugin 2.0Woo on the SDK, hostedand modal, per-storeflag, connect flow,both receivers kept 4 · Open it upNext.js example,public documentation,test-mode scenarios,first outside store
      Step 1 can help existing WooCommerce merchants before any protocol change if the new delivery engine also drives today's /status callback format with the longer retry window, as Claude Opus 5.5 proposed. Each later step has its own gates in Chapter 16. The plan carries no dates because nobody has estimated the effort, and inventing a schedule would repeat the arena's habit of presenting guesses as plans.
      What stays exactly as it is

      Standard Stripe and the other providers keep their current node paths. Stripe Connect keeps its central service, its two webhook destination roles and its pinned SDK versions Owner-confirmed. The new public API sits above both paths and never chooses between them after a checkout starts. The separate migration of standard providers to new fulfillment jobs remains independent work.

      Check yourself

      Why does this plan put events before keys and sessions, when keys come first in the request flow?

      Show the answer

      Events fix a problem today's stores already have: callbacks give up after about 18 hours and cannot be caught up. If the new engine also delivers today's callback format, building it first helps every existing WooCommerce merchant before the protocol changes, and every later feature depends on it for delivering results.

      Chapter 16

      Honest benchmarks and launch gates

      "Industry-grade" needs evidence: measurements, gates and a pilot. This chapter shows what PayAssist has measured so far, what the new protocol must measure, and the gates each release must pass before real money depends on it.

      What has been measured so far

      The proposed protocol has never run, so it has no benchmarks. The closest real numbers come from the current Stripe Connect checkout on staging, measured with a passive browser trace on 16 and 17 October 2026, one fresh load and three reloads before and after a latency patch Measured. They are the baseline any new design must at least match.

      Figure 16.1Staging Connect checkout timings before and after the 17 October 2026 latency patchMeasured
      Dumbbell chart. Fresh checkout, popup to ready form: 11.6 seconds before, 9.2 after. Reload, node page to ready form, median of three: 6.6 before, 2.3 after. Reload, server preparation, median of three: 2.3 before, 0.5 after. 0 s 2 4 6 8 10 12 s before the patch after the patch Fresh checkout:popup to ready form, 1 load Before: 11.612 s After: 9.190 s 11.69.2 Reload: node pageto ready form, median of 3 Before: 6.606 s After: 2.336 s 6.62.3 Reload: serverpreparation, median of 3 Before: 2.282 s After: 0.521 s 2.30.5 Source: docs/connect-run/HANDOFF.md, "Actual staging comparison after the latency patch, 2026-10-17". One sample per row; not a percentile or a guarantee.
      Show the numbers as a table
      Measurement (staging, Connect path)BeforeAfter
      Fresh checkout, popup to ready form (one load)11.612 s9.190 s
      Reload, node navigation to ready form (median of 3)6.606 s2.336 s
      Reload, server preparation (median of 3)2.282 s0.521 s
      Fresh checkout, server preparation (one load)6.314 s6.570 s

      The record warns that component medians come from different observations and must not be added together, and that fresh server preparation did not improve in this sample.

      Hollow dots are before the patch, filled dots after. The patch overlapped Stripe's form start-up with server preparation and kept database connections warm. Reloads improved a lot; the first, fresh load improved less, and its server preparation did not improve at all in this sample. Hover a dot for the exact value.
      Other recorded numbersValueWhere it comes from
      Backend admission before the Connect popupabout 1.5 sStaging traces, 16 and 17 October 2026 Measured
      Pay click to confirmation, one Connect order3.576 sStaging order trace, 16 October 2026 Measured
      Backend applies the payment; Woo callback on the first try1.179 s; 1.026 sSame order; these stages overlap and must not be summed
      Production Square /pay server step on a node0.93 s median of 95 checkouts over 24 hoursLocal form-load report, 14 October 2026 (E8) Measured
      Woo callback retry windowabout 18 h of scheduled waitsComputed from Backend/src/services/outbox.ts Today

      None of these is a load test. There is no measured figure for how many checkouts or events per second PayAssist can sustain, which is why Chapter 14's ladder uses measurements as triggers instead of capacity targets.

      What the new protocol must measure

      MeasureDefined asStarting target
      Session create latencyAPI receives the request to response sent, p99Under the current Backend admission time Proposed
      Form readyPage asks for the form to form ready, fresh and reloadNo worse than Figure 16.1's after-patch values Proposed
      Commit to first delivery attemptEvent committed to first send started, p99, healthy endpoints5 s, as Codex GPT-6.1-Sol proposed Proposed
      Delivery successShare of events acknowledged within 72 h, and age of the oldest pendingReport both; never hide failing endpoints
      Unknown resolutionAttempt marked unknown to final state, p99 and maximumAlert at 15 minutes Illustrative
      Money invariantsDuplicate provider charge per attempt, refunds above captured, mismatched amountsZero; any one pages a person

      The gates

      Figure 16.2Ten gates between a working build and live merchantsThis book
      1correctness 2crashinjection 3deliverydurability 4security 5compliancereview 6browsers 7observability 8migrationrehearsal 9docs + SDKconformance 10pilot
      Each gate needs written evidence that a reviewer other than the author checked. Gates 1 to 4 and 7 are engineering tests; gate 5 needs outside people, an acquirer and a qualified security assessor; gate 10 starts with a synthetic store, then one willing merchant in live mode at low volume.
      GateEvidence required
      1 · CorrectnessHundreds of concurrent creates for one order revision with the same and different keys produce one session; concurrent confirms produce one provider call; concurrent refunds never exceed the captured amount. An old tab confirming a declined Stripe object while a replacement is admitted finds the replacement waiting for a provider-confirmed cancel. Repeated loads of one attempt send identical provider parameters. Concurrent creates for different revisions of one order, and a delayed create for an older revision, leave exactly one live payment. The event feed never skips an event that commits late (Chapter 7's counterexample). Run against a real database, not mocks.
      2 · Crash injectionKill the process at every boundary in Figure 3.3, plus after the store's commit and before its 200. Cut the database connection before a commit, between two writes and during a refund send. Pause a worker past its lease while a second one runs. Cut the order lock's database session right after the ownership check. Pause a worker after it renews the stock reservation until the deadline passes, sell the unit to another order, then resume it. Every payment ends in one consistent state with no duplicate effect.
      3 · Delivery durabilityA test store offline for 72 hours, then for 5 days; every order converges through retries or the feed. A slow endpoint does not delay healthy ones.
      4 · SecurityCross-tenant and cross-mode suite on every endpoint; key rotation and revocation under load; signature test vectors shared by all SDKs; clock skew and replay tests; SSRF and DNS-rebinding tests against webhook URLs.
      5 · Compliance reviewThe acquirer and a qualified assessor review the hosted and modal flows, the nested-frame question and PayAssist's service-provider responsibilities; written responsibility statements ready for merchants.
      6 · Browser certificationSafari on iOS and macOS, Chrome, Firefox and common in-app browsers; 3DS challenges; wallets where offered; network drops at each browser state.
      7 · ObservabilityEvery new endpoint and worker follows the project's telemetry contract; alerts exist for every row of Chapter 12's operator table. A latency check compares form-ready time, fresh and reload, with Figure 16.1's after-patch values for the same store, provider and device class, over a sample size agreed in advance.
      8 · Migration rehearsalFlip a store between legacy and v1 repeatedly under load; no checkout ever runs on both; rollback tested (Chapter 17).
      9 · Documentation and SDK conformanceA developer outside the team builds the Next.js example from the documentation alone; every SDK passes the shared conformance suite.
      10 · PilotSynthetic store first, then one merchant in live mode with low volume, watched daily, with a written exit plan.

      After version 1: the road to industry-grade

      1. Stabilize. Version 1 in production for existing WooCommerce merchants, gates passed, measurements published internally.
      2. Developer experience. The CLI for local webhook forwarding and test triggers, test-mode scenario cards that fail on purpose, a Python SDK, a public changelog.
      3. Embedded checkout. PayAssist.js and the embedded element, with the browser certification from gate 6 extended to it.
      4. Scale evidence. The skewed load test from Chapter 14, then rung 1 of the ladder if its trigger fires.
      5. Owner decisions. The stable frame origin trial, saved cards per provider, and any regional requirements, each with its own measured case.
      Check yourself

      A dashboard shows "99.9% of webhooks delivered". What else must it show to be honest?

      Show the answer

      The age of the oldest pending delivery and the list of failing endpoints. A percentage can hide one large merchant whose endpoint has been down for days. Showing ages and failing endpoints keeps the unhealthy cases visible.

      Chapter 17

      Moving stores without breaking them

      Live stores keep taking orders while the protocol changes underneath them. The migration plan has one rule above all others: every checkout belongs to exactly one path from its first request to its last event.

      Figure 17.1Five stages for one store, each reversible except the lastProposed
      1 · Legacy onlytoday's plugin andpayment-request route 2 · Readyplugin 2.0 installed,connected, still legacy;both receivers live 3 · Flag onnew orders use v1;open legacy ordersfinish on legacy 4 · v1 onlylegacy receiver keptuntil no legacy checkoutor refund remains 5 · Legacy offfor this store; routedeleted only afterevery store is here rollback: flag off for new orders rollback a checkout never changes path once it has started
      (1) The store starts on today's plugin. (2) Plugin 2.0 is installed and connected but keeps using the legacy route, with both the legacy /status receiver and the new event receiver live. (3) A per-store flag sends new orders to v1 while open legacy checkouts finish where they started. (4) Only v1 remains for new work, but the legacy receiver stays until no legacy checkout, refund or dispute can still produce a callback. (5) Legacy is turned off for that store; the old route is deleted only when every store has reached this stage.
      Rule R17 · One owner per checkout

      During migration each checkout is created, paid, refunded and disputed on the path where it started, legacy or v1. Rollback changes where new checkouts go; it never moves a live one, and no checkout ever runs on both paths. Codex GPT-6.1-Sol's design stated the same rule: route each checkout to one owner and never dual-execute a payment Proposed.

      What moves, and how

      ItemMigration
      Store credentialsEach store goes through the connect flow once (Figure 8.2). The old shared secret keeps working for legacy traffic until stage 5, then is revoked.
      Callback endpointThe connect flow registers the plugin's new event route as a webhook endpoint with its own secret. The legacy callback URL stays configured until stage 5.
      Past ordersNot moved. Refunds and disputes for legacy orders keep flowing through the legacy callback until they are final, which is why stage 4 waits.
      Payment recordsNew sessions point at Backend's existing payment requests and attempts, so reporting, accounting and the dashboard keep one history.
      MonitoringDashboards split every metric by path, legacy or v1, so a regression shows up as a difference between the two.

      What the migration must never touch

      • The two Stripe paths. Standard stripe and stripe_connect remain separate supported integrations with no automatic fallback, and the migration does not move a merchant from one to the other Owner-confirmed.
      • Platform keys. The Stripe Connect platform key stays in the central Connect service and never reaches a node Owner-confirmed.
      • Provider calls. Standard-provider calls stay on account nodes and their dedicated IPs, and Connect calls stay in the central Connect service. The new public API does not call providers itself.
      • The external order receiver. The separate e-commerce receiver used for order generation stays unchanged; PayAssist adapts to its actual behavior on its own side Owner-confirmed.

      When to stop and roll back

      Write the rollback triggers before the first store flips, so nobody argues about them during an incident. Reasonable triggers: any money-invariant alert from Chapter 16; form-ready time on v1 worse than legacy for the same store over a day; delivery failures on v1 that do not occur on legacy; a support pattern that points at the new flow. Rolling back is a flag change for new orders, which is why stages 2 to 4 keep both receivers running.

      Check yourself

      A store at stage 3 rolls back to legacy while order 1050 is waiting in 3DS on v1. Where does order 1050's result arrive?

      Show the answer

      Through v1. The order started on v1, so v1 keeps owning it: its payment, its event, and any later refund or dispute. The rollback only sends the next new order to the legacy route. That is why the plugin keeps both receivers until stage 5.

      Part V

      WooCommerce, worked through

      Four chapters follow one invented store from installation to a paid order, then through every failure in this book, and finally into the plugin's code. Everything in this part is Proposed and Illustrative unless labeled otherwise; the store, the order and every id are synthetic.

      Chapter 18

      Meet Larkspur Tea

      Larkspur Tea is a small WooCommerce shop that sells loose-leaf tea in tins. This chapter installs PayAssist plugin 2.0, connects it without pasting a secret, and looks at the settings that decide how its checkout behaves.

      Figure 18.1The example store and the objects each side keepsIllustrative
      Larkspur Tea · larkspur-tea.example WordPress, WooCommerce with HPOS, PayAssist plugin 2.0, test mode CartSilver Needle tin $22.00Dragonwell tin $16.50Total $38.50 Plugin tablesinbox · bindings · versionsjobs · refunds · reviewsoptions: key, secret, cursor Background jobs (the plugin's own table)complete order · late payments · refunds · feed syncwoken by Action Scheduler every minute; retried withbackoff, then reported to the store admin PayAssist, test mode store: Larkspur Teamode test · allowed origin https://larkspur-tea.example restricted key pa_rk_test_…checkout_sessions:write · checkout_sessions:readpayments:read · refunds:write · events:read webhook endpoint we_…/wp-json/payassist/v2/events · own secret whsec_… · all payment events later, per order: cs_ · pay_ · pat_ · evt_created by the checkout in Chapter 19
      Left: what lives in the store. The plugin adds its own tables for event ids, payment bindings, versions, jobs, refunds and review cases, a few order fields, and background jobs. Right: what PayAssist keeps for this store after the connect flow. Neither side ever holds a card number.

      Installing and connecting

      The merchant installs plugin 2.0 and presses "Connect to PayAssist" in WooCommerce's payment settings. The connect flow from Chapter 8 runs: the merchant approves the plugin's scopes in the PayAssist dashboard, and the plugin receives a restricted test key and the webhook secret over a server-to-server call. PayAssist registers the plugin's event route as a webhook endpoint and sends a test event to prove it works.

      The settings that matter

      Figure 18.3The plugin's settings screen, sketchedIllustrative
      PayAssist · WooCommerce › Settings › Payments 1Connection Connected · test mode · key ends …k3Pw Switch to live 2Display mode Hosted page Popup embedded comes later 3Session lifetime up to 30 minutes each session ends before the order's stock reservation 4Late payment Fulfill if stock, else refund Hold for review 5Events endpoint /wp-json/payassist/v2/events · last event 2 min ago · feed cursor evt_… Sync events now View failed deliveries
      (1) Connection status names the mode and the last characters of the key, never the key. (2) Display mode offers the two modes version 1 ships. (3) Session lifetime is an upper limit: the plugin ends each session before the order's stock reservation runs out, with a margin. It cannot stop a payment already in flight, so late successes go to the policy in (4). (4) The late-payment policy decides what happens when money arrives after expiry. (5) Event status shows the endpoint's health and offers a manual sync from the feed.

      WooCommerce runs two clocks here, and neither is the session's. When the checkout creates an order, WooCommerce reserves its stock until that moment plus the "Hold stock" minutes, and other carts respect the reservation until it expires S99. Separately, a job cancels unpaid orders whose last update is older than the hold; it runs once per hold interval and, by default, only for orders created by the checkout or the Store API S78 S100. No setting can line those clocks up by itself.

      The plugin reads the order's reservation and gives each session an expiry before it, minus a margin. If a buyer comes back late, the plugin renews the reservation with wc_reserve_stock_for_order(), which fails when the stock is gone, and only then opens a new session. The API's shortest session is 15 minutes, so the plugin needs a renewed reservation that lasts at least 15 minutes plus the margin. With a shorter "Hold stock" setting, renewing cannot reach that, so the plugin refuses to open sessions and asks the merchant to raise the setting. With a blank hold nothing is reserved, and every late success goes to the late-payment policy. A session's expiry stops new tries, never a payment already in flight, so every success still goes through the late-payment policy This book.

      What WooCommerce requires of a payment plugin

      • A gateway class for the classic checkout. It extends WC_Payment_Gateway, and its process_payment() returns a result and a redirect S72.
      • A separate integration for the block-based checkout. A JavaScript registration plus a server class; WooCommerce's documentation stresses it does not replace the gateway class S73.
      • Order access through WooCommerce's own functions. Stores using high-performance order storage keep orders in custom tables, so the plugin uses wc_get_order() and order methods, never raw post queries, and declares compatibility S77.
      • Marking paid with payment_complete(). It reduces stock and sets the right status; WooCommerce recommends it over setting a status directly S72. Today's plugin already calls it from the signed callback Today.
      • Explicit permissions on REST routes. Every route the plugin registers declares a permission callback; the event route is public and authenticates by signature S79.
      The headless alternative

      Claude Sonnet 5.5's arena design described a second pattern: a separate storefront, for example in Next.js, that uses WooCommerce only for its catalog and orders through WooCommerce's REST and Store APIs, with no PHP plugin Proposed. It is a valid choice for headless shops. It needs its own care: REST keys act with the permissions of the WordPress user who created them S74, orders created through the REST API are not cancelled automatically, because by default WooCommerce cancels unpaid orders only when the checkout or the Store API created them S100, and the storefront must apply payment events with the same inbox pattern. A classic theme checkout still needs the gateway plugin.

      Check yourself

      Hold stock is 45 minutes. A buyer places an order at 10:00, leaves, and comes back at 10:40 to pay. What must the plugin do before it opens a session?

      Show the answer

      WooCommerce reserved the stock until 10:45, so any new session would outlive the reservation. The plugin first renews the reservation with wc_reserve_stock_for_order(), which fails if another buyer has taken the stock, and then opens a session that ends before the renewed reservation does. A payment already in flight when a session ends still goes through the late-payment policy.

      Chapter 19

      One order, end to end

      A buyer pays $38.50 for order #1042 at Larkspur Tea using the popup. Watch the whole trip first, then open each step to see the actual messages and the database rows they leave behind.

      The messages, step by step

      Open any step below. Hostnames, ids, keys and signatures are invented, and the request shapes are the proposal, not running code Proposed Illustrative.

      Steps 1 to 4 · WooCommerce creates the order, the plugin creates a session

      WooCommerce saves order #1042 as "pending payment" and calls the gateway's process_payment(). The plugin reads the order's revision, a counter it keeps on the order and raises whenever the cart changes after a session was created, and asks for a session:

      POST /v1/checkout_sessions
      Authorization: Bearer pa_rk_test_…            plugin's restricted key
      Idempotency-Key: wc-1042-r1-s1
      PayAssist-Version: 2026-11-01
      
      { "amount": 3850, "currency": "usd", "order_reference": "1042", "order_revision": 1,
        "ui_mode": "modal", "expires_in": 1800,
        "return_url": "https://larkspur-tea.example/checkout/order-received/1042/?key=wc_order_EXAMPLE" }
      
      201 { "id": "cs_test_8Lk", "status": "open", "payment": "pay_3Qd", "livemode": false,
            "client_secret": "cs_test_8Lk_secret_…", "frame_nonce": "fn_4Tq…",
            "url": "https://node-a.payassist.example/c/cs_test_8Lk#cap=cs_test_8Lk_secret_…",
            "expires_at": 1792302600 }

      Inside one transaction PayAssist stores the idempotency row, the session and the payment. No event is written, because the store already has the answer in the response. Behind the scenes the existing routing core picks the account and node, exactly as it does today for a payment request. Before the buyer sees anything, the plugin writes its binding row: payment pay_3Qd, session cs_test_8Lk, revision 1, test mode, 3850 usd. Every later fact about this payment is checked against that row.

      Steps 5 to 8 · The popup opens and the card goes to the provider

      The order-pay page receives the session URL and opens it in the modal's frame. The capability sits in the URL fragment, and browsers never send the part after # to a server or put it in a Referer header:

      https://node-a.payassist.example/c/cs_test_8Lk#cap=cs_test_8Lk_secret_…

      The path names the session, which is not a secret. The node looks it up before answering, so it can send that session's store origin as the page's frame-ancestors, and the page and frame then complete the handshake with fn_4Tq… (Chapter 9). The node creates the provider's payment object for attempt pat_1 with a provider idempotency key derived from the attempt, which is what the node already does today with the attempt id Today:

      POST https://api.stripe.com/v1/payment_intents       from the node's own IP, with the account's key
      Idempotency-Key: pat_1:create
      amount=3850&currency=usd&metadata[payassist_attempt]=pat_1

      The buyer types the card into Stripe's own fields. Stripe.js sends it straight to Stripe and confirms the payment from the browser. PayAssist's page sees only the result status.

      Steps 9 to 14 · The bank approves, the node checks and reports, PayAssist commits

      The issuer approves. Stripe.js tells the frame, and the frame tells the node through /confirm. The node does not trust the browser's word; it reads the payment from Stripe itself. If the browser's report never arrives, the node's scheduled read about 3 minutes later finds the result instead Today. Standard nodes do not rely on a Stripe success webhook today, so adding one would be new work. The node then reports over the existing signed node contract, which this proposal does not change Today. Backend applies it like this:

      -- transaction pseudocode: every branch is real control flow, not a comment
      BEGIN
        payment := SELECT * FROM payments WHERE id = 'pay_3Qd' AND store_id = 'larkspur' AND mode = 'test' FOR UPDATE
        attempt := SELECT * FROM attempts WHERE id = 'pat_1' AND payment_id = payment.id AND account_id = <the reporting node's account>
        IF attempt IS NULL OR the provider's amount or currency differ THEN
            record a review case; COMMIT; stop                       -- no success event
        IF payment.status = 'succeeded' THEN
            COMMIT; return the saved result                          -- a repeated report changes nothing
        IF payment.status = 'canceled' THEN
            record a provider-contradiction review; COMMIT; stop   -- rule R6
        changed := UPDATE payments SET status = 'succeeded', amount_received = 3850, version = version + 1
                   WHERE id = payment.id
                     AND status IN ('requires_payment_method', 'processing', 'requires_action')
                   RETURNING *                                           -- version 4: versions 2 and 3 recorded the attempt and processing
        IF changed IS NOT NULL THEN
            event := INSERT INTO events: payment.succeeded, version changed.version, snapshot of changed   -- rule R7
            INSERT INTO webhook_deliveries: one row per endpoint of this store, in this mode, subscribed to
                payment.succeeded, each with the body frozen in that endpoint's API version
      COMMIT

      Four things make this safe. The FOR UPDATE lock makes two reports about the same payment take turns, and the attempt must belong to this payment and to the account that reported it, or a person reviews it. Each status is handled by name: a repeated report returns the saved result, and a success for a canceled payment, which the provider already confirmed can no longer charge, goes to a person instead of becoming an event. requires_payment_method is among the allowed earlier states because a declined Stripe PaymentIntent can still be confirmed with another card, and that money must not be lost (rule R6). Finally, the event takes its version and snapshot from the row the update returned, and deliveries go only to this store's endpoints in the same mode that asked for this event type, each frozen in its own API version.

      Steps 15 and 16 · The event reaches the plugin
      POST https://larkspur-tea.example/wp-json/payassist/v2/events
      webhook-id: evt_1QxL
      webhook-timestamp: 1792301846
      webhook-signature: v1,K3m9xQ…=
      Content-Type: application/json
      
      {"id":"evt_1QxL","type":"payment.succeeded","livemode":false,"api_version":"2026-11-01",
       "data":{"object":{"id":"pay_3Qd","object":"payment","checkout_session":"cs_test_8Lk","session_status":"complete",
       "status":"succeeded","amount":3850,"amount_received":3850,"currency":"usd","order_reference":"1042","order_revision":1,
       "version":4}}}

      The plugin verifies the signature over evt_1QxL.1792301846. plus the raw body, then runs its apply function in one MySQL transaction on its own connection, touching only its own tables:

      START TRANSACTION;
        INSERT INTO wp_payassist_inbox (event_id, received_at) VALUES ('evt_1QxL', UTC_TIMESTAMP())
            ON DUPLICATE KEY UPDATE event_id = event_id;                -- 0 rows: a proven duplicate, stop
        SELECT … FROM wp_payassist_bindings WHERE order_id = 1042 FOR UPDATE;   -- one fact at a time per order
        -- binding for pay_3Qd: session cs_test_8Lk, revision 1, test mode, 3850 usd; the fact matches.
        -- No version stored yet for payment:pay_3Qd, so 4 is newer.
        INSERT version 4 for payment:pay_3Qd INTO wp_payassist_versions
        INSERT INTO wp_payassist_jobs (job_key, order_id, payment_id, fact, state)
            VALUES ('complete:pay_3Qd', 1042, 'pay_3Qd', '{…the validated fact…}', 'pending')
            ON DUPLICATE KEY UPDATE job_key = job_key;                  -- one completion job per payment, ever
      COMMIT;
      -- then answer 200; any failure above answers 500 and PayAssist retries

      Within a minute the plugin's runner claims complete:pay_3Qd, takes the order's lock, renews the stock reservation and calls $order->payment_complete('pay_3Qd'). WooCommerce moves the order to processing, reduces stock and sends its emails. In WooCommerce's current source, payment_complete() acts on pending, failed, on-hold and cancelled orders, and an order already processing takes another branch that does not repeat the payment-complete hooks S96. It returns true either way, and it is not a lock, so the job reads the order again afterwards and checks it is paid with this payment's id. An order lock stops a second worker from completing the order at the same time, but only while the lock's own database session lives; Chapter 21 lists that limit.

      Step 17 · The thank-you page

      The popup closes and the browser goes to the order-received page. If the job has already run, the page shows "processing". If the buyer is faster than the webhook, the page's server code asks PayAssist and applies the answer with the same checks and the same job as a webhook:

      $b = Bindings::current($order);                                   // saved before checkout was shown
      if ($b && !$order->is_paid()) {                                // cancelled orders too: a late success still counts
        $session = $payassist->checkoutSessions->retrieve($b->session_id);
        if ($session->payment->status === 'succeeded') ApplyPayment::read($session->payment);
      }

      A read is not an event, so it has no event id to put in the inbox. It is checked against the same binding, recorded through the same per-object version, and it adds the same complete:pay_3Qd job, which the later webhook then finds already there. Inventing a new event id for every page visit would defeat that.

      What each side holds afterwards

      After stepPayAssistLarkspur Tea
      4idempotency row wc-1042-r1-s1; session cs_test_8Lk open; payment pay_3Qd requires_payment_methodorder #1042 pending payment; binding row for pay_3Qd
      7attempt pat_1 pinned to account A; provider object created with key pat_1:createunchanged
      14payment succeeded, version 4; event evt_1QxL; one delivery row per subscribed endpointunchanged
      16delivery acknowledgedinbox row evt_1QxL; version 4 recorded; job complete:pay_3Qd pending
      job runsunchangedorder processing; stock reduced; emails sent; job done

      How long it should take

      There are no measurements of this proposed flow. The closest guide is the current staging Connect checkout in Figure 16.1: about 9 seconds from popup to a ready form on a fresh load after the October patch, about 2.3 seconds on a reload, and a callback to WooCommerce in about a second once Backend has applied the payment Measured. The new flow adds one API call before the popup and changes no provider step, so it should land near those numbers. That is a hypothesis until it is measured; the latency check in gate 7 of Chapter 16 is there to test it.

      Check yourself

      At step 16 the plugin answers 200 before WooCommerce has reduced stock. Is that a bug?

      Show the answer

      No. The 200 means the plugin has durably recorded the event and one completion job for the payment, in one committed transaction. Stock, status and emails happen in the job, which the plugin's runner retries until done and reports to the admin if it keeps failing. Answering only after slow work would risk PayAssist's 10-second timeout and needless retries.

      Chapter 20

      Every way it can go wrong

      The happy path and thirty other scenarios around Larkspur Tea's checkout, including a double click, a lost answer, a forged webhook and a key pushed to GitHub. Pick one, press Play, and read what protects the money, what the buyer sees, and what today's system does in the same situation.

      The colored dot beside each scenario, in the list and next to its title, tells you how it ends: ends well, recovers after a delay or a compensation, shows what goes wrong without the rule. Each scenario's link is shareable: the address bar updates as you choose.

      What the five groups teach

      GroupThe lesson underneathRules it leans on
      Creating and retryingEvery write must be safe to repeat, and the store's order revision is the business identity that ties retries together.R1 R2 R3 R14
      Money and the providerSilence is not an answer. Unknown blocks new attempts, retries reuse the attempt's key, and real money beats every clock.R3 R4 R5 R6 R18
      Events and webhooksDelivery is at least once and in any order; the receiver dedupes, compares versions and can always pull.R8 R9 R10 R11 R12
      Store sideRecord the decision and the work to do in one transaction, then do the outside work as a retried job with its own key.R13 R6
      Security and operationsThe credential decides the tenant, keys are scoped and rotatable, frames check every message, and migration never splits a checkout.R14 R15 R16 R17

      The same scenarios appear as one table, with today's behavior and a test for each, in the failure matrix of Appendix E.

      Where this list stops

      These scenarios cover the failure classes that the arena documents, PayAssist's incident records and the primary sources point to. They do not cover everything: a provider changing an API without notice, a bank that approves and then reverses, a merchant database restored from an old backup, or a person with dashboard access acting maliciously all need their own runbooks. Appendix G lists these limits.

      Chapter 21

      The plugin code, walked through

      Eight short pieces, mostly PHP with two small JavaScript files, make plugin 2.0: a bootstrap, the gateway, the Blocks integration, the order-pay page, the event route, the apply function, the jobs and refunds. Each is shown with the rule it implements, and the chapter ends with what the sketch still leaves out.

      Illustrative code, not a release

      The code below teaches the shape of a correct plugin. It has not been compiled or run, and the PayAssist PHP SDK it calls does not exist yet. The WooCommerce, WordPress and Action Scheduler behavior it relies on was read from their source on 18 October 2026 S91 S95 S96 S97 S99 and must still be checked against the installed versions. Treat every line as a design sketch Illustrative. If you are not a programmer, read the sentences under each piece and skip the code: they say what it does and why.

      The files and tables

      payassist-woocommerce/
        payassist-woocommerce.php     bootstrap: compatibility flags, gateway registration
        src/Gateway.php               classic checkout: process_payment, the order-pay page, process_refund
        src/BlocksIntegration.php     block checkout, server side
        assets/blocks.js              block checkout, browser side
        assets/checkout-modal.js      opens the payment frame and runs the handshake (R16)
        src/EventsRoute.php           POST /wp-json/payassist/v2/events
        src/ApplyPayment.php          one function for events and server reads (R8, R9, R12)
        src/Db.php                    money-path transactions on their own connection
        src/Jobs.php                  the plugin's own job table, its runner and the feed sync (R10, R13)
        src/Refunds.php               refund commands
        src/Install.php               the tables below, and the connect flow
        src/…                         helpers the sketch calls but does not show: Bindings, Commands, Revisions,
                                      Stock, Versions, Reviews, LatePayments, Locks, Checkpoints, Fact, Money
        vendor/payassist/payassist-php  the proposed PHP SDK
      
      wp_payassist_inbox        event_id (primary key), received_at
      wp_payassist_bindings     payment_id (primary key), order_id (index), session_id, revision, mode, amount_minor, currency
      wp_payassist_versions     object_key (primary key, such as "payment:pay_3Qd"), version
      wp_payassist_jobs         job_key (primary key), order_id, payment_id, fact (the validated fact, as JSON), state,
                                attempts, next_run_at, lease_owner, lease_until, last_error
      wp_payassist_refunds      command_id (primary key), order_id, wc_refund_id, payment_id, amount_minor, currency,
                                reason, payassist_refund_id (unique), state, created_at
      wp_payassist_checkpoints  store_id + mode (primary key), cursor
      wp_payassist_reviews      id, order_id, reason, fact, created_at, resolved_at

      A binding row is written for every session before the buyer sees a payment page. It records exactly which payment this order expects, in which mode, for which session, revision and amount, and every later fact is checked against it. The order also carries three meta fields: the revision counter, the session generation and the saved create command.

      1 · Bootstrap: declare what the plugin supports

      // payassist-woocommerce.php
      add_action( 'before_woocommerce_init', function () {
          if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
              // orders live in HPOS tables: use WooCommerce CRUD only, never raw post queries
              \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
              \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
          }
      } );
      add_filter( 'woocommerce_payment_gateways', function ( $gateways ) {
          $gateways[] = \PayAssist\Woo\Gateway::class;
          return $gateways;
      } );

      2 · The gateway: create a session, never charge directly

      // src/Gateway.php (excerpt)
      public $supports = [ 'products', 'refunds' ];
      
      public function process_payment( $order_id, bool $renewed = false ) {
          $order = wc_get_order( $order_id );
          // The whole create command is built once, saved on the order, and resent unchanged on every retry.
          $cmd = Commands::saved_or_new( $order, fn( int $rev, int $gen ) => [
              'body' => [
                  'amount'          => Money::toMinor( $order->get_total(), $order->get_currency() ), // the currency's decimals, not "x 100"
                  'currency'        => strtolower( $order->get_currency() ),
                  'order_reference' => (string) $order->get_id(),     // WooCommerce's internal id, not the display number
                  'order_revision'  => $rev,                          // a counter raised when the cart changes (R2)
                  'ui_mode'         => 'popup' === $this->get_option( 'display_mode' ) ? 'modal' : 'hosted',
                  'expires_in'      => Stock::session_seconds( $order ), // fixed once, from the stock reservation (Chapter 18)
                  'return_url'      => $this->get_return_url( $order ),
              ],
              'key' => "wc-{$order_id}-r{$rev}-s{$gen}",              // R1: one command, one key
          ] );
          try {
              $created = $this->client()->checkoutSessions->create( $cmd->body, [ 'idempotency_key' => $cmd->key ] );
              $session = $this->client()->checkoutSessions->retrieve( $created->id );  // a replay shows the first answer; read today's state
          } catch ( \PayAssist\Exception\OutcomeUnknown $e ) {
              wc_add_notice( __( 'The payment service did not answer. Please try again.', 'payassist' ), 'error' );
              return [ 'result' => 'failure' ];                       // creating a session moves no money; the retry resends the command
          } catch ( \PayAssist\Exception\Conflict $e ) {
              wc_add_notice( __( 'We are still checking an earlier payment for this order. Please try again in a minute.', 'payassist' ), 'error' );
              return [ 'result' => 'failure' ];                       // 409: an earlier session for this order may still take money
          }
          if ( 'succeeded' === $session->payment->status ) {          // paid, even if the session expired first (R6)
              ApplyPayment::read( $session->payment );
              return [ 'result' => 'success', 'redirect' => $this->get_return_url( $order ) ];
          }
          if ( 'expired' === $session->status ) {
              if ( $renewed || 'canceled' !== $session->payment->status ) {   // the old payment might still charge: wait
                  wc_add_notice( __( 'We are still checking an earlier payment for this order. Please try again in a minute.', 'payassist' ), 'error' );
                  return [ 'result' => 'failure' ];
              }
              Stock::renew_or_fail( $order );                         // throws if the stock is gone or the hold is too short
              Commands::next_generation( $order );                    // under the order lock; the next call builds a new command
              return $this->process_payment( $order_id, true );       // at most one renewal per click
          }
          if ( 'open' !== $session->status ) {                        // complete without a successful payment: for a person
              Reviews::open_for( $order, null, 'session complete without a successful payment' );
              wc_add_notice( __( 'We could not confirm this payment. The store has been told.', 'payassist' ), 'error' );
              return [ 'result' => 'failure' ];
          }
          Bindings::save( $order, $session );                         // payment, session, revision, mode, amount, currency
          return [ 'result'   => 'success',
                   'redirect' => 'hosted' === $session->ui_mode
                       ? $session->url                                // the capability rides in the URL fragment
                       : $order->get_checkout_payment_url( true ) ];  // the order-pay page opens the modal (piece 4)
      }

      Four details come from real bugs and reviews. Today's plugin once looked orders up by the number shown to customers, which some stores change with sequential-numbering plugins; the staging source fixed it by saving the correlation before calling Backend, and the sketch keeps that habit Today. Claude Sonnet 5.5's headless sketch converted amounts by multiplying by 100, which is wrong for currencies with no minor unit such as the Japanese yen. The revision is a counter rather than a hash of the cart, because a cart that changes from A to B and back to A must not reuse the first revision's key. And the whole create command is saved before the first request: if a retry recomputed expires_in a few seconds later, the request would differ under the same key and the API would correctly answer 422. Because a replayed create only returns the first answer, the plugin then reads the session's current state before deciding anything.

      3 · The block checkout needs its own registration

      // src/BlocksIntegration.php
      final class BlocksIntegration extends \Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType {
          protected $name = 'payassist';
          public function initialize() { $this->settings = get_option( 'woocommerce_payassist_settings', [] ); }
          public function is_active() { return 'yes' === ( $this->settings['enabled'] ?? 'no' ); }
          public function get_payment_method_script_handles() {
              wp_register_script( 'payassist-blocks', plugins_url( 'assets/blocks.js', PAYASSIST_FILE ),
                                  [ 'wc-blocks-registry', 'wp-element' ], PAYASSIST_VERSION, true );
              return [ 'payassist-blocks' ];
          }
      }
      add_action( 'woocommerce_blocks_payment_method_type_registration',
                  fn( $registry ) => $registry->register( new BlocksIntegration() ) );
      
      // assets/blocks.js
      const { registerPaymentMethod } = window.wc.wcBlocksRegistry;
      const el = window.wp.element.createElement;
      registerPaymentMethod( {
        name: 'payassist', label: 'Card', ariaLabel: 'Pay by card with PayAssist',
        content: el( 'p', null, 'You will pay in a secure window after placing the order.' ),
        edit: el( 'p', null, 'PayAssist card payment' ),
        canMakePayment: () => true,
      } );

      Both checkouts end in the same process_payment(), so the hosted redirect and the modal behave the same way whichever checkout the store uses.

      4 · The order-pay page opens the modal

      // src/Gateway.php (excerpt): WooCommerce runs this on the order-pay page after checking the order key
      add_action( 'woocommerce_receipt_payassist', function ( int $order_id ) {
          $order = wc_get_order( $order_id );
          $b     = Bindings::current( $order );
          if ( ! $b || $order->is_paid() ) return;
          $session = payassist_client()->checkoutSessions->retrieve( $b->session_id );   // today's state; never a create here
          if ( 'succeeded' === $session->payment->status ) {                              // paid, even if the session expired first
              ApplyPayment::read( $session->payment );                                    // same checks and job as a webhook
              printf( '<p>%s <a href="%s">%s</a></p>', esc_html__( 'Payment received.', 'payassist' ),
                      esc_url( $order->get_checkout_order_received_url() ), esc_html__( 'See your order', 'payassist' ) );
              return;
          }
          if ( 'open' !== $session->status ) {                                            // ended unpaid: process_payment decides if renewal is safe
              printf( '<p><a href="%s">%s</a></p>', esc_url( $order->get_checkout_payment_url() ),
                      esc_html__( 'This checkout has ended. Start again', 'payassist' ) );
              return;
          }
          wp_enqueue_script( 'payassist-modal' );
          printf( '<div id="payassist-pay" data-url="%s" data-session="%s" data-nonce="%s"></div>',
                  esc_attr( $session->url ), esc_attr( $session->id ), esc_attr( $session->frame_nonce ) );
      } );
      
      // assets/checkout-modal.js (excerpt)
      const box  = document.getElementById( 'payassist-pay' );
      const url  = new URL( box.dataset.url );                        // the capability is in url.hash, never sent to a server
      const want = { v: 1, session: box.dataset.session, nonce: box.dataset.nonce };
      const SHOW = new Set( [ 'ready', 'resize', 'change', 'overlay', 'status', 'error', 'redirect' ] );
      const frame = Object.assign( document.createElement( 'iframe' ), { src: url.href, allow: 'payment', title: 'Card payment' } );
      let port = null, timer = null, hid = '';
      frame.addEventListener( 'load', () => {                         // every load, including a reload: a fresh handshake
        port?.close(); port = null; clearTimeout( timer );
        hid = crypto.randomUUID();                                    // this load's handshake id
        let tries = 0;
        const init = () => {                                          // the page speaks first, to the exact frame origin,
          if ( port ) return;                                         // and repeats every 500 ms until hello, for 10 s
          if ( ++tries > 20 ) return showRetry();
          frame.contentWindow.postMessage( { t: 'init', ...want, hid }, url.origin );
          timer = setTimeout( init, 500 );
        };
        init();
      } );
      window.addEventListener( 'message', ( e ) => {
        const d = e.data || {};
        if ( e.source !== frame.contentWindow || e.origin !== url.origin || d.t !== 'hello' || port ) return;
        if ( d.v !== want.v || d.session !== want.session || d.nonce !== want.nonce || d.hid !== hid
             || e.ports.length !== 1 ) return;                        // R16; a hello from an older load fails the hid check
        clearTimeout( timer );
        port = e.ports[0];
        port.onmessage = ( m ) => { if ( m.data?.v === 1 && SHOW.has( m.data.t ) ) showStatus( m.data ); };   // the screen only (R12)
      } );
      openOverlay( frame );

      The order-pay page resumes the bound session and never creates one, so a reload cannot start a second payment. When the session has ended, the buyer goes back through process_payment(), which renews the stock reservation and the session together. Every load of the frame, including a reload, closes the old port and starts a fresh handshake with a new handshake id. The page accepts a hello only from the frame's own window and origin, carrying the same protocol version, session, nonce and handshake id, with exactly one private port, and it uses port messages only from a short, versioned list. After 10 seconds without an answer it offers the buyer a retry. The frame does its half: it starts listening before it verifies the capability and holds an early init until verification finishes.

      5 · The event route: verify the bytes, then apply

      // src/EventsRoute.php
      add_action( 'rest_api_init', function () {
          register_rest_route( 'payassist/v2', '/events', [
              'methods'             => 'POST',
              'permission_callback' => '__return_true',   // public by design: the signature authenticates
              'callback'            => function ( \WP_REST_Request $request ) {
                  $raw = $request->get_body();             // exact bytes; never re-encode before verifying (R11)
                  try {
                      $event = \PayAssist\Webhook::verify( $raw, $request->get_headers(), payassist_signing_secrets() );
                  } catch ( \PayAssist\Exception\SignatureVerification $e ) {
                      return new \WP_REST_Response( null, 400 );   // nothing parsed, nothing changed
                  }
                  try {
                      \PayAssist\Woo\ApplyPayment::event( $event );
                  } catch ( \Throwable $e ) {
                      payassist_log( 'apply_failed', $event->id, $e );
                      return new \WP_REST_Response( null, 500 );   // not committed, or commit unknown: PayAssist retries
                  }
                  return new \WP_REST_Response( null, 200 );       // inbox row and job are committed
              },
          ] );
      } );

      WordPress requires a permission callback on every REST route since version 5.5 S79. The event route is deliberately public; the SDK's verifier checks the Standard Webhooks signature, the 5-minute window and every currently valid secret, and copes with WordPress's normalized header names. A 200 tells PayAssist to stop retrying, so the route sends it only after the apply function's commit returned, and 500 for anything else.

      6 · One apply function for every door

      // src/ApplyPayment.php
      // A fact is what an authenticated source says about one object at one version: a verified event
      // (webhook or feed) or a server-side read. Events are deduplicated by event id. A read has no
      // event id, so it relies on the version check and on one completion job per payment.
      final class ApplyPayment {
          public static function event( object $event ): void {
              Db::transaction( function ( \mysqli $db ) use ( $event ) {
                  if ( ! Db::claim_event( $db, $event->id ) ) return;      // R8: a proven duplicate
                  self::fact( $db, Fact::from_event( $event ) );
              } );
          }
      
          public static function read( object $payment ): void {           // the thank-you and order-pay pages (R12)
              Db::transaction( fn( \mysqli $db ) => self::fact( $db, Fact::from_payment( $payment ) ) );
          }
      
          private static function fact( \mysqli $db, Fact $f ): void {
              $b = Bindings::lock_for_payment( $db, $f->payment_id );     // locks every binding of that order: one fact at a time
              // Check the fact against what was saved when checkout started: connected store, mode, payment,
              // session, order reference, revision, amount and currency, as far as this kind of fact carries them.
              if ( ! $b || ! $b->matches( $f ) ) {
                  Reviews::open( $db, $f, 'the fact does not match the payment saved for this order' );   // never silence
                  return;
              }
              if ( 'payment.succeeded' === $f->type
                   && ( 'succeeded' !== $f->status || $f->amount_received !== (int) $b->amount_minor ) ) {
                  Reviews::open( $db, $f, 'success without the full amount' );   // never mark paid on a mismatch
                  return;
              }
              if ( ! Versions::advance( $db, $f->object_type, $f->object_id, $f->version ) ) return;   // R9, per object, after validation
              if ( 'payment.succeeded' === $f->type ) {
                  Jobs::add( $db, "complete:{$f->payment_id}", $b, $f );   // one completion job per payment, carrying the fact (R13)
              } elseif ( in_array( $f->object_type, [ 'refund', 'dispute' ], true ) ) {
                  Jobs::add( $db, "{$f->object_type}:{$f->object_id}:{$f->version}", $b, $f );
              }
          }
      }
      
      // src/Db.php (excerpt): money-path transactions use their own mysqli connection.
      // wpdb reconnects after a lost connection and runs the failed statement again outside the
      // transaction, so a group of wpdb writes cannot promise to commit together.
      final class Db {
          private static ?\mysqli $link = null;
      
          public static function transaction( callable $work ) {
              mysqli_report( MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT );           // every failure throws
              $db = self::$link ??= new \mysqli( DB_HOST, DB_USER, DB_PASSWORD, DB_NAME );   // no automatic reconnect
              $db->begin_transaction();
              try {
                  $result = $work( $db );
                  $db->commit();               // a lost acknowledgement throws too, so the route answers 500
                  return $result;
              } catch ( \Throwable $e ) {
                  try { $db->rollback(); } catch ( \Throwable $ignored ) {}
                  self::$link = null;          // never reuse a connection after a failure
                  throw $e;                    // every write is keyed, so PayAssist's retry is safe
              }
          }
      
          public static function claim_event( \mysqli $db, string $id ): bool {
              $q = $db->prepare( 'INSERT INTO ' . payassist_table( 'inbox' ) . ' (event_id, received_at)
                    VALUES (?, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE event_id = event_id' );
              $q->bind_param( 's', $id );
              $q->execute();
              return 1 === $q->affected_rows;  // 1: new; 0: the id was already there
          }
      }

      WordPress's own database helper, wpdb, reports errors through return values rather than exceptions S91, and when a connection drops it reconnects and runs the failed statement again on the new connection. That is convenient for page loads and wrong for money: a COMMIT replayed on a fresh connection reports success for a transaction that was already rolled back. So the money path opens its own mysqli connection, which fails loudly instead, and every write in it is keyed, so a retry after an unknown commit cannot double anything. All of it lives in the plugin's own InnoDB tables (InnoDB is MySQL's storage engine with transactions); WooCommerce's order is changed later, by the job. The lock on the order's bindings makes a webhook, a thank-you page read and the feed take turns, which the event id alone cannot do, because those are different messages about the same payment. Every fact is checked against the saved binding before its version is recorded, and versions are kept per object, so a refund at version 1 is never mistaken for old news about a payment at version 4. A mismatch opens a review case instead of throwing, because retrying a wrong amount for three days would only delay the alert.

      7 · Jobs: finish the order, retry, and pull what pushes missed

      // src/Jobs.php
      // Retry state lives in the plugin's own table. Action Scheduler only wakes the runner, because it
      // marks a failed one-time action as failed and never retries it (S95).
      final class Jobs {
          public static function run_due(): void {
              while ( $job = self::claim() ) {     // sets lease_owner to a fresh random token and lease_until to now + 5 min
                  try {
                      self::handle( $job );        // checks it still owns the lease before any order write
                      self::finish( $job );        // UPDATE … WHERE job_key = ? AND lease_owner = ?: a stale worker changes nothing
                  } catch ( \Throwable $e ) {
                      self::retry_later( $job, $e );   // same owner check; attempts + 1, next_run_at = now + min(6 h, 30 s × 2^attempts);
                  }                                    // after 12 failures: state = 'failed', a review case and an alert
              }
          }
      
          private static function complete( object $job ): void {          // job key "complete:pay_…"
              $f = Fact::from_json( $job->fact );                           // the validated success, saved with the job
              Locks::order( $job->order_id, function () use ( $job, $f ) {  // one worker per order for the read and the write
                  $order = wc_get_order( $job->order_id );
                  $b     = Bindings::for_payment( $job->payment_id );
                  if ( $order->is_paid() ) {                                // finished before a crash, or paid by another payment
                      if ( $order->get_transaction_id() !== $b->payment_id ) Reviews::conflict( $order, $f );
                      return;
                  }
                  $late = 'expired' === $f->session_status || (int) $b->revision !== Revisions::current( $order );
                  if ( $late || $order->has_status( 'cancelled' ) || ! Stock::hold_again( $order ) ) {   // renew; false if gone (S99)
                      LatePayments::decide( $order, $b, $f );               // the store's setting: fulfill if stock, refund, or review
                      return;
                  }
                  if ( ! $order->has_status( [ 'pending', 'failed', 'on-hold', 'cancelled' ] ) ) {
                      Reviews::open_for( $order, $f, 'paid while the order was ' . $order->get_status() );
                      return;
                  }
                  Jobs::assert_owner( $job );                               // stop if another worker took the lease meanwhile
                  $order->payment_complete( $b->payment_id );               // its true return value proves nothing (S96)
                  $fresh = wc_get_order( $job->order_id );
                  if ( ! $fresh->is_paid() || $fresh->get_transaction_id() !== $b->payment_id ) {
                      throw new \RuntimeException( 'order not paid after payment_complete' );   // retried, then a review case
                  }
              } );
          }
      }
      
      add_action( 'init', function () {       // wake-ups: jobs every minute, the feed every 5 minutes (R10)
          if ( ! function_exists( 'as_has_scheduled_action' ) ) return;
          if ( ! as_has_scheduled_action( 'payassist_run_jobs' ) )    as_schedule_recurring_action( time(), 60, 'payassist_run_jobs', [], 'payassist' );
          if ( ! as_has_scheduled_action( 'payassist_sync_events' ) ) as_schedule_recurring_action( time(), 300, 'payassist_sync_events', [], 'payassist' );
      } );
      add_action( 'payassist_run_jobs', [ Jobs::class, 'run_due' ] );
      add_action( 'payassist_sync_events', function () {
          $c = payassist_connection();                                       // the connected store id and mode
          Locks::named( "payassist_sync_{$c->store_id}_{$c->mode}", function () use ( $c ) {   // one sync at a time
              $cursor = Checkpoints::get( $c->store_id, $c->mode );         // a checkpoint per store and mode, set at connect time
              do {
                  $page = payassist_client()->events->list( [ 'after' => $cursor, 'limit' => 100 ] );   // oldest first
                  foreach ( $page->data as $event ) \PayAssist\Woo\ApplyPayment::event( $event );       // the same inbox as pushes
                  Checkpoints::advance( $c->store_id, $c->mode, $cursor, $page->next_cursor );   // compare-and-set; throws if not saved
                  $cursor = $page->next_cursor;
              } while ( $page->has_more );
          } );
      } );

      A late success is never dropped, and late means more than a cancelled order. The job carries the verified fact, so it knows whether the session had expired when the money moved and which revision was paid. Every late case, every cancelled order and every case where the stock cannot be reserved again goes to LatePayments::decide(). It fulfills automatically only a pending order whose reservation it has just renewed; otherwise it creates one refund command or opens a review case, as the merchant's setting says, and it tells the merchant which. On the normal path the job checks the order afterwards instead of trusting payment_complete(), which returns true even when it changed nothing S96. A worker that pauses past its lease loses ownership, and the owner token stops it from finishing the job; the order lock stops a second worker from completing the order at the same time, but only while the lock's own database session lives (see the list at the end of this chapter). One limit stays: payment_complete() saves the paid status before it runs WooCommerce's hooks, so a crash between the two can skip a hook on the next run. Work the plugin owns, such as a warehouse call, therefore goes in its own job with its own key, not in a hook. By default Action Scheduler runs from WP-Cron and loopback requests S93, so a quiet site can run jobs late; for money paths, run its queue from a real system schedule with WP-CLI S94. The feed checkpoint belongs to one store and one mode, so moving from test to live starts a separate stream instead of sending a test cursor with a live key. WooCommerce's own webhooks run through WP-Cron and switch themselves off after five failed deliveries S75, which is why they are not used for payment truth. The backoff and the twelve-failure alert are illustrative numbers.

      8 · Refunds from WooCommerce admin

      // src/Refunds.php (excerpt)
      // WooCommerce saves its refund record and then calls process_refund(); if the gateway returns false
      // or an error, it deletes that record (S97). So the plugin keeps its own refund command row.
      add_action( 'woocommerce_create_refund', function ( \WC_Order_Refund $refund ) {   // before WooCommerce saves it
          // The plugin's admin script puts one action id on the refund screen when it opens, so a
          // double-click or a resubmitted form carries the same id. Other callers get a fresh one.
          $action = sanitize_key( $_POST['payassist_refund_action'] ?? '' ) ?: wp_generate_uuid4();
          $refund->update_meta_data( '_payassist_action', $action );
          Refunds::$current = $refund;                                   // the same PHP request calls process_refund() next
      } );
      
      // src/Gateway.php (excerpt)
      public function process_refund( $order_id, $amount = null, $reason = '' ) {
          $order  = wc_get_order( $order_id );
          $record = Refunds::$current;
          $minor  = Money::toMinor( $amount, $order->get_currency() );
          try {
              $cmd = Db::transaction( function ( \mysqli $db ) use ( $order, $record, $minor, $reason ) {
                  Bindings::lock_order( $db, $order->get_id() );                         // one refund decision at a time per order
                  // get or create: the same action id returns its saved command; different terms are refused
                  $cmd = Refunds::get_or_create( $db, $record->get_meta( '_payassist_action' ), $order, $minor, $reason );
                  if ( $cmd->created_now && Refunds::unresolved_other( $db, $order->get_id(), $cmd->command_id ) ) {
                      throw new RefundBlocked( 'An earlier refund is still being confirmed.' );
                  }
                  return $cmd;                                                           // committed before any money moves
              } );
          } catch ( RefundBlocked $e ) {
              return new \WP_Error( 'payassist_refund', $e->getMessage() );             // nothing was sent; WooCommerce deletes its record
          }
          if ( ! $cmd->created_now ) {                                                  // a second submission of the same action
              return new \WP_Error( 'payassist_refund', __( 'This refund was already sent.', 'payassist' ) );   // WooCommerce deletes the extra record
          }
          return Refunds::send( $cmd );
      }
      
      public static function send( object $cmd ) {                                   // Refunds::send, also used by the recovery job
          try {
              $r = payassist_client()->refunds->create(
                  [ 'payment' => $cmd->payment_id, 'amount' => $cmd->amount_minor, 'reason' => $cmd->reason,
                    'client_reference' => $cmd->command_id ],                           // unique per payment forever (Appendix D)
                  [ 'idempotency_key' => 'wc-refund-' . $cmd->command_id ] );
          } catch ( \PayAssist\Exception\OutcomeUnknown $e ) {
              return true;    // the command stays "sending"; a job asks again with the same client_reference until it knows
          }
          Refunds::record_result( $cmd->command_id, $r );                              // PayAssist refund id and status on the command
          return 'failed' === $r->status ? new \WP_Error( 'payassist_refund', $r->failure_reason ) : true;
      }

      Each refund the merchant asks for gets one action id when the refund screen opens, and becomes one command row, saved with its amount, reason and id before any money moves. A double-click or a resubmitted form carries the same action id, finds the same command and goes no further; WooCommerce deletes the extra record. The command id travels to PayAssist as client_reference, which the proposed API keeps unique per payment forever, not just for the seven days it keeps idempotency replays. So if PHP dies after PayAssist accepted the call, the recovery job can ask again with the same reference a day or a month later and get the same refund back, never a second one. While any refund on the order is unresolved, new refunds are refused, and a failed refund needs a new refund action. Events and reads carry the refund's client_reference, so they find their command even if they beat the POST response or WooCommerce deleted its own record. When refund.succeeded or refund.failed arrives, its job updates that command and its WooCommerce record. A refund that failed after WooCommerce already counted it puts the order on hold with a note that no money moved, for a person to correct. A refund started outside WooCommerce, for example in the PayAssist dashboard, gets one WooCommerce record with refund_payment set to false, once per refund id. No event ever starts a provider refund.

      Today refunds happen in the provider's dashboard, and the plugin only records them in WooCommerce when the refund event arrives; it does not advertise refund support Today. Version 1 lets a merchant refund from the order screen as shown above.

      What this sketch still leaves out

      • The helper classes it calls. Each hides real rules: Commands saves the create command and advances the session generation under the order lock; Refunds::get_or_create() refuses a known action id with different terms; the admin script adds the refund action id to WooCommerce's refund screen.
      • Fencing a stale worker. Locks::order() is a MySQL named lock, and it stops a second worker from completing the order only while the lock's own database session lives. If that session dies after the ownership check, a paused worker can still write through WooCommerce's connection while a new worker runs. A real implementation must fail closed at the order write itself; until then this is an open limit, and gate 2 must cut the lock's session right after the ownership check.
      • Proving stock admission. Renewing WooCommerce's reservation is not proof that the stock stays allocated until completion: WooCommerce counts a reservation only while its order is pending or a checkout draft and before its deadline, so it does not protect a cancelled order, and a worker that pauses past the deadline can lose the unit. Until that proof exists, the sketch fulfills a late payment automatically only for a pending order whose reservation it has just renewed, and sends every other late success to a refund or a review case. Gate 2 must pause a worker after renewal until the deadline passes, sell the unit elsewhere and resume it.
      • Parsing DB_HOST for a port or socket the way WordPress does, and TLS to the database.
      • Admin screens for review cases, failed jobs and refund commands.
      • Uninstall, data retention, and the personal data inside saved facts.
      • Multisite installs.
      • Every gate in Chapter 16. Gate 2 in particular must cut the database connection before the commit, between writes and during a refund send.
      Check yourself

      Why does the gateway save the whole create command on the order before calling PayAssist, instead of rebuilding it on each try?

      Show the answer

      If the call succeeds but the response is lost or PHP times out, the retry must send exactly the same request under the same key. A rebuilt command could differ, for example by a few seconds of expires_in, and the API would refuse it with 422. Saving first means a retry, a background job or the order-pay page all send the same command and reach the same session.

      Appendices

      Reference

      The glossary, the decision ledger, every corrected claim, the proposed API, the rules with the full failure matrix, the sources, the limits of this book, and the evidence behind it.

      Appendix A

      Glossary

      Plain definitions for every technical word in the book. Dotted words in the chapters open these definitions in place.

      3-D Secure (3DS)
      A check run by the buyer's bank during an online card payment, such as approving it in a banking app. The bank decides when to ask. In the European Economic Area it implements strong customer authentication.
      Account node
      A PayAssist server for one standard provider account, with its own dedicated IP address and that account's credentials. It makes that account's provider calls and serves the payment page. On the Stripe Connect path the node still serves the checkout, but the central Connect service holds the platform key and calls Stripe.
      Acquirer
      The bank or company that accepts card payments on a merchant's behalf and moves the money to them. Providers such as Stripe act as or work with the acquirer. Acquirers decide which PCI questionnaire a merchant must file.
      Action Scheduler
      A job queue library that ships with WooCommerce. It stores background jobs in database tables and runs them from WP-Cron and background requests, or from WP-CLI. It marks a failed one-time job as failed and does not retry it.
      At-least-once delivery
      A delivery promise in which a message may arrive more than once, so receivers must ignore repeats. In this book's design, a receiver that comes back within the retry and feed windows still gets every event; after those windows, the reconciliation export covers the gap.
      Authorization
      The bank's yes or no to "may I charge this amount to this card?". A yes reserves the money; a capture then takes it.
      Bearer token
      A credential that works for whoever holds it, sent in the Authorization header over HTTPS. API keys are bearer tokens, which is why they must stay secret.
      Capability token
      A short-lived, unguessable string that allows a few actions on one object, for example paying one checkout. Stripe calls its version a client secret.
      Capture
      Turning an authorization into a real charge. It can happen immediately or later, for example after confirming stock.
      Card issuer
      The buyer's bank, which issued the card and approves or declines each charge.
      Card network
      The system that routes card payments between banks, such as Visa or Mastercard. Networks also set the rules for disputes and retries.
      Cell
      An independent copy of a whole system that serves only some customers, so a failure in one cell does not reach the others.
      Change-data capture (CDC)
      Reading a database's own log of committed changes and copying each change somewhere else, such as Kafka, without the application writing twice.
      Checkout session
      In the proposal, the store's handle on paying for one order revision. It owns one payment and expires after a set time; if it expires unpaid, a new session and payment can follow for the same revision.
      Connect service
      PayAssist's central service for Stripe Connect payments. It holds the platform key and makes the Stripe calls; account nodes never hold that key.
      Cumulative Layout Shift (CLS)
      A score for how much a page jumps around while loading. Late-loading frames that push content down raise it.
      Cursor
      A bookmark in a list, such as "the last event I processed", used to ask for everything after it. It is only safe if nothing can later appear behind it.
      Debezium
      An open-source tool that reads a database's change log and publishes each committed change, for example into Kafka.
      Deficit round-robin
      A fair way to share workers among many queues: each queue gets a turn and a small budget per round, so one large or slow queue cannot take every worker.
      Dispute (chargeback)
      A buyer asking their bank to reverse a charge. The card network decides it, with deadlines for the merchant's evidence. It is separate from the original payment.
      ERP (enterprise resource planning)
      The business software a merchant uses for stock, accounting and shipping.
      Event
      A record that something changed, such as payment.succeeded, with a snapshot of the object and its version. Events are delivered by webhooks and readable from the feed.
      Exactly-once effect
      The goal that a message's consequence happens once even if the message arrives several times. It comes from at-least-once delivery plus deduplication.
      Fenced failover
      Switching to a standby system in a way that guarantees the old primary can no longer write, so two primaries never accept changes at once.
      frame-ancestors
      A Content Security Policy rule, sent as an HTTP header, that lists which sites may show a page inside a frame.
      Full jitter
      A retry rule where each wait is a random time between zero and a growing limit. It spreads retries out so failed clients do not all retry at once.
      HMAC
      Hash-based message authentication code: a short signature made by mixing a message with a shared secret. It proves who sent the message and that it was not changed. It does not hide the message.
      HPOS
      WooCommerce's high-performance order storage, which keeps orders in their own tables. Plugins must use WooCommerce's order functions instead of raw post queries.
      Idempotency
      The property that doing something twice has the same effect as doing it once.
      Idempotency key
      A unique name for one intended action, sent with every retry of it, so the server can return the saved result instead of acting twice.
      iframe
      A page from one website shown inside another page. Scripts on the outer page cannot read what is inside an iframe from another origin.
      Inbox table
      A table in the receiver's database that records each event id it has applied, so repeats can be recognized and ignored.
      InnoDB
      MySQL's default storage engine. It supports transactions, so a group of writes either all happen or none do.
      Intelligent Tracking Prevention (ITP)
      Safari's privacy feature that blocks third-party cookies and limits storage for sites embedded in other sites.
      Kafka
      A durable, ordered log of messages that many consumers can read at their own pace. Useful at high volume; a cluster to operate.
      Lease
      A time-limited claim on a job. If the worker holding it dies, the claim runs out and another worker can take the job; an owner token stops the first worker from finishing it late.
      MessageChannel
      A browser feature that creates a private two-way pipe between a page and a frame. After one checked handshake, messages travel only through it.
      Mode (test or live)
      Which world a key and every object belong to. Test mode uses sandboxes and fake providers; live mode moves real money. They never mix.
      Named lock (GET_LOCK)
      A MySQL lock that has a name instead of a row. One database connection holds it until it releases the lock or the connection ends.
      OAuth
      A standard way for one app to get limited access to another on a user's behalf without the user sharing a password. The plugin's connect flow uses its authorization-code flow.
      Object version
      A number on each object that increases with every change. It tells a receiver which of two events is newer.
      OpenAPI
      A standard format for describing an HTTP API: its paths, fields and errors. Tools generate documentation and client libraries from it.
      Origin
      The scheme, host and port of a web address together, such as https://larkspur-tea.example. Browsers isolate pages by origin.
      Outbox
      A table where a service writes outgoing messages in the same transaction as the change they describe; a separate sender delivers them later.
      p99 (99th percentile)
      The time that 99 of every 100 requests beat. It shows the slow tail that an average hides.
      PAN
      Primary account number: the long number on a card. It is the data PCI DSS protects most.
      Payment attempt
      One provider object on one provider account, used to take a payment. A declined card may leave it able to take another card, so a new attempt waits until the old one can no longer take money.
      PCI DSS
      The Payment Card Industry Data Security Standard: the security rules for anyone who stores, processes or transmits card data, or can affect its security.
      PKCE
      Proof Key for Code Exchange: a secret the requesting app makes at the start of an OAuth flow and shows again at the end, so a stolen one-time code is useless to anyone else.
      postMessage
      The browser function that lets windows and frames from different origins send each other messages. Receivers must check who sent each one.
      Provider
      The payment company that processes the card, such as Stripe, Square, PayPal, NMI, Authorize.net or Clover. Also called a payment service provider.
      Publishable key
      An identifier for a store that is safe to put in a web page. It cannot read or change anything by itself.
      Qualified Security Assessor (QSA)
      A person certified by the PCI Security Standards Council to assess PCI DSS compliance and its scope.
      Quorum
      The number of copies, usually a majority, that must accept a write before it counts, so the data survives losing a minority of machines.
      Reconciliation
      Asking the source of truth what really happened and correcting your own records, on a schedule, for anything left unresolved.
      Recovery point objective (RPO)
      How much recently written data a system may lose in a disaster, measured in time. RPO 0 means no committed data may be lost.
      Recovery time objective (RTO)
      How long a system may stay down after a disaster before it must work again.
      Redpanda
      A streaming log that speaks Kafka's protocol, packaged as a single program.
      Refund
      Money sent back to the buyer at the merchant's request. It is its own object and does not change the original payment's status.
      Replay attack
      Sending a genuine, previously captured message again. Timestamps limit how old a replay can be; event-id deduplication makes it harmless.
      Restricted key
      A secret key limited to a listed set of actions, such as creating sessions and reading events. The WooCommerce plugin uses one.
      Same-origin policy
      The browser rule that a page's scripts can read and change content from their own origin but not from others.
      SAQ A
      The shortest PCI self-assessment questionnaire for online merchants, allowed when every element of the payment form comes from a compliant provider. Since 31 March 2025 the merchant must also confirm the site is protected from script attacks.
      SDK
      Software development kit: a library that wraps an API for one programming language, handling keys, retries and signatures.
      Secret key
      A key that can do everything for one store and mode. It belongs only on the store's server.
      Serializable
      The strictest database isolation level: transactions running at the same time behave as if they ran one after another.
      Server-side request forgery (SSRF)
      Tricking a server into calling an address the attacker chose, such as an internal service. Webhook URLs are a common way in.
      SKIP LOCKED
      A PostgreSQL option that lets several workers claim different rows of a job table at once without waiting on each other.
      Standard Webhooks
      A published specification for signing webhooks with webhook-id, webhook-timestamp and webhook-signature headers, with libraries in many languages.
      Store API
      WooCommerce's public API for carts and checkout, used by block checkouts and headless storefronts.
      Strong customer authentication (SCA)
      The EU rule that most online card payments need two independent proofs of the payer's identity. 3-D Secure is how card payments meet it.
      Tenant
      One customer of a shared system, here one store. Tenant isolation means no store can see or change another store's data.
      Third-party service provider (TPSP)
      PCI's term for a company that handles card data or can affect its security on someone else's behalf. PayAssist is one for its merchants.
      Tombstone
      A record kept after something is deleted, saying that it existed, so a late duplicate cannot bring it back.
      Unknown outcome
      The state after a request got no answer: it may or may not have happened. It is resolved by asking, never by guessing.
      Webhook
      An HTTP request a service sends to a URL you registered, to tell you something happened.
      WP-CLI
      WordPress's command-line tool. A real system scheduler can call it to run background jobs on time.
      WP-Cron
      WordPress's built-in scheduler. It runs due tasks when someone loads a page, so a quiet site runs them late.

      Appendix B

      Decision ledger

      Every significant design choice in the book: who proposed what, what this book decided, and how much weight the evidence carries. Filter by outcome.

      Sources use short codes. C, O, S and A are the arena documents by Codex GPT-6.1-Sol, Claude Opus 5.5, Claude Sonnet 5.5 and Codex GPT-6-Astra, with their section numbers. E1 to E12 are local evidence files and S1 to S100 primary sources, both listed in Appendix F. An independent evidence agent audited the attributions; see Appendix H.

      IdTopic and proposalsDecision in this bookOutcomeEvidence and status
      D01Public contract: REST, JSON, OpenAPI 3.1 C §1, O §1Adopted as proposed.AcceptedAgent recommendationTwo independent recommendations, no vote (E2 C25)
      D02Public gRPC or GraphQL C §1, O §1Not for the public contract; internal use only if measured.RejectedAgent recommendation"Unanimously rejected" in summaries was overstated (E2 C25)
      D03Session over payment over attempt O §1, C §2Adopted; reuse payment_requests and payment_attempts underneath.AcceptedObserved codeAttempt table and token-bound attempt id exist (E1 §12, E2 K10)
      D04Direct payment intents for advanced users C §2After version 1.DeferredAgent recommendation
      D05Saved cards / setup intents O §1 no; C §2 yes, not first; S no vaultNot in version 1; design per provider if a merchant needs it.DeferredPrimary sourcesPortability is provider-specific (S32 to S37)
      D06Key families publishable, secret, restricted O "Key types", C §3Adopted with a PayAssist prefix, such as pa_rk_live_, plus checksum.AcceptedThis bookPrefix change is this book's; scanner guidance S86; Stripe recommends restricted keys S29
      D07API-key verifier storage: SHA-256 O vs a keyed hash, plus required encryption for outbound signing secrets C §3; O leaves signing-secret storage unspecifiedAdopt C's explicit separation.AcceptedAgent recommendationA one-way hash suits a verifier, not signing material the sender must recover
      D08Plugin install without pasted secrets O button; C OAuth with PKCECombined connect flow (Figure 8.2).AcceptedAgent recommendation
      D09Request HMAC with one shared secret as the store contract todayReplaced by keys over HTTPS and per-endpoint webhook secrets.ReplacedObserved codepluginAuth.ts and outbox.ts share one secret (E1 §2, E2 K01, K02)
      D10Idempotency in Postgres with the change; Redis only as a cache O §4, C §9, A §3Adopted (rule R1).AcceptedAgent recommendationAll three agreed
      D11Idempotency status codes O, A: 409 running, 422 reused; C: 409 reused, 202 pendingDraft codes: 400 missing, 409 running, 422 reused.AcceptedThis bookIETF draft -07, expired April 2026 (S58, S59)
      D12Replay retention O 24 h; C, A 7 days7 days, plus permanent business uniqueness.AcceptedThis bookStripe keeps keys at least 24 h (S22)
      D13Fingerprint C canonical command; A JCS; O body hashHash of the validated canonical command; JCS acceptable.AcceptedAgent recommendationNo real conflict, see E2 §B
      D14Business uniqueness on order and revision C §9Adopted (rule R2).AcceptedAgent recommendationToday: unique store and order (E1 §4)
      D15Card entry in provider-hosted fields, no PayAssist vault all fourAdopted (rule R15).AcceptedOwner-confirmedLocked principle: card data never touches PayAssist servers (E6)
      D16"Zero PCI", automatic SAQ A, out of Level 1 scope coordinator summaries; S position 1Replaced by the safe wording in Chapter 5.CorrectedPrimary sourcesS1 to S14, S17; E2 C01 to C04
      D17Display modes O §2 three modes; C §4 hosted firstVersion 1: hosted and modal; embedded later.AcceptedThis bookBoth exist today in staging plugin 0.1.26 (E1 §8)
      D18One stable frame origin O §2, S §10Keep node origins first and evaluate a stable origin separately, with measurement; the owner decides.Open: D1Owner-confirmedObservation only: today's node-domain pattern is confirmed for Connect (E6)This bookKeeping those origins first is a recommendation, not an approved target topology; ISP-fix claim corrected (E2 C07 to C11, E7)
      D19Frame handshake O §2 nonce + port; C §4 origin and nonces; S codeAdopted with a server nonce and a resilient listener.AcceptedNeeds testsSketch lacks nonce; first-message listener issue (E2 K16)
      D20Appearance as compiled tokens S §3With the embedded element, after version 1.DeferredSynthetic testCompiler and reducer suite: 10 tests pass, 41,509 assertions under Bun; browser behavior untested (E2 §D, E10)
      D21Layout hints and "CLS below 0.02" S §2Technique kept for later; number treated as an unmeasured target.DeferredCorrectedNever measured (E2 C12, C13)
      D22Browser unknown state; new key only after a server-reported failure S §8Adopt in the target design; the processing-state reload needs a fix and a regression test before anyone uses the reducer.AcceptedSynthetic testReproduction confirms the gap: reload during processing minted a new key (E2 K27). No repaired reducer or passing repair test exists.
      D23Event transport O Postgres first; C CDC + Kafka at stated scale; A Kafka per cellPostgres outbox now; Kafka on a measured trigger.AcceptedThis bookS67, S68; ladder in Chapter 14
      D24Retry window and formula C 5 s/6 h/72 h; O 30 s/6 h; A 2 s/1 hFull jitter, 30 s base, 6 h cap, 72 h deadline.AcceptedThis bookS18, S66; today 24 attempts over about 18 h (E1 §3)
      D25Pull feed O 30 days; C cursor API/v1/events, oldest first, 30 days.AcceptedAgent recommendationStripe keeps events 30 days (S21)
      D26Ordering C, O versions; A per-order lanesVersions now; lanes deferred.AcceptedStrict-order claims correctedS19; E2 C20, C21
      D27Webhook signature format all arena: t= and v1= headerStandard Webhooks v1.AcceptedThis bookS63, S64; lab verified against node:crypto
      D28Isolating slow endpoints O caps + breaker; C, A fair schedulingCaps, timeouts and breakers now; deficit round-robin later.AcceptedAgent recommendation
      D29SSRF protection for webhook egress O §3, C §7Adopted.AcceptedPrimary sourceS87
      D30Store-side fulfillment O insert-then-act; C inbox + outbox + downstream keysCodex's pattern (rule R13).AcceptedInsert-only sketch correctedE2 C23; S27
      D31Return page reads server state O 3 s bounded read; C authenticated lookupAdopted (rule R12); timeout still means processing.Accepted"Eliminates pending" correctedE2 C28
      D32Session lifetime O 30 min default (D4)30 min default, never above the store's stock hold.Open: D4Observed codeToday 12 min token, 15 min backstop (E1 §5)
      D33Late success after expiry O auto_refund option; C paid-but-unfulfillableReport, never discard; merchant policy (rule R6).AcceptedObserved codeSuccess supersedes cancel today (E1 §5, E2 K09)
      D34Retry on another account O after decline; C, A unknown blocks; summaries: automaticBuyer-initiated attempt after a definite decline; unknown blocks; account policy open.AcceptedAutomatic reroute correctedE2 C22; card-network retry rules not researched
      D35Cells, distributed SQL, regions A §1, §6; C §1Deferred with triggers.DeferredPrimary sourcesS70, S71
      D36100,000 events/s and 50,000 orders/s as requirements coordinator briefsNot requirements; arithmetic only.RejectedIllustrative modelPrompt-imposed (E2 §A, C14, C15)
      D37Generated SDKs O, C; tool choice Stainless or openapi-generator O D3Generate from OpenAPI with hand-written webhook helpers; tool open.AcceptedOpen: D3
      D38Developer CLI with forwarding and triggers O §5, C §11After version 1.DeferredAgent recommendation
      D39WooCommerce as a thin SDK client S §7, O §6, C §11; headless option S §7Gateway plugin rebuilt on the SDK; headless documented as optional.AcceptedPrimary sourcesS72, S73, S77
      D40Legacy route as a temporary adapter; one owner per checkout O §6, C endingAdopted (rule R17).AcceptedAgent recommendation
      D41Separate stripe and stripe_connect paths, no automatic fallback; no platform key at nodes; proxy may carry Connect transport; no new local Connect serviceRespected everywhere.BindingOwner-confirmedAGENTS.md and project instructions (E6)
      D42Current WooCommerce paid truth comes only from the signed Backend /status callback; browser messages drive the screenPreserved for the existing plugin. Accepting a server read in the new protocol is this book's proposal (R12).BindingOwner-confirmedCurrent rule only (E6); today's plugin follows it (E1 §7)This bookServer-read extension; the thank-you-page pull was reverted on 4 September 2026 (E6)
      D43Build order O events first; C hosted first; S state machine first; A one cell firstEvents, then API, then plugin 2.0, then opening up.AcceptedThis bookNo effort estimate exists (E2 §F)

      Appendix C

      Claims corrected

      Statements from the arena period that went further than the evidence, with what the records show and wording this book uses instead. Ids such as C07 refer to the evidence ledger's claim audit.

      ClaimWhere it appearedWhat the evidence showsSafe wording
      "Zero PCI", "automatic SAQ A", "completely out of PCI Level 1 scope"Coordinator summaries; SDK blueprint position 1; Opus platform §2 also said PayAssist stays out of scope (C01 to C03)SAQ A keeps the embedding page in scope, needs script protection since 31 March 2025, is chosen by the acquirer, and is not for service providers (S1 to S12).Card numbers stay in provider fields and never reach PayAssist or the store's servers; SAQ A eligibility and PayAssist's own obligations are confirmed with the acquirer and an assessor.
      "Complete immunity to Safari ITP"Coordinator summaries (C05, C06)The SDK itself listed remaining browser risks; nothing ran on Safari (S48 to S52).PayAssist's frame needs no third-party cookie; provider frames and wallets still need Safari tests.
      A stable origin "solved" ISP blocking of "up to 73% of checkout forms"Opus platform §2, amplified in summaries (C07 to C11)The 5 September to 4 October network study found 159 of 217 Charter-attributed orders with no observed reach (E7). The later investigation note records recovery on the existing domain around 5 October, after unblock requests (E12; E2 C11). No stable-origin trial was recorded.A stable origin simplifies CSP and wallet setup; it is untested against ISP filters and concentrates risk.
      "Zero layout shift (CLS below 0.02)"Summaries (C12)The SDK labeled it a target, not measured.Reserve space from a server hint; measure CLS before claiming a number.
      "50,000 orders/sec cell architecture" verified; 100,000 events/s a requirementSummaries; briefs (C14, C15)Both numbers were written into the agents' briefs; no benchmark ran.Illustrative arithmetic for a hypothetical load.
      "Passed 20,000 fuzz specs" proves a secure SDKSummaries (C16 to C18)One test generates 20,000 random style objects for the appearance compiler; the shared suite of 10 tests passes under Bun (41,509 assertions on rerun).The appearance compiler rejected the tested strings under Bun; browser and provider behavior remain untested.
      "Production-grade blueprints", "complete production code"Summaries (C19, K17)Two small modules tested; Next.js, frame, host and Woo code are unexecuted sketches with gaps.Design documents with sketches.
      Strict per-order or per-merchant webhook orderingSummaries (C20, C21)No design guarantees HTTP arrival order; Stripe does not either (S19).No ordering promise; versions decide which event is newer.
      A decline or failover automatically retries on account B in the same sessionSummaries (C22)Codex required resolved attempts; Astra froze uncertain ones; nothing implemented.A new attempt only after a definite decline and buyer action; unknown blocks; the account choice follows routing policy.
      INSERT … ON CONFLICT DO NOTHING makes shipping happen onceOpus §4; summaries (C23)A crash after the insert loses the shipment forever.Record intent and jobs in one transaction; run outside work as retried jobs with keys.
      Gemini research "verified" Stripe vs Adyen vs Shopify, retry curves and ITP bypassSummaries (C24)The batch has only a placeholder; three coordinator searches cited mostly secondary sites.No comparative research report was produced.
      Agents "unanimously rejected gRPC"; winners chosen "through debate"Summaries (C25, C27)Four independent runs, no votes or rebuttal round.Two agents independently recommended REST; the coordinator named its own preferences.
      A 3-second return-page read "eliminates" stale pendingOpus §4; summaries (C28)The same section says a timeout still returns processing.A bounded read improves freshness; timeouts still show processing.
      Payments and inventory "never desync"Pre-arena answer; briefs (C29)No cross-system transaction exists between a store and a processor.No duplicate local effects, explicit in-between states, bounded detection, written compensation.
      The coordinator's "Implementation Scope Summary" could be mistaken for owner-approved scopeCoordinator synthesis; approval is an inferred impression, not an explicit statement (C30)No owner approval in the records.Recommendations awaiting owner decisions.
      Today's Woo callbacks give up after 2 to 4 hoursOpus §0 and §3 (K04, K05)Backend's Woo queue waits about 18 h 3 min over 24 attempts; 2 to 4 hours described the old Connect queue.About 18 hours of scheduled waits, then dead.
      Backend sends only two callback kindsOpus §0 (K03)Six kinds reach Woo, including refunds and disputes.Six kinds: succeeded, refunded, partially refunded, dispute opened, won, lost.
      Answering a webhook, then fulfilling in Next.js after(), is safeSDK Next.js sketch (K18)after() work is lost if the process dies (S84).Commit the inbox row and jobs before answering 200.
      A cancel redirect means the buyer was not chargedSDK Next.js sketch (K19)A browser navigation proves nothing about money.Show "checking" until the server says.
      The merchant's page never receives the provider tokenSDK §1 (K20)The host transport sketch passes the credential through merchant JavaScript.Pick one token path and test it.
      A new confirm key appears only after a server-reported failure, even on reloadSDK §8 (K27)Reproduced: reload during processing minted a new key.Resume treats every in-flight phase as unknown.
      The current callback URL is "hardcoded"Briefs (K11)The URL is per-store configuration; only the route name is fixed.One configurable callback URL per store.

      Appendix D

      Proposed API reference

      The version 1 contract in one place, organized by resource. Every path, field and event name here is a proposal for discussion and has not been built Proposed Illustrative.

      Every request

      HeaderRequiredMeaning
      Authorization: Bearer pa_…yesA restricted or secret key; decides store, mode and scopes.
      Idempotency-Keyon every POSTNames the intended action; 1 to 255 printable characters; replays kept 7 days.
      PayAssist-VersionrecommendedDated contract version; defaults to the key's pinned version.
      Request-Id (response)alwaysQuote it to support; it appears in PayAssist's logs.
      Idempotent-Replayed: true (response)on replaysThe body is the stored response of an earlier identical request.

      Resources and endpoints

      ResourceEndpointScopeNotes
      Checkout session cs_POST /v1/checkout_sessionscheckout_sessions:writeamount, currency, order_reference, order_revision, ui_mode (hosted, modal; embedded later), expires_in (900 to 86400 s), return_url, metadata. One live payment per order, decided by the order's admission record (R2). The response's url carries the browser capability in its fragment, and frame_nonce is for the store page's handshake (R16).
      GET /v1/checkout_sessions/{id}checkout_sessions:readIncludes the payment with its status and version; used by thank-you pages (R12).
      POST /v1/checkout_sessions/{id}/expirecheckout_sessions:writeCloses the session to new tries at once and asks the provider to cancel any object that can still take money. payment.canceled follows the provider's confirmation, or payment.succeeded if the object already succeeded (R3, R6).
      Payment pay_GET /v1/payments/{id}payments:readstatus, amount, amount_received, amount_refunded, currency, version, latest_attempt, checkout_session, session_status.
      Attempt pat_GET /v1/payments/{id}/attemptspayments:readRead-only history: status, safe decline code and message. The provider account is never exposed.
      Refund re_POST /v1/refundsrefunds:writepayment, amount, reason, client_reference, metadata. client_reference is unique per payment forever: a second create with the same reference returns the existing refund, and different terms get 409, long after idempotency replays expire. Metadata is saved and returned on reads and events, for correlation only. Pending plus succeeded refunds can never exceed the captured amount.
      GET /v1/refunds/{id}refunds:readstatus: pending, succeeded, failed; client_reference; metadata. A refund can also be found by payment and client_reference.
      Dispute dp_GET /v1/disputes/{id}disputes:readstatus, reason, evidence due date. Submitting evidence comes after version 1.
      Event evt_GET /v1/events?after={cursor}&limit=100events:readOldest first after the cursor, kept 30 days (R10). Feed positions go only to committed events, in order, so a late commit cannot land behind a saved cursor. Each page returns next_cursor; save it after applying the page. A cursor belongs to one store and mode, and an expired one gets a clear error pointing to a reconciliation export.
      GET /v1/events/{id}events:readThe same event id and object version a webhook carried, rendered in the reading key's pinned version, which the body names.
      Webhook endpoint we_POST /v1/webhook_endpointswebhook_endpoints:writeurl (HTTPS, checked against private ranges), enabled event types, pinned version. Returns the signing secret once. A version change applies to deliveries created afterwards.
      POST /v1/webhook_endpoints/{id}/roll_secretwebhook_endpoints:writeNew secret; old one valid for the chosen overlap, up to 24 hours.
      POST /v1/webhook_endpoints/{id}/replaywebhook_endpoints:writeRe-sends chosen events with fresh signatures, the same event ids and the same frozen bodies; audited and rate-limited.
      API keysDashboard only in version 1owner loginCreate, scope, roll with overlap, revoke, view last use.

      The payment frame uses separate, frame-only endpoints authenticated by the checkout capability, such as confirming an attempt. They are not part of the store-facing contract and accept no amount field.

      Events

      EventWhenWhat a store usually does
      checkout.session.completedThe session's payment succeeded before expiry.Nothing extra if it handles payment.succeeded.
      checkout.session.expiredThe session expired with no successful payment.Release held stock; optionally email the buyer.
      payment.processing, payment.requires_actionThe provider is deciding, or the bank wants 3DS.Show "processing"; do not fulfill.
      payment.succeededMoney captured, including late success after expiry.Apply once: mark paid, enqueue fulfillment (R13).
      payment.canceledNothing can be charged any more: the provider confirmed the cancel, or no provider object existed.Cancel the order, or start a new session for the same revision.
      payment.declinedA card was declined. The provider object may still take another card.Usually nothing; useful for analytics.
      refund.created, refund.succeeded, refund.failedA refund's lifecycle.Update the one store refund mapped to this refund id; create one only for a refund started outside the store; alert on failure.
      dispute.created, dispute.updated, dispute.closedA dispute's lifecycle.Put the order on hold, gather evidence, restore or record the outcome.

      Webhook headers (Standard Webhooks v1)

      webhook-id: evt_1QxL…                 stable across retries; the dedup key
      webhook-timestamp: 1792301846         Unix seconds at send time; reject if more than 300 s away
      webhook-signature: v1,K3m9… v1,Q8nb…  base64 HMAC-SHA256 of "id.timestamp.body"; two during rotation

      Appendix E

      Rules and failure matrix

      The eighteen rules the book keeps returning to, then every scenario with its outcome, its rules, today's behavior and a test that would prove it.

      RuleIn one line
      R1One request, one key: the key, fingerprint and response commit with the change.
      R2One live payment per order, for its newest revision, and one open session at a time.
      R3One payable provider object per payment; an unknown attempt counts as payable, and a declined Stripe object still is.
      R4Unknown is not failure: resolve by asking, never by a new key or another account.
      R5Provider idempotency keys come from the attempt id and operation, reused with the same parameters only inside the provider's retention window.
      R6Real money wins over expiry and PayAssist-side cancels; never discard a success.
      R7State change and event commit in one transaction.
      R8Deliver at least once; receivers apply each event id once.
      R9Versions, not arrival order, decide what is newer.
      R10A 30-day event feed covers whatever pushes miss.
      R11Verify the exact bytes first; 5-minute window; any listed secret.
      R12The browser never decides "paid".
      R13Outside work runs as durable jobs with their own keys.
      R14The credential decides the tenant and mode; others get 404.
      R15Card data stays in provider-hosted fields.
      R16Check every frame message's origin and source, then use a private channel.
      R17One owner per checkout during migration.
      R18Reconcile on a clock, with alerts by age and a daily ledger check.

      Failure matrix

      Generated from the same data as the scenario explorer, so the two never disagree. Click a scenario to open it in Chapter 20.

      ScenarioOutcomeRulesTodayA test that proves it
      Creating and retrying
      Happy path: one order, paid onceEnds wellR1 one request, one idempotency key.
      R7 state change and event commit together.
      R8 at-least-once delivery, idempotent receipt.
      R12 the browser drives the screen, never the order status.
      R15 card data stays with the provider.
      The plugin signs a POST to /api/v1/plugin/payment-request with the store's shared secret. Backend picks an account, returns a 12-minute signed checkout token and the node URL, and later sends a signed /status callback that calls payment_complete(). There is no public session API, event feed or per-endpoint secret.Run the full path in test mode against the existing mock node, which boots the real proxy with a fake provider.
      Create times out, plugin retriesEnds wellR1 same key and same body return the saved answer.
      R2 one live payment per order, even with a different key.
      Backend deduplicates repeated requests on (store_id, external_order_id) and handles insert races. No public Idempotency-Key header exists; an optional body field is stored but not used for lookup.Fault-inject a dropped response after commit; assert the retry returns the same session id.
      Same key, different bodyEnds wellR1 422 for a reused key with a different body; 409 while the first request still runs.
      R2 a new order revision gets a new session only once the old revision can no longer charge.
      There is no public idempotency key, so there is no key-reuse check. Duplicate protection is by Woo order identity.Property test: random bodies with reused keys never create a second row.
      Double click on PayEnds wellR1 the key belongs to the attempt, so every click maps to it.
      R3 one payable provider object per payment.
      R5 provider keys come from the attempt id.
      Nodes pass the payment attempt id (or the token nonce) as the provider idempotency key, and /pay and /confirm replay a recorded success instead of charging again.Fire 1,000 concurrent confirms for one attempt in a test; assert one provider call.
      Two tabs, one cartEnds wellR2 one live payment per order, decided by its admission record.
      R3 one payable provider object per payment.
      Limit: if a store creates two different orders for one cart, PayAssist sees two honest orders. The store must reuse its pending order.
      Backend's unique store-plus-order identity returns the same payment request for the same Woo order.Two browser contexts sharing a cookie jar race Place order and Pay.
      Buyer tampers with the amountEnds wellR14 only a server credential can set amounts.
      The store checks amount and currency on every payment event before it marks an order paid.
      The plugin sends the order total from the server, and the signed checkout token carries amountMinor, so the browser cannot change it.Contract test: the frame-facing endpoints accept no amount field.
      Money and the provider
      Charged, but the answer is lostRecoversR4 unknown is not failure.
      R5 any retry uses the same provider key.
      R18 unresolved attempts are re-read on a clock.
      R12 the screen waits for server truth.
      Standard nodes start a recovery read about 3 minutes after /pay, and the staging source keeps re-reading unresolved payments for up to 7 days. A September 2026 incident record shows the cost of getting this wrong: a node whose public URL was misconfigured never received browser confirms, buyers were told their payment failed after a real charge, and some paid twice.Fault proxy that drops provider responses after the provider commits; assert one charge and a final "paid".
      3DS challenge, passedEnds wellR3 the attempt stays pinned to the same provider object through the challenge.
      R12 the result reaches the store as an event, not through the browser.
      Not applicable today.Provider test cards that force a challenge, in each provider sandbox the release supports.
      3DS abandoned, tab closedRecoversR3 an object that can still take money blocks a second one.
      R6 if the provider answers "already succeeded" to the cancel, success wins.
      R18 the reconciler covers lost cancel answers.
      The abandon beacon is telemetry only. A 15-minute backstop recovers first and then cancels unfinished provider objects.Close the browser at each 3DS step in an automated run; assert no orphaned provider object outlives expiry plus the reconcile margin.
      Declined, then a second cardEnds wellR3 one provider object that can take money per payment, and a declined Stripe object still can.
      Moving card B to another account means canceling pat_1 and waiting for the provider's confirmation, then a routing-policy decision with fraud and card-network rules attached. It never happens automatically after an unknown outcome.
      A decline returns the buyer to the form. Backend notifies Woo only on the first success; failures are not sent to the plugin.Sandbox decline cards for each provider; assert one payable provider object per payment and one success event.
      Provider error or lost answerEnds wellR5 retries reuse the key and the saved parameters, and only inside the provider's retention window.
      R4 a 500 or an exhausted retry budget makes the attempt unknown; reconciliation reads, it never re-creates with a new key.
      Nodes pass the attempt id as the Stripe idempotency key and as PayPal-Request-Id. Standard /pay can still re-select the basket under the same key, which changes the create parameters behind it; that defect is diagnosed and unfixed.Fake provider that commits and drops the answer; assert one provider object.
      Auto-reroute after a timeoutGoes wrong without the ruleR3 one payable object per payment; unknown counts as payable.
      R4 unknown is resolved by asking, never by trying elsewhere.
      Binding: standard stripe and stripe_connect never fall back to each other.
      Owner rule: stripe and stripe_connect never fall back to each other. Backend can move a resumed checkout to another account before the buyer pays and marks the old provider object for closure. That is a pre-payment reroute, not a retry after an unknown charge.Chaos test: drop provider A's answer; assert no attempt is ever created on another account while pat_1 is unresolved.
      Expiry, then late successRecoversR6 success beats cancel and expiry.
      R13 any refund runs as a durable job with its own key.
      Today PayAssist already has a "success supersedes cancel" reconcile rule; see the Today note.
      Backend accepts signed node results after token expiry and lets success supersede an earlier cancel or failure. Woo receives payment_request.succeeded with no late-payment flag or policy.Deterministic fake provider: succeed after the expiry timer; assert one payment.succeeded with session status expired.
      Events and webhooks
      Duplicate webhookEnds wellR8 at-least-once delivery, idempotent receipt.
      R13 the inbox row and the job share one transaction.
      The plugin deduplicates the same event id with an atomic in-flight claim and a one-hour completed marker.Deliver every event twice in CI for the plugin and SDK examples.
      Events out of orderEnds wellR9 no ordering promise; versions decide.
      Refund and dispute events are separate objects, so a version check never hides a second refund.
      Backend orders lifecycle facts in its own ledger, and the staging source serializes callbacks per payment request. The plugin has no general version check, so an older distinct dispute event can still reopen a dispute.Shuffle event order in the SDK test suite; assert the final state matches the newest version.
      Missed webhook, pull feedRecoversR10 the pull feed is the safety net under push.
      R8 the same inbox deduplicates both paths.
      There is no pull feed. After 24 failed attempts, about 18 hours of scheduled waits, a callback row is marked dead and appears in the admin overview.Block the webhook route on a test store for 48 hours; assert every order converges through sync.
      Forged or replayed webhookEnds wellR11 verify raw bytes first, reject stale timestamps.
      R8 the inbox stops replays inside the window.
      The plugin verifies payassist-signature over the exact body with a 300 s tolerance and constant-time comparison. The same per-store secret also signs the plugin's own requests to Backend.Signature test vectors shared by every SDK; mutation tests that change one byte of the body.
      Store down for 63 hoursRecoversR8 a three-day retry window outlasts a weekend.
      R10 the pull feed covers longer outages.
      Today Backend's callback outbox gives up much sooner; see the Today note and chapter 10.
      Backend's Woo callback outbox stops at attempt 24, about 18 hours after the first send. A 63-hour outage leaves paid orders unpaid in Woo until an operator acts.Outage drill: endpoint down for 72 hours in staging; measure time to converge after recovery.
      Redirect before webhookEnds wellR12 the browser never marks an order paid.
      R13 one completion job per payment, whichever door arrives first.
      The thank-you flow polls the store's own order status, so navigation alone never marks an order paid. Some embedded receipt polling navigates after ten polls even without seeing paid.Race the redirect and the webhook in both orders, thousands of times, in an integration test.
      Store side
      Insert first, crash before shippingGoes wrong without the ruleThe fix is the fulfillment job pattern: record intent and work status together, and let a worker finish the work.Not applicable today.Crash injection between every two lines of the fulfillment path; assert every paid order reaches the shipper, and that a test shipper which deduplicates by key creates exactly one shipment.
      Durable fulfillment jobEnds wellR13 inbox, order update and job row share one transaction.
      Workers hold leases and pass a stable key to every outside call.
      Standard nodes ask the local receiver to generate the order only after Backend acknowledges the result. Connect uses Backend-owned fulfillment jobs relayed by the proxy.Kill the worker at each step against a test shipper that deduplicates by key; assert exactly one shipment per order.
      Last item and a late paymentRecoversR6 late success is reported, never dropped.
      The completion job reserves the stock again before completing, and sends every late case to the late-payment setting, whether the order is pending or cancelled.
      End each session before the order's stock reservation runs out. Separate authorization and capture would be a later extension.
      Woo's hold-stock minutes and PayAssist's 12-minute checkout window are separate clocks that nothing links.Expire the reservation while order 1042 is still pending, sell the stock, then deliver the success; assert one refund command and no completion.
      Partial refundEnds wellPending plus succeeded refunds can never exceed the captured amount.
      R1 the refund call carries a key saved on the WooCommerce refund record.
      Refunds are issued at the provider. The plugin records them in Woo from refund events with refund_payment=false and does not offer refunds from Woo admin.Concurrent refunds that together exceed the total; assert the second is refused.
      Dispute (chargeback)RecoversDisputes and refunds have their own lifecycles and events.
      Evidence submission can ship after v1; the events cannot.
      Woo receives dispute_opened (order on hold), dispute_won (status restored) and dispute_lost (note only).Provider sandbox dispute flows where they exist; PayAssist's lifecycle-tests already drive Stripe and PayPal dispute sandboxes.
      Security and operations
      Cross-store access attemptEnds wellR14 tenant and mode come from the credential.
      Mode is part of every unique index and every query.
      Plugin requests carry a per-store HMAC. Backend reads store_id from the not-yet-verified body only to choose which secret to check, then verifies the exact bytes.Automated cross-tenant suite: every endpoint called with another store's ids must return 404.
      Leaked secret keyRecoversRevoke an exposed key immediately; overlap is only for planned rotation.
      Each key has scopes, a mode and optional expiry; the plugin's scopes are the most a leak of it can do.
      Keys are stored as keyed hashes and shown once.
      One per-store shared secret signs in both directions, so rotating it means changing Backend and the plugin together.Leak drill in test mode: revoke a plugin key while orders flow; assert the old key gets 401 everywhere within seconds and the new key carries on.
      Webhook secret rotationEnds wellR11 the receiver accepts any matching v1.
      Signing secrets are encrypted at rest because PayAssist must use them; API keys are hashed because PayAssist only compares them.
      Retries pick up the store's current secret at send time, but there is no overlap window with two valid secrets.Rotation test in every SDK verifier using shared vectors.
      Spoofed frame messagesEnds wellR16 exact origin and source checks, then a private channel.
      R15 card data stays in provider fields.
      Node pages send postMessage with target '*'; the Woo modal checks the frame window, the exact origin and a source marker. /pay sends frame-ancestors *, and no MessageChannel exists.Browser tests that send forged messages from other origins and from opened windows.
      Skimmer on the store pageGoes wrong without the ruleEmbedded modes depend on the store page being clean. Hosted redirect removes the store page from the card path.
      PayAssist cannot promise a merchant "zero PCI". It can reduce what is in scope.
      The store page embeds the node's payment page in a modal or redirects to it, so the same script-exposure question applies today.Script inventory and CSP reports on the checkout page; a hosted-redirect option for high-risk stores.
      Migration and rollbackRecoversR17 one owner per checkout.
      The plugin keeps both receivers (v1 webhooks and the legacy /status) until every v1 checkout has ended.
      Not applicable today.Flip the flag repeatedly under load in staging; assert every order is owned by exactly one path.
      Next.js store, same protocolEnds wellThe store's secret key lives only in server code, never in a NEXT_PUBLIC_ variable.
      Server actions are public POST endpoints; they must authenticate the shopper and price the cart themselves.
      No non-Woo client exists today.The SDK ships this example with the webhook raw-body test included.

      Appendix F

      Sources

      Local evidence first, then every primary source, each read on 18 October 2026. The primary-source table is generated from the research file, so its quotes are exactly as recorded there.

      Local evidence

      IdFileWhat it supports
      E1HTML/evidence/store-connectivity-book/current-code-woo.mdToday's WooCommerce flow, signatures, retries, idempotency, clocks and display modes, with file and line references to the local checkouts and staging snapshots. Written for this book by Codex GPT-6.1-Sol.
      E2HTML/evidence/store-connectivity-book/arena-ledger.mdThe recovered arena timeline, each agent's brief and proposals, and the audit of 103 claims. Written for this book by Codex GPT-6.1-Sol from the T3 thread records.
      E3HTML/evidence/store-connectivity-book/primary-sources.mdThe 100 primary sources below, with quotes and safe wording. S1 to S88 were gathered for this book by a Claude Opus 5.5 research task; S89 to S100 were added by the lead author while resolving the technical review.
      E4docs/connect-run/HANDOFF.mdCurrent Stripe Connect state, staging releases and the 16 and 17 October 2026 checkout measurements.
      E5docs/STRIPE_CONNECT.md, HTML/stripe-connect/00-start-here.htmlConnect decisions and the separation from standard Stripe.
      E6AGENTS.md, .claude/CLAUDE.mdOwner decisions and locked principles: separate Stripe paths, no fallback, no platform key at nodes, card data rule, paid truth.
      E7HTML/tpl-dead-forms-by-network-2026-10-04.htmlThe form-reach study behind the 73% figure, with its denominators and method.
      E8HTML/connect-form-load-time-2026-10-14.htmlProduction Square server step of 0.93 s (median of 95) and staging form-load timings.
      E9HTML/store-connectivity-architecture.html (C), HTML/store-connectivity-platform-2026-10-18.html (O), HTML/payassist-js-sdk-blueprint-2026-10-18.html (S), HTML/store-connectivity-engine-arena.html (A), HTML/standardized-store-connectivity-architecture.html (pre-arena)The arena documents themselves.
      E10sdk-blueprint/, _store-connectivity-book/arena-records/research-test-rerun.txt, research-resume-repro.mjsThe SDK sketch's code, the test rerun (10 pass, 41,509 assertions, 3.37 s) and the reproduced reload gap.
      E11HTML/double-charge-30265-30267-confirm-unreachable.htmlThe September 2026 incident where unreachable browser confirms led buyers to pay twice.
      E12~/.claude/projects/C--Users-Asfandayr-Naeem-Desktop-PayAssist/memory/tpl-square-iframe-never-reaches-node.md, line 39The prior investigation note for the same store. It records the unblock requests filed on 4 October 2026 at about 22:20 UTC and the recovery measured from about 16:00 UTC on 5 October: Charter orders went from 16 dead and 11 loaded to 0 dead of 18, Comcast from 3 of 9 to 0 of 15. Networks are recorded as classifications only.

      Primary sources

      IdSourceWhat it supports
      PCI DSS v4.0.1, SAQ A, and who decides
      S1Important Updates Announced for Merchants Validating to Self-Assessment Questionnaire A (PCI SSC blog, posted 30 Jan 2025)https://blog.pcisecuritystandards.org/important-updates-announced-for-merchants-validating-to-self-assessment-questionnaire-aAccessed 18 October 2026In January 2025 PCI SSC removed Req. 6.4.3, 11.6.1 and 12.3.1 from SAQ A and added a script-attack eligibility criterion. The January 2025 SAQ A took effect 31 March 2025; the October 2024 version retired the same day.Quote: "they do not remove or diminish the underlying requirements within PCI DSS"
      S2Self-Assessment Questionnaire A and Attestation of Compliance, for use with PCI DSS v4.0.1, Revision 1 (Jan 2025), p. iiihttps://docs-prv.pcisecuritystandards.org/SAQ%20(Assessment)/SAQ/PCI-DSS-v4_0_1-SAQ-A-r1.pdfAccessed 18 October 2026Current SAQ A e-commerce eligibility: every payment-page element must come only and directly from a compliant TPSP. Official PDF returned HTTP 403 to automated fetch; I read the text from a verbatim copy of the official form published by Report URI (https://cdn.ams.report-uri.com/pdf/Report%20URI%20-%20PCI%20DSS%20SAQ%20A%202026.pdf), cover "Revision 1, Publication Date: January 2025".Quote: "All elements of the payment page(s)/form(s) delivered to the customer's browser originate only and directly from a PCI DSS compliant TPSP/payment processor."
      S3SAQ A v4.0.1 r1, p. i (Document Changes) and p. iiihttps://docs-prv.pcisecuritystandards.org/SAQ%20(Assessment)/SAQ/PCI-DSS-v4_0_1-SAQ-A-r1.pdfAccessed 18 October 2026The new script criterion, and the change-log row that introduced it ("Removed Requirements 6.4.3, 11.6.1, and 12.3.1 and added an Eligibility Criteria..."). Same access caveat as S2.Quote: "The merchant has confirmed that their site is not susceptible to attacks from scripts that could affect the merchant's e-commerce system(s)."
      S4SAQ A v4.0.1 r1, p. iiihttps://docs-prv.pcisecuritystandards.org/SAQ%20(Assessment)/SAQ/PCI-DSS-v4_0_1-SAQ-A-r1.pdfAccessed 18 October 2026SAQ A is a merchant form only.Quote: "This SAQ is not applicable to service providers."
      S5SAQ A v4.0.1 r1, p. iii, Note under eligibility criteriahttps://docs-prv.pcisecuritystandards.org/SAQ%20(Assessment)/SAQ/PCI-DSS-v4_0_1-SAQ-A-r1.pdfAccessed 18 October 2026For merchants that embed a TPSP iframe, the merchant page holding the iframe stays in scope for Req. 2, 6, 8 and 11 and for CDE-related requirements.Quote: "applicable because the above merchant webpages impact how the account data is transmitted, even though the webpages themselves do not receive account data."
      S6SAQ A v4.0.1 r1, pp. 20-22, Req. 12.8.1 Applicability Noteshttps://docs-prv.pcisecuritystandards.org/SAQ%20(Assessment)/SAQ/PCI-DSS-v4_0_1-SAQ-A-r1.pdfAccessed 18 October 2026Req. 12.8.1 to 12.8.5 remain in SAQ A: list TPSPs, written agreements, due diligence, annual monitoring, responsibility split. 12.8.2 covers TPSPs "to the extent that the TPSP could impact the security" of account data.Quote: "The use of a PCI DSS compliant TPSP does not make an entity PCI DSS compliant"
      S7FAQ 1588: How does an e-commerce merchant meet the SAQ A eligibility criteria for scripts?https://www.pcisecuritystandards.org/faqs/1588/Accessed 18 October 2026FAQ 1588 (Feb 2025): the script criterion can be met with techniques like 6.4.3/11.6.1 run by the merchant or a third party, or by "Obtaining confirmation from the merchant's PCI DSS compliant TPSP/payment processor". It applies only to embedded forms. A provider of unrelated scripts is not a TPSP for SAQ A.Quote: "does not apply to e-commerce merchants with a webpage that redirects customers"
      S8FAQ 1438: How is the payment page determined for SAQ A merchants using iframe?https://pcisecuritystandards.org/faq/articles/Frequently_Asked_Question/how-is-the-payment-page-determined-for-saq-a-merchants-using-iframeAccessed 18 October 2026FAQ 1438 (Sept 2016): for iframe merchants, all card-capture fields must sit inside the iframe; any card-capture element on or from the merchant site disqualifies SAQ A. The FAQ does not address nested iframes.Quote: "all fields and web elements associated with capturing payment card data must be contained within the iframe"
      S9If a merchant's e-commerce implementation meets the criteria that all elements of payment pages originate from a PCI DSS compliant service provider, is the merchant eligible to complete SAQ A or SAQ A-EP?https://www.pcisecuritystandards.org/faqs/if-a-merchant-s-e-commerce-implementation-meets-the-criteria-that-all-elements-of-payment-pages-originate-from-a-pci-dss-compliant-service-provider-is-the-merchant-eligible-to-complete-saq-a-or-saq-a-ep/Accessed 18 October 2026SAQ A versus SAQ A-EP: for SAQ A no element may come from the merchant site; every other criterion must also be met. No FAQ number or date shown.Quote: "no single element of a payment page can originate from the merchant's website"
      S10FAQ 1579: Does PCI DSS apply to service providers that can impact the security of payment account data, if the service provider does not directly store, process, or transmit payment account data?https://www.pcisecuritystandards.org/faqs/1579/Accessed 18 October 2026FAQ 1579 (June 2024): PCI DSS applies to service providers that can affect CDE security without storing, processing or transmitting card data. Examples include direct or indirect access to system components. The PCI DSS standard page says the same in its intended-audience text (https://www.pcisecuritystandards.org/standards/pci-dss/: "or could impact the security of the cardholder data environment (CDE).").Quote: "service providers that can impact the security of a cardholder data environment (CDE)" (answer begins "Yes.")
      S11FAQ 1576: What evidence is a TPSP expected to provide to customers to demonstrate PCI DSS compliance?https://www.pcisecuritystandards.org/faq/articles/Frequently_Asked_Question/what-evidence-is-a-tpsp-expected-to-provide-to-customers-to-demonstrate-pci-dss-compliance/Accessed 18 October 2026FAQ 1576 (Feb 2024): a TPSP must hand customers its AOC on request, or specific evidence if it has none, plus which requirements it owns (Req. 12.9.1, 12.9.2).Quote: "in accordance with PCI DSS Requirement 12.9.1 and 12.9.2, the TPSP is obligated to provide information to its customers"
      S12FAQ 1133: Why are there multiple PCI DSS Self-assessment Questionnaires (SAQs)?https://www.pcisecuritystandards.org/faqs/1133/Accessed 18 October 2026FAQ 1133 (Apr 2024): merchants ask the entity that receives the SAQ whether they must file one and which one.Quote: "typically, an acquirer (merchant bank) or the payment brands"
      S13FAQ 1473: What is the role of compliance-accepting entities and assessors in determining the applicability of PCI DSS requirements...https://www.pcisecuritystandards.org/faqs/1473/Accessed 18 October 2026FAQ 1473 (Mar 2023): compliance-accepting entities set validation and reporting methods; assessors validate assessment scope and confirm "not applicable" by testing.Quote: "are responsible for determining the PCI DSS validation and reporting methods of their merchants and service providers"
      S14PCI Security Standards Council Bulletin: Revised Update to FAQ 1331 (4 August 2026)https://www.pcisecuritystandards.org/wp-content/uploads/2026/08/2026_FAQ_1331_Bulletin_FINAL.pdfAccessed 18 October 2026Recent change: on 4 Aug 2026 PCI SSC revised FAQ 1331 (May 2025 version) because it "may result in misinterpretations". FAQ page: https://www.pcisecuritystandards.org/faqs/1331/ (dated August 2026).Quote: "clarify that merchants should always consult with their Compliance Accepting Entities to confirm their PCI DSS validation and reporting requirements"
      S15Validation of Compliance (Visa, Caribbean region)https://caribbean.visa.com/run-your-business/small-business/information-security/compliance-validation.htmlAccessed 18 October 2026Visa service provider levels: Level 1 is over 300,000 transactions a year (annual ROC by QSA, quarterly ASV scan, AOC); Level 2 is under 300,000 (annual SAQ, ASV scan, AOC). Regional Visa page; I did not find the same table on a Visa US page.Quote: "any service provider that stores, processes and/or transmits over 300,000 transactions per year"
      S16Account Information Security (AIS) Program and PCIhttps://corporate.visa.com/en/resources/security-compliance.htmlAccessed 18 October 2026Visa places merchant and service-provider compliance on acquirers and issuers, with service providers showing compliance at least every 12 months.Quote: "Issuers and acquirers are responsible for ensuring the PCI DSS compliance of its service providers and merchants"
      S17Stripe Guides: PCI compliance (exact page title not captured)https://stripe.com/guides/pci-complianceAccessed 18 October 2026Stripe says its own Checkout and Elements meet SAQ A eligibility. This is a vendor statement about Stripe-hosted fields used directly by a merchant.Quote: "both Checkout and Elements allow you the flexibility and customizability of a self-hosted form, while also meeting PCI eligibility for the SAQ A"
      Stripe: webhooks, idempotency, PaymentIntents, Checkout, keys, Stripe.js, CSP
      S18Receive Stripe events in your webhook endpointhttps://docs.stripe.com/webhooksAccessed 18 October 2026Live-mode retries last up to three days with exponential backoff; sandbox retries "three times over the course of a few hours". Dashboard Resend works for 15 days, CLI resend for 30 days. A manual resend does not cancel automatic retries.Quote: "Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode."
      S19Receive Stripe events in your webhook endpoint (Event ordering; Handle duplicate events)https://docs.stripe.com/webhooksAccessed 18 October 2026No ordering guarantee. Snapshot created has one-second resolution, so it must not be used for ordering or dedup. Duplicates happen, and sometimes Stripe sends two separate Event objects for one change (dedupe on data.object id plus event.type).Quote: "Stripe doesn't guarantee the delivery of events in the order that they're generated."
      S20Receive Stripe events in your webhook endpoint (Verify manually; Preventing replay attacks; Roll endpoint signing secrets)https://docs.stripe.com/webhooksAccessed 18 October 2026Stripe-Signature carries t= and one or more v1= HMAC-SHA256 signatures over timestamp + "." + raw body; test events add a fake v0. Raw body must be unmodified. Rolling a secret can keep the old one valid "for up to 24 hours", and Stripe signs with each active secret. Return 2xx before slow work.Quote: "Our libraries have a default tolerance of 5 minutes between the timestamp and the current time."
      S21Events (API reference)https://docs.stripe.com/api/eventsAccessed 18 October 2026Events are retrievable through the API for 30 days.Quote: "You can access events through the Retrieve Event API for 30 days."
      S22Idempotent requestshttps://docs.stripe.com/api/idempotent_requestsAccessed 18 October 2026API v1 idempotency: keys up to 255 characters; may be pruned once at least 24 hours old; parameter mismatch errors; v1 replays the saved status and body, including 500s; nothing is saved if validation fails or a concurrent request with the same key is executing. All v1 POSTs accept keys.Quote: "The idempotency layer compares incoming parameters to those of the original request and errors if they're not the same to prevent accidental misuse."
      S23Advanced error handlinghttps://docs.stripe.com/error-low-levelAccessed 18 October 2026A 409 means another request with the same key is still running: wait and retry with the same key and parameters. Treat a 500 as indeterminate and reconcile before using a new key. Retry with the same key only within 24 hours. API v2 uses different replay behaviour and a 30-day window.Quote: "If a 409 indicates that another request with the same key is still executing, wait and retry with that key and the same parameters."
      S24How Payment Intents and Setup Intents workhttps://docs.stripe.com/payments/paymentintents/lifecycleAccessed 18 October 2026PaymentIntent states: requires_payment_method, requires_confirmation, requires_action, processing, requires_capture, succeeded, canceled. A failed attempt returns the intent to requires_payment_method so it can be retried.Quote: "If the payment requires additional actions, such as authenticating with 3D Secure, the PaymentIntent has a status of requires_action."
      S25The PaymentIntent objecthttps://docs.stripe.com/api/payment_intents/objectAccessed 18 October 2026cancellation_reason values include abandoned (user-provided) and automatic and expired (Stripe-generated).Quote: "either user-provided (duplicate, fraudulent, requested_by_customer, or abandoned) or generated by Stripe internally (failed_invoice, void_invoice, automatic, or expired)"
      S26Create a Checkout Sessionhttps://docs.stripe.com/api/checkout/sessions/createAccessed 18 October 2026Checkout Session expires_at range and default. Current ui_mode values are hosted_page (default), embedded_page, elements and form; I did not verify when these names replaced older ones.Quote: "It can be anywhere from 30 minutes to 24 hours after Checkout Session creation. By default, this value is 24 hours from creation."
      S27Fulfill orders (Full hosted page)https://docs.stripe.com/checkout/fulfillment?payment-ui=stripe-hostedAccessed 18 October 2026Webhooks are required for fulfillment; the landing page is a speed-up only. The fulfill function must handle repeat and concurrent calls and record status. Checkout waits up to 10 s for the webhook response before redirecting (not for organization endpoints).Quote: "your fulfill_checkout function might be called multiple times, possibly concurrently, for the same Checkout Session."
      S28Versioninghttps://docs.stripe.com/api/versioningAccessed 18 October 2026Since 2024-09-30.acacia Stripe ships monthly non-breaking versions and two major releases a year. Stripe-Version overrides the account default per request; webhook payloads use the endpoint's version or the account default.Quote: "The current version is 2026-09-30.endive."
      S29API keyshttps://docs.stripe.com/keysAccessed 18 October 2026pk_ keys are safe to expose; rk_ keys carry chosen permissions; sk_ keys are unrestricted and Stripe now recommends RAKs instead. Dashboard rotation keeps old and new keys working for up to 7 days. Access policies can restrict keys by IP, ASN or country.Quote: "Because you can't limit their permissions, we don't recommend using secret keys for new use cases"
      S30Including Stripe.jshttps://docs.stripe.com/js/includingAccessed 18 October 2026Stripe.js must be loaded from Stripe's origin, on every page for fraud signals.Quote: "it should always be loaded directly from https://js.stripe.com, rather than included in a bundle or hosted yourself."
      S31Integration security guidehttps://docs.stripe.com/security/guideAccessed 18 October 2026Stripe.js CSP: frame-src https://*.js.stripe.com https://js.stripe.com https://hooks.stripe.com; script-src https://*.js.stripe.com https://js.stripe.com https://maps.googleapis.com; connect-src https://api.stripe.com https://maps.googleapis.com. Stripe does not support cross-origin isolated sites.Quote: "If you're using a payment method with redirect functions (for example, cards that might require 3D Secure or iDeal), include https://hooks.stripe.com as a frame-src directive."
      Saved credentials and portability
      S32Share payment methods across multiple accounts for direct chargeshttps://docs.stripe.com/connect/direct-charges-multiple-accountsAccessed 18 October 2026To reuse a card across connected accounts, save it on a platform Customer (SetupIntent, or PaymentIntent with setup_future_usage), then clone it per connected account with POST /v1/payment_methods and the Stripe-Account header. Only card and us_bank_account clone. A charge consumes the unattached clone. Cross-country setups may need on_behalf_of for SCA. The older cloning page (https://docs.stripe.com/connect/cloning-customers-across-accounts) is marked deprecated.Quote: "The cloned PaymentMethod is an independent object with a unique ID. It isn't linked to, or kept in sync with, the PaymentMethod on your platform."
      S33Receive Stripe events in your webhook endpoint (Create an event destination for Connect)https://docs.stripe.com/webhooksAccessed 18 October 2026Customers and payment methods created on a connected account belong to that account; platforms need a "Connected accounts" event destination to hear about them.Quote: "Register an event destination for Connected accounts to receive events for resources that belong to your connected accounts."
      S34Understanding the benefits of network tokens (Stripe guide, undated)https://stripe.com/guides/understanding-benefits-of-network-tokensAccessed 18 October 2026Stripe requests network tokens on the merchant's behalf; tokens are bound to a card and merchant. The docs URL docs.stripe.com/payments/network-tokens returned 404, so this is Stripe's guide page.Quote: "Network tokens are payment credentials specific to a card-merchant pair"
      S35Request a payment data exporthttps://docs.stripe.com/get-started/data-migrations/pan-exportAccessed 18 October 2026Stripe exports card data only to another PCI DSS Level 1 processor: it needs the processor's AOC or Visa registry listing and a 4096-bit-or-larger PGP key hosted on the processor's domain. Link credentials are excluded; payment history and subscriptions are not exported.Quote: "we can only transfer your card data to another PCI DSS Level 1-compliant payment processor."
      S36Cards API (Square developer docs)https://developer.squareup.com/docs/cards-api/overviewAccessed 18 October 2026Square stores cards on file in the seller's account by default; developers can instead store "shared" cards in their own developer account for payments to several sellers. Square tells developers to ask buyers before saving.Quote: "Cards are typically stored in a seller account and used only for payments to that seller."
      S37Payment Method Tokens v3https://developer.paypal.com/api/payment-tokens/v3Accessed 18 October 2026PayPal Payment Method Tokens v3 stores tokens in "the Vault of the associated customer". The reference page does not say which merchant owns the vault or whether other merchants can use a token.Quote: "Creates a Payment Token from the given payment source and adds it to the Vault of the associated customer."
      Browser platform: messaging, frames, CSP, referrer, payments in iframes
      S38Window: postMessage() method (MDN)https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessageAccessed 18 October 2026Senders must name an exact target origin; receivers must check event.origin and possibly event.source; pages that expect no messages should not listen.Quote: "Always specify an exact target origin, not *, when you use postMessage to dispatch data to other windows."
      S39MessageChannel (MDN)https://developer.mozilla.org/en-US/docs/Web/API/MessageChannelAccessed 18 October 2026MessageChannel gives two linked MessagePorts; one port can be transferred to an iframe in the postMessage transfer list, giving a private channel after the handshake.Quote: "we register an onmessage handler for MessageChannel.port1 and transfer MessageChannel.port2 to the IFrame using the window.postMessage method"
      S40<iframe>: The Inline Frame element (MDN)https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframeAccessed 18 October 2026Same-origin sandboxed frames given both allow-scripts and allow-same-origin can remove their own sandbox. The allow attribute narrows, and never replaces, the Permissions-Policy header.Quote: "it is strongly discouraged to use both allow-scripts and allow-same-origin, as that lets the embedded document remove the sandbox attribute"
      S41Content-Security-Policy: frame-ancestors directive (MDN)https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestorsAccessed 18 October 2026frame-ancestors lists who may embed a page; it is ignored in <meta>; 'none' is similar to X-Frame-Options: deny.Quote: "specifies valid parents that may embed a page using <frame>, <iframe>, <object>, or <embed>."
      S42Referrer-Policy header (MDN)https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-PolicyAccessed 18 October 2026no-referrer drops the Referer header. The default policy since the November 2020 Fetch change is strict-origin-when-cross-origin.Quote: "The Referer header will be omitted: sent requests do not include any referrer information."
      S43Document: referrer property (MDN)https://developer.mozilla.org/en-US/docs/Web/API/Document/referrerAccessed 18 October 2026In a cross-origin iframe, document.referrer starts as the parent's origin by default. MDN does not state the no-referrer result; that it becomes empty is my inference from S42, not a quote.Quote: "In cross-origin requests, it's the origin of the parent's Window.location by default."
      S44Permissions-Policy: payment directive (MDN)https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy/paymentAccessed 18 October 2026payment Permissions-Policy default allowlist is self; blocked use makes new PaymentRequest() throw SecurityError. MDN marks it Limited availability and experimental.Quote: "The default allowlist for payment is self."
      S45Payment Request API (W3C)https://www.w3.org/TR/payment-request/Accessed 18 October 2026Payment Request is a W3C Candidate Recommendation Draft dated 22 June 2026 (last full Recommendation Sept 2022). The constructor throws if the document may not use "payment"; show() MAY require transient activation.Quote: "the allow attribute along with the "payment" keyword can be specified on the" [iframe element]
      S46WebKit Features in Safari 17.0https://webkit.org/blog/14445/webkit-features-in-safari-17-0/Accessed 18 October 2026Safari 17.0 (18 Sept 2023) allows Apple Pay in third-party iframes through allow="payment", and WebKit advises framing protection. Apple's own Apple Pay on the Web docs (JSON fetched) list Safari platforms but say nothing about iframes.Quote: "You can now enable Apple Pay to be used in third-party iframes by setting the allow="payment" attribute."
      S47Tutorial, Google Pay API for Webhttps://developers.google.com/pay/api/web/guides/tutorialAccessed 18 October 2026Google Pay inside an iframe needs allow="payment" on every iframe in a nested chain, and allow="payment *" for iframes without src.Quote: "If nested iframes are involve, this attribute must be set for every <iframe>." (typo in source)
      WebKit tracking prevention and Chrome's third-party cookie status
      S48Full Third-Party Cookie Blocking and More (WebKit blog, 24 Mar 2020)https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/Accessed 18 October 2026Safari 13.1 / iOS 13.4 (March 2020) blocks third-party cookies by default and deletes script-writable storage after seven days of Safari use without interaction with the site.Quote: "Cookies for cross-site resources are now blocked by default across the board."
      S49Tracking Prevention in WebKithttps://webkit.org/tracking-prevention/Accessed 18 October 2026Current ITP rules: no exceptions to third-party cookie blocking; third-party LocalStorage and IndexedDB are partitioned per first-party site and ephemeral; link-decorated landings get JS cookies capped at 24 hours; cookie access only via the Storage Access API or a popup compatibility fix. Page shows no update date.Quote: "Third-party LocalStorage and IndexedDB are partitioned per first-party website and also made ephemeral."
      S50Private Browsing 2.0 (WebKit blog, 16 Jul 2024)https://webkit.org/blog/15697/private-browsing-2-0/Accessed 18 October 2026Cross-site sessionStorage partitioned since Safari 16.1; cross-site blob URLs since Safari 17.2. I found no WebKit page giving the Safari version for LocalStorage/IndexedDB partitioning.Quote: "Beginning in Safari 16.1 cross-site Session Storage is partitioned by first-party web site."
      S51Document: requestStorageAccess() method (MDN)https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccessAccessed 18 October 2026requestStorageAccess() is denied unless called during a user gesture (or permission already granted); a sandboxed frame needs allow-storage-access-by-user-activation; browsers add their own checks.Quote: "requests are automatically denied unless the embedded content is currently processing a user gesture such as a tap or click"
      S52Update on Plans for Privacy Sandbox Technologies (17 Oct 2025, Anthony Chavez)https://privacysandbox.google.com/blog/update-on-plans-for-privacy-sandbox-technologiesAccessed 18 October 2026Chrome is keeping user choice for third-party cookies and dropped the standalone prompt (22 Apr 2025). On 17 Oct 2025 Google retired most Privacy Sandbox APIs, including Related Website Sets and requestStorageAccessFor, and kept CHIPS, FedCM and Private State Tokens. Policy link: https://privacysandbox.google.com/blog/privacy-sandbox-next-steps. I found no 2026 update.Quote: "Chrome will maintain our current approach to offering users third-party cookie choice in Chrome."
      3-D Secure and SCA
      S53Authenticate with 3D Securehttps://docs.stripe.com/payments/3d-secure/authentication-flowAccessed 18 October 2026Stripe's 3DS: the issuer, not Stripe, decides frictionless versus challenge; Stripe supports 3DS up to 2.3.1; a custom 3DS iframe must not be sandboxed; the return_url page must postMessage to the top page, which then re-reads the PaymentIntent; failure returns to requires_payment_method.Quote: "You can't use the sandbox attribute on the 3DS iframe."
      S54Authenticate with 3D Secure (Disputes and liability shift)https://docs.stripe.com/payments/3d-secure/authentication-flowAccessed 18 October 2026Liability shift is expected but never guaranteed, even after successful 3DS.Quote: "Successful 3DS authentication doesn't guarantee liability shift."
      S55EMV 3-D Secure (EMVCo)https://www.emvco.com/emv-technologies/3-d-secure/Accessed 18 October 2026EMVCo describes EMV 3DS as merchant-to-issuer data exchange to prevent CNP fraud. Its current bulletin covers v2.2.0 to 2.3.1.1 (SB 279, 11 Aug 2025); a v2.4.0 draft had comments open to 1 July 2026.Quote: "enables the exchange of data, or messages, between the merchant and the issuer"
      S56Directive (EU) 2015/2366 (PSD2), Art. 4(30) and 97https://eur-lex.europa.eu/eli/dir/2015/2366/oj/engAccessed 18 October 2026PSD2 Art. 97(1): SCA when the payer accesses an account online, initiates an electronic payment, or acts remotely with fraud risk. Art. 97(2): remote payments need dynamic linking to amount and payee. Art. 4(30) defines SCA as two or more independent elements from knowledge, possession, inherence.Quote: "carries out any action through a remote channel which may imply a risk of payment fraud or other abuses."
      S57Payment services deal: More protection from online fraud and hidden fees (European Parliament)https://www.europarl.europa.eu/news/en/press-room/20251121IPR31540/payment-services-deal-more-protection-from-online-fraud-and-hidden-feesAccessed 18 October 2026Parliament and Council negotiators provisionally agreed the Payment Services Regulation and PSD3 in late November 2025 (page timestamp 27 Nov 2025). Formal adoption was still required.Quote: "The deal needs to be formally adopted by Parliament and Council before it can come into force."
      Idempotency and HTTP standards
      S58The Idempotency-Key HTTP Header Field (IETF Datatracker)https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/Accessed 18 October 2026Latest Idempotency-Key draft is -07 (15 Oct 2025), httpapi WG; it expired 18 Apr 2026 and is not an RFC.Quote: "This Internet-Draft is no longer active."
      S59draft-ietf-httpapi-idempotency-key-header-07https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07Accessed 18 October 2026Draft -07 error semantics: missing key SHOULD get 400; key reused with a different payload SHOULD get 422; retry while the original is still processing SHOULD get 409 with a problem body. Expiry policy and fingerprints are optional.Quote: "the resource SHOULD reply with an HTTP 409 status code with body containing problem description."
      S60RFC 9110: HTTP Semantics, §9.2.2https://www.rfc-editor.org/rfc/rfc9110.html#name-idempotent-methodsAccessed 18 October 2026RFC 9110 defines idempotent methods (PUT, DELETE, safe methods) and says clients SHOULD NOT auto-retry non-idempotent requests without a way to know they are safe.Quote: "the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request."
      S61RFC 8785: JSON Canonicalization Scheme (JCS)https://www.rfc-editor.org/rfc/rfc8785.htmlAccessed 18 October 2026RFC 8785 JCS canonicalises JSON for hashing and signing: I-JSON subset, ECMAScript primitive serialisation, deterministic property sorting. Informational, Independent Submission (June 2020).Quote: "constraining JSON data to the Internet JSON (I-JSON) subset, and by using deterministic property sorting."
      S62RFC 9457: Problem Details for HTTP APIshttps://www.rfc-editor.org/rfc/rfc9457.htmlAccessed 18 October 2026RFC 9457 defines problem details (application/problem+json) and obsoletes RFC 7807. Standards Track.Quote: "This document obsoletes RFC 7807."
      Webhook standards
      S63Standard Webhooks specification (v1.0.0), linked from standardwebhooks.comhttps://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.mdAccessed 18 October 2026Standard Webhooks v1.0.0 headers: webhook-id, webhook-timestamp (Unix seconds), webhook-signature. Signed content is msg_id.timestamp.payload. Symmetric HMAC-SHA256 signatures carry the v1, prefix and base64 value; secrets are base64 with a whsec_ prefix; asymmetric ed25519 uses v1a. The id stays the same across retries and serves as the idempotency key.Quote: "The content to be signed is therefore: msg_id.timestamp.payload."
      S64Standard Webhooks specification (v1.0.0)https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.mdAccessed 18 October 2026Rotation: sign with current and old keys for a period, space-delimited in one header. SSRF: route webhook egress through a filtering proxy such as smokescreen and isolate workers in a private subnet. Recommended timeout 15 to 30 s.Quote: "the webhook is signed both using the current key, and using an old key (for a set period of time), and both signatures are sent"
      S65CloudEvents - Version 1.0.2https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.mdAccessed 18 October 2026CloudEvents 1.0.2: source plus id must be unique per distinct event; a re-sent duplicate MAY reuse the id; consumers MAY treat identical source+id as duplicates.Quote: "Consumers MAY assume that Events with identical source and id are duplicates."
      Reliability patterns
      S66Exponential Backoff And Jitter (AWS Architecture Blog)https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/Accessed 18 October 2026Full Jitter is sleep = random_between(0, min(cap, base * 2 ** attempt)); Equal Jitter and Decorrelated Jitter (sleep = min(cap, random_between(base, sleep * 3))) are alternatives. The formulas are images in the post; I read them from the image files. Post dated 4 Mar 2015, Marc Brooker.Quote: "The "Full Jitter" approach uses less work, but slightly more time."
      S67Pattern: Transactional outbox (microservices.io)https://microservices.io/patterns/data/transactional-outbox.htmlAccessed 18 October 2026Transactional outbox: write the message in the same database transaction as the business change; a relay publishes it. The relay may publish more than once, so consumers must be idempotent.Quote: "a message consumer must be idempotent"
      S68SELECT (PostgreSQL 18.6 documentation, The Locking Clause)https://www.postgresql.org/docs/current/sql-select.htmlAccessed 18 October 2026SKIP LOCKED skips rows that cannot be locked at once; the docs warn it gives an inconsistent view and suits queue-like tables. Docs version 18.6.Quote: "Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work"
      S69Outbox Event Router (Debezium 3.7)https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.htmlAccessed 18 October 2026Debezium 3.7 outbox event router expects id, aggregatetype, aggregateid, type, payload columns; the id can be used to drop duplicate messages.Quote: "You can use this ID, for example, to remove duplicate messages."
      S70What is a cell-based architecture? (AWS whitepaper: Reducing the Scope of Impact with Cell-Based Architecture)https://docs.aws.amazon.com/wellarchitected/latest/reducing-scope-of-impact-with-cell-based-architecture/what-is-a-cell-based-architecture.htmlAccessed 18 October 2026A cell is an independent copy of the workload with no shared state, reached through a thin router keyed by a partition key such as customer ID. Cells contain failures such as bad deployments and poison-pill requests.Quote: "Each cell is independent, does not share state with other cells, and handles a subset of the overall workload requests."
      S71When to use a cell-based architecture? (same whitepaper)https://docs.aws.amazon.com/wellarchitected/latest/reducing-scope-of-impact-with-cell-based-architecture/when-to-use-a-cell-based-architecture.htmlAccessed 18 October 2026AWS reserves cells for workloads where any downtime is very costly, RPO under 5 s, RTO under 30 s, or tenants needing dedicated cells. Costs: more complexity, infrastructure cost, specialised tooling, a routing layer.Quote: "Cell-based architecture will not be a good choice for all your workloads, but it can bring great benefits for some your most critical workloads."
      WooCommerce and WordPress
      S72WooCommerce Payment Gateway APIhttps://developer.woocommerce.com/docs/features/payments/payment-gateway-apiAccessed 18 October 2026Classic gateways extend WC_Payment_Gateway; process_payment returns an array with result and redirect; on failure add a notice and return 'result' => 'failure'. Use payment_complete() rather than update_status when paid.Quote: "This ensures stock reductions are made, and the status is changed to the correct value."
      S73Payment method integrationhttps://developer.woocommerce.com/docs/block-development/extensible-blocks/cart-and-checkout-blocks/checkout-payment-methods/payment-method-integration/Accessed 18 October 2026Checkout Blocks: client registers with registerPaymentMethod from the blocks registry; server class extends AbstractPaymentMethodType, registered on woocommerce_blocks_payment_method_type_registration. This does not replace the gateway class.Quote: "It is not the same as the Payment Gateway API that you need to implement separately for payment processing."
      S74WooCommerce REST API Documentation - WP REST API v3https://woocommerce.github.io/woocommerce-rest-api-docs/Accessed 18 October 2026REST API over HTTPS uses Basic Auth with consumer key as username and secret as password; over plain HTTP it requires OAuth 1.0a one-legged. Keys are generated for a chosen user with Read, Write or Read/Write access, follow that user's roles, and stop working if the user is deleted. Order field set_paid: "Define if the order is paid. It will set the status to processing and reduce stock items."Quote: "Use of the REST API with the generated keys will conform to that user's WordPress roles and capabilities."
      S75WooCommerce REST API Documentation (Webhooks: Topics, Delivery/payload)https://woocommerce.github.io/woocommerce-rest-api-docs/Accessed 18 October 2026Webhook topics are resource.event (order.created, order.updated, order.deleted and others) plus custom action.* topics. Delivery is wp_remote_post() run in the background via wp-cron. Headers include X-WC-Webhook-ID, -Topic, -Delivery-ID. A webhook is disabled after 5 consecutive non-2xx deliveries.Quote: "X-WC-Webhook-Signature - a base64 encoded HMAC-SHA256 hash of the payload."
      S76Store API: nonce tokenshttps://developer.woocommerce.com/docs/apis/store-api/nonce-tokens/Accessed 18 October 2026Store API is unauthenticated. POSTs to /cart and all /checkout requests need a nonce made with wp_create_nonce('wc_store_api'), unless a Cart-Token is used; the cart-tokens page (https://developer.woocommerce.com/docs/apis/store-api/cart-tokens/) says "When using a Cart-Token a Nonce Token is not required." The nonce check can be disabled by filter, for development only.Quote: "POST requests to the /cart endpoints and all requests to the /checkout endpoints require a nonce to function."
      S77High Performance Order Storage (HPOS)https://developer.woocommerce.com/docs/features/orders/high-performance-order-storage/Accessed 18 October 2026HPOS stores orders in custom tables through WooCommerce CRUD; on by default for new installs from WooCommerce 8.2 (Oct 2023). Extensions should use wc_get_order and order meta methods plus save(), and declare compatibility via FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true) (recipe book: https://developer.woocommerce.com/docs/features/orders/high-performance-order-storage/recipe-book/).Quote: "uses the WooCommerce CRUD design to store order data in custom tables."
      S78Products Settings Documentation - WooCommercehttps://woocommerce.com/document/configuring-woocommerce-settings/products/Accessed 18 October 2026Hold stock cancels unpaid orders after the set minutes and releases stock; blank disables it; it touches only "Pending payment" orders.Quote: "When your entered limit is reached, the pending order will be canceled and held stock released back to available inventory."
      S79register_rest_route() (WordPress Developer Resources)https://developer.wordpress.org/reference/functions/register_rest_route/Accessed 18 October 2026register_rest_route has emitted _doing_it_wrong() since WordPress 5.5 when permission_callback is missing; public routes should use __return_true.Quote: "Added a _doing_it_wrong() notice when the required permission_callback argument is not set."
      S80Authentication (REST API Handbook)https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/Accessed 18 October 2026Cookie auth needs a wp_rest nonce (X-WP-Nonce), else the request runs as user 0. Application Passwords (WordPress 5.6) use Basic Auth over HTTPS.Quote: "If no nonce is provided the API will set the current user to 0."
      S81Application Passwords: Integration Guide (Make WordPress Core, 5 Nov 2020)https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/Accessed 18 October 2026Application Passwords are stored per user, revocable one by one from Edit User, available by default only on HTTPS sites, and cannot log in to wp-admin.Quote: "From the Edit User page, you can generate new, and view or revoke existing application passwords."
      Next.js
      S82How to think about data security in Next.jshttps://nextjs.org/docs/app/guides/data-securityAccessed 18 October 2026Exported Server Actions are reachable by direct POST even if no UI imports them; each must check authentication and authorization. Next.js compares Origin to Host (or X-Forwarded-Host) and aborts on mismatch; serverActions.allowedOrigins widens that. Page last updated 2026-10-06.Quote: "you should still treat Server Actions as reachable via direct POST requests and verify authentication and authorization inside each one."
      S83route.js (Next.js docs)https://nextjs.org/docs/app/api-reference/file-conventions/routeAccessed 18 October 2026Route Handlers read webhook bodies with await request.text() and need no bodyParser config.Quote: "You can use a Route Handler to receive webhooks from third-party services"
      S84after (Next.js docs)https://nextjs.org/docs/app/api-reference/functions/afterAccessed 18 October 2026after() runs a callback after the response, within the route's max duration; stable since v15.1.0. On serverless it relies on the platform's waitUntil. It runs even if the response errored.Quote: "after will run for the platform's default or configured max duration of your route."
      S85How to upgrade to version 16https://nextjs.org/docs/app/guides/upgrading/version-16Accessed 18 October 2026Next.js 16 deprecates the middleware file convention and named export and renames them to proxy; proxy runs on Node.js only, so Edge users keep middleware. The proxy.js reference (https://nextjs.org/docs/app/api-reference/file-conventions/proxy) lists "v16.0.0: Middleware is deprecated and renamed to Proxy". Upgrade page last updated 2026-10-15.Quote: "The middleware filename is deprecated, and has been renamed to proxy to clarify network boundary and routing focus."
      Secret handling
      S86Secret scanning partner program (GitHub Docs)https://docs.github.com/en/code-security/secret-scanning/secret-scanning-partnership-program/secret-scanning-partner-programAccessed 18 October 2026GitHub's partner program sends the issuer a payload when its secret format appears publicly; it asks for precise patterns and lists a unique prefix, high-entropy random strings and a 32-bit checksum as helpful traits.Quote: "We recommend you are as precise as possible, because this will help reduce the number of false positives."
      S87Server-Side Request Forgery Prevention Cheat Sheet (OWASP)https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.htmlAccessed 18 October 2026For user-supplied URLs such as webhooks (Case 2): resolve the name, check every A and AAAA record against private and link-local ranges, connect only to the validated IP, disable redirects, and filter at the network layer. Allowlisting alone does not stop DNS rebinding.Quote: "A second, unchecked DNS lookup between validation and connection can bypass these checks."
      S88Secrets Management Cheat Sheet (OWASP)https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.htmlAccessed 18 October 2026Rotate secrets regularly, preferably automatically; revoke exposed or unneeded secrets at once; restarting does not revoke stolen credentials; give secrets an expiry where possible.Quote: "You should regularly rotate secrets so that any stolen credentials will only work for a short time."
      Sources added while resolving the technical review
      S89Cancel a PaymentIntent (Stripe API reference)https://docs.stripe.com/api/payment_intents/cancelAccessed 18 October 2026After a PaymentIntent is canceled it makes no further charges and later operations on it fail. It can be canceled from requires_payment_method, requires_capture, requires_confirmation, requires_action and, in rare cases, processing; the call returns an error if the intent is not in a cancelable state.Quote: "After it's canceled, no additional charges are made by the PaymentIntent and any operations on the PaymentIntent fail with an error."
      S90Card payments (Square Developer documentation)https://developer.squareup.com/docs/payments-api/take-payments/card-paymentsAccessed 18 October 2026When the issuing bank rejects a CreatePayment request, the Square payment's status is FAILED, which the page defines as a declined payment request. The page does not say in so many words that FAILED is final.Quote: "If the issuing bank rejects the request, the status is FAILED."
      S91wpdb::query() (WordPress Developer Resources)https://developer.wordpress.org/reference/classes/wpdb/query/Accessed 18 October 2026wpdb::query() returns the number of rows affected or selected, true for CREATE, ALTER, TRUNCATE and DROP, and false on error. The page documents errors only through that return value.Quote: "Boolean false on error."
      S92API Functions (Action Scheduler)https://actionscheduler.org/api/Accessed 18 October 2026as_enqueue_async_action( $hook, $args, $group, $unique, $priority ) returns the new action's ID, or zero if scheduling failed; $unique defaults to false. The library's own docblock adds that a unique action is not scheduled when a pending or running action has the same hook and group.Quote: "the action's ID. Zero if there was an error scheduling the action."
      S93Action Scheduler (project home)https://actionscheduler.org/Accessed 18 October 2026Action Scheduler's default runner starts every minute through WP-Cron and continues in asynchronous loopback requests, 25 actions per batch.Quote: "The scheduler will attempt to run every minute by attaching itself as a callback to the 'action_scheduler_run_schedule' hook"
      S94WP-CLI (Action Scheduler)https://actionscheduler.org/wp-cli/Accessed 18 October 2026Action Scheduler has WP-CLI commands for processing its queue (action-scheduler run). The docs call WP-CLI the better runner for large sites and warn that disabling the default runner means running the queue some other way.Quote: "For large sites, WP CLI is a much better choice for running queues of actions than the default WP Cron runner."
      S95ActionScheduler_Abstract_QueueRunner (source)https://github.com/woocommerce/action-scheduler/blob/trunk/classes/abstracts/ActionScheduler_Abstract_QueueRunner.phpAccessed 18 October 2026In the queue runner, an action that throws is handled as a failure, and only a recurring action gets its next instance scheduled afterwards. Nothing in the runner re-runs a failed one-time action.Quote: "if ( isset( $action ) && is_a( $action, 'ActionScheduler_Action' ) && $action->get_schedule()->is_recurring() ) {"
      S96WC_Order (source); statuses in src/Enums/OrderStatus.phphttps://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-order.phpAccessed 18 October 2026payment_complete() acts on orders that are on-hold, pending, failed or cancelled: it sets the paid date and the processing or completed status, saves the order, and only then fires woocommerce_payment_complete. For any other status it fires a status-specific action instead. needs_payment() is true only for pending and failed orders with a positive total.Quote: "array( OrderStatus::PENDING, OrderStatus::FAILED )"
      S97wc_create_refund() and wc_refund_payment() (source)https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/wc-order-functions.phpAccessed 18 October 2026wc_create_refund() fires woocommerce_create_refund before saving the refund, saves it, then calls wc_refund_payment(), which calls the gateway's process_refund(). A false or WP_Error result makes WooCommerce delete the refund it saved.Quote: "do_action( 'woocommerce_create_refund', $refund, $args );"
      S98Using HTTP cookies (MDN)https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CookiesAccessed 18 October 2026A request is cross-site when its site (the registrable domain) or scheme differs from the site the user is visiting; third-party cookies are set by third-party content embedded through iframes.Quote: "Cross-site requests are requests where the site (the registrable domain) and/or the scheme (http or https) do not match the site the user is currently visiting."
      S99ReserveStock (source)https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.phpAccessed 18 October 2026When the checkout creates an order, WooCommerce reserves its stock in wc_reserved_stock until NOW() + hold minutes. Calling wc_reserve_stock_for_order() again renews the expiry and throws when stock cannot be reserved. The reservation is released on payment, processing, completion, on-hold and cancellation. Read in ReserveStock.php and includes/wc-stock-functions.php.Quote: "ON DUPLICATE KEY UPDATE expires = VALUES( expires )"
      S100wc_cancel_unpaid_orders() (source)https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/wc-order-functions.phpAccessed 18 October 2026The unpaid-order job cancels pending orders whose last update is older than the hold and reschedules itself once per hold interval. By default it cancels only orders created by checkout or, since WooCommerce 10.6, store-api; admin, REST API and plugin orders are left alone.Quote: "Orders created through other means (admin, REST API, plugins) are not cancelled."

      Appendix G

      Limits of this book

      What this book deliberately leaves out, what it could not verify, and where its proposals need more work before anyone builds them.

      Outside the scope

      • Tax calculation, shipping rates, subscriptions, invoices, payouts to merchants, multi-currency conversion and fraud scoring.
      • The standard-provider fulfillment migration and the general all-provider protocol rewrite, which the owner keeps as separate workstreams.
      • Provider-specific details for every provider PayAssist supports; the book uses Stripe as its main example.
      • Operational runbooks: on-call rotation, incident communication, backup and restore drills, data deletion requests.
      • Failures the scenario list does not model: a provider changing an API without notice, a bank reversing an approval, restoring a database from an old backup, or a person with dashboard access acting maliciously.

      Not verified

      • Card-network rules on re-attempting declined transactions were not researched; Chapter 6 only says they exist and must be checked.
      • Mastercard's service-provider levels, the official SAQ A PDF's page numbers (read from a verbatim copy), Apple's own documentation on Apple Pay in frames, and the Safari version that first partitioned third-party local storage (E3, "Facts I could not verify").
      • WooCommerce's payment_complete() status rules, stock reservation and unpaid-order defaults were read in its source (S96, S99, S100) but not tested on an installed store; the default Hold stock value was not confirmed.
      • No PayAssist production system, database, provider account or Grafana instance was queried for this book. Current-code statements describe local checkouts and staging snapshots on 18 October 2026, not a verified deployment.

      Open design work

      • Owner decisions D1 (frame origin), D2 (saved cards), D3 (SDK generator) and D4 (session lifetime) from Claude Opus 5.5's list are still open.
      • A per-provider policy for which account a second attempt may use after a decline.
      • The nested-frame PCI question for PayAssist's payment page around provider fields, for an acquirer and a qualified assessor.
      • Effort estimates, staffing and cost: none exist, and this book does not invent them.
      • Every code sample is a sketch. None has been compiled or run against real WooCommerce, Next.js or provider sandboxes.

      Appendix H

      How this book was checked

      Who gathered the evidence, which tests ran, which reviews looked at the finished book, what they found, and what none of those checks can prove.

      Who did what

      TaskModelOutput
      Lead author: structure, text, figures, runtime, final assembly, fixes after each reviewClaude Opus 5.5This book and its build sources
      Recover the arena history and audit its claimsCodex GPT-6.1-Sol, extra-high reasoningHTML/evidence/store-connectivity-book/arena-ledger.md (E2)
      Map today's WooCommerce path in codeCodex GPT-6.1-Sol, extra-high reasoningHTML/evidence/store-connectivity-book/current-code-woo.md (E1)
      Check technical and compliance facts against primary sourcesClaude Opus 5.5, extra-high reasoning; S89 to S100 added by the leadHTML/evidence/store-connectivity-book/primary-sources.md (E3)

      Reviews of the finished book

      Every review read a fixed build of the book and wrote one report. The lead answered each finding in a dispositions file, and the next round checked those answers. No review approves this edition; each records what it found on the build it saw.

      Round and reviewReviewerReportResult
      1 · Evidence, attribution and numbersCodex GPT-6.1-Sol, highreview-evidence.md0 blockers, 6 major, 6 minor. Round 2 found all 12 resolved except Appendix H's own review rows, fixed here.
      1 · Technical and securityCodex GPT-6.1-Sol, extra highreview-technical.md10 blockers, 13 major, 6 minor.
      1 · Reading, visual and interactionClaude Opus 5.5, highreview-reading-qa.md2 P1, 12 P2, 14 P3.
      2 · Recheck of every technical and evidence finding, and regressionsCodex GPT-6.1-Sol, extra highreview-technical-r2.mdOf 41 earlier findings, 29 resolved and 12 partly resolved. New: 3 blockers, 10 major, 6 minor, mostly in the rewritten plugin and SQL sketches.
      2 · Recheck of every reading finding, and a close read of Chapters 11 to 18, 21 and Appendices C to HClaude Opus 5.5, highreview-reading-qa-r2.mdOf 28 earlier findings, 20 resolved, 6 partly resolved, 2 not (one kept on purpose: proposals stay attributed to named agents). New: 2 P1, 9 P2, 16 P3.
      3 · Check of the round 2 technical fixesCodex GPT-6.1-Sol, extra highreview-technical-r3.mdOf 19 round 2 findings, 14 resolved and 5 partly resolved. New: 1 blocker (refund identity after the replay window), 5 major, 1 minor.
      4 · Verification of the round 3 fixesCodex GPT-6.1-Sol, highreview-technical-r4.mdOf 7 round 3 findings, 6 resolved and 1 partly resolved: proving stock admission stays a stated limit in Chapter 21. No new blocker or major. Verdict: ready for independent review as an illustrative proposal.

      All reports and the lead's responses (dispositions-r1.md, dispositions-r2.md, dispositions-r3.md) are in HTML/evidence/store-connectivity-book/, with a summary in qa-report.md. The round 2 reading fixes were checked by the lead in a browser, not by a third reading review.

      Tests that ran

      • The arena's SDK sketch. bun test in sdk-blueprint/: 10 passed, 0 failed, 41,509 assertions, 3.37 seconds, rerun on 18 October 2026 under Bun 1.4.0, plus a separate reproduction of the reload gap (E10) Synthetic test.
      • Today's code. The current-code investigation ran only tests that need no database, network or containers: 19 Backend contract tests, 21 node tests and 19 primary WooCommerce JavaScript tests passed, and 70 staging-snapshot WooCommerce JavaScript tests passed. Two staging TypeScript runs could not load their dependencies and were not repaired (E1 §11).
      • This book's payment state explorer. test-explorer.mjs drives the real code behind Figure 6.2 through 19 paths, including every counterexample the reviewers found, and checks attempt numbering across a renewed session.
      • The frame handshake as printed. test-handshake.mjs runs the checkout-modal.js excerpt from Chapter 21 against small stubs: first load, reload after a handshake, reload after the retry budget ran out, a late hello from an earlier load and a wrong nonce.
      • This book's signature lab. test-hmac.mjs checks the in-page SHA-256 and HMAC against Node's crypto library on standard test vectors; the reviewers also checked that the lab accepts only well-formed v1, signatures.
      • The build. Every build checks for external assets, duplicate ids, broken internal links, glossary terms without entries, missing scenarios, em dashes, curly quotes and a list of filler words.
      • The page in a browser. Chrome at 1280, 1440 and 390 pixels wide: no runtime errors, no sideways page overflow on a phone, all 31 explorer scenarios step to their end, and the print styles applied on screen give readable tables.

      What these checks cannot prove

      No check touched production, a provider sandbox, a real WooCommerce store or a real Safari device, and nobody printed the book on paper. The proposed protocol has never run, so nothing in this book measures its speed or correctness, and the PHP, SQL and JavaScript sketches were never executed. Synthetic tests prove that small models behave as described, not that a payment system built from them will. Statements about today's code describe local checkouts and staging snapshots on 18 October 2026. Compliance statements describe what the primary sources say, not an assessment of PayAssist.

      How to read the labels and colors

      TodayObserved in PayAssist code or records on 18 October 2026. File paths are given where it matters. Owner-confirmedA decision the owner made and recorded. It binds the design. MeasuredA number from a recorded local, staging or production measurement. One sample unless stated. ProposedA design recommendation from the arena agents or engineering review, not yet built. This bookThis book's own assessment, made on 18 October 2026 and kept separate from the arena's history. IllustrativeExample values, URLs and code. They teach the shape and are not production-ready. Synthetic testA test that ran against simulated inputs. It does not prove production behavior. UnverifiedA claim nobody has checked yet. CorrectedAn earlier claim that was too strong, with the safe wording.

      Actor colors

      The same color means the same actor in every diagram and in the text.

      Buyer   Store   PayAssist   Account node   Connect service   Provider   Card issuer

      Keyboard: / search, arrow keys step a focused diagram, Space plays or pauses it, Esc closes pop-ups.