> ## 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.

# Building a Token-Metered AI Tool

> Use Profy Events API's METERED mode to call AI models with automatic per-token billing — no need to build your own billing system.

## What You'll Build

An AI writing assistant: users enter a topic, and AI generates a draft article. Each call is billed based on actual token consumption from the user's credits — you don't need to manage API keys or build a billing system; the Profy platform handles everything.

The end result:

* Users authorize your App via OAuth
* Your App calls Profy's OpenAI-compatible endpoint to generate content
* Profy automatically tracks token usage and deducts from the user's credits
* You (the creator) earn diamond revenue based on your revenue share ratio

## METERED vs PER\_USE

| Dimension            | METERED (Usage-Based)                          | PER\_USE (Per-Call)             |
| -------------------- | ---------------------------------------------- | ------------------------------- |
| Billing Unit         | Actual token consumption                       | Fixed price per call            |
| Typical Scenarios    | Conversations, article generation, translation | Report export, image processing |
| API Call             | `POST /openapi/v1/events/chat`                 | `profy.reportEvent()`           |
| Price Predictability | Variable based on usage                        | Completely fixed                |
| Best For             | AI scenarios with unpredictable output length  | Well-defined single operations  |

<Tip>
  This tutorial uses METERED mode. If your scenario involves fixed-price single operations, refer to the `reportEvent()` usage in the [SDK Quick Start](/en/developers/quickstart).
</Tip>

## Prerequisites

1. Have a Profy developer account with approved creator verification
2. Created an App in [Studio](https://app.profy.cn/studio) with billing type set to **METERED**
3. Obtained the App's `clientId` and `clientSecret`
4. Configured an OAuth callback URL

## Step 1: OAuth Authorization

Obtain the user's Access Token via OAuth. All subsequent AI calls are authenticated and billed through this Token.

<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!,
  });

  const token = await profy.exchangeCode(code, redirectUri);
  // token.accessToken — valid for 1 hour
  // token.refreshToken — valid for 90 days, rotated on use
  ```

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

  async def exchange_code(code: str, redirect_uri: str) -> dict:
      async with httpx.AsyncClient() as client:
          resp = await client.post(
              "https://app.profy.cn/oauth/token",
              json={
                  "grant_type": "authorization_code",
                  "code": code,
                  "redirect_uri": redirect_uri,
                  "client_id": PROFY_APP_ID,
                  "client_secret": PROFY_APP_SECRET,
              },
          )
          resp.raise_for_status()
          return resp.json()
  ```
</CodeGroup>

<Note>
  For the complete OAuth flow (authorization page redirect, callback handling, Token storage), refer to the [SDK Quick Start](/en/developers/quickstart).
</Note>

## Step 2: Call AI Models (Non-Streaming)

With the Access Token in hand, call Profy's OpenAI-compatible endpoint directly. The request format is identical to OpenAI's `/v1/chat/completions`.

<CodeGroup>
  ```typescript TypeScript theme={null}
  const PROFY_CHAT_URL = "https://app.profy.cn/openapi/v1/events/chat";

  async function chat(accessToken: string, prompt: string) {
    const res = await fetch(PROFY_CHAT_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${accessToken}`,
      },
      body: JSON.stringify({
        model: "deepseek-chat",
        messages: [
          { role: "system", content: "You are a professional writing assistant." },
          { role: "user", content: prompt },
        ],
        temperature: 0.7,
        max_tokens: 2000,
      }),
    });

    if (!res.ok) {
      throw new Error(`Chat failed: ${res.status} ${await res.text()}`);
    }

    const data = await res.json();
    return data.choices[0].message.content;
  }
  ```

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

  PROFY_CHAT_URL = "https://app.profy.cn/openapi/v1/events/chat"

  async def chat(access_token: str, prompt: str) -> str:
      async with httpx.AsyncClient() as client:
          resp = await client.post(
              PROFY_CHAT_URL,
              headers={"Authorization": f"Bearer {access_token}"},
              json={
                  "model": "deepseek-chat",
                  "messages": [
                      {"role": "system", "content": "You are a professional writing assistant."},
                      {"role": "user", "content": prompt},
                  ],
                  "temperature": 0.7,
                  "max_tokens": 2000,
              },
              timeout=60.0,
          )
          resp.raise_for_status()
          return resp.json()["choices"][0]["message"]["content"]
  ```
