Embed
Default- The charging business serves a minimal card page in an iframe on the website’s checkout.
- The customer never leaves the website.
- Frame ancestors are locked to the website’s origin, and both sides verify every message.
How it works
Before the payment form appears, the website asks one question — which business charges this order, and with which payment account? Everything after that happens directly between the shops.
Watch it
Pick an example, or click any step to stop on it.
Payment by Lash & Brow Co. Ltd
Waiting for the payment…
Illustrative example — the businesses in the demo are invented.
Who is involved
Nobody does two of these jobs at once. That separation is the whole design.
A rule can also pick the selling website itself as the business that charges. Same path, same ledger entry.
Step by step
We are asked once, before the payment form renders — and again only if the total changes. We are never part of the charge itself.
The website asks who should charge
As the payment step loads, the website’s plugin sends the amount, the currency, the customer’s country and the basket. No card fields exist yet.
Your rules decide — and we sign the answer
Rules run from top to bottom and the first match wins. A business that is offline, over its limit or not yet verified is skipped, and the next one in line takes it. The answer is signed and expires after thirty minutes.
The customer is told who charges them
A required checkbox names the business that charges, on whose behalf and for how much. The order cannot be placed without it, and the evidence is stored on both orders.
The charging business serves the payment form
Inside the website’s checkout, or on its own payment page. The card goes from the customer’s browser to that business’s payment provider — never to the website, never to us.
The payment is taken and booked on both sides
The charging business charges with its own account and creates a mirror of the order in its own shop, so its bookkeeping, invoices and refunds keep working.
Everyone sees it in one ledger
The charging business reports the outcome. Statements roll the ledger up per business, currency and month — the numbers one business invoices another.
For developers
Seven calls, with their real requests and responses. The long payloads are folded — one click away, and readable without JavaScript.
Step 1 of 7 · seller → GatewayChanger
POST /api/v1/route as the payment step renders.2499 is €24.99.Authorization: Bearer shk_test_…
Idempotency-Key: route_1187_2499_a3f19c
Content-Type: application/json
{
"amount": 2499,
"currency": "EUR",
"country": "DE",
"shipping_country": "DE",
"customer": { "type": "guest", "locale": "de_DE" },
"items": [
{ "sku": "TRAIL-JKT-M", "qty": 1, "amount": 2499,
"categories": ["outerwear"], "tags": ["new"] }
],
"exclude_shops": [],
"mode_hint": "embed"
}
Step 2 of 7 · GatewayChanger → seller
priority (the default), weighted, round_robin, least_volume.{
"kid": "gwc-2026-09", "jti": "dec_01J8YR4K…",
"iat": 1790071200, "exp": 1790073000, "env": "test", "org": "org_01JB8ZQ2…",
"seller": {
"id": "shp_01JB8ZQ3…", "public_key": "R2tN8xV1…",
"base_url": "https://shop-a.example",
"api_base": "https://shop-a.example/wp-json/gwc/v1",
"name": "Shop A", "legal_name": "Shop A GmbH"
},
"charger": {
"id": "shp_01JB8ZQ4…", "public_key": "M4kP7rT0…",
"base_url": "https://shop-b.example",
"api_base": "https://shop-b.example/wp-json/gwc/v1",
"name": "Shop B", "legal_name": "Shop B Ltd"
},
"processor": "stripe", "amount": 2499, "currency": "EUR", "capture": "auto",
"rule": { "id": "rul_01JB8ZQ6…", "name": "EU cards over €50" },
"consent": {
"text": "I agree that my payment of €24.99 is processed by Shop B Ltd on behalf of Shop A GmbH.",
"version": "2026-09-16"
}
}
On the wire the grant is v1.<base64url payload>.<base64url signature>.
The signature covers the ASCII bytes of "v1." plus the payload segment.
Our public keys are published, unauthenticated, at /.well-known/gatewaychanger.json.
A charger refuses a grant that is expired, that names another shop, that belongs to another
environment, or whose amount does not match the session.
Step 3 of 7 · seller → customer
What the customer ticks at checkout
“I agree that my payment of €24.99 is processed by Shop B Ltd on behalf of Shop A GmbH.”
{
"text": "I agree that my payment of €24.99 is processed by Shop B Ltd on behalf of Shop A GmbH.",
"version": "2026-09-16",
"text_hash": "9c1f2b5a0e7d4c38bb62a1f0d9e8c7b6a5948372615049382716f5e4d3c2b1a0",
"accepted_at": "2026-09-22T10:04:11Z",
"ip": "203.0.113.24",
"user_agent": "Mozilla/5.0 …"
}
text_hash is the SHA-256 of the exact text that was shown, as bare lower-case hex.
user_agent is truncated to 255 characters.
Step 4 of 7 · seller ↔ charger, direct
What is signed, exactly
timestamp \n nonce \n POST \n /wp-json/gwc/v1/sessions \n sha256_hex(raw_body)One LF between each part, no trailing newline. The request target appears exactly as sent, so the path includes the platform’s own REST prefix.
GWC-Shop-Id: shp_01JB8ZQ3…
GWC-Timestamp: 1790071215
GWC-Nonce: c2FtcGxlLW5vbmNlLTE4Ynl0ZXM
GWC-Signature: v1=Rk9PQkFSc2lnbmF0dXJl…
// request body
{
"grant": "v1.eyJraWQiOiJnd2MtMjAyNi0wOSIsIm…",
"amount": 2499, "currency": "EUR",
"mode": "embed",
"seller_origin": "https://shop-a.example",
"locale": "de_DE"
}
// 201 Created
{
"session_id": "ses_A1B2C3D4E5F60718293A4B5C6D",
"status": "ready",
"amount": 2499, "currency": "EUR",
"processor": "stripe",
"mode": "embed",
"embed_url": "https://shop-b.example/?gwc-embed=ses_A1B2…&k=Qh7…",
"expires_at": "2026-09-22T10:28:00Z",
"charger": { "shop_id": "shp_01JB8ZQ4…", "name": "Shop B",
"legal_name": "Shop B Ltd", "origin": "https://shop-b.example" },
"consent": { "text": "I agree that my payment of €24.99 is …",
"version": "2026-09-16" }
}
embed_url carries a one-time session key k; the charger stores only its hash.
Step 5 of 7 · browser → charger → processor
frame-ancestors is locked to the seller’s origin, and the page is noindex and no-store.event.origin on every postMessage.pt_ plus 32 random bytes, single-use, session-bound, fifteen minutes.// response headers on the embed page
Content-Security-Policy: default-src 'self';
frame-ancestors https://shop-a.example;
script-src 'self' 'unsafe-inline' https://js.stripe.com;
form-action 'none'; base-uri 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Robots-Tag: noindex, nofollow
Cache-Control: no-store
// every postMessage, in both directions
{ "gwc": 1, "session": "ses_A1B2…", "type": "result",
"status": "confirmed", "payment_token": "pt_3f8Kq2LmN9rTvXwZa1BcDeFg…",
"card": { "brand": "visa", "last4": "4242" } }
Step 6 of 7 · seller → charger
idempotency_key replays the stored answer for 24 hours instead of charging twice.402. A technical failure comes back as 502.// request (abridged — seller_order carries the full order)
{
"grant": "v1.eyJraWQiOiJnd2MtMjAyNi0wOSIsIm…",
"mode": "embed",
"payment_token": "pt_3f8Kq2LmN9rTvXwZa1BcDeFg…",
"seller_order": { "id": "1187", "number": "#1187",
"total": 2499, "currency": "EUR", … },
"consent": { "accepted_at": "2026-09-22T10:04:11Z",
"text_hash": "9c1f2b5a0e7d4c38…",
"ip": "203.0.113.24", "user_agent": "Mozilla/5.0 …" },
"idempotency_key": "1187-2499-ses_A1B2"
}
{
"charge_id": "chg_9F8E7D6C5B4A39281706F5E4D3",
"status": "captured",
"processor": "stripe",
"processor_ref": "pi_3Qk2mB2eZvKYlo2C1AbCdEfG",
"card": { "brand": "visa", "last4": "4242",
"exp": "12/28" },
"amount": 2499, "currency": "EUR",
"captured_amount": 2499,
"charger_order_ref": "1187",
"transaction_id": "txn_01JB8ZQB…"
}
What is reported to GatewayChanger in step 7 is brand,
last4, country and
funding — our API has no field for a PAN, a CVV or an expiry
date, and our database has no column for one.
Step 7 of 7 · charger → GatewayChanger
charge_id).issued.| Charger | Seller | Ccy | Gross | Refunds | Net |
|---|---|---|---|---|---|
| Shop B Ltd | Shop A GmbH | EUR | 48,210.00 | −1,240.00 | 46,970.00 |
| Shop B Ltd | Shop C SRL | EUR | 12,880.50 | −310.00 | 12,570.50 |
| Shop A GmbH | Shop C SRL | USD | 9,415.00 | −0.00 | 9,415.00 |
Illustrative figures.
Authorization: Bearer shk_test_…
{
"decision_id": "dec_01J8YR4K…",
"session_id": "ses_A1B2C3D4E5F60718293A4B5C6D",
"charge_id": "chg_9F8E7D6C5B4A39281706F5E4D3",
"seller_order_ref": "#1187",
"charger_order_ref": "1187",
"processor": "stripe",
"processor_ref": "pi_3Qk2mB2eZvKYlo2C1AbCdEfG",
"amount": 2499, "currency": "EUR",
"status": "captured", "captured_amount": 2499,
"card": { "brand": "visa", "last4": "4242",
"country": "DE", "funding": "credit" },
"customer": { "country": "DE",
"email_hash": "sha256:8d2f0a1c…" },
"occurred_at": "2026-09-22T10:04:19Z"
}
When something fails
A decline is the customer’s to solve with another card. A technical failure is ours to route around. Confusing the two is how payment systems charge people twice.
declined, insufficient_funds, incorrect_cvc, expired_card.processor_error, unavailable.exclude_shops.failed_over event, and a decision.failed_over webhook fires.If the first candidate is offline, over its limit, unverified or its payment account is not connected, the decision simply names the next available one. The customer never notices.
These are the machine reasons from the availability check, in evaluation order. Every one appears in the routing trace in your dashboard, next to the candidate it removed.
unknown_shopexcludedexclude_shops — normally after a technical failure.wrong_environmentdisablednot_chargernot_verifiedpausedofflineprocessor_suspendedprocessor_missingprocessor_disabledprocessor_not_connectedcurrency_unsupportedcountry_unsupportedcap_reachedDaily and monthly limits are set per candidate inside a rule, in amount and count. Usage counts captured amounts less refunds, plus amounts reserved by decisions not yet reported, so a burst cannot overshoot.
Every shop checks in every 5 minutes. A shop that has not checked in for more than 15 minutes is marked offline and leaves routing until it is back.
POST /route answers 404 with a no_route error and the full trace. The plugin then hides the payment method — or shows an error, if you prefer.
Payment modes
Environments
The sandbox is open today, free, and never expires. The test processor needs no credentials, so none of this has to stay theoretical.