Phoenix Gamification

Rewards Catalog Complete Guide

The Phoenix Rewards Catalog provides a flexible reward system for managing purchasable rewards, automatic grants, and order fulfillment. This guide covers everything you need to integrate the rewards catalog into your application.

Authentication

All rewards catalog endpoints support multiple authentication methods:

GatewayOperationEndpointsAuth Methods
Admin GatewayCreate/manage rewards/v1/tenants/{tenant_id}/rewards-catalog/*Admin JWT, API Key
Admin GatewayGrant rewards/v1/tenants/{tenant_id}/grantsAdmin JWT, API Key
Admin GatewayManage orders/v1/tenants/{tenant_id}/reward-orders/*Admin JWT, API Key
Query GatewayList rewards/v1/rewards/{tenant_id}/catalogTenant JWT, HMAC
Query GatewayGet reward/v1/rewards/{tenant_id}/catalog/{slug}Tenant JWT, HMAC
Query GatewayPurchase reward/v1/rewards/{tenant_id}/purchase/{slug}Tenant JWT, HMAC
Query GatewayClaim order/v1/rewards/{tenant_id}/orders/{order_id}/claimTenant JWT, HMAC
Query GatewayList user orders/v1/rewards/{tenant_id}/ordersTenant JWT, HMAC

Recommended for frontend apps: Use JWT authentication. Your proxy server issues JWTs after user login, and the frontend calls Phoenix APIs directly with the token.

See Getting Started for authentication setup details.

Key Features

  • Reward Catalog: Define purchasable rewards with pricing and inventory
  • Multiple Fulfillment Types: Wallet-based, manual, or API fulfillment
  • Order Tracking: Full lifecycle tracking from purchase to delivery
  • Custom Fields: Collect user information needed for fulfillment
  • Stock Management: Inventory limits with automatic decrement
  • Idempotent Purchases: Prevent duplicate orders with idempotency keys

Core Concepts

Rewards

Rewards are items users can purchase or receive as grants. Each reward includes:

PropertyTypeDescription
slugstringUnique human-readable identifier
namestringDisplay name
descriptionstringOptional description
image_urlstringOptional image URL
activebooleanWhether reward is available
fieldsarrayUser input fields required for fulfillment
fulfillmentobjectHow to deliver the reward
requires_claimbooleanWhether user must claim before fulfillment
purchaseobjectPurchase configuration with prices (optional)
stockintegerInventory limit (null = unlimited)
configobjectExtra data for fulfillment handler

Fulfillment Types

Wallet Fulfillment

Automatically grants XP or badges via the wallet service:

{
  "type": "wallet",
  "item_type": "xp",
  "amount": 1000
}
{
  "type": "wallet",
  "item_type": "badge",
  "item_id": "vip_badge_2026"
}

Manual Fulfillment

For physical items requiring admin action:

{
  "type": "manual",
  "notes": "Ship to user's address"
}

API Fulfillment

Calls your webhook for external fulfillment:

{
  "type": "api",
  "retry": {
    "max_attempts": 3,
    "backoff_seconds": [60, 300, 900]
  }
}

Order States

Orders progress through these states:

StateDescription
pending_claimWaiting for user to provide required fields
fulfillingBeing processed
completedSuccessfully delivered
failedDelivery failed (may be retryable)
expiredExpired before claiming

User Endpoints (Query Gateway)

List Purchasable Rewards

Get all active rewards available for purchase.

GET /v1/rewards/{tenant_id}/catalog

Query Parameters:

ParameterTypeDescription
balanceintegerUser's balance (optional, for affordability check)

Response:

{
  "rewards": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "slug": "airtime-topup",
      "name": "Airtime Top-up",
      "description": "Mobile airtime for any network",
      "image_url": "https://example.com/airtime.png",
      "purchase": {
        "prices": [
          { "currency": "gold_coins", "amount": 500 }
        ]
      },
      "stock": 100,
      "fields": [
        {
          "name": "phone_number",
          "label": "Phone Number",
          "required": true,
          "type": "text",
          "pattern": "^\\+251[0-9]{9}$"
        }
      ],
      "affordable": true,
      "points_needed": null
    }
  ]
}

Get Reward Details

Get details for a specific reward.

GET /v1/rewards/{tenant_id}/catalog/{slug}

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "slug": "airtime-topup",
  "name": "Airtime Top-up",
  "description": "Mobile airtime for any network",
  "image_url": "https://example.com/airtime.png",
  "fields": [
    {
      "name": "phone_number",
      "label": "Phone Number",
      "required": true,
      "type": "text",
      "pattern": "^\\+251[0-9]{9}$"
    }
  ],
  "purchase": {
    "prices": [
      { "currency": "gold_coins", "amount": 500 }
    ]
  },
  "stock": 100
}

Purchase Reward

Purchase a reward using wallet currency.

POST /v1/rewards/{tenant_id}/purchase/{slug}

Request:

{
  "user_id": "user_123",
  "idempotency_key": "purchase_user123_airtime_1706345678",
  "currency": "gold_coins",
  "fields": {
    "phone_number": "+251912345678"
  }
}

Response:

{
  "order": {
    "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "tenant_id": "your_tenant",
    "user_id": "user_123",
    "reward_id": "550e8400-e29b-41d4-a716-446655440000",
    "variant_id": "default",
    "source_type": "purchase",
    "status": {
      "state": "fulfilling",
      "started_at": "2026-01-28T10:30:00Z"
    },
    "fields": {
      "phone_number": "+251912345678"
    },
    "fulfillment": {
      "type": "api"
    },
    "requires_claim": false,
    "created_at": "2026-01-28T10:30:00Z",
    "updated_at": "2026-01-28T10:30:00Z"
  },
  "was_cached": false
}

Claim Order

Submit required fields to claim a pending order (for grants with requires_claim: true).

POST /v1/rewards/{tenant_id}/orders/{order_id}/claim

Request:

{
  "user_id": "user_123",
  "fields": {
    "phone_number": "+251912345678",
    "address": "123 Main St, Addis Ababa"
  }
}

Response:

{
  "order": {
    "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "status": {
      "state": "fulfilling",
      "started_at": "2026-01-28T10:35:00Z"
    }
  }
}

List User Orders

Get all orders for a user.

GET /v1/rewards/{tenant_id}/orders/{user_id}

Query Parameters:

ParameterTypeDescription
statusstringFilter by status (optional)
limitintegerPage size (default: 20)
offsetintegerPagination offset

Response:

{
  "orders": [
    {
      "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "reward_id": "550e8400-e29b-41d4-a716-446655440000",
      "reward_name": "Airtime Top-up",
      "reward_image_url": "https://example.com/airtime.png",
      "variant_id": "default",
      "source_type": "purchase",
      "status": {
        "state": "completed",
        "data": {
          "type": "api",
          "external_id": "TXN123456"
        },
        "completed_at": "2026-01-28T10:31:00Z"
      },
      "requires_claim": false,
      "created_at": "2026-01-28T10:30:00Z"
    }
  ],
  "total": 15,
  "limit": 20,
  "offset": 0
}

Get Recent Field Values

Get auto-fill values from user's recent orders.

GET /v1/rewards/{tenant_id}/recent-fields?user_id={user_id}

Response:

{
  "fields": {
    "phone_number": "+251912345678",
    "address": "123 Main St, Addis Ababa"
  }
}

Admin Endpoints (Admin Gateway)

Create Reward

Create a new reward in the catalog.

POST /v1/tenants/{tenant_id}/rewards-catalog

Request:

{
  "slug": "airtime-topup",
  "name": "Airtime Top-up",
  "description": "Mobile airtime for any network",
  "image_url": "https://example.com/airtime.png",
  "active": true,
  "fields": [
    {
      "name": "phone_number",
      "label": "Phone Number",
      "required": true,
      "type": "text",
      "pattern": "^\\+251[0-9]{9}$",
      "placeholder": "+251912345678"
    }
  ],
  "fulfillment": {
    "type": "api",
    "retry": {
      "max_attempts": 3,
      "backoff_seconds": [60, 300, 900]
    }
  },
  "requires_claim": false,
  "purchase": {
    "prices": [
      { "currency": "gold_coins", "amount": 500 }
    ]
  },
  "stock": 1000,
  "config": {
    "amount_etb": 10,
    "networks": ["ethio-telecom", "safaricom"]
  }
}

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "tenant_id": "your_tenant",
  "slug": "airtime-topup",
  "name": "Airtime Top-up",
  "description": "Mobile airtime for any network",
  "image_url": "https://example.com/airtime.png",
  "active": true,
  "fields": [...],
  "fulfillment": {...},
  "requires_claim": false,
  "purchase": {
    "prices": [
      { "currency": "gold_coins", "amount": 500 }
    ]
  },
  "stock": 1000,
  "config": {
    "amount_etb": 10,
    "networks": ["ethio-telecom", "safaricom"]
  },
  "created_at": "2026-01-28T10:00:00Z",
  "updated_at": "2026-01-28T10:00:00Z"
}

List Rewards

List all rewards in the catalog.

GET /v1/tenants/{tenant_id}/rewards-catalog

Query Parameters:

ParameterTypeDescription
activebooleanFilter by active status (default: true)
limitintegerPage size (default: 50)
offsetintegerPagination offset

Get Reward

Get a specific reward by ID.

GET /v1/tenants/{tenant_id}/rewards-catalog/{reward_id}

Update Reward

Update an existing reward.

PUT /v1/tenants/{tenant_id}/rewards-catalog/{reward_id}

Request:

{
  "name": "Airtime Top-up (Updated)",
  "stock": 500,
  "active": true
}

All fields are optional. Only provided fields are updated.

Delete Reward

Deactivate (soft delete) a reward.

DELETE /v1/tenants/{tenant_id}/rewards-catalog/{reward_id}

Grant Reward

Grant a reward to a user (bypass purchase).

POST /v1/tenants/{tenant_id}/grants

Request:

{
  "user_id": "user_123",
  "reward_id": "550e8400-e29b-41d4-a716-446655440000",
  "source_type": "leaderboard",
  "source_ref": "weekly_top10",
  "expires_at": "2026-02-28T23:59:59Z"
}

Response:

{
  "order": {
    "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "tenant_id": "your_tenant",
    "user_id": "user_123",
    "reward_id": "550e8400-e29b-41d4-a716-446655440000",
    "variant_id": "default",
    "source_type": "leaderboard",
    "source_ref": "weekly_top10",
    "status": {
      "state": "pending_claim"
    },
    "fields": {},
    "fulfillment": {...},
    "requires_claim": true,
    "expires_at": "2026-02-28T23:59:59Z",
    "created_at": "2026-01-28T10:30:00Z",
    "updated_at": "2026-01-28T10:30:00Z"
  }
}

Update Order Status (Manual Fulfillment)

Complete or fail a manual fulfillment order.

PUT /v1/tenants/{tenant_id}/reward-orders/{order_id}

Complete:

{
  "status": {
    "state": "completed",
    "tracking_number": "TRACK123456",
    "shipped_at": "2026-01-28T14:00:00Z",
    "notes": "Shipped via DHL"
  }
}

Failed:

{
  "status": {
    "state": "failed",
    "reason": "Item out of stock"
  }
}

Retry Failed Order

Retry a failed order fulfillment.

POST /v1/tenants/{tenant_id}/reward-orders/{order_id}/retry

Refund Order

Refund a purchase order.

POST /v1/tenants/{tenant_id}/reward-orders/{order_id}/refund

Request:

{
  "reason": "Customer requested cancellation"
}

JavaScript Integration Example

class RewardsCatalogClient {
  constructor(apiUrl, jwt) {
    this.apiUrl = apiUrl;
    this.jwt = jwt;
  }

  async listRewards(tenantId, balance = null) {
    const params = new URLSearchParams();
    if (balance !== null) {
      params.set('balance', balance);
    }
    
    const url = `${this.apiUrl}/v1/rewards/${tenantId}/catalog${params.toString() ? '?' + params : ''}`;
    const response = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${this.jwt}`,
        'Content-Type': 'application/json'
      }
    });
    return response.json();
  }

  async getReward(tenantId, slug) {
    const response = await fetch(
      `${this.apiUrl}/v1/rewards/${tenantId}/catalog/${slug}`,
      {
        headers: {
          'Authorization': `Bearer ${this.jwt}`,
          'Content-Type': 'application/json'
        }
      }
    );
    return response.json();
  }

  async purchase(tenantId, slug, userId, currency, fields = {}) {
    const response = await fetch(
      `${this.apiUrl}/v1/rewards/${tenantId}/purchase/${slug}`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${this.jwt}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          user_id: userId,
          idempotency_key: `purchase_${userId}_${slug}_${Date.now()}`,
          currency,
          fields
        })
      }
    );
    
    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Purchase failed');
    }
    
    return response.json();
  }

  async listOrders(tenantId, userId, options = {}) {
    const params = new URLSearchParams(options);
    
    const response = await fetch(
      `${this.apiUrl}/v1/rewards/${tenantId}/orders/${userId}?${params}`,
      {
        headers: {
          'Authorization': `Bearer ${this.jwt}`,
          'Content-Type': 'application/json'
        }
      }
    );
    return response.json();
  }

  async claim(tenantId, orderId, userId, fields) {
    const response = await fetch(
      `${this.apiUrl}/v1/rewards/${tenantId}/orders/${orderId}/claim`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${this.jwt}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ user_id: userId, fields })
      }
    );
    return response.json();
  }

  async getRecentFields(tenantId, userId) {
    const response = await fetch(
      `${this.apiUrl}/v1/rewards/${tenantId}/recent-fields?user_id=${userId}`,
      {
        headers: {
          'Authorization': `Bearer ${this.jwt}`,
          'Content-Type': 'application/json'
        }
      }
    );
    return response.json();
  }
}

// Example usage
const rewards = new RewardsCatalogClient('https://query.phoenix.example.com', userJwt);

// Browse catalog with affordability check
const { rewards: catalog } = await rewards.listRewards('tenant_abc', 1500);
console.log(`Available rewards: ${catalog.length}`);

// Show affordable rewards
catalog.filter(r => r.affordable).forEach(r => {
  console.log(`${r.name} - ${r.purchase?.prices[0]?.amount} coins`);
});

// Purchase airtime
const order = await rewards.purchase('tenant_abc', 'airtime-topup', 'user_123', 'gold_coins', {
  phone_number: '+251912345678'
});
console.log(`Order created: ${order.order.id}`);

// Check order history
const { orders } = await rewards.listOrders('tenant_abc', 'user_123');
orders.forEach(o => {
  console.log(`${o.reward_name}: ${o.status.state}`);
});

Field Types

Rewards can require user input fields for fulfillment:

Text Field

{
  "name": "phone_number",
  "label": "Phone Number",
  "required": true,
  "type": "text",
  "pattern": "^\\+[0-9]{10,15}$",
  "min_length": 10,
  "max_length": 15,
  "placeholder": "+251912345678"
}

Select Field

{
  "name": "network",
  "label": "Network Provider",
  "required": true,
  "type": "select",
  "options": [
    { "value": "ethio", "label": "Ethio Telecom" },
    { "value": "safaricom", "label": "Safaricom" }
  ]
}

Multiline Text

{
  "name": "address",
  "label": "Shipping Address",
  "required": true,
  "type": "text",
  "multiline": true,
  "max_length": 500
}

Webhooks

Configure webhooks to receive reward order events.

Event Types

EventDescription
order.createdNew order created (purchase or grant)
order.claimedUser submitted claim fields
order.fulfillingFulfillment started
order.completedOrder successfully fulfilled
order.failedOrder fulfillment failed

Webhook Payload

{
  "event_type": "order.completed",
  "tenant_id": "your_tenant",
  "timestamp": "2026-01-28T10:31:00Z",
  "data": {
    "order_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "user_id": "user_123",
    "reward_id": "550e8400-e29b-41d4-a716-446655440000",
    "reward_slug": "airtime-topup",
    "fulfillment_type": "api",
    "fulfillment_data": {
      "external_id": "TXN123456"
    }
  }
}

Webhooks include an X-Webhook-Signature header with an HMAC-SHA256 signature of the payload.

Best Practices

  1. Idempotency Keys: Always generate unique keys for purchases to prevent duplicates
  2. Field Validation: Validate fields client-side before submission
  3. Stock Monitoring: Monitor stock levels and disable purchases when low
  4. Error Handling: Handle insufficient balance and out-of-stock gracefully
  5. Recent Fields: Use the recent-fields endpoint for auto-fill
  6. Order Polling: Poll order status for long-running fulfillments
  7. Affordability Check: Pass user balance to catalog endpoint for better UX

Next Steps