Protocol v1
Everything two shops say to each other, and everything they need to verify before they act on it. All JSON, UTF-8,
HTTPS only. Amounts are integer minor units (1999 = €19.99) and currency is an upper-case ISO-4217 code.
Timestamps are ISO-8601 UTC strings unless the field is named *_ts, which is unix seconds. Identifiers are prefixed:
shp_, dec_, txn_, ses_, chg_, rfd_, pt_.
base64url means RFC 4648 §5 without padding.
1. Keys
The GatewayChanger signing key is Ed25519. Public keys are published, unauthenticated, at
GET https://gatewaychanger.com/.well-known/gatewaychanger.json
{
"version": 1,
"api_base": "https://gatewaychanger.com/api/v1",
"keys": [ { "kid": "gwc-2026-09", "alg": "ed25519", "public_key": "<base64url 32 bytes>" } ]
}
Plugins cache that for 24 hours and refresh when they meet an unknown kid.
Each shop has its own Ed25519 key pair, generated by the plugin at activation. The private key never leaves the
shop. The public key is sent on activation and on every heartbeat, and is embedded in grants and route responses so
peers can verify each other without calling us. Rotation: generate a new pair and send both public_key and
previous_public_key for seven days.
2. The grant
A grant is the signed routing decision:
v1.<base64url(payload JSON)>.<base64url(ed25519 signature)>
The signature covers the ASCII bytes of "v1." + base64url(payload JSON). To verify: split on ., check the prefix,
verify with our public key for payload.kid, then check exp, that charger.id is your own shop id, and that env
matches your environment.
{
"kid": "gwc-2026-09",
"jti": "dec_01J8…",
"iat": 1758000000,
"exp": 1758001800,
"env": "test",
"org": "org_01J8…",
"seller": { "id": "shp_A…", "public_key": "…", "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_B…", "public_key": "…", "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": "USD",
"capture": "auto",
"rule": { "id": "rul_…", "name": "EU cards → Shop B" },
"consent": { "text": "I agree that my payment of {amount} is processed by Shop B Ltd on behalf of Shop A.",
"version": "2026-09-16" }
}
jti is the decision id and doubles as the idempotency scope for the charge. Grants live 30 minutes. A charge may only
be for a different amount if the session was updated with a new grant.
3. Request signatures
Every shop-to-shop request carries:
GWC-Shop-Id: shp_A…
GWC-Timestamp: 1758000123
GWC-Nonce: <random 16+ bytes base64url>
GWC-Signature: v1=<base64url(ed25519(signing_string))>
Content-Type: application/json
with
signing_string = timestamp + "\n" + nonce + "\n" + METHOD + "\n" + path_and_query + "\n" + sha256_hex(raw_body)
path_and_query is the request target as sent, METHOD is upper-case, and an empty body hashes as sha256_hex("").
Sign the exact raw bytes you send; verify the exact raw bytes you received — never re-encode the JSON in between. The receiver rejects a timestamp more than 300 seconds from its own clock, and a nonce it has already seen from that shop within ten minutes.
The receiver finds the caller's public key, in order, from: the grant carried in the request, its own session record
(the peer key is pinned when the session is created), or the directory endpoint GET /api/v1/shops/{id}/public-key
(cached one hour).
Responses are signed too — the charger signs with RESPONSE in place of the method, over the request path — so the
seller can pin the charger's key and detect tampering in either direction. Callbacks between shops are signed exactly
like requests.
4. The charger API
Base path per platform: WooCommerce {base_url}/wp-json/gwc/v1, Magento {base_url}/rest/V1/gatewaychanger. The
plugin reports its exact api_base on activation and peers always use the reported value.
Errors are 4xx or 5xx with:
{ "error": { "code": "grant_expired|grant_invalid|signature_invalid|session_not_found|amount_mismatch|processor_error|declined|unavailable|validation",
"message": "…", "details": {} } }
declined is a customer-facing card decline — retry with another card, never fail over. processor_error and
unavailable are technical, and the seller may re-route with exclude_shops.
GET /capabilities — public
{ "shop_id": "shp_B…", "platform": "woocommerce", "plugin_version": "1.0.0",
"roles": ["seller", "charger"],
"processors": [ { "id": "stripe", "label": "Stripe", "currencies": "*", "countries": "*",
"features": ["embed", "redirect"], "capture": ["auto", "manual"],
"refunds": true, "partial_refunds": true, "three_ds": "processor" } ],
"currencies": ["EUR", "USD"], "public_key": "…", "protocol": "v1", "time": 1758000123 }
Also used by the platform to verify a shop after activation and by heartbeat consistency checks.
POST /sessions — signature + grant
{ "grant": "v1.…", "amount": 2499, "currency": "EUR", "mode": "embed",
"seller_order_preview": { "number": "1042", "items": [ { "name": "Widget", "qty": 1, "amount": 2499 } ],
"customer": { "email": "…", "name": "…", "country": "DE", "postcode": "…" } },
"locale": "de_DE", "seller_origin": "https://shop-a.example" }
→ 201 with {session_id, status, amount, currency, processor, embed_url, redirect_url, expires_at, charger: {legal_name}, consent: {text, version}}.
The charger creates processor state now if the processor needs it — for Stripe, a PaymentIntent with the capture method
from the grant. embed_url and redirect_url carry a session key (k=) so that only the customer who was handed the
URL can open it.
PATCH /sessions/{id} — signature + grant
{grant, amount, currency} with a new grant carrying the final total. The charger updates its processor state.
Allowed only while the session is created or ready.
GET /sessions/{id} — signature
{session_id, status, amount, currency, processor, payment_token_issued, charge_id?}.
POST /sessions/{id}/charge — signature + grant
{ "grant": "v1.…", "mode": "embed", "payment_token": "pt_…",
"seller_order": { "id": 1042, "number": "1042", "total": 2499, "currency": "EUR",
"customer": { "email": "…", "name": "…", "country": "DE", "postcode": "…" },
"items": [ { "name": "Widget", "sku": "GWC-WIDGET", "qty": 1, "amount": 2499 } ],
"return_url": "…", "cancel_url": "…" },
"consent": { "accepted_at": "…", "text_hash": "sha256:…", "ip": "203.0.113.24", "user_agent": "…" },
"idempotency_key": "…" }
In embed mode the answer is 200 with {charge_id, status: "captured|authorized|failed", processor, processor_ref, charger_order_ref, card: {brand, last4}, amount, currency, captured_amount, transaction_id, failure?}.
In redirect mode it is 200 {charge_id, status: "pending", redirect_url}. Idempotent on idempotency_key: the same
key replays the stored response for 24 hours.
Charge management — signature
GET /charges/{id} returns the current state including refunds · POST /charges/{id}/capture takes an optional
amount for partial capture · POST /charges/{id}/void cancels an authorization · POST /charges/{id}/refunds takes
{amount, reason?, idempotency_key} and returns 201 {refund_id, status, amount, processor_ref}.
POST /callbacks — on the seller, signed by the charger
{type: "charge.updated|refund.updated|dispute.opened", charge_id, session_id, seller_order_id, status, amount, processor_ref, occurred_at}.
The seller acknowledges with 200; the charger retries with backoff at 1 minute, 5 minutes, 30 minutes, 2 hours and
12 hours.
5. The embed page
Served by the charger, for example https://shop-b.example/?gwc-embed=ses_…&k=…. Minimal HTML with no theme, and
these headers:
Content-Security-Policy: frame-ancestors {seller_origin};
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
It loads the processor's client library — Stripe.js with the charger's publishable key, or the test processor's own
form — and speaks postMessage with the parent page. Every message is
{ "gwc": 1, "session": "ses_…", "type": …, … } and both sides check event.origin: the parent against the
charger origin, the iframe against the seller_origin recorded in the session.
- iframe → parent:
ready {height}·change {complete, brand?, error?}·resize {height}·result {status: "confirmed", payment_token, card}or{status: "failed", error}or{status: "requires_redirect", redirect_url}·focus/blur. - parent → iframe:
confirm {billing}after the seller has finalized the amount ·reset.
The payment_token (pt_ plus 32 random bytes, base64url) is minted by the charger's backend once the processor
confirmed inside the iframe. It is single-use, bound to the session, expires after 15 minutes, and is redeemed by the
charge call.
6. The hosted pay page
https://shop-b.example/?gwc-pay=ses_…&k=… — a themed page showing the seller's name, the amount, the consent already
accepted (as information) and the processor's form. On success the charger charges server-side, redirects to
return_url?gwc_result=<v1.<payload>.<signature>> signed with the charger's key, and posts the same information to
the seller's /callbacks. The payload is
{session_id, charge_id, status, amount, currency, processor_ref, card, seller_order_id, iat, exp} with a ten-minute
expiry. The seller must accept whichever arrives first and de-duplicate the other.
7. The test processor
Every plugin ships it, and its behaviour is identical everywhere — see test cards.
8. Seller calls to GatewayChanger
POST /route before rendering the payment form, and again on finalize if the total changed ·
POST /decisions/{id}/events for telemetry · and, from the charger, POST /transactions and
POST /transactions/{id}/events. The seller never reports the outcome itself: the charger owns the processor truth and
forwards the seller's order reference. Full details in the API reference.