> ## Documentation Index
> Fetch the complete documentation index at: https://docs.profy.cn/llms.txt
> Use this file to discover all available pages before exploring further.

# Per-Use Billing SaaS Tutorial

> Build a per-use billing SaaS application with the Profy App SDK: billing event configuration, idempotent reporting, balance pre-checks, and error handling.

This tutorial uses a document generation tool as an example to demonstrate how to implement per-use billing through Profy's PER\_USE billing model.

## What You'll Build

A document generation SaaS — every time a user performs a paid action, Profy automatically deducts the corresponding credits from their account:

* Generate report (`generate_report`) → deducts 10 credits
* Export PDF (`export_pdf`) → deducts 5 credits
* Generate summary (`generate_summary`) → deducts 3 credits

The entire billing flow is handled by your application calling the SDK, with Profy guaranteeing idempotency and atomicity.

## Prerequisites

| Requirement               | Description                                                             |
| ------------------------- | ----------------------------------------------------------------------- |
| Profy Creator Account     | Creator verification approved                                           |
| App Created               | Created an App in Profy Studio and obtained `clientId` / `clientSecret` |
| Billing Events Configured | Added Meters in the "Billing Events" tab in Studio                      |
| OAuth Completed           | Holding the user's `accessToken` / `refreshToken`                       |

## Step 1: Configure Billing Events in Studio

Go to **Profy Studio → Your App → Billing Events** and add the following Meters:

| Event Name         | Display Name     | Unit Price (Credits) | Description                     |
| ------------------ | ---------------- | -------------------- | ------------------------------- |
| `generate_report`  | Generate Report  | 10                   | Generate a full analysis report |
| `export_pdf`       | Export PDF       | 5                    | Export document as PDF format   |
| `generate_summary` | Generate Summary | 3                    | AI-generated document summary   |

<Note>
  Event names cannot be changed once created — use `snake_case` naming. Unit prices can be adjusted at any time and take effect immediately.
</Note>

## Step 2: Install and Initialize the SDK

<CodeGroup>
  ```bash TypeScript theme={null}
  npm install @profy-ai/sdk
  ```

  ```bash Python theme={null}
  pip install profy-sdk
  ```
</CodeGroup>

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { ProfyApp } from "@profy-ai/sdk";

  const profy = new ProfyApp({
    clientId: process.env.PROFY_APP_ID!,
    clientSecret: process.env.PROFY_APP_SECRET!,
    onTokenRefresh: (newToken) => {
      saveToDatabase(newToken);
    },
  });
  ```

  ```python Python theme={null}
  from profy_sdk import ProfyApp

  profy = ProfyApp(
      client_id=os.environ["PROFY_APP_ID"],
      client_secret=os.environ["PROFY_APP_SECRET"],
      on_token_refresh=lambda token: save_to_database(token),
  )
  ```
</CodeGroup>

## Step 3: Implement Billing Reporting

When a user triggers a paid action, call `reportEvent` to report the billing event.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { randomUUID } from "crypto";

  async function generateReport(userId: string, token: string) {
    const idempotencyKey = `${userId}-generate_report-${randomUUID()}`;

    const result = await profy.reportEvent("generate_report", {
      token,
      idempotencyKey,
      metadata: { userId, action: "generate_report" },
    });

    console.log(`Charged: ${result.charged}, Balance: ${result.balance_remaining}`);
    return result;
  }
  ```

  ```python Python theme={null}
  import uuid

  async def generate_report(user_id: str, token: str):
      idempotency_key = f"{user_id}-generate_report-{uuid.uuid4()}"

      result = await profy.report_event("generate_report",
          token=token,
          idempotency_key=idempotency_key,
          metadata={"user_id": user_id, "action": "generate_report"},
      )

      print(f"Charged: {result.charged}, Balance: {result.balance_remaining}")
      return result
  ```
</CodeGroup>

<Warning>
  `idempotencyKey` is a required parameter (max 128 characters). The API returns a 400 error if it's missing.
</Warning>

**Response structure:**

| Field               | Type    | Description                                                       |
| ------------------- | ------- | ----------------------------------------------------------------- |
| `charged`           | number  | Credits actually deducted this time                               |
| `balance_remaining` | number  | User's remaining credits after deduction                          |
| `idempotent_replay` | boolean | `true` indicates this was an idempotent replay — no actual charge |

