# OpenCredits Developer Docs

Let users buy AI credits in your app, use them for AI requests, and earn a referral share on every one.

> AI agent? See [skill.md](/skill.md) for step-by-step integration instructions, or [llms.txt](/llms.txt) for a quick API reference.

## How it works

OpenCredits gives your users a single credit balance that works across AI models (Claude, GPT, Gemini, and more). Here's the flow:

1. **User buys credits** via the checkout widget you embed in your app
2. **You make AI requests** through our OpenAI/Anthropic-compatible API using the user's key
3. **Credits are deducted** based on token price, plus a referral share that goes to you

### What you get

- A **publishable key** (`oc_pk_...`) to identify your app in the checkout widget
- **User keys** (`oc_sk_...`) returned after purchase — use these to make API calls on behalf of your users
- An **OpenAI and Anthropic-compatible API** that accepts the user key as auth

> **Base URL:** All API requests go to `https://api.opencredits.ai`

---

## Quickstart

Get credits working in your app in under 5 minutes.

### 1. Add the SDK

```html
<script src="https://opencredits.ai/v1/sdk.js"></script>
```

### 2. Initialize

```javascript
OpenCredits.init({
  publishableKey: 'oc_pk_your_key_here',
  currency: 'usd',

  // Called when the user makes credits available to your app:
  // a purchase, or allowing credits bought elsewhere (data.type)
  onCreditsAdded(data) {
    console.log('User key:', data.user_key);
    console.log('Balance:', data.balance);
  }
});
```