</CodeGroup>

<Warning>
  The Access Token is a user-level credential — Profy identifies the user through this Token and deducts from their credits. Never mix Tokens between different users.
</Warning>

## Step 3: Streaming Responses

Set `stream: true` to receive SSE streaming responses, ideal for displaying AI output in real-time.

<CodeGroup>
  ```typescript TypeScript theme={null}
  async function* chatStream(accessToken: string, prompt: string) {
    const res = await fetch(PROFY_CHAT_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${accessToken}`,
      },
      body: JSON.stringify({
        model: "deepseek-chat",
        messages: [
          { role: "system", content: "You are a professional writing assistant." },
          { role: "user", content: prompt },
        ],
        stream: true,
        temperature: 0.7,
        max_tokens: 2000,
      }),
    });

    if (!res.ok) {
      throw new Error(`Chat failed: ${res.status} ${await res.text()}`);
    }

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop()!;

      for (const line of lines) {
        if (!line.startsWith("data: ")) continue;
        const data = line.slice(6);
        if (data === "[DONE]") return;

        const chunk = JSON.parse(data);
        const content = chunk.choices[0]?.delta?.content;
        if (content) yield content;
      }
    }
  }

  // Usage
  for await (const text of chatStream(token.accessToken, "Write an article about AI")) {
    process.stdout.write(text);
  }
  ```

  ```python Python theme={null}
  import httpx
  from collections.abc import AsyncIterator

  async def chat_stream(access_token: str, prompt: str) -> AsyncIterator[str]:
      async with httpx.AsyncClient() as client:
          async with client.stream(
              "POST",
              PROFY_CHAT_URL,
              headers={"Authorization": f"Bearer {access_token}"},
              json={
                  "model": "deepseek-chat",
                  "messages": [
                      {"role": "system", "content": "You are a professional writing assistant."},
                      {"role": "user", "content": prompt},
                  ],
                  "stream": True,
                  "temperature": 0.7,
                  "max_tokens": 2000,
              },
              timeout=60.0,
          ) as resp:
              resp.raise_for_status()
              async for line in resp.aiter_lines():
                  if not line.startswith("data: "):
                      continue
                  data = line[6:]
                  if data == "[DONE]":
                      return
                  import json
                  chunk = json.loads(data)
                  content = chunk["choices"][0].get("delta", {}).get("content")
                  if content:
                      yield content

  # Usage
  async for text in chat_stream(access_token, "Write an article about AI"):
      print(text, end="", flush=True)
  ```
</CodeGroup>

## Step 4: OpenAI SDK Integration

Profy's chat endpoint is OpenAI-compatible, so you can use the official OpenAI SDK directly — just change the `baseURL` and `apiKey`.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import OpenAI from "openai";

  const openai = new OpenAI({
    apiKey: token.accessToken,
    baseURL: "https://app.profy.cn/openapi/v1/events",
  });

  // Non-streaming
  const completion = await openai.chat.completions.create({
    model: "deepseek-chat",
    messages: [
      { role: "system", content: "You are a professional writing assistant." },
      { role: "user", content: "Write a product description" },
    ],
    temperature: 0.7,
    max_tokens: 2000,
  });

  console.log(completion.choices[0].message.content);

  // Streaming
  const stream = await openai.chat.completions.create({
    model: "deepseek-chat",
    messages: [{ role: "user", content: "Write a short essay" }],
    stream: true,
  });

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content;
    if (content) process.stdout.write(content);
  }
  ```

  ```python Python theme={null}
  from openai import AsyncOpenAI

  client = AsyncOpenAI(
      api_key=access_token,
      base_url="https://app.profy.cn/openapi/v1/events",
  )

  # Non-streaming
  completion = await client.chat.completions.create(
      model="deepseek-chat",
      messages=[
          {"role": "system", "content": "You are a professional writing assistant."},
          {"role": "user", "content": "Write a product description"},
      ],
      temperature=0.7,
      max_tokens=2000,
  )

  print(completion.choices[0].message.content)

  # Streaming
  stream = await client.chat.completions.create(
      model="deepseek-chat",
      messages=[{"role": "user", "content": "Write a short essay"}],
      stream=True,
  )

  async for chunk in stream:
      content = chunk.choices[0].delta.content
      if content:
          print(content, end="", flush=True)
  ```