## Step 4: Pre-Check Balance

Before performing expensive operations, check if the user has sufficient balance to avoid completing an operation only to discover the charge fails:

<CodeGroup>
  ```typescript TypeScript theme={null}
  const PRICES: Record<string, number> = {
    generate_report: 10,
    export_pdf: 5,
    generate_summary: 3,
  };

  async function canAfford(token: string, action: string): Promise<boolean> {
    const balance = await profy.getBalance(token);
    return balance.credits >= PRICES[action];
  }

  async function handleAction(userId: string, token: string, action: string) {
    if (!await canAfford(token, action)) {
      throw new Error("INSUFFICIENT_BALANCE");
    }

    const document = await performExpensiveOperation(action);

    const result = await profy.reportEvent(action, {
      token,
      idempotencyKey: `${userId}-${action}-${randomUUID()}`,
    });

    return { document, charged: result.charged };
  }
  ```

  ```python Python theme={null}
  PRICES = {
      "generate_report": 10,
      "export_pdf": 5,
      "generate_summary": 3,
  }

  async def can_afford(token: str, action: str) -> bool:
      balance = await profy.get_balance(token)
      return balance.credits >= PRICES[action]

  async def handle_action(user_id: str, token: str, action: str):
      if not await can_afford(token, action):
          raise ValueError("INSUFFICIENT_BALANCE")

      document = await perform_expensive_operation(action)

      result = await profy.report_event(action,
          token=token,
          idempotency_key=f"{user_id}-{action}-{uuid.uuid4()}",
      )

      return {"document": document, "charged": result.charged}
  ```
</CodeGroup>

<Tip>
  Pre-checking is only a UX optimization — the actual charge can still fail due to concurrent operations. Always handle `InsufficientBalance` errors.
</Tip>

## Step 5: Handle Charge Failures

<CodeGroup>
  ```typescript TypeScript theme={null}
  import {
    AuthExpired,
    InsufficientBalance,
    InvalidEvent,
    ProfyApiError,
  } from "@profy-ai/sdk";

  async function safeCharge(userId: string, token: string, action: string) {
    try {
      return await profy.reportEvent(action, {
        token,
        idempotencyKey: `${userId}-${action}-${randomUUID()}`,
      });
    } catch (err) {
      if (err instanceof InsufficientBalance) {
        return { success: false, reason: "balance_insufficient" } as const;
      }
      if (err instanceof InvalidEvent) {
        return { success: false, reason: "event_not_configured" } as const;
      }
      if (err instanceof AuthExpired) {
        return { success: false, reason: "auth_expired" } as const;
      }
      if (err instanceof ProfyApiError && err.statusCode === 429) {
        return { success: false, reason: "rate_limited" } as const;
      }
      throw err;
    }
  }
  ```

  ```python Python theme={null}
  from profy_sdk.errors import (
      AuthExpired,
      InsufficientBalance,
      InvalidEvent,
      ProfyApiError,
  )

  async def safe_charge(user_id: str, token: str, action: str):
      try:
          return await profy.report_event(action,
              token=token,
              idempotency_key=f"{user_id}-{action}-{uuid.uuid4()}",
          )
      except InsufficientBalance:
          return {"success": False, "reason": "balance_insufficient"}
      except InvalidEvent:
          return {"success": False, "reason": "event_not_configured"}
      except AuthExpired:
          return {"success": False, "reason": "auth_expired"}
      except ProfyApiError as e:
          if e.status_code == 429:
              return {"success": False, "reason": "rate_limited"}
          raise
  ```
</CodeGroup>

**Error handling strategy:**

| Error                 | HTTP | Handling                                                   |
| --------------------- | ---- | ---------------------------------------------------------- |
| `InsufficientBalance` | 402  | Prompt the user to top up, abort the operation             |
| `InvalidEvent`        | 400  | Development-time error, check Studio Meter configuration   |
| `AuthExpired`         | 401  | Guide the user to re-authorize                             |
| Rate Limit            | 429  | Exponential backoff retry (keep the same `idempotencyKey`) |

## Step 6: Idempotency Best Practices

`idempotencyKey` ensures the same business operation is never charged twice. When requests time out or encounter network issues, you can safely retry.

