Skip to content
GatewayChanger

How it works

One checkout. One signed decision. One business gets paid.

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.

  • Nothing changes for the customer
  • The money never passes through us
  • Every decision signed and recorded
A customer completing a checkout on a smartphone at a café table.
Payment by Lash & Brow Co. Ltdits own Stripe account Decision signedRoom 2 bookings → Lash & Brow Co.

Watch it

One payment, end to end.

Pick an example, or click any step to stop on it.

lindenhouse.example/checkout
Linden House Your booking
Lash lift & tint · 50 min Room 2 · Thursday 10:30
£65.00

Payment by Lash & Brow Co. Ltd

4242 4242 4242 4242VISA
12 / 28
•••
Pay £65.00
GatewayChanger Your rules
  1. 1 Room 1 bookings Clearskin Studio
  2. 2 Room 2 bookings Lash & Brow Co.
  3. 3 Room 3 bookings Northside Laser Clinic
Signed: Lash & Brow Co. charges with Stripe
Card details & money: shop to shop, never through us
Ledger Today

Waiting for the payment…

Lash & Brow Co. £65.00 Visa •••• 4242 · sold on Linden House Paid
  • CS Clearskin Studio Room 1 · facials · Mollie
  • LB Lash & Brow Co. Room 2 · lashes & brows · Stripe +£65.00
  • NL Northside Laser Clinic Room 3 · laser · Adyen

Illustrative example — the businesses in the demo are invented.

Who is involved

Five parties. Each with one job.

Nobody does two of these jobs at once. That separation is the whole design.

The customer
Buys on the website and types the card into a form that belongs to the business that charges.
The selling website
Sells, owns the order and the customer, and shows the consent checkbox.
GatewayChanger
Answers who charges and with which account, signs the answer and keeps the ledger.
The charging business
Serves the payment form, charges with its own account and keeps a mirror of the order.
Its payment provider
For example Stripe or Adyen. Its keys never leave the charging business’s shop.

A rule can also pick the selling website itself as the business that charges. Same path, same ledger entry.

Step by step

What happens, in plain language.

We are asked once, before the payment form renders — and again only if the total changes. We are never part of the charge itself.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

The same payment, on the wire.

Seven calls, with their real requests and responses. The long payloads are folded — one click away, and readable without JavaScript.