Then register the origin your app runs on (for example `https://yourapp.com`) in the partner dashboard under **Sites that open your checkout**. The checkout completes only on pages from origins you listed; `localhost` always works, so nothing is needed while you develop. See [Origins](#origins).

### 3. Open the checkout

```javascript
// Let user pick an amount
OpenCredits.open();

// Or preset the amount (in dollars/euros)
OpenCredits.open({ amount: 10 });
```

### 4. Make API calls

Once you have the user's key, make AI requests with it. The API is OpenAI and Anthropic-compatible — just change the base URL and auth header:

**JavaScript (OpenAI-compatible)**

```javascript
// OpenAI-compatible endpoint
const response = await fetch('https://api.opencredits.ai/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-User-Key': userKey,  // oc_sk_... from checkout
  },
  body: JSON.stringify({
    model: 'anthropic/claude-sonnet-4-20250514',
    messages: [{ role: 'user', content: 'Hello!' }],
  }),
});
```

**JavaScript (Anthropic-compatible)**

```javascript
// Anthropic-compatible endpoint
const response = await fetch('https://api.opencredits.ai/v1/messages', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-User-Key': userKey,  // oc_sk_... from checkout
  },
  body: JSON.stringify({
    model: 'anthropic/claude-sonnet-4-20250514',
    max_tokens: 1024,
    messages: [{ role: 'user', content: 'Hello!' }],
  }),
});
```

> **Tip:** The SDK automatically persists the user key in `localStorage`. For returning users, pass it back via `OpenCredits.getUserKey()` so they don't have to repurchase.

### 5. Works with OpenAI SDKs

**Python**

```python
from openai import OpenAI

client = OpenAI(
    base_url="https://api.opencredits.ai/v1",
    api_key=user_key,  # oc_sk_... from checkout
)

response = client.chat.completions.create(
    model="anthropic/claude-sonnet-4-20250514",
    messages=[{"role": "user", "content": "Hello!"}],
)
```

**Node.js**

```javascript
import OpenAI from 'openai';

const client = new OpenAI({
  baseURL: 'https://api.opencredits.ai/v1',
  apiKey: userKey,  // oc_sk_... from checkout
});

const response = await client.chat.completions.create({
  model: 'anthropic/claude-sonnet-4-20250514',
  messages: [{ role: 'user', content: 'Hello!' }],
});
```

### 6. Works with Anthropic SDKs

**Python**

```python
import anthropic

client = anthropic.Anthropic(
    base_url="https://api.opencredits.ai",  # no /v1 — SDK adds it
    api_key=user_key,  # oc_sk_... from checkout
)

message = client.messages.create(
    model="anthropic/claude-sonnet-4-20250514",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello!"}],
)
```

**Node.js**

```javascript
import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({
  baseURL: 'https://api.opencredits.ai',  // no /v1 — SDK adds it
  apiKey: userKey,  // oc_sk_... from checkout
});

const message = await client.messages.create({
  model: 'anthropic/claude-sonnet-4-20250514',
  max_tokens: 1024,
  messages: [{ role: 'user', content: 'Hello!' }],
});
```

### 7. Works with Vercel AI SDK

**Node.js (OpenAI provider)**

```javascript
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';

const openai = createOpenAI({
  baseURL: 'https://api.opencredits.ai/v1',
  apiKey: userKey,  // oc_sk_... from checkout
});

const { text } = await generateText({
  model: openai('anthropic/claude-sonnet-4-20250514'),
  prompt: 'Hello!',
});
```

**Node.js (Anthropic provider)**

```javascript
import { generateText } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';

const anthropic = createAnthropic({
  baseURL: 'https://api.opencredits.ai/v1',
  apiKey: userKey,  // oc_sk_... from checkout
});

const { text } = await generateText({
  model: anthropic('anthropic/claude-sonnet-4-20250514'),
  prompt: 'Hello!',
});
```

### 8. Works with Claude Code

Point Claude Code at OpenCredits by setting environment variables:

```shell
export ANTHROPIC_BASE_URL=https://api.opencredits.ai
export ANTHROPIC_API_KEY=oc_sk_...  # user key from checkout

claude
```

---

## Checkout SDK

The SDK opens a branded checkout modal in your app. It handles amount selection, Stripe payment, account login, and returns the user key when done.

### OpenCredits.init(options)

Initialize the SDK. Call this once on page load.

| Option | Type | Description |
|---|---|---|
| `publishableKey` | string | **Required.** Your partner publishable key (`oc_pk_...`) |
| `currency` | string | `'usd'` or `'eur'`. Default: `'usd'` |
| `display` | string | Where the OpenCredits checkout appears: `'embedded'` (default, an overlay in your page), `'popup'`, or `'new_tab'`. See Display for the trade-offs |
| `checkoutMode` | string | How Stripe's payment form is shown, inside whichever display you chose: `'embedded'` (default), `'new_tab'`, or `'manual'` |
| `metadata` | object | Your own identifiers for this user, e.g. `{ user_id: 'usr_123' }`. A flat object of strings, numbers or booleans, up to 500 characters serialized. Echoed in every [webhook event](#webhooks) about this user's keys and purchases |
| `onCreditsAdded` | function | **The one to handle.** Called whenever the user makes credits available to your app: a purchase here, or allowing credits they bought elsewhere. Receives `{ type, user_key, balance, credits_added }` — `type` is `'purchase'` or `'permission'`, `balance` is what your app can spend now, `credits_added` is how much that grew. Your app earns per request either way, so the reaction is the same: store the key, retry |
| `onLoginCompleted` | function | Called when user signs in. Receives `{ user_key, balance }` — `balance` is what your app can spend for this user (`0` if it could not be read) |
| `onCheckoutOpened` | function | Called when checkout session starts. Receives `{ session_id, user_key }` |
| `onPurchaseError` | function | Called on payment failure. Receives `{ error }` |
| `onError` | function | Called on any error. Receives `{ error }` |
| `onComplete` | function | Called when the user dismisses the success screen after purchase. Receives `{ user_key, balance, credits_added }` |
| `onLoggedOut` | function | Called when user logs out from the checkout widget |
| `onPurchaseCompleted`, `onPermissionUpdated` | function | **Deprecated.** The two cases of `onCreditsAdded` as separate callbacks, same fields minus `type`. They still fire (the SDK logs one console warning when either is set); handle `onCreditsAdded` instead |

### OpenCredits.open(options?)

Open the checkout modal.

| Option | Type | Description |
|---|---|---|
| `amount` | number | Preset dollar/euro amount. If omitted, user picks from $5 / $10 / $25 / $50 / $100 or enters a custom amount. |

### OpenCredits.close()

Programmatically close the checkout modal.

### OpenCredits.getUserKey()

Returns the current user key (`oc_sk_...`) or `null` if no user is logged in. The SDK persists this in `localStorage` automatically.

### Display

Where the OpenCredits checkout itself appears. Your callbacks and the user's account are the same in all three; what differs is the browser context, and with it how a returning user is recognised.

| Display | What it is | Pros | Cons |
|---|---|---|---|
| `embedded` (default) | An overlay in your page with the checkout in an iframe | Feels native to your app. Never blocked. Works everywhere | The iframe is third-party on your domain, so browsers keep its storage separate. A user your app has a key for is recognised; a user new to your app signs in again even if signed in to OpenCredits elsewhere. "Manage apps" opens the dashboard signed out |
| `popup` | A small window on opencredits.ai, opened by `OpenCredits.open()` | First-party: a user signed in to OpenCredits is recognised in any **website** on open — the checkout appears already signed in, no Sign in click — and dashboard links open signed in. An app that is not a website (a webview, registered by a scheme wildcard) is the exception: it always asks for one Sign in click, see [Origins](#origins). Stripe's form still renders inside it | `open()` must run inside the user's click or the browser blocks it (the SDK then falls back to the overlay). If your page sends `Cross-Origin-Opener-Policy: same-origin`, the browser cuts the link to the window; the SDK reads your page's headers when it initialises and uses the overlay instead, with a console warning — use `same-origin-allow-popups` to keep the popup. A second `open()` while a payment is in progress brings the window forward; a window idling on the picker is re-opened with the new options. Phones and some desktops show it as a tab. The user can close it; you get `onComplete` if a purchase had finished |
| `new_tab` | The checkout in a full tab | Everything the popup gives, plus room, and the natural shape on phones | The user leaves your page until they are done. Same click rule as the popup |

`checkoutMode` is independent: it chooses how Stripe's payment form appears inside whichever display you picked.

### Origins

Your publishable key is public — it is in your page source — so the key alone cannot say which site is yours. The checkout therefore completes only on pages whose origin (scheme and host, e.g. `https://yourapp.com`) you registered in the partner dashboard under **Sites that open your checkout**. Anywhere else it shows a refusal naming the origin, and shares nothing with the page around it: no key, no balance, no events.

- `localhost`, `127.0.0.1` and `*.localhost` always work, on any port, so development needs no setup.
- Applying with a bare site address registers it for you; add staging or a second domain in the dashboard.
- The dashboard also lists where your key was opened recently, registered or not, with one-click registration — and shows you if someone else's site is using it.
- A registered origin is exact: `https://yourapp.com` does not cover `http://yourapp.com` or `https://app.yourapp.com`.
- An app that is not a website (a VS Code webview, an Electron shell) is registered by its scheme wildcard, e.g. `vscode-webview://*`; the refusal page names the wildcard to add. A wildcard admits every app on the user's device using that scheme (another extension's webview, another Capacitor app), the same trust `localhost` gets, and such a host is never signed in without a click — not when the checkout opens and not when the user presses Buy: register one only if you ship such an app.
- The "asked about recently" list is unverified: anyone who knows your publishable key can make an origin appear there. Register only sites you run.

### Checkout modes

| Mode | Behavior |
|---|---|
| `embedded` | Stripe checkout renders inside the modal (default, recommended) |
| `new_tab` | Opens Stripe checkout in a new tab. Modal shows a waiting state. |
| `manual` | Returns the `checkout_url` in `onCheckoutOpened` — you handle opening it yourself. |

### Full example

```html
<script src="https://opencredits.ai/v1/sdk.js"></script>
<script>
  OpenCredits.init({
    publishableKey: 'oc_pk_your_key',
    onCreditsAdded(data) {
      // Store the key server-side for this user
      fetch('/api/save-credits-key', {
        method: 'POST',
        body: JSON.stringify({ userKey: data.user_key }),
      });
    },
  });

  document.getElementById('buy-btn').addEventListener('click', () => {
    OpenCredits.open();
  });
</script>
```

---

## API

Make AI requests on behalf of your users. The API is compatible with both OpenAI and Anthropic SDKs — just change the base URL and use the user's key for auth.

### Authentication

Pass the user key in the `X-User-Key` header. When using the OpenAI SDK, this maps to the `apiKey` field.

### OpenAI-compatible endpoint

**POST** `/v1/chat/completions`

Accepts the standard OpenAI chat completions request format. Supports streaming, tools, vision, and all other OpenAI features.

```json
{
  "model": "openai/gpt-4o",
  "messages": [
    { "role": "user", "content": "Explain quantum computing" }
  ],
  "stream": true
}
```

### Anthropic-compatible endpoint

**POST** `/v1/messages`

Accepts the standard Anthropic messages request format. Supports streaming, thinking, vision, and all other Anthropic features.

```json
{
  "model": "anthropic/claude-sonnet-4-20250514",
  "max_tokens": 1024,
  "messages": [
    { "role": "user", "content": "Explain quantum computing" }
  ]
}
```

### Model IDs

Models are namespaced by provider. Use the full ID in your requests:

| Provider | Example model ID |
|---|---|
| Anthropic | `anthropic/claude-sonnet-4-20250514` |
| OpenAI | `openai/gpt-4o` |
| Google | `google/gemini-2.5-pro` |
| xAI | `xai/grok-3` |

> **Streaming:** Both endpoints support server-sent events (SSE) for real-time responses.

### Server-side requests

You can also make requests from your backend. Store the user key after checkout and attach it when making requests:

```javascript
app.post('/api/chat', async (req, res) => {
  const userKey = getUserKeyFromSession(req);  // your auth logic

  const response = await fetch('https://api.opencredits.ai/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-User-Key': userKey,
    },
    body: JSON.stringify(req.body),
  });

  res.status(response.status).send(await response.json());
});
```

---

### Users with credits from other apps

Credits belong to the user, but **your app can only spend what the user allows it to**. Credits bought inside your app are always spendable there. A balance the user bought in another app is not, until they set a limit for your app — which the checkout offers them the moment they sign in with an existing balance, and again after any purchase.

So when a request fails with `402 partner_not_permitted` or `402 partner_limit_reached`, open the checkout: the user sees the prompt and either buys credits here or allows your app to use their balance. `onCreditsAdded` fires with `type: 'permission'` when they do. Never treat those two as "no credits".

## Webhooks

Webhooks deliver user keys and purchases to your backend, signed with a secret only you hold. They are the server-side alternative to catching `user_key` from the SDK callbacks: use them when your backend needs to know about a key or a purchase without trusting the browser.

Configure them in the partner dashboard (`/partners` → Webhooks): set a public HTTPS endpoint, copy the signing secret, and use **Send test event** to verify your handler end to end.

### Events

| Event | Fires when | `data` |
|---|---|---|
| `user_key.created` | A key is minted for one of your users: email login, sign-in token exchange, or first checkout | `user_key` (the plaintext key), `key_id`, `user_id` (your app's id for this user, see below), `metadata` |
| `user_key.revoked` | A key you were sent stopped working | `key_id`, `user_id`, `reason`, `metadata` |
| `credits.added` | The user made credits available to your app: a purchase here (`type: 'purchase'`, once per Stripe session, after the credits were granted) or a permission they set from the checkout prompt, the post-purchase opt-in, or their dashboard (`type: 'permission'`). The server-side twin of `onCreditsAdded`. A permission fires on every save, even one that adds nothing (`credits_added: 0`) | `type`, `user_id`, `metadata`, `balance` (what your app can spend for them now), `credits_added`, plus `transaction_id` on a purchase or `granted_credits` on a permission |
| `test` | You pressed **Send test event** | `partner_id`, `message` |

`reason` on a revocation is `user_revoked` (from their OpenCredits dashboard), `session_revoked` (they logged that device out) or `evicted` (a user holds up to 10 live keys per app; an eleventh pushes out the most idle one). Delete that key from your storage. The user's other keys keep working, and a new `created` event never invalidates keys you already hold.

Example delivery:

```
POST /webhooks/opencredits
X-OpenCredits-Event: user_key.created
X-OpenCredits-Signature: t=1783015200,v1=5f0c…

{
  "id": "evt_5f0c…",
  "type": "user_key.created",
  "created_at": "2026-07-09T18:00:00.000Z",
  "data": {
    "user_key": "oc_sk_…",
    "key_id": "…",
    "user_id": "au_…",
    "metadata": { "user_id": "usr_123" }
  }
}
```

`user_id` is **your app's id for the user**: an `au_…` value minted per app, stable for this user in your app and unrelated to their id in any other app. OpenCredits never sends its own account id or the user's email, in any event or on any partner page. Attach your own identifiers as `metadata` to map events to your records.

### Identifying your users

Pass `metadata` to `OpenCredits.init` with your own identifiers. It rides through login and checkout and comes back in every event, so your handler can map a key or a purchase to your user without a lookup:

```javascript
OpenCredits.init({
  publishableKey: 'oc_pk_...',
  metadata: { user_id: 'usr_123', plan: 'pro' },
});
```

A flat object of strings, numbers or booleans, up to 500 characters serialized. It is opaque to OpenCredits and only ever returned to you. If you call the API directly instead of through the SDK, the same field is accepted by `POST /v1/auth/verify-code`, `POST /auth/token` and `POST /v1/credits/checkout`.

### Verifying signatures

Every delivery carries `X-OpenCredits-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA-256 with your signing secret over `<t>.<raw body>`. Verify against the raw request body, before any JSON parsing, and reject stale timestamps:

```javascript
// Node.js (Express)
import { createHmac, timingSafeEqual } from 'node:crypto';

app.post('/webhooks/opencredits', express.raw({ type: '*/*' }), (req, res) => {
  const raw = req.body.toString('utf8');
  const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(req.get('X-OpenCredits-Signature') || '');
  if (!m) return res.sendStatus(400);
  const [, t, sig] = m;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400); // 5 min tolerance
  const expected = createHmac('sha256', process.env.OC_WEBHOOK_SECRET).update(`${t}.${raw}`).digest('hex');
  if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.sendStatus(400);

  const event = JSON.parse(raw);
  // store or delete the key, record the purchase — then respond fast
  res.sendStatus(200);
});
```

### Delivery

- **At least once, best effort.** Three attempts: immediately, after 2 s, after 8 s, each with a 5 s timeout. Respond 2xx quickly and do the work asynchronously. Any other 4xx except 408 and 429 stops retries for that event.
- **Deduplicate by `id`.** Retries reuse the event id.
- **Order is not guaranteed** across events. Use `created_at` if it matters.
- **Additive.** The SDK callbacks still fire in the browser whether or not the delivery succeeds, so a missed webhook never breaks a user.
- **Redirects are never followed**, and the endpoint must be a public HTTPS host.
- Deliveries pause while your partner account is disabled and stop if you clear the endpoint URL. Your signing secret is retained either way.

> **Tip:** store keys by `key_id`, not by value, and delete on `user_key.revoked`. Disabling your account suspends keys rather than revoking them, so no event fires for that: they resume when you are re-enabled.

## Balance & Models

### Check balance

**GET** `/v1/credits/balance`

Returns the credits **your app can spend** for this user right now — their money, capped by what they've allowed your app to use. Requires the `X-User-Key` header.

```json
{
  "balance": 270,
  "currency": "credits",
  "minimum_required": 10,
  "status": "ok"
}
```

When `balance` is below `minimum_required`, `status` says why, using the same codes the API returns as `402` errors:

| `status` | What to show |
|---|---|
| `insufficient_credits` | Buy credits |
| `partner_not_permitted` | Buy credits here, or allow your app on the OpenCredits dashboard |
| `partner_limit_reached` | Raise the limit on the OpenCredits dashboard |

> **Tip:** Show this number in your UI so users know when to top up. It is what your app can use, not the user's total across every app.

### Usage history

**GET** `/v1/credits/history`

Returns the user's transaction and usage history. Requires the `X-User-Key` header.

| Param | Type | Description |
|---|---|---|
| `limit` | number | Number of entries to return (default 50) |
| `offset` | number | Pagination offset (default 0) |

### List models

**GET** `/v1/models`

Returns all available models. No authentication required.

```json
{
  "data": [
    {
      "id": "anthropic/claude-sonnet-4-20250514",
      "name": "Claude Sonnet 4",
      "type": "language",
      "context_window": 200000,
      "max_tokens": 8192
    }
  ]
}
```

### Credit pricing estimates

**POST** `/v1/credits/pricing`

Returns models with estimated credits per request. Useful for showing users "how many requests will my credits buy?" Results are filtered to your allowed models and include your partner commission. Also available as GET with query params.

| Field | Type | Description |
|---|---|---|
| `publishable_key` | string | Required. Your partner publishable key. |
| `models` | string[] | Optional. Filter to specific model IDs. |
| `input_tokens` | number | Optional. Assumed input tokens per request (default 2500). |
| `output_tokens` | number | Optional. Assumed output tokens per request (default 2500). |

```bash
curl -X POST https://api.opencredits.ai/v1/credits/pricing \
  -H "Content-Type: application/json" \
  -d '{
    "publishable_key": "oc_pk_...",
    "models": ["anthropic/claude-opus-4.6", "openai/gpt-4o"],
    "input_tokens": 1000,
    "output_tokens": 500
  }'
```

```json
{
  "token_assumption": { "input": 2500, "output": 2500 },
  "models": [
    {
      "id": "anthropic/claude-sonnet-4-20250514",
      "name": "Claude Sonnet 4",
      "credits_per_request": 1.44,

    },
    {
      "id": "openai/gpt-4o",
      "name": "GPT-4o",
      "credits_per_request": 0.94,

    }
  ]
}
```

---

## Errors

The API returns errors in the format matching the endpoint you're using (OpenAI or Anthropic format).

### OpenAI format

Returned from `/v1/chat/completions`:

```json
{
  "error": {
    "message": "Insufficient credits. Please top up your balance.",
    "type": "insufficient_credits",
    "param": null,
    "code": "402"
  }
}
```

### Anthropic format

Returned from `/v1/messages`:

```json
{
  "type": "error",
  "error": {
    "type": "insufficient_credits",
    "message": "Insufficient credits. Please top up your balance."
  }
}
```

### Error codes

| Status | Code | Description |
|---|---|---|
| 400 | `invalid_request` | Malformed request body or missing required fields |
| 401 | `missing_api_key` | No `X-User-Key` header provided |
| 401 | `invalid_user_key` | The user key is invalid or has been revoked |
| 402 | `insufficient_credits` | User doesn't have enough credits. Open the checkout to top up. |
| 403 | `model_not_allowed` | Your partner account doesn't have access to this model |
| 404 | `model_not_found` | The requested model ID doesn't exist |
| 429 | `rate_limited` | Too many requests. Back off and retry. |
| 502 | `upstream_error` | The AI provider returned an error |

> **Handle 402:** When you get `insufficient_credits`, prompt the user to buy more credits by calling `OpenCredits.open()`.

---

## Referrals

You earn a referral share on every AI request your users make through OpenCredits. This is automatic — no extra integration needed.

### How it works

Each AI request deducts credits from the user based on token price, plus a referral share that goes to you. Your referral rate is set on your partner account (e.g. 10%). You can see per-request breakdowns in your usage logs.

### Example

With a 10% referral rate and a request that costs $5 in provider fees:

| | Amount |
|---|---|
| Provider cost for request | $5.00 |
| Your referral share (10%) | $0.50 |
| Total charged to user | $5.50 |

The more your users use AI features in your app, the more you earn. At scale this adds up — 1,000 requests/month at this rate = **$500/month** in referral revenue.

By bringing users to OpenCredits, you're rewarded every time they use AI — better features mean more usage and more revenue for you.