### Idempotency Key Design Principles

| Scenario              | Recommended Format              | Example                        |
| --------------------- | ------------------------------- | ------------------------------ |
| User-triggered action | `{userId}-{action}-{requestId}` | `u123-export_pdf-a1b2c3`       |
| Scheduled task        | `{taskId}-{scheduledAt}`        | `task_daily_report-2026-06-02` |
| Queue consumer        | Use the message's unique ID     | `msg-5f3a8b2c`                 |

### Safe Retry Pattern

<CodeGroup>
  ```typescript TypeScript theme={null}
  async function chargeWithRetry(
    userId: string,
    token: string,
    action: string,
    maxRetries = 3
  ) {
    const idempotencyKey = `${userId}-${action}-${randomUUID()}`;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const result = await profy.reportEvent(action, { token, idempotencyKey });

        if (result.idempotent_replay) {
          console.log("Idempotent replay — no additional charge");
        }
        return result;
      } catch (err) {
        if (err instanceof ProfyApiError && err.statusCode === 429) {
          await sleep(2 ** attempt * 1000);
          continue;
        }
        throw err;
      }
    }
    throw new Error("Max retries exceeded");
  }
  ```

  ```python Python theme={null}
  async def charge_with_retry(
      user_id: str, token: str, action: str, max_retries: int = 3
  ):
      idempotency_key = f"{user_id}-{action}-{uuid.uuid4()}"

      for attempt in range(max_retries + 1):
          try:
              result = await profy.report_event(action,
                  token=token,
                  idempotency_key=idempotency_key,
              )
              if result.idempotent_replay:
                  print("Idempotent replay — no additional charge")
              return result
          except ProfyApiError as e:
              if e.status_code == 429:
                  await asyncio.sleep(2 ** attempt)
                  continue
              raise

      raise RuntimeError("Max retries exceeded")
  ```
</CodeGroup>

<Warning>
  You must reuse the same `idempotencyKey` when retrying. Generating a new key for each retry will result in duplicate charges.
</Warning>

## Complete Example