</CodeGroup>

<Tip>
  Using the OpenAI SDK gives you well-defined types, automatic retries, and robust streaming support. This approach is recommended for production environments.
</Tip>

## Step 5: Automatic Token Expiry Handling

Access Tokens are valid for 1 hour. Wrap calls in an auto-refreshing function to avoid manual checks every time.

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

  interface TokenPair {
    accessToken: string;
    refreshToken: string;
    expiresAt: number;
  }

  class ProfyChat {
    private profy: ProfyApp;
    private token: TokenPair;

    constructor(profy: ProfyApp, token: TokenPair) {
      this.profy = profy;
      this.token = token;
    }

    private async getValidToken(): Promise<string> {
      if (Date.now() >= this.token.expiresAt - 60_000) {
        this.token = await this.profy.refreshToken(this.token.refreshToken);
      }
      return this.token.accessToken;
    }

    async chat(messages: Array<{ role: string; content: string }>) {
      const accessToken = await this.getValidToken();

      const res = await fetch(PROFY_CHAT_URL, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${accessToken}`,
        },
        body: JSON.stringify({ model: "deepseek-chat", messages }),
      });

      if (res.status === 401) {
        this.token = await this.profy.refreshToken(this.token.refreshToken);
        return this.chat(messages);
      }

      if (!res.ok) throw new Error(`Chat failed: ${res.status}`);
      return res.json();
    }
  }
  ```

  ```python Python theme={null}
  import time
  import httpx

  class ProfyChat:
      def __init__(self, token: dict, client_id: str, client_secret: str):
          self.token = token
          self.client_id = client_id
          self.client_secret = client_secret

      async def _refresh_if_needed(self):
          if time.time() * 1000 >= self.token["expires_at"] - 60_000:
              async with httpx.AsyncClient() as client:
                  resp = await client.post(
                      "https://app.profy.cn/oauth/token",
                      json={
                          "grant_type": "refresh_token",
                          "refresh_token": self.token["refresh_token"],
                          "client_id": self.client_id,
                          "client_secret": self.client_secret,
                      },
                  )
                  resp.raise_for_status()
                  self.token = resp.json()

      async def chat(self, messages: list[dict]) -> dict:
          await self._refresh_if_needed()

          async with httpx.AsyncClient() as client:
              resp = await client.post(
                  PROFY_CHAT_URL,
                  headers={"Authorization": f"Bearer {self.token['access_token']}"},
                  json={"model": "deepseek-chat", "messages": messages},
                  timeout=60.0,
              )

              if resp.status_code == 401:
                  await self._refresh_if_needed()
                  return await self.chat(messages)

              resp.raise_for_status()
              return resp.json()
  ```
</CodeGroup>

## Step 6: Error Handling and Retries

| HTTP Status | Meaning                                         | Handling                                       |
| ----------- | ----------------------------------------------- | ---------------------------------------------- |
| 400         | Invalid request parameters or model unavailable | Check the `model` name and request body format |
| 401         | Token expired or invalid                        | Refresh with Refresh Token and retry           |
| 402         | Insufficient user credits                       | Prompt the user to top up — do not retry       |
| 502         | Upstream model service error                    | Exponential backoff retry (max 3 attempts)     |

<CodeGroup>
  ```typescript TypeScript theme={null}
  async function chatWithRetry(
    profyChat: ProfyChat,
    messages: Array<{ role: string; content: string }>,
    maxRetries = 3,
  ) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await profyChat.chat(messages);
      } catch (err: any) {
        const status = err.status ?? err.statusCode;

        if (status === 402) {
          throw new Error("Insufficient user credits. Please top up and try again.");
        }

        if (status === 502 && attempt < maxRetries) {
          const delay = Math.min(1000 * 2 ** attempt, 10_000);
          await new Promise((r) => setTimeout(r, delay));
          continue;
        }

        throw err;
      }
    }
  }
  ```

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

  async def chat_with_retry(
      profy_chat: ProfyChat,
      messages: list[dict],
      max_retries: int = 3,
  ) -> dict:
      for attempt in range(max_retries + 1):
          try:
              return await profy_chat.chat(messages)
          except httpx.HTTPStatusError as exc:
              if exc.response.status_code == 402:
                  raise ValueError("Insufficient user credits. Please top up and try again.") from exc

              if exc.response.status_code == 502 and attempt < max_retries:
                  delay = min(1.0 * 2**attempt, 10.0)
                  await asyncio.sleep(delay)
                  continue

              raise
  ```
</CodeGroup>

<Warning>
  Do not retry on 402 — the user's insufficient balance won't change with retries. Show a top-up prompt instead.
</Warning>

## Available Models

The Profy platform administrator configures which models are available. The models your App can use depend on the platform configuration.

How to check:

* **API**: `GET /openapi/v1/meters` returns the currently available Meter configuration
* **Studio**: Check the "Billing Configuration" in your App settings page

<Note>
  Common models include `deepseek-chat`, `deepseek-reasoner`, `qwen-plus`, and more. The specific list of available models depends on the platform configuration.
</Note>

## Complete Example

An AI writing assistant backend integrating OAuth, Token refresh, streaming output, and error handling.

<CodeGroup>
  ```typescript server.ts (Express) theme={null}
  import express from "express";
  import OpenAI from "openai";
  import { ProfyApp } from "@profy-ai/sdk";

  const app = express();
  app.use(express.json());

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

  const tokenStore = new Map<string, any>();

  app.get("/auth/callback", async (req, res) => {
    const { code } = req.query;
    const token = await profy.exchangeCode(code as string, process.env.REDIRECT_URI!);
    const userId = "user-from-session";
    tokenStore.set(userId, token);
    res.redirect("/chat");
  });

  app.post("/api/chat", async (req, res) => {
    const userId = "user-from-session";
    let token = tokenStore.get(userId);
    if (!token) return res.status(401).json({ error: "Unauthorized" });

    if (Date.now() >= token.expiresAt - 60_000) {
      token = await profy.refreshToken(token.refreshToken);
      tokenStore.set(userId, token);
    }

    const openai = new OpenAI({
      apiKey: token.accessToken,
      baseURL: "https://app.profy.cn/openapi/v1/events",
    });

    try {
      const stream = await openai.chat.completions.create({
        model: "deepseek-chat",
        messages: [
          { role: "system", content: "You are a professional writing assistant." },
          ...req.body.messages,
        ],
        stream: true,
        temperature: 0.7,
        max_tokens: 2000,
      });

      res.setHeader("Content-Type", "text/event-stream");
      res.setHeader("Cache-Control", "no-cache");

      for await (const chunk of stream) {
        const content = chunk.choices[0]?.delta?.content;
        if (content) {
          res.write(`data: ${JSON.stringify({ content })}\n\n`);
        }
      }

      res.write("data: [DONE]\n\n");
      res.end();
    } catch (err: any) {
      const status = err.status ?? 500;
      if (status === 402) {
        return res.status(402).json({ error: "Insufficient credits. Please top up." });
      }
      return res.status(status).json({ error: err.message });
    }
  });

  app.listen(3000);
  ```

  ```python server.py (FastAPI) theme={null}
  import os
  import time

  import httpx
  from fastapi import FastAPI, Request
  from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
  from openai import AsyncOpenAI

  app = FastAPI()

  PROFY_BASE = os.environ["PROFY_BASE_URL"]  # https://app.profy.cn
  CLIENT_ID = os.environ["PROFY_APP_ID"]
  CLIENT_SECRET = os.environ["PROFY_APP_SECRET"]
  REDIRECT_URI = os.environ["PROFY_CALLBACK_URL"]

  token_store: dict[str, dict] = {}


  async def exchange_code(code: str) -> dict:
      async with httpx.AsyncClient() as client:
          resp = await client.post(
              f"{PROFY_BASE}/oauth/token",
              json={
                  "grant_type": "authorization_code",
                  "code": code,
                  "redirect_uri": REDIRECT_URI,
                  "client_id": CLIENT_ID,
                  "client_secret": CLIENT_SECRET,
              },
          )
          resp.raise_for_status()
          return resp.json()


  async def refresh_if_needed(user_id: str) -> str:
      token = token_store[user_id]
      if time.time() >= token["expires_at"] - 60:
          async with httpx.AsyncClient() as client:
              resp = await client.post(
                  f"{PROFY_BASE}/oauth/token",
                  json={
                      "grant_type": "refresh_token",
                      "refresh_token": token["refresh_token"],
                      "client_id": CLIENT_ID,
                      "client_secret": CLIENT_SECRET,
                  },
              )
              resp.raise_for_status()
              token_store[user_id] = resp.json()
      return token_store[user_id]["access_token"]


  @app.get("/auth/callback")
  async def callback(code: str):
      token = await exchange_code(code)
      user_id = "user-from-session"
      token_store[user_id] = token
      return RedirectResponse("/chat")


  @app.post("/api/chat")
  async def chat(request: Request):
      user_id = "user-from-session"
      if user_id not in token_store:
          return JSONResponse({"error": "Unauthorized"}, status_code=401)

      access_token = await refresh_if_needed(user_id)

      body = await request.json()

      client = AsyncOpenAI(
          api_key=access_token,
          base_url=f"{PROFY_BASE}/openapi/v1/events",
      )

      async def stream_response():
          try:
              stream = await client.chat.completions.create(
                  model="deepseek-chat",
                  messages=[
                      {"role": "system", "content": "You are a professional writing assistant."},
                      *body["messages"],
                  ],
                  stream=True,
                  temperature=0.7,
                  max_tokens=2000,
              )
              async for chunk in stream:
                  content = chunk.choices[0].delta.content
                  if content:
                      yield f"data: {{'content': '{content}'}}\n\n"
              yield "data: [DONE]\n\n"
          except Exception as exc:
              yield f"data: {{'error': '{exc}'}}\n\n"

      return StreamingResponse(stream_response(), media_type="text/event-stream")
  ```
</CodeGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="PER_USE Billing Tutorial" icon="coins" href="/en/developers/cookbook/per-use-billing">
    Fixed-price per-use billing for deterministic operations like report exports
  </Card>

  <Card title="Expert Invocation" icon="robot" href="/en/developers/cookbook/expert-invoke">
    Invoke published AI Experts on the platform and receive SSE streaming responses
  </Card>

  <Card title="Events API Reference" icon="code" href="/en/api/chat-completions">
    Complete field documentation for the AI model call endpoint
  </Card>

  <Card title="Publish to Marketplace" icon="store" href="/en/developers/quickstart">
    After development, submit your App to the Profy Marketplace
  </Card>
</CardGroup>
