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.
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.
| Credential | Acts as | user_id |
|---|---|---|
App secret (sk_live_…) — your backend | the user you name | required |
| User JWT — minted for one person | itself | ignored |
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.
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:
| Value | Whose credits | What the user sees |
|---|---|---|
user (default) | the reader's own AI wallet | a balance, and a top-up button |
app | the tenant's own AI key — the same credential captions and summaries already spend | nothing; 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.
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.
| Limit | Value | What happens |
|---|---|---|
| Messages per request | 50 | 400, checked before de-duplication |
| Characters per message | 2000 | longer text is refused |
| Charged translations per user per day | 500 by default | not an error — see below |
| Provider minimum balance | 5 credits | the 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.
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:
| Operation | Time |
|---|---|
| Opening a conversation — 50 messages looked up at once | 2.5 ms |
| One message | 0.2 ms |
| Expiry sweep | 0.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.
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:
confident;Short strings — ok, 555, emoji, digits — are never confident. Use it,
your own detector, or skip detection and translate on demand.
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.
| Status | Meaning |
|---|---|
400 | no user_id with a server key; empty or oversized message_ids; the user's wallet is below the provider's minimum |
403 | the named user is not in one of those conversations |
200 with capped: true | the 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.
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.