A complete implementation of a document generation service:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { randomUUID } from "crypto";
  import { ProfyApp, InsufficientBalance, AuthExpired, ProfyApiError } from "@profy-ai/sdk";

  const profy = new ProfyApp({
    clientId: process.env.PROFY_APP_ID!,
    clientSecret: process.env.PROFY_APP_SECRET!,
    onTokenRefresh: (newToken) => saveToDatabase(newToken),
  });

  const PRICES: Record<string, number> = {
    generate_report: 10,
    export_pdf: 5,
    generate_summary: 3,
  };

  interface ChargeResult {
    success: true;
    charged: number;
    balance: number;
  }

  interface ChargeError {
    success: false;
    reason: "balance_insufficient" | "auth_expired" | "rate_limited" | "unknown";
  }

  async function chargeUser(
    userId: string,
    token: string,
    action: string
  ): Promise<ChargeResult | ChargeError> {
    const idempotencyKey = `${userId}-${action}-${randomUUID()}`;

    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        const result = await profy.reportEvent(action, {
          token,
          idempotencyKey,
          metadata: { userId, action, attempt },
        });

        return {
          success: true,
          charged: result.charged,
          balance: result.balance_remaining,
        };
      } catch (err) {
        if (err instanceof InsufficientBalance) {
          return { success: false, reason: "balance_insufficient" };
        }
        if (err instanceof AuthExpired) {
          return { success: false, reason: "auth_expired" };
        }
        if (err instanceof ProfyApiError && err.statusCode === 429) {
          await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
          continue;
        }
        return { success: false, reason: "unknown" };
      }
    }
    return { success: false, reason: "rate_limited" };
  }

  async function handleGenerateReport(userId: string, token: string) {
    const balance = await profy.getBalance(token);
    if (balance.credits < PRICES.generate_report) {
      return { error: "Insufficient balance. Please top up and try again." };
    }

    const report = await generateReportContent(userId);

    const charge = await chargeUser(userId, token, "generate_report");
    if (!charge.success) {
      return { error: `Charge failed: ${charge.reason}` };
    }

    return { report, charged: charge.charged, balance: charge.balance };
  }
  ```

  ```python Python theme={null}
  import os
  import uuid
  import asyncio
  from profy_sdk import ProfyApp
  from profy_sdk.errors import InsufficientBalance, AuthExpired, ProfyApiError

  profy = ProfyApp(
      client_id=os.environ["PROFY_APP_ID"],
      client_secret=os.environ["PROFY_APP_SECRET"],
      on_token_refresh=lambda token: save_to_database(token),
  )

  PRICES = {
      "generate_report": 10,
      "export_pdf": 5,
      "generate_summary": 3,
  }


  async def charge_user(user_id: str, token: str, action: str) -> dict:
      idempotency_key = f"{user_id}-{action}-{uuid.uuid4()}"

      for attempt in range(3):
          try:
              result = await profy.report_event(action,
                  token=token,
                  idempotency_key=idempotency_key,
                  metadata={"user_id": user_id, "action": action, "attempt": attempt},
              )
              return {
                  "success": True,
                  "charged": result.charged,
                  "balance": result.balance_remaining,
              }
          except InsufficientBalance:
              return {"success": False, "reason": "balance_insufficient"}
          except AuthExpired:
              return {"success": False, "reason": "auth_expired"}
          except ProfyApiError as e:
              if e.status_code == 429:
                  await asyncio.sleep(2 ** attempt)
                  continue
              return {"success": False, "reason": "unknown"}

      return {"success": False, "reason": "rate_limited"}


  async def handle_generate_report(user_id: str, token: str) -> dict:
      balance = await profy.get_balance(token)
      if balance.credits < PRICES["generate_report"]:
          return {"error": "Insufficient balance. Please top up and try again."}

      report = await generate_report_content(user_id)

      charge = await charge_user(user_id, token, "generate_report")
      if not charge["success"]:
          return {"error": f"Charge failed: {charge['reason']}"}

      return {"report": report, "charged": charge["charged"], "balance": charge["balance"]}
  ```
</CodeGroup>

## Billing Event Design Guide

### Single Event vs Multiple Events

| Strategy                | Use Case                                        | Example                                                          |
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------- |
| One event per operation | Clearly defined operations with fixed pricing   | `export_pdf`, `send_email`                                       |
| One event per category  | Uniform billing for similar operations          | `ai_generation` (covering summarization, translation, rewriting) |
| Tiered events           | Same operation with different complexity levels | `generate_basic`(3), `generate_pro`(10)                          |

### Naming Conventions

* Use `snake_case`
* Start with a verb: `generate_`, `export_`, `analyze_`
* Avoid generic names (e.g., `action`, `event`)
* Recommended length ≤ 32 characters

### Granularity Decisions

<Tip>
  A rule of thumb: if users perceive two operations as "one thing," they should be a single event. If users would ask "why was I charged this much," the granularity needs to be finer.
</Tip>

## FAQ

**Q: What happens if an idempotency key is reused?**

The API returns `{ idempotent_replay: true }` and doesn't charge again. The `charged` value reflects the original charge amount.

**Q: What if the balance runs out between pre-check and actual charge?**

`reportEvent` will return an `InsufficientBalance` error. Pre-checking is only a UX optimization and cannot replace proper error handling.

**Q: Which price applies when the unit price is changed after a request is sent?**

The latest unit price at the time the request arrives is used. Changes take effect immediately with no transition period.

**Q: Do idempotency keys expire?**

Yes. The same key is valid for 24 hours. After that, it's treated as a new request.

**Q: What if one operation needs to charge multiple events?**

Call `reportEvent` sequentially. Use an independent `idempotencyKey` for each event. If one fails, previously charged events are not automatically refunded — it's best to design a single event per user action whenever possible.

## Next Steps

<CardGroup cols={2}>
  <Card title="SDK Quick Start" icon="bolt" href="/en/developers/quickstart">
    OAuth authorization and basic event reporting
  </Card>

  <Card title="AI Metered Billing" icon="gauge-high" href="/en/developers/cookbook/ai-metered-billing">
    Token-based billing for AI application integration
  </Card>

  <Card title="Invoke Platform Expert" icon="robot" href="/en/developers/cookbook/expert-invoke">
    Embed AI Expert capabilities in your App
  </Card>

  <Card title="API Reference" icon="code" href="/en/api/events">
    Complete Events API endpoint documentation
  </Card>
</CardGroup>
