Chat translation

Let everyone read the conversation in their own language.

POST /v1/messages/translate is the whole API. It takes message ids rather than text, because the server reads the messages itself — that is what lets it check the caller may see them, and it is why nobody can pay to translate a conversation they are not part of.

The short version

curl -X POST https://pizton.com/v1/messages/translate \
  -H "Authorization: Bearer $SERVER_KEY" \
  -H 'content-type: application/json' \
  -d '{
        "message_ids": ["88933157612556288", "88933157612556289"],
        "target_lang": "en",
        "user_id": "usr_86761264071577600"
      }'
{
  "items": [
    { "message_id": "88933157612556288", "translation": "Hello, what time is the meeting tomorrow?", "cached": false },
    { "message_id": "88933157612556289", "translation": "I have sent the document.",                "cached": true  }
  ],
  "billed": 1,
  "target_lang": "en",
  "capped": false,
  "daily_remaining": 499
}

One request per screenful, not one per message.

Who calls it, and who pays

CredentialActs asuser_id
App secret (sk_live_…) — your backendthe user you namerequired
User JWT — minted for one personitselfignored

user_id answers two questions that are really one: whose AI credits pay, and which conversations may be read. Neither can be derived from a server key, so it cannot be defaulted. A request without it is a 400; one naming somebody who is not in the conversation is a 403.

A user token can only ever act as itself. Sending your own id in the body is harmless; sending somebody else's gains nothing.

Charges never touch the tenant's balance. They come out of the named user's own AI wallet, so one person's reading is never funded by another's.

Who pays: the reader, or the company

By default the reader pays, from their own AI wallet. That is right for a consumer messenger — nobody should fund a stranger's reading — and wrong for a company rolling the feature out to its staff, where one account holds the credits and nobody should ever see a balance.

So the tenant chooses. ai_translate_billing on the app takes two values:

ValueWhose creditsWhat the user sees
user (default)the reader's own AI walleta balance, and a top-up button
appthe tenant's own AI key — the same credential captions and summaries already spendnothing; it simply works

To have your own AI Token account pay for everybody: set the tenant's AI key to that account in the admin console (AI settings), then switch billing to app. Every translation your users ask for is then billed to you, and none of them needs an AI wallet of their own.

The daily cap still applies per reader. A company wallet that any one auto-translate switch left on can drain overnight is worse, not better, than a personal one — so the per-person ceiling stays exactly where it is. Cache hits, as always, cost nothing and count against nothing.

The cache is the part worth understanding

Translations are stored per tenant against a hash of the wording, not against the message:

key = (your app id, SHA-256 of the exact message text, target language)

Three consequences, and they are the reason for the design:

cached: true on an item means it came from that store and cost nothing. billed is how many actually reached the model, and it is the number to watch if you care about spend.

The original text is never stored — only its hash. There is no table anywhere holding a second copy of your users' messages.

Limits, and what they do when reached

LimitValueWhat happens
Messages per request50400, checked before de-duplication
Characters per message2000longer text is refused
Charged translations per user per day500 by defaultnot an error — see below
Provider minimum balance5 creditsthe call is refused by the provider

The daily cap does not fail the request. A wallet is a balance, not a spending limit, and an auto-translate switch left on will happily ask for fifty translations nobody requested, one at a time. When the cap is reached, cached items are still returned — they cost nothing, and withholding them would take away what was already paid for. The rest are simply absent from items, and capped: true says why. daily_remaining tells you how much room is left.

A wallet holding fewer than 5 credits behaves exactly like an empty one, because the provider refuses to start the call. Read min_credits and below_minimum from GET /v1/me/aitoken/wallet rather than hardcoding the number; if it ever moves, it moves there.

Does it stay fast?

The cache grows with distinct wordings, not with messages. A billion messages between people who say "ok" and "ขอบคุณครับ" to each other is a few thousand rows; the table only grows when somebody writes a sentence nobody in your tenant has written before.

Measured against a million cached rows on the production database:

OperationTime
Opening a conversation — 50 messages looked up at once2.5 ms
One message0.2 ms
Expiry sweep0.1 ms

The lookup is an index scan on the primary key, which is exactly the three columns it filters on — tenant, wording hash, target language. That is the whole scaling story: it is a key lookup, and it stays one. Ten times the rows costs roughly fifteen percent more time, not ten times.

Serving from cache performs no write. Reuse counters are accumulated in memory and flushed in one statement every few seconds, so a fully cached request touches the database once, to read. Before that, showing fifty cached translations wrote fifty rows to record that nothing had been bought — more than half the cost of the request, and the half that does not scale.

Working out what language a message is in

The API does not detect the source language, and does not need to: it translates whatever you send. Deciding whether a message needs translating is a client-side question, and answering it on the server would mean a round trip per message just to be told "that one was already readable".

Pizton's own clients use a dependency-free detectLang(text) returning { lang, confident }. The confidence flag exists because the two modes must treat doubt differently:

Short strings — ok, 555, emoji, digits — are never confident. Use it, your own detector, or skip detection and translate on demand.

Deleting

Deleting a message deletes the cached translation of its wording along with it, and unused entries are swept after 90 days. The cache is content-addressed and therefore attached to no message row, so without the first of those, deleting a conversation would leave its sentences readable in a side table.

Errors you should expect to handle

StatusMeaning
400no user_id with a server key; empty or oversized message_ids; the user's wallet is below the provider's minimum
403the named user is not in one of those conversations
200 with capped: truethe daily cap was reached; some items are missing, and that is not an error

The provider's own failure text is passed through on a 400. It is written for operators, not for readers — map it to your own wording.

SDK

const cp = ChatPlatform({ key: process.env.SERVER_KEY });

const r = await cp.messages.translate(ids, { targetLang: 'en', userId: 'usr_…' });
r.items.filter(i => !i.cached);   // the ones that actually cost something

From a browser or app holding a user token, drop userId — the token already says who it is.