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.
| Field | Description |
|---|---|
zitadel_org_id | The Zitadel organization ID (primary key) |
callback_url | HTTPS URL where outbound webhooks are delivered |
enabled | Whether the tenant is active |
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.
| Field | Description |
|---|---|
psp | The PSP identifier (nowpayments or chapa) |
currencies | List of currencies this config handles (e.g., ["USDT", "BTC"]) |
credentials_encrypted | PSP API keys/secrets, encrypted at rest with Cloak |
priority | Selection priority when multiple configs match a currency (lower = higher priority) |
enabled | Whether this PSP config is active |
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:
- Look up all enabled PSP configs for the tenant's
org_id - Filter to configs that support the requested
currency - Select the config with the highest priority (lowest priority number)
- 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_idand 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.
| Capability | Platform User | Tenant User |
|---|---|---|
| View own payments | Yes | Yes |
| Create deposits/payouts | Yes | Yes |
| Manage own PSP configs | Yes | Yes |
| View all tenants' payments | Yes | No |
| List all tenants | Yes | No |
Access /api/admin/* endpoints | Yes | No |
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 accessProducts 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.