Skip to content

Guides

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.

View as Markdown

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

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;
}
  • 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

EventWhen
credits.lowThe balance fell to the product's alert. Once per crossing: it comes again only after a top-up lifts it above.
credits.depletedThe 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.

Credits from many packs add up; a spend takes the oldest pack first.