Payment Gateway

Overview

Payment Gateway puts one REST API in front of every payment provider (PSP). A shop, app or billing service creates a payment and pays it; Payment Gateway routes it to the tenant’s configured provider, normalizes the result and keeps the call safe to retry. Stripe, PayPal and a NoOp test provider ship in the box, and a new provider is a new module behind the provider SPI.

Capabilities

Capability Description
Provider abstraction One contract for Stripe, PayPal, NoOp and any provider you add through the SPI.
Idempotent payments POST /payments/{id}/pay with an Idempotency-Key returns the original transaction on replay; it never charges twice.
Per-tenant providers Each tenant enables providers and holds their credentials; one deployment serves many merchants.
Webhooks and returns PSP webhooks and browser returns are handled per provider, and a terminal transaction is never overwritten by a late event.
Recurring payments A payment can carry a recurrence rule (DURATION or CRON).
Lifecycle events payment.created, payment.finalized and payment.closed can be published to AuditFlow.

Architecture

Payment Gateway sits behind the API Gateway, which authenticates the caller and passes the tenant and scopes as trusted headers. It stores payments in PostgreSQL, uses Redis for idempotency keys and calls the provider through the SPI.

sequenceDiagram
    autonumber
    participant C as Caller (shop, app, billing)
    participant PG as Payment Gateway
    participant PSP as Provider (Stripe, PayPal, NoOp)
    participant AF as AuditFlow

    C->>PG: POST /payments (paymentProviderId, purchaseOrder, billingInfo)
    PG-->>C: 201 Payment (READY)
    C->>PG: POST /payments/{id}/pay (Idempotency-Key)
    PG->>PSP: Provider call
    PSP-->>PG: Success, or a next action (redirect)
    PG-->>C: Payment (CLOSED) + PaymentTransaction (SUCCESS)
    PSP--)PG: Webhook: POST /providers/{provider}/webhooks
    PG--)AF: payment.* events (HTTP, as a service principal)

The repository is split into three Maven modules:

Module Stack Role
payment-gateway-api Java 17, OpenAPI Canonical contract and validated shared models
payment-gateway-be Java 25, Spring Boot 4 REST API, idempotency, webhooks, provider registry
payment-gateway-providers Java 25 The provider SPI and one module per PSP (noop, paypal, stripe)

Quick Start

The Payment Gateway is installed by the Quickstart and by the full local ecosystem. With a token from either guide, list the providers configured for your tenant:

curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:8000/payment-gateway/api/v1/payment-providers

Install with Helm

With a cluster, PostgreSQL and a Redis-compatible store, install the chart and point it at them:

helm repo add labs64io https://labs64.github.io/labs64.io-helm-charts
helm repo update

helm install my-payments labs64io/payment-gateway -n labs64io --create-namespace \
  --set global.postgresql.host=postgres.mycompany.internal \
  --set global.redis.host=redis.mycompany.internal

Database credentials go in a Kubernetes Secret (see the chart’s values). The preflight chart can check both services from inside your cluster before you install. See Deploy to your Kubernetes cluster and the chart on Artifact Hub.

Try a payment with Docker

To see a payment end to end without a cluster, run the published image with PostgreSQL and Valkey next to it. Only Docker and Git are needed, and there is nothing to build:

git clone https://github.com/Labs64/labs64.io-payment-gateway.git && cd labs64.io-payment-gateway
docker compose -f docker-compose.images.yml up -d

Once the stack is healthy (about 30 seconds), pay for an order. The built-in NoOp provider succeeds without a PSP account, and the headers stand in for the ones the API Gateway adds after authenticating a caller:

api() { curl -s -H "X-Auth-User: demo-user" -H "X-Auth-Tenant: demo" -H "X-Request-ID: demo" \
  -H "X-Auth-Scopes: payment-provider:read,payment-provider:write,payment:read,payment:write,payment:pay,payment-transaction:read" \
  -H "Content-Type: application/json" "$@"; }
id() { grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4; }

# 1. Enable the NoOp provider for this tenant
PROVIDER=$(api -X POST localhost:8080/payment-providers -d '{"provider":"noop","active":true}' | id)

# 2. Create a payment for a 30.00 USD order
PAYMENT=$(api -X POST localhost:8080/payments -d '{
  "paymentProviderId": "'$PROVIDER'",
  "purchaseOrder": {"currency":"USD","netAmount":3000,"taxAmount":0,"grossAmount":3000,
                    "items":[{"name":"Widget Pro","sku":"WIDGET-PRO","price":3000,"quantity":1}]},
  "billingInfo": {"firstName":"Jane","lastName":"Doe","email":"jane.doe@example.com",
                  "country":"US","city":"San Francisco","address1":"123 Market St","postalCode":"94105"}
}' | id)

# 3. Pay it twice with the same Idempotency-Key
for i in 1 2; do
  api -X POST localhost:8080/payments/$PAYMENT/pay -H "Idempotency-Key: order-1001" \
    | grep -o '"status":"[A-Z]*"' | tr '\n' ' '; echo
done

# 4. How many transactions were created?
api "localhost:8080/payment-transactions?paymentId=$PAYMENT" | grep -o '"totalItems":[0-9]*'
"status":"CLOSED" "status":"SUCCESS"
"status":"CLOSED" "status":"SUCCESS"
"totalItems":1

You paid twice and were charged once: the replay returned the original transaction. The payment is CLOSED, the transaction SUCCESS. Swagger UI is at http://localhost:8080/swagger-ui/index.html; clean up with docker compose -f docker-compose.images.yml down -v.

That stack runs without an API Gateway and with authorization off: do not expose port 8080.

Build from source

Needs Docker with Compose v2, JDK 25 and Maven 3.6.3+:

mvn -B clean package -DskipTests -pl payment-gateway-be -am   # build the backend and its modules
cd payment-gateway-be && cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.app.yml up -d --build

The repository’s AGENTS.md describes the layout, commands and tests, and CONTRIBUTING.md covers pull requests. To add a payment provider, start from the SPI in payment-gateway-providers/providers-spi and use the noop module as a template.

Configuration

Service

Variable Description Default
SPRING_PROFILES_ACTIVE local for a gateway-less run; the chart sets the production profile. none
DB_HOST, DB_PORT, DB_NAME PostgreSQL connection. localhost, 5432, paymentgateway
DB_USERNAME, DB_PASSWORD PostgreSQL credentials. paymentgateway (local profile only)
REDIS_HOST, REDIS_PORT Redis-compatible store for idempotency keys. localhost, 6379
LABS64_AUTH_AUTHZ_PDPADDRESS Central Cerbos policy engine (gRPC). none
AUDITFLOW_ENABLED Publish payment lifecycle events to AuditFlow. false
AUDITFLOW_URL AuditFlow base URL, when events are enabled. none

Provider credentials

Provider credentials are not environment variables. They are part of the tenant’s provider configuration, set through the API when the provider is created:

POST /payment-providers
{
  "provider": "stripe",
  "active": true,
  "name": "Stripe",
  "config": { "apiKey": "sk_test_…", "webhookSecret": "whsec_…" }
}

GET /payment-definitions lists the supported provider identifiers. Webhook authenticity is verified with the provider’s signature, using the secret stored in this configuration.

REST APIs

All paths are relative to the service; behind the API Gateway they are served under /payment-gateway/api/v1.

Endpoint Method Description
/payment-definitions GET Supported providers (public).
/payment-providers GET, POST List or enable a provider for the tenant.
/payment-providers/{id} GET, PATCH, DELETE Read, update or remove a tenant provider.
/payments GET, POST List payments or create one (READY).
/payments/{id} GET Payment status and any pending next action.
/payments/{id}/pay POST Execute the payment. Send an Idempotency-Key.
/payments/{id}/close POST Close a payment.
/payment-transactions GET List transactions, filter by paymentId.
/payment-transactions/{id} GET One transaction.
/providers/{provider}/webhooks POST Ingress for a provider’s webhooks (public, signature-verified).
/providers/{provider}/checkout-sessions/{sessionId}/return GET Browser return after an external checkout (public).
/providers/{provider}/checkout-sessions/{sessionId}/cancel GET Browser cancel after an external checkout (public).
/checkout-sessions/{sessionId}/confirmation GET Confirmation of a finished checkout session.

Operations require scopes such as payment-provider:read, payment-provider:write, payment:read, payment:write, payment:pay and payment-transaction:read. The OpenAPI contract is the source of truth: openapi-payment-gateway-v1.yaml.

Events

When AUDITFLOW_ENABLED is true, Payment Gateway publishes lifecycle events to AuditFlow over its HTTP API, as the service:payment-gateway principal. It has no message-broker dependency.

Event Type Description
payment.created A payment was created.
payment.finalized A payment reached a final outcome.
payment.closed A payment was closed.

Examples

Paying an order

# 1. Enable the NoOp provider for the tenant
POST /payment-providers
{ "provider": "noop", "active": true }
# -> { "id": "<providerId>", ... }

# 2. Create a payment (201, status READY)
POST /payments
{
  "paymentProviderId": "<providerId>",
  "purchaseOrder": {
    "currency": "USD", "netAmount": 3000, "taxAmount": 0, "grossAmount": 3000,
    "items": [{ "name": "Widget Pro", "sku": "WIDGET-PRO", "price": 3000, "quantity": 1 }]
  },
  "billingInfo": { "firstName": "Jane", "lastName": "Doe", "email": "jane.doe@example.com",
                   "country": "US", "city": "San Francisco", "address1": "123 Market St", "postalCode": "94105" }
}

# 3. Pay it. Replaying with the same key returns the same transaction
POST /payments/{paymentId}/pay          Idempotency-Key: order-1001
# -> payment.status CLOSED, paymentTransaction.status SUCCESS

Operations

Payment Gateway stores payments and transactions in PostgreSQL and idempotency keys in Redis; both are required. Because this module handles payment orchestration, restrict network egress to the providers you use (the chart’s NetworkPolicy allows HTTPS only) and keep provider credentials in tenant configuration protected by your access controls.

Troubleshooting

Symptom Cause Resolution
401 Unauthorized on every call The trusted X-Auth-* headers are missing or invalid Call through the API Gateway, or send X-Auth-User, X-Auth-Tenant, X-Auth-Scopes (comma-separated) and X-Request-ID.
VALIDATION_ERROR on paymentProviderId The payment names no provider Create the provider with POST /payment-providers first and pass its id.
404 for /api/v1/... on a direct call The /api/v1 prefix is added by the gateway Call /payments directly, or /payment-gateway/api/v1/payments through the gateway.
Payment fails with the provider Wrong or missing provider credentials Check the provider’s config for the tenant; test with the NoOp provider first.
Webhook does not update state The provider cannot reach the webhook URL or the signature does not match Make /providers/{provider}/webhooks reachable by the provider and verify the webhook secret.