Phoenix Pay

Authentication

How to authenticate with Phoenix Pay using Zitadel machine-to-machine tokens.

Authentication

Phoenix Pay uses Zitadel for identity and access management. All API requests must include a valid JWT access token obtained via the OAuth 2.0 client credentials grant. The token's organization ID automatically determines your tenant scope.

Overview

┌──────────────┐                          ┌──────────────┐
│              │  1. Client Credentials   │              │
│  Your App    │─────────────────────────▶│   Zitadel    │
│              │◀─────────────────────────│              │
│              │  2. JWT Access Token     │              │
└──────┬───────┘                          └──────────────┘

       │ 3. API Request
       │    Authorization: Bearer <token>

┌──────────────┐
│              │  4. Validates JWT via JWKS
│ Phoenix Pay  │  5. Extracts org_id from claims
│              │  6. Scopes all data to org_id
└──────────────┘

Client Credentials Flow

Step 1: Create a Service Account

In the Zitadel console, create a service account (machine user) within your organization:

  1. Navigate to your organization in the Zitadel console
  2. Go to Users and select Service Users
  3. Create a new service user
  4. Under Keys or Client Credentials, generate a client ID and client secret
  5. Grant the service user the appropriate roles on the Phoenix Pay project

Step 2: Request an Access Token

Exchange your credentials for a JWT access token.

Token request
curl -X POST https://auth.phoenixverse.io/oauth/v2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=284762139458273649" \
  -d "client_secret=your-client-secret" \
  -d "scope=openid urn:zitadel:iam:org:project:id:PROJECT_ID:aud"
Token response
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjI0OTc...",
  "token_type": "Bearer",
  "expires_in": 43199
}

The scope parameter must include urn:zitadel:iam:org:project:id:PROJECT_ID:aud where PROJECT_ID is the Phoenix Pay project ID. This ensures the token's audience claim matches what Phoenix Pay expects. Without it, your requests will be rejected with a 401.

Step 3: Use the Token

Include the token in the Authorization header of every API request.

Authenticated request
curl https://pay.phoenixverse.io/api/deposits \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."

Token Format

Phoenix Pay validates the JWT using Zitadel's JWKS (JSON Web Key Set) endpoint. The token contains these relevant claims:

Decoded JWT payload (relevant claims)
{
  "iss": "https://auth.phoenixverse.io",
  "sub": "284762139458273649",
  "aud": ["PROJECT_ID"],
  "exp": 1741689600,
  "urn:zitadel:iam:user:resourceowner:id": "293847561029384756",
  "urn:zitadel:iam:user:resourceowner:name": "Acme Corp",
  "urn:zitadel:iam:org:project:PROJECT_ID:roles": {
    "tenant_admin": {
      "293847561029384756": "Acme Corp"
    }
  }
}
ClaimDescription
issThe Zitadel issuer URL. Must match Phoenix Pay's configured issuer.
subThe service account's user ID.
audMust include the Phoenix Pay project ID.
expToken expiration time (Unix timestamp).
urn:zitadel:iam:user:resourceowner:idThe organization ID. This is used as the tenant identifier. All data is scoped to this org.
urn:zitadel:iam:user:resourceowner:nameThe organization's display name.
urn:zitadel:iam:org:project:PROJECT_ID:rolesRoles granted on the Phoenix Pay project.

Tenant Scoping

The resourceowner:id claim in your JWT determines your tenant. This mapping is automatic and cannot be overridden.

  • All API calls are scoped to the org_id extracted from your token
  • Deposits and payouts are only visible to the tenant that created them
  • PSP configurations are isolated per tenant
  • Webhook callbacks are sent to tenant-specific callback URLs

There is no X-Tenant-ID header or query parameter. Tenant isolation is enforced at the authentication layer through Zitadel's organization model.

Platform vs. Tenant Users

Phoenix Pay distinguishes between two types of users based on the token's organization:

TypeConditionAccess
Platform Userorg_id matches the configured Phoenix Games organizationCan access all tenants' data, manage tenant configurations, view cross-tenant statistics via /api/admin/* endpoints
Tenant Userorg_id is any other organizationCan only access their own organization's payments and configuration

Platform users are internal Phoenix Games operators. The platform organization ID is configured on the server and is not something tenants need to know about.

Roles

Phoenix Pay recognizes the following project roles:

RoleDescription
platform_adminFull access to all tenants and admin endpoints. Reserved for Phoenix Games operators.
tenant_adminFull access to the tenant's own payments, configuration, and PSP settings.

Roles are extracted from the urn:zitadel:iam:org:project:PROJECT_ID:roles claim in the JWT.

Token Refresh

Access tokens are short-lived (typically 12 hours). Your application should handle token refresh.

token-manager.js
class TokenManager {
  constructor(clientId, clientSecret, issuerUrl, projectId) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.tokenUrl = `${issuerUrl}/oauth/v2/token`;
    this.projectId = projectId;
    this.token = null;
    this.expiresAt = 0;
  }

  async getToken() {
    // Refresh if token expires within 5 minutes
    if (this.token && Date.now() / 1000 < this.expiresAt - 300) {
      return this.token;
    }

    const params = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.clientId,
      client_secret: this.clientSecret,
      scope: `openid urn:zitadel:iam:org:project:id:${this.projectId}:aud`,
    });

    const res = await fetch(this.tokenUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: params,
    });

    const data = await res.json();
    this.token = data.access_token;
    this.expiresAt = Date.now() / 1000 + data.expires_in;
    return this.token;
  }
}
token_manager.py
import time
import requests

class TokenManager:
    def __init__(self, client_id, client_secret, issuer_url, project_id):
        self.client_id = client_id
        self.client_secret = client_secret
        self.token_url = f"{issuer_url}/oauth/v2/token"
        self.project_id = project_id
        self.token = None
        self.expires_at = 0

    def get_token(self):
        # Refresh if token expires within 5 minutes
        if self.token and time.time() < self.expires_at - 300:
            return self.token

        response = requests.post(self.token_url, data={
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "scope": f"openid urn:zitadel:iam:org:project:id:{self.project_id}:aud",
        })

        data = response.json()
        self.token = data["access_token"]
        self.expires_at = time.time() + data["expires_in"]
        return self.token

Error Responses

StatusMeaning
401 UnauthorizedMissing or invalid token. The token may be expired, have an invalid signature, or be missing the required audience claim.
403 ForbiddenThe token is valid but your role does not permit the requested action (e.g., tenant user accessing admin endpoints).
401 response
{
  "error": "unauthorized"
}

Never expose your client secret in client-side code or public repositories. The client credentials grant is designed for server-to-server communication only.

On this page