# Sell credits

> Prepaid credits for an API or an AI service: buyers top up themselves, your server spends one call at a time. 0% on every top-up.

Credits suit anything sold by use: tokens, images, minutes, searches. The buyer pays for a pack in crypto; you spend from it on every call; when it runs low they top up on their own page. Nothing is ever charged automatically — the buyer always pays themselves — and Perly takes nothing from a top-up.

## Set it up

1. **Make a product and its packs** — Dashboard → Products → New, then New price → **Credit pack**: e.g. 1,000 credits for $10, 5,000 for $40. Or `pay.prices.create({ product, model: "credit_pack", unit_amount: 1000, credits: 1000 })`.
2. **Choose when buyers hear they are low** — On the product's page, under **Credits**: the low-balance alert, the buyer's letter, and **Any amount** — N of one pack, so a buyer can top up $25 instead of a fixed pack.
3. **Give each buyer a customer** — Make one per user with your own id: `pay.customers.create({ email, external_id: user.id })`. From then on the SDK takes that id.

## Spend, check, top up

**Node**

```js
import { PerlyPay, InvalidRequestError } from "perly-pay";
const pay = new PerlyPay(process.env.PERLY_PAY_SECRET_KEY);

// Before the work: enough left?
const { balance, top_up_url } = await pay.credits.balance(user.id);

// After it: spend. The same idempotencyKey never spends twice.
try {
  await pay.credits.consume(user.id, 3, { action: "chat", idempotencyKey: request.id });
} catch (e) {
  if (e instanceof InvalidRequestError && e.code === "insufficient_credits") {
    return { error: "Out of credits", topUp: await pay.credits.topUpUrl(user.id) };
  }
  throw e;
}
```

**PHP**

```php
$perly = new \Perly\Pay\PerlyPay(getenv('PERLY_PAY_SECRET_KEY'));

$left = $perly->credits->balance($user->id)['balance'];

try {
    $perly->credits->consume($user->id, 3, ['action' => 'chat', 'idempotency_key' => $requestId]);
} catch (\Perly\Pay\Exception\InvalidRequestException $e) {
    if ($e->getErrorCode() === 'insufficient_credits') {
        header('Location: ' . $perly->credits->topUpUrl($user->id));
    }
}
```

**curl**

```bash
curl https://pay.perly.io/api/v1/credits/consume \
  -H "Authorization: Bearer sk_test_…" \
  -H "Idempotency-Key: req_8f2c" \
  -d '{"customer_external_id":"user_42","credits":3,"action":"chat"}'
```

- The customer is a `cus_…` id, an email, or your own id for them (`external_id`). Name a `product` when you sell credits for more than one.
- A spend is atomic and never goes below zero: two at once never both pass on the same credits. Short is `insufficient_credits` (409), and nothing is spent.
- Send your request's id as the idempotency key. A retry — after a timeout, a crash, even a day later — returns the first spend instead of spending again.
- `action` is your label (`chat`, `image.hd`, `api/search`). The product's page sums spends by action and by customer.

## The top-up page

`top_up_url` is the buyer's own page, the same every time: it shows what is left and your packs, in your checkout's look, and opens a normal invoice when they choose one. When it is paid, the credits land on their balance once. Put the link in your app, in an "out of credits" error, or send it — the buyer portal has a **Top up** button too.

## When credits run low

| Event | When |
| --- | --- |
| `credits.low` | The balance fell to the product's alert. Once per crossing: it comes again only after a top-up lifts it above. |
| `credits.depleted` | The balance reached zero. Once per crossing. |

Both carry the customer as `data.object`, with `data.product` and `data.balance`. With the alert set and the letter on, the buyer also gets one email with their top-up link — at most one a day. In test mode letters go to your team only.

> **Note:** Credits from many packs add up; a spend takes the oldest pack first.
