Skip to content
GatewayChanger

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.