Semantic Icons

Writing

Charge for the stage that costs you money

4 min read pricingapiarchitecture

How should an icon generation API charge?

Charge per generated icon, not per request. A retrieval cascade answers most requests from a cache or an index, which costs nothing. Take payment at the moment a model is about to run, so a free answer never costs the caller a credit, and refund it when the drawing fails.

Our icon API has four ways to answer. Only one of them spends money.

How it answeredWhat it cost us
The exact phrase was in the cacheone database read
The phrase named a curated iconone database read
A vector search found a close curated iconone embedding call
A model drew something newone model call

The first three are effectively free. The fourth is not. So the fourth is the only thing we charge for.

That sounds obvious. Most metered APIs do not do it.

Metering the request punishes the wrong people

Charge per request and you charge for the cache hit that cost you a database read. You also charge twice for the same phrase, because a second request for trash can returns the row the first one stored.

The customer notices. They start caching on their side to avoid your bill, which means they build the thing you already built, and your cache hit rate falls, and your costs go up. Metering the wrong unit makes your own system work worse.

Charge per generated icon and the incentives line up. A customer who searches ten thousand times and draws five icons pays for five icons.

Take the payment where the cost is

The obvious version is to check the balance at the start of the request. That is wrong in two ways.

It charges for answers that were free, because at the start you do not yet know which stage will answer. And it has a race: two requests both read a balance of one, both pass, and both draw.

Instead, hand the cascade a callback and let it call you at the moment it needs to draw:

const result = await handleGenerateIconRequest(icons, generate, phrase, {
  allowGenerate: async () => {
    const left = await spendCredit(keyId, phraseKey);
    return left !== null;   // null means the balance was already zero
  },
});

The cascade calls this only after the cache and the vector search have both missed. So a free answer never touches the balance, and the balance is read exactly once, at the moment it matters.

Make the spend atomic, in the database

The race is real, and the fix is not a lock. It is one statement:

update api_keys
  set credits = credits - 1
  where id = key and credits > 0
  returning credits;

The guard and the decrement happen together. Two requests cannot both take the last credit: one gets a row back, the other gets nothing and is refused.

A check in the application followed by a write cannot do this, however carefully it is written. We had that design first and had written the overrun off as acceptable. It is not acceptable, and it was never necessary, because moving the guard into the same statement costs nothing.

Refund what you did not deliver

Two failures happen after the credit is taken.

The model errors. The model answers that the phrase is too complex to draw and suggests simpler ones instead. In both cases the caller has no icon, so the credit goes back, with a ledger row that says why.

A ledger, rather than a bare counter, is what lets you explain a balance six months later. Every movement is a row: a grant, a purchase, a spend, a refund, and the balance after it.

Keep the free stages free at zero

This is the part customers notice most.

When the balance reaches zero, the cascade still runs. The cache still answers. The curated set still answers. The vector search still finds the nearest curated icon. Only the drawing is refused, with 402, which is the honest status for a prepaid balance that has run out.

Those three stages never called a model, so they were always free. Charging for them at zero would be charging for nothing.

Prepaid, not invoiced

Credits are bought before they are spent. That removes unpaid invoices, chargebacks, and any need to wire a billing system before somebody wants to pay. You can open a paid tier by hand: create a key with a balance, take the money however you like, and add credits when they run out.

Metered postpaid billing is a real product decision with real overhead. It is not a prerequisite for taking money.

Questions people ask

Is the icon search free?

Yes, and it needs no key. The ranking runs in your page over a static index, with no request and no server. See how to find an icon without knowing its name.

What exactly is one credit?

One new drawing. If the cascade answers from the cache, from the curated set, or from the vector search, you spend nothing.

What happens when the balance reaches zero?

Search, cache and nearest-match answers keep working. Only a new drawing is refused, with a 402 and a NO_CREDITS error.

Do I get charged if the drawing fails?

No. A model error and a "too complex" answer are both refunded, because neither delivered an icon.

Why not charge per API request?

Because most requests cost nothing to serve. Charging for them would also punish your own cache, and push customers to build a second cache in front of yours.

Search 3000 icons with a sentence

Ask for an icon the way you would say it.

Try the search