API reference — v1
Base URL https://gatewaychanger.com/api/v1. JSON only. Authenticate with Authorization: Bearer <key>.
This reference mirrors the platform specification. The API is being implemented alongside these docs during early access; the changelog records what has landed.
Authentication
Two kinds of key:
- Shop keys —
shk_test_…/shk_live_…. One per shop, used by the plugins, scoped to that shop and its environment. - Organization keys —
sk_test_…/sk_live_…. For automation, scoped to one environment, with areadorwriterole.
The dashboard itself uses session authentication on /app/* and not this API, though it calls the same services.
Conventions
- Amounts are integer minor units;
currencyis an upper-case ISO-4217 code. - Cursor pagination:
?limit=50&cursor=…returns{"data": […], "next_cursor": "…"}. - Every
POSThonoursIdempotency-Key: the same key replays the same response for 24 hours, and the same key with a different body returns409. - Errors:
{
"error": {
"type": "validation|authentication|permission|not_found|conflict|rate_limit|no_route|plan|server",
"code": "…",
"message": "…",
"param": "…"
}
}
- Rate limit: 600 requests per minute per key, answered with
429and aRetry-Afterheader. - Every response carries
X-Request-Id. - Public identifiers are prefixed:
org_,shp_,rul_,dec_,txn_,tev_(transaction event),whe_(webhook endpoint),key_,usr_.
Plugin-facing endpoints
These are called by the store plugins with a shop key.
POST /shops/self/activate
Registers the shop, its public key and its capabilities.
{
"platform": "woocommerce",
"plugin_version": "1.0.0",
"base_url": "https://shop-a.example",
"api_base": "https://shop-a.example/wp-json/gwc/v1",
"public_key": "<base64url 32 bytes>",
"previous_public_key": null,
"roles": ["seller", "charger"],
"capabilities": { "processors": ["test", "stripe"], "currencies": ["EUR", "USD"] },
"admin_email": "[email protected]"
}
Responds with {shop, environment, saas_public_keys, consent_template, heartbeat_interval} where
environment is test or live and heartbeat_interval is 300 seconds.
The platform then probes GET {api_base}/capabilities in a background job with a 10-second timeout and sets
verified_at when the returned shop_id and public_key match. Until then the shop is status: "pending_verification"
and cannot act as a charger.
POST /shops/self/heartbeat
{
"plugin_version": "1.0.0",
"roles": ["seller", "charger"],
"capabilities": { "processors": ["test"], "currencies": ["EUR"] },
"status": "online",
"metrics": { "pending_callbacks": 0, "last_error": null }
}
Responds {"ok": true, "status": "online", "next_heartbeat_in": 300}. A shop with no heartbeat for more than 15
minutes is marked offline, excluded from routing, and raises an alert.
GET /shops/self
The caller's own shop record, its environment, the organization name and information about the key in use.
GET /shops/{id}/public-key
{shop_id, public_key, previous_public_key?, api_base, base_url} — only for shops in the caller's organization and
environment. Used by a peer to verify a signature when it has no pinned key yet.
POST /route
The routing decision. The seller's key implies seller_shop_id.
{
"amount": 2499,
"currency": "USD",
"country": "DE",
"shipping_country": "DE",
"customer": { "type": "guest", "email_hash": "sha256:…", "locale": "de_DE" },
"items": [{ "sku": "GWC-WIDGET", "name": "Test Widget", "qty": 1, "amount": 1999, "categories": ["widgets"], "tags": [] }],
"metadata": { "wc_order_key": "…", "campaign": "summer" },
"exclude_shops": [],
"previous_decision_id": null,
"mode_hint": "embed"
}
200 OK:
{
"decision_id": "dec_…",
"environment": "test",
"rule": { "id": "rul_…", "name": "Default" },
"candidate": {
"shop": {
"id": "shp_B",
"name": "Shop B",
"legal_name": "Shop B Ltd",
"platform": "woocommerce",
"base_url": "…",
"api_base": "…",
"public_key": "…"
},
"processor": { "id": "stripe", "label": "Stripe", "features": ["embed", "redirect"], "capture": "auto" }
},
"alternatives": [{ "shop": {}, "processor": {} }],
"grant": "v1.…",
"grant_expires_at": "2026-09-16T10:30:00Z",
"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",
"tos_url": "https://shop-a.example/terms"
},
"trace": [
{ "rule": "rul_1", "matched": false, "reason": "currency" },
{
"rule": "rul_2",
"matched": true,
"candidates": [
{ "shop": "shp_B", "processor": "stripe", "available": true },
{ "shop": "shp_C", "processor": "test", "available": false, "reason": "offline" }
]
}
]
}
When nothing is available the call returns 404 with {"error": {"type": "no_route", "message": "…", "trace": […]}}.
In the live environment, an organization whose plan is not active gets the same 404 no_route with
"code": "plan_inactive" (and no trace) — your plugin's no-route behaviour applies, and the dashboard shows how to renew.
Decisions expire after 30 minutes. Their status moves issued → used when a charge is reported, and then to
completed, failed or expired.
POST /decisions/{id}/events
Telemetry from the seller: {"type": "session_created|consent_accepted|charge_requested|charge_failed|failed_over|abandoned", "data": {}}
→ 202.
POST /transactions
Reported by the charger, which owns the processor truth. Idempotent on the pair (charger shop, charge_id).
{
"decision_id": "dec_…",
"session_id": "ses_…",
"charge_id": "chg_…",
"seller_shop_id": "shp_A…",
"seller_order_ref": "1042",
"charger_order_ref": "#1234",
"processor": "stripe",
"processor_ref": "pi_3Q1…",
"amount": 2499,
"currency": "EUR",
"status": "captured",
"captured_amount": 2499,
"card": { "brand": "visa", "last4": "4242", "country": "DE", "funding": "credit" },
"customer": { "country": "DE", "email_hash": "sha256:…" },
"failure": null,
"occurred_at": "2026-09-16T10:05:12Z"
}
→ 201 {"transaction": {"id": "txn_…", …}}. Two optional flags: "orphan": true — money that arrived with no order
behind it (a payment that completed after the shopper was told it failed), recorded without a charge's side effects
and never on a statement; "late": true — a charge reported after the fact. A late report carries the charge's
current state, so status may also be partially_refunded, refunded, voided or disputed, with
refunded_amount and disputed_amount next to captured_amount. A late report of a charge that is already recorded
updates that transaction (until the charger reports it by seq) and answers with its existing txn_. A shop that no
longer has the charger role may still send late and orphan reports for its own past charges.
POST /charges/{charge_id}/events
How a charger reports what happened to a charge afterwards — a capture or refund that settled or failed, a dispute, a
refund made in the processor's own dashboard. Addressed by the charger's own chg_…, with the charge's absolute
state and a per-charge sequence number, and Idempotency-Key: {charge_id}:{seq}:
{ "seq": 7, "type": "refunded", "occurred_at": "…",
"state": { "status": "partially_refunded", "captured_amount": 1999, "refunded_amount": 500, "disputed_amount": 0 },
"processor_ref": "re_…", "reason": "", "failure": null }
type is one of authorized, captured, capture_failed, voided, void_failed, refunded, refund_failed,
dispute_opened, dispute_won, dispute_lost, failed. The state is applied only when seq is higher than the
highest one applied so far; an older or repeated event is kept as history ("applied": false), so order and
duplicates do not matter. → 201 {event, transaction} (200 with the stored event for a resend).
409 transaction_not_recorded means the ledger has no POST /transactions for this charge yet — retry later. Only
the charging shop (or an organization key) may report; a seller's key gets 403. Send "failure": {…, "synchronous": true} with a refund_failed the processor refused on the spot: it is recorded, but no transaction.refund_failed is
sent, since the merchant already saw that error.
POST /transactions/{id}/events
The older form, kept for plugins that predate the endpoint above:
{"type": "captured|voided|refunded|refund_failed|dispute_opened|dispute_won|dispute_lost|failed", "amount": 1000, "processor_ref": "…", "reason": "…", "occurred_at": "…"}
→ 201 {event, transaction} with the transaction's status and amounts recomputed — until the charge's first
sequenced report arrives; after that such an event is kept as history only. Charging shop or organization key only.
GET /transactions/{id} · GET /transactions?decision_id=…
The transaction with its events, and lookup by decision for the seller side.
Automation endpoints
Called with an organization key; the dashboard mirrors all of them.
Shops
GET/POST /shops · GET/PATCH/DELETE /shops/{id} with the fields name, legal_name, platform, base_url,
roles, enabled, notes, attestation_accepted_at · POST /shops/{id}/keys/rotate (issues a new shop key; the old
one stays valid for 24 hours) · POST /shops/{id}/probe (re-run verification).
Connecting a shop needs room on your plan (pricing): POST /shops, or a PATCH that switches a shop back
on, answers 402 with {"error": {"type": "plan", "code": "plan_limit_reached", "limit": 10, "connected": 10}} — or
"code": "plan_required" for a live shop without a plan. Shops you have switched off do not count.
Rules
GET/POST /rules · GET/PUT/DELETE /rules/{id} · POST /rules/reorder {"ids": […]} ·
POST /rules/simulate — takes the same body as /route plus seller_shop_id and returns the same shape with
"simulated": true, persisting nothing.
Decisions and transactions
GET /decisions?status=&seller=&charger=&from=&to=&q= · GET /decisions/{id} ·
GET /transactions?…&q= where q matches references, the last four digits and order numbers ·
GET /transactions/export.csv?….
Settlements
GET /settlements?period=2026-09 returns, per charger, seller and currency: gross, refunds, disputes, net and count ·
GET /settlements/{period}/export.csv.
Webhooks, events and keys
GET/POST /webhooks · DELETE /webhooks/{id} · POST /webhooks/{id}/test · GET /webhooks/{id}/deliveries ·
GET /events?type=&from= for the audit and activity log · GET /keys · POST /keys · DELETE /keys/{id}.
Outgoing webhooks
Organization-level. Events:
decision.issued · decision.failed_over · transaction.created · transaction.captured · transaction.failed ·
transaction.refunded · transaction.disputed · transaction.capture_failed · transaction.refund_failed ·
transaction.voided · transaction.dispute_won · transaction.orphan_needs_refund · shop.online · shop.offline ·
shop.verified · cap.reached
Payload:
{ "id": "evt_…", "type": "transaction.captured", "created_at": "…", "environment": "test", "data": {} }
Signature header:
GWC-Webhook-Signature: t=<unix ts>,v1=<hex hmac_sha256(secret, ts + "." + body)>
Deliveries are retried with backoff and are visible — with their responses — in the dashboard, where they can be replayed.
Something wrong or missing on this page? Tell us and we will fix it.