Skip to content
GatewayChanger

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 a read or write role.

The dashboard itself uses session authentication on /app/* and not this API, though it calls the same services.

Conventions

  • Amounts are integer minor units; currency is an upper-case ISO-4217 code.
  • Cursor pagination: ?limit=50&cursor=… returns {"data": […], "next_cursor": "…"}.
  • Every POST honours Idempotency-Key: the same key replays the same response for 24 hours, and the same key with a different body returns 409.
  • 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 429 and a Retry-After header.
  • 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.