Read the protocol
  1. Step 1 of 7 · seller → GatewayChanger

    The selling shop asks who should charge

    • The plugin calls POST /api/v1/route as the payment step renders.
    • It sends the amount, the currency, the countries and the cart. No card fields exist yet.
    • Amounts are always integer minor units. 2499 is €24.99.
    • If the total changes later, the plugin routes again and the session gets a fresh grant.
    • A grant is bound to one amount and one currency, so an old decision can never charge a new total.
    amount
    2499 — integer minor units
    currency
    EUR
    country
    DE — billing
    shipping_country
    DE
    customer
    type guest, locale de_DE
    items[]
    1 line · TRAIL-JKT-M ×1 · 2499 · categories, tags
    exclude_shops
    empty — nothing has failed yet
    mode_hint
    embed
    POST /api/v1/routeraw request
    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"
    }
  2. Step 2 of 7 · GatewayChanger → seller

    We decide, and we sign the decision

    • Rules are an ordered list, evaluated top to bottom.
    • The first rule whose conditions match wins. There is no scoring and no hidden precedence.
    • Conditions inside one rule are ANDed. To express OR you write two rules.
    • The matched rule’s candidates are filtered by availability, then ordered by the strategy.
    • Four strategies exist in code: priority (the default), weighted, round_robin, least_volume.
    • The answer is an Ed25519-signed grant with a thirty-minute expiry.
    • Every evaluation is stored as a trace: which rule matched, which candidate was skipped and why.
    grant payload, decodedEd25519
    {
      "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.

  3. Step 3 of 7 · seller → customer

    The customer is told who charges them

    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.”

    consent · version 2026-09-16 · required checkbox · hash, timestamp and IP stored on both orders

    • The route response carries the consent sentence, with the real legal names and the real amount.
    • The plugin renders it as a required checkbox next to the card form. The order cannot be placed without it.
    • Three elements must survive any translation: who charges, on whose behalf, and for how much.
    • The evidence is stored on the seller’s order and again on the charger’s mirror order.
    consent evidence, recorded on the orderstored
    {
      "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.

    Consent and shop terms →

  4. Step 4 of 7 · seller ↔ charger, direct

    The shops open a session between themselves

    What is signed, exactly

    timestamp \n nonce \n POST \n /wp-json/gwc/v1/sessions \n sha256_hex(raw_body)
    • The seller calls the charger’s own API with the grant attached. We are not in this call.
    • Every request is signed with the caller’s Ed25519 key. Each shop generates its own pair, and the private key never leaves the shop.
    • Clocks more than 300 seconds apart are rejected outright.
    • A nonce already seen from that shop within 600 seconds is rejected as a replay.
    • Responses are signed too, so tampering is detectable in either direction.
    • The charger creates whatever processor state it needs — for Stripe, a PaymentIntent.

    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
    • GWC-Timestamp
    • GWC-Nonce
    • GWC-Signature
    POST {api_base}/sessionsheaders · body · 201
    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.

  5. Step 5 of 7 · browser → charger → processor

    The card form belongs to the charger

    card data path
    • The charger serves a minimal, unthemed page in an iframe inside the seller’s checkout.
    • frame-ancestors is locked to the seller’s origin, and the page is noindex and no-store.
    • Both sides check event.origin on every postMessage.
    • Card data goes browser → the charger’s form → the processor.
    • It never reaches the seller’s server, and it never reaches ours.
    • On success the charger mints a payment token: pt_ plus 32 random bytes, single-use, session-bound, fifteen minutes.
    GET {base_url}/?gwc-embed=ses_…&k=…headers
    // 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
    postMessage envelopeboth directions
    // every postMessage, in both directions
    { "gwc": 1, "session": "ses_A1B2…", "type": "result",
      "status": "confirmed", "payment_token": "pt_3f8Kq2LmN9rTvXwZa1BcDeFg…",
      "card": { "brand": "visa", "last4": "4242" } }
  6. Step 6 of 7 · seller → charger

    The charge, and the mirror order

    • The seller redeems the single-use payment token with the charge call.
    • The call is idempotent: the same idempotency_key replays the stored answer for 24 hours instead of charging twice.
    • The session amount and currency must still match the grant, or the charger refuses.
    • The charger creates a mirror order in its own store, so its bookkeeping, VAT and refunds keep working.
    • A decline comes back as 402. A technical failure comes back as 502.
    • Either way the charger records the attempt and reports it to the ledger.
    POST {api_base}/sessions/{id}/chargesigned request
    // 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"
    }
    200 OKcharger → seller
    {
      "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.

  7. Step 7 of 7 · charger → GatewayChanger

    One ledger, and what one shop owes another

    • The charger reports the outcome. The seller never does.
    • The report is idempotent on the pair (charger shop, charge_id).
    • Failed charges are reported too, so no decision hangs at issued.
    • Later events append to the same transaction: captured, voided, refunded, refund_failed, dispute_opened, dispute_won, dispute_lost, failed.
    • Both order references travel with it, so either shop can find the other side in one search.
    • Settlement reports roll the ledger up per charger, seller, currency and period: gross, refunds, disputes, net, count.
    • We never hold or move the money. That transfer is yours to make.
    Example settlement report between charger and seller shops
    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.

    POST /api/v1/transactionsthe report that builds that table
    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 card decline never fails over. A technical failure does.

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.

Never re-routed

Card declines

  • Codes: declined, insufficient_funds, incorrect_cvc, expired_card.
  • The payment form resets and the customer sees a specific message.
  • They try another card.
  • Routing to a second business would only get the same answer from the same card scheme — and would risk double charges.
Re-routed

Technical failures

  • Timeouts, 5xx, processor_error, unavailable.
  • The website asks again with the failed shop in exclude_shops.
  • The next candidate takes it — at most twice.
  • Each attempt is written to the decision as a failed_over event, and a decision.failed_over webhook fires.

Availability is checked before any charge is attempted

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.

Every reason a candidate can be skipped

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_shop
The candidate shop no longer exists.
excluded
The seller passed the shop in exclude_shops — normally after a technical failure.
wrong_environment
A different environment, or a different organization, from the seller.
disabled
The shop is disabled.
not_charger
The shop does not hold the charger role.
not_verified
The capability probe has not passed yet.
paused
The shop reported itself paused.
offline
Status is not online, or the heartbeat is more than 15 minutes stale.
processor_suspended
A safety switch suspended this payment account after it stopped working; only a person can turn it back on.
processor_missing
The processor is not in the shop’s dashboard config, or was never reported.
processor_disabled
The processor exists in the config but is switched off.
processor_not_connected
The plugin reported the processor as anything other than connected, outside the grace window.
currency_unsupported
The processor or the shop does not support the request currency.
country_unsupported
The processor does not support the customer’s country.
cap_reached
A daily or monthly limit would be exceeded.

Limits remove a candidate. They never fail a checkout.

Daily 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.

Offline is a heartbeat, not a guess

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.

When nobody is available

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

Three ways to take the payment.

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.

Redirect

Universal fallback
  • The customer goes to the charging business’s hosted pay page.
  • They return with a signed result, backed by a signed server-to-server callback.
  • Works on every platform we ship a plugin for.

Self

Same code path
  • A rule picks the website itself as the business that charges, with its own payment accounts.
  • Same session, same signed decision, same ledger entry.

Environments

Test and live never touch.

  • Every organization has exactly two environments.
  • Shops, keys, rules, decisions and payments belong to exactly one of them.
  • A test shop can only route to another test shop, and a test key is refused by a live shop.
  • The environment is baked into every key and every signed decision, so a mismatch fails loudly.
  • The plugin shows a TEST MODE or LIVE badge in the store admin.
shk_test_…
Test shop key. Routes only to test shops. The built-in test processor needs no credentials and gives the same outcome for the same test card every time.
shk_live_…
Live shop key. The shop must be verified as a charger, and the charger confirmation accepted, before it can charge.

Route a test payment yourself.

The sandbox is open today, free, and never expires. The test processor needs no credentials, so none of this has to stay theoretical.