Phoenix Pay
Concepts

Multi-Tenancy

How Phoenix Pay isolates data and configuration across tenants using Zitadel organizations.

Multi-Tenancy

Phoenix Pay is a multi-tenant system. Each tenant is a separate organization in Zitadel, with isolated PSP credentials, callback URLs, and payment data. This design lets Phoenix Pay serve multiple products -- Phoenix Shop, ET Lottery, and external third-party integrators -- from a single deployment.

Flat Tenancy Model

Phoenix Pay uses a flat tenancy model. There is no hierarchy of tenants or sub-tenants. Each Zitadel organization maps directly to one tenant.

Zitadel                          Phoenix Pay
┌─────────────────────┐         ┌─────────────────────┐
│ Org: Phoenix Games  │────────▶│ Platform Operator   │
│ (phoenix_org_id)    │         │ (sees all tenants)  │
├─────────────────────┤         ├─────────────────────┤
│ Org: Phoenix Shop   │────────▶│ Tenant A            │
│ (org_id: abc123)    │         │ own PSP creds,      │
│                     │         │ own payments         │
├─────────────────────┤         ├─────────────────────┤
│ Org: ET Lottery     │────────▶│ Tenant B            │
│ (org_id: def456)    │         │ own PSP creds,      │
│                     │         │ own payments         │
├─────────────────────┤         ├─────────────────────┤
│ Org: Acme Corp      │────────▶│ Tenant C            │
│ (org_id: ghi789)    │         │ own PSP creds,      │
│                     │         │ own payments         │
└─────────────────────┘         └─────────────────────┘

There is no tenants table in Phoenix Pay. Zitadel is the source of truth for tenant identity. Phoenix Pay only stores operational configuration -- PSP credentials and callback URLs -- keyed by the Zitadel organization ID.

Tenant Identification

The tenant is determined automatically from the JWT access token. Specifically, the urn:zitadel:iam:user:resourceowner:id claim contains the organization ID, which Phoenix Pay uses as the tenant identifier.

There is no way to override the tenant via headers, query parameters, or request body. This ensures tenants cannot accidentally (or intentionally) access another tenant's data.

Tenant Configuration

Each tenant has two types of configuration stored in Phoenix Pay:

Tenant Config

General tenant settings, stored in the tenant_configs table.

FieldDescription
zitadel_org_idThe Zitadel organization ID (primary key)
callback_urlHTTPS URL where outbound webhooks are delivered
enabledWhether the tenant is active
Update tenant config
curl -X PUT https://pay.phoenixverse.io/api/config \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://myapp.example.com/webhooks/phoenix-pay"
  }'

PSP Configs

Per-PSP credentials and routing rules, stored in the tenant_psp_configs table. Each tenant can have multiple PSP configurations for different currencies or as fallbacks.

FieldDescription
pspThe PSP identifier (nowpayments or chapa)
currenciesList of currencies this config handles (e.g., ["USDT", "BTC"])
credentials_encryptedPSP API keys/secrets, encrypted at rest with Cloak
prioritySelection priority when multiple configs match a currency (lower = higher priority)
enabledWhether this PSP config is active
Add a PSP configuration
curl -X POST https://pay.phoenixverse.io/api/config/psp \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "psp": "nowpayments",
    "currencies": ["USDT", "BTC", "ETH"],
    "credentials": {
      "api_key": "your-nowpayments-api-key",
      "ipn_secret": "your-ipn-secret"
    },
    "priority": 1,
    "enabled": true
  }'

PSP credentials are encrypted at rest using Cloak (AES-256-GCM). They are never returned in API responses -- only the PSP name, currencies, and priority are visible.

PSP Selection

When a payment is created, Phoenix Pay selects the PSP using the Registry:

  1. Look up all enabled PSP configs for the tenant's org_id
  2. Filter to configs that support the requested currency
  3. Select the config with the highest priority (lowest priority number)
  4. Decrypt the credentials and call the PSP adapter

If no matching PSP config is found, the API returns a 422 error.

Data Isolation

All payment data is scoped to the tenant's organization ID:

  • Payments are stored with zitadel_org_id and filtered on every query
  • Payment events inherit the tenant scope from their parent payment
  • PSP configs are keyed by zitadel_org_id
  • Tenant configs are keyed by zitadel_org_id

A tenant can never see, modify, or interact with another tenant's data through the API.

Platform vs. Tenant Access

Phoenix Pay recognizes one special organization: the Phoenix Games platform organization. Users authenticated with tokens from this organization are "platform users" and have elevated access.

CapabilityPlatform UserTenant User
View own paymentsYesYes
Create deposits/payoutsYesYes
Manage own PSP configsYesYes
View all tenants' paymentsYesNo
List all tenantsYesNo
Access /api/admin/* endpointsYesNo

The platform organization ID is configured on the Phoenix Pay server via the phoenix_org_id setting. It is compared against the org_id extracted from each request's JWT.

Platform user: token.org_id == configured phoenix_org_id → full access
Tenant user:   token.org_id != configured phoenix_org_id → scoped access

Products like Phoenix Shop may serve multiple operators internally. In this case, Phoenix Shop itself is the tenant, and it handles operator-level routing within its own system. Phoenix Pay does not need to know about these sub-divisions.

On this page