Reference
Batteries
6 services the platform runs so a small app does
not have to. They are HTTP APIs on
batteries.saasie.io, reached with a token that
identifies your Saasie — not you.
Analytics
Register the events you care about, log them, and count them back. Types are declared up front so a typo is an error at the call that made it rather than a dimension that reads zero forever.
3 endpoints →
Your Saasie sends from its own address on saasie.email, assigned by the platform — and receives at the same one. Sending is one endpoint; the inbox is polled, holds 200 messages, and keeps a body but never an attachment.
3 endpoints →
Inference
Two models, named for what they are for rather than what they are. Generation reads pictures as well as words. Nothing you send is stored; only the token count is.
3 endpoints →
Secrets
The first battery that runs the other way: the other three are things your app does, a secret is something its owner decides. They write, your app reads. Declare the names in saasie.yaml; the values are set by a person and never returned to one.
3 endpoints →
Files
Durable storage for what does not belong in Postgres — an upload, a generated PDF, a CSV somebody imported. The bytes never pass through your container: you get an id and a short-lived signed URL, and the client sends them straight to object storage.
3 endpoints →
Queues
Work that survives the app restarting, and the platform's own events on the same three calls. Postgres underneath rather than a broker, which is why the API is the subset Postgres serves well.
4 endpoints →
Getting the credential
Declare the battery in saasie.yaml
and the platform injects its address and token. Declare nothing and
you get no credential at all — which is the difference between a
platform that hands out secrets and one that answers requests.
BATTERIES
Six services the platform runs so your app does not have to: analytics,
email in and out, inference, secrets, files and queues. All are HTTP APIs,
reached with a token that identifies your Saasie.
None is on by default. Declare what you use, and the platform injects the
address and the token for it — a blueprint that declares nothing gets no
credential at all.
version: 1
batteries:
analytics:
mail:
inference:
files:
queues:
secrets:
- STRIPE_SECRET_KEY
services:
backend:
dockerfile: Dockerfile
context: .
port: 3000
health:
path: /health
env:
SAASIE_ANALYTICS_URL: { battery: analytics, property: url }
SAASIE_ANALYTICS_TOKEN: { battery: analytics, property: token }
SAASIE_MAIL_URL: { battery: mail, property: url }
SAASIE_MAIL_TOKEN: { battery: mail, property: token }
SAASIE_MAIL_FROM: { battery: mail, property: from }
SAASIE_INFERENCE_URL: { battery: inference, property: url }
SAASIE_INFERENCE_TOKEN: { battery: inference, property: token }
SAASIE_SECRETS_URL: { battery: secrets, property: url }
SAASIE_FILES_URL: { battery: files, property: url }
SAASIE_QUEUES_URL: { battery: queues, property: url }
The env var names are yours; only the references are fixed. "token" is the
same value whichever battery you ask — it identifies the Saasie, not the
battery — so one variable is enough if you would rather.
Send it as: Authorization: Bearer <token>
The URLs point at the batteries service, which is a different host from the
one this CLI talks to. Read them from the environment; do not hardcode them.
ANALYTICS
Register a type before you log events of it. Registration is idempotent, so
call it at startup and do not track whether you have.
POST $SAASIE_ANALYTICS_URL/types
{ "name": "signup", "properties": { "plan": "string" } }
POST $SAASIE_ANALYTICS_URL/events
{ "events": [ { "type": "signup", "properties": { "plan": "pro" } } ] }
POST $SAASIE_ANALYTICS_URL/query
{ "type": "signup", "since": 1754438400000, "interval": "day",
"groupBy": "plan" }
An event whose type is not registered is refused, and so is a property the
type did not declare. That is the point: a typo in an event name is caught at
the call that made it, rather than becoming a second dimension that reads as
zero forever while the real one looks like it dropped.
"at" is optional — epoch milliseconds or ISO — and defaults to now.
Property values may be strings, numbers or booleans; all are stored as
strings.
Up to 100 events per request. A batch is stored whole or not at all.
MAIL — SENDING
POST $SAASIE_MAIL_URL/send
{ "to": "someone@example.com", "subject": "Your receipt",
"text": "Thanks!" }
It is sent from $SAASIE_MAIL_FROM — your Saasie's own address, assigned by
the platform. You cannot choose it. It looks like:
acme-x7k2p9@saasie.email
The random half is not decoration. Names can be claimed again after a Saasie
is deleted, and without it the next "acme" would receive mail meant for the
last one.
MAIL — RECEIVING
The same address receives. Mail sent to it arrives in an inbox your app
polls; the platform never calls into your app, so nothing is pushed to you.
GET $SAASIE_MAIL_URL/inbox what is waiting, newest first
GET $SAASIE_MAIL_URL/inbox/<id> one message, with its body
DELETE $SAASIE_MAIL_URL/inbox/<id> acknowledged and gone
So a flow built around somebody replying does work — a support address, an
invoice inbox, a reply-to-confirm.
Do not poll on a timer. "email.received" is a platform event on the
queues battery, carrying the message id — consume that and read the one
message it names.
200 messages, kept 7 days. At 200 the oldest is dropped, so read and
delete rather than letting the inbox be your database.
The body is the plain-text part, capped at 256 KB. Over that,
"truncated": true and it ends where it was cut — check it before you
parse a body as though it were whole. HTML-only mail is converted to
text rather than handed to you as markup.
Attachments are named and counted, never stored:
"attachments": [{ "filename", "contentType", "bytes" }] and no content.
An app expecting a PDF can tell "there was none" from "there was one and
the platform does not keep it", and should say so to its user.
Spam is delivered, flagged: "spam": true with reasons. It is not
withheld, because only you know what your inbox is for. Decide what to
do with it; do not assume everything that arrives is wanted.
Mail to an address nothing owns is accepted and silently dropped, so a
sender never learns which Saasie names exist.
INFERENCE
Two models, named by what they are for rather than by what they are:
POST $SAASIE_INFERENCE_URL/generate
{ "prompt": "Summarise this in one line: ..." }
POST $SAASIE_INFERENCE_URL/embed
{ "input": ["first passage", "second passage"] }
GET $SAASIE_INFERENCE_URL/models
"saasie/fast" generates and is the default for /generate. "saasie/embed"
embeds and is the default for /embed. You do not get to choose the model
behind either, and that is deliberate: it means we can move to a better one
without breaking your app.
Send "messages" instead of "prompt" for a conversation:
[ { "role": "user", "content": "..." } ]
"maxTokens" caps the answer. Ask for more than the platform allows and you
get the platform's ceiling — check "finishReason": "length" tells you the
answer was cut off.
/embed takes one string or a batch. Vectors come back in the order you
sent them; pair them with your own ids by position. Read "dimensions"
rather than hardcoding it.
PICTURES
"saasie/fast" can look at an image. Attach them and ask about them in the
same request:
POST $SAASIE_INFERENCE_URL/generate
{ "prompt": "What is the total on this receipt?",
"images": ["data:image/jpeg;base64,..."] }
With "messages", put them on the turn they belong to instead.
**Ask /models first, do not assume.** Which model serves "saasie/fast" is
the platform's to change, and not every model has eyes. /models answers
"readsImages", "maxImages" and "maxImageBytes" for this deployment.
Bytes, never a link. A URL in that field is refused rather than fetched.
If the picture is in the files battery or on the web, read it yourself
and send what you read.
PNG, JPEG, WebP or GIF. A .heic off an iPhone is the likeliest thing to
be refused; convert it first.
Four per request, 1.5 MB each by default. Resize before sending — every
model downscales anyway, so a bigger file buys a slower request and a
bigger bill and nothing else.
A picture reserves a flat estimate against the day's tokens before it is
sent, and is billed on what it actually cost. Images are not cheap
against a 50,000-token day; do not run one over every upload by reflex.
Nothing you send is stored, and that includes the pictures.
Nothing you send is stored. We record how many tokens a call used, and never
the prompt, the answer, or the vectors.
SECRETS
Credentials your app needs and your repository must not contain. Unlike the
other three, this one is configured in the blueprint: you list the names your
app may read, and only those.
batteries:
secrets:
- STRIPE_SECRET_KEY
- GITHUB_TOKEN
Deploy that, then set the values — they never appear in the file.
saasie app <name> secret list
pass show stripe | saasie app <name> secret set STRIPE_SECRET_KEY
Your app reads them at runtime:
GET $SAASIE_SECRETS_URL
{ "secrets": [ { "name": "STRIPE_SECRET_KEY", "set": true,
"version": 2, "setAt": 1754438400000 } ] }
GET $SAASIE_SECRETS_URL/STRIPE_SECRET_KEY
{ "name": "STRIPE_SECRET_KEY", "value": "sk_live_...", "version": 2 }
Fetch at boot and keep it in memory. Do not write it to disk, and do not put
it in a log line.
404 the blueprint does not declare that name — fix saasie.yaml and deploy
409 it is declared and nobody has set it yet — a person has to
Both are worth telling apart: the first is yours to fix, the second is not.
Nothing pushes a change to you. If a secret can be rotated under a running
app, either refetch when the upstream service rejects your credential, or
poll the list route and watch "version" — it counts writes, and moves without
ever carrying the value.
WHEN THE KEY IS SOMEBODY ELSE'S
Your app can ask for a link that lets a person set one secret, without
letting them near anything else — and without your app ever seeing what they
type:
POST $SAASIE_SECRETS_URL/STRIPE_SECRET_KEY/link
-> { "url": "https://app.saasie.io/secret/...", "expiresAt": ... }
Send that URL to whoever holds the credential. It sets that one secret on
your Saasie, once, expires within the hour, and cannot read the current
value. They need no account here.
That is the whole reason this exists. An app — or an agent building one —
can ask a user for their API key and never be in a position to read it.
No route anywhere returns a stored secret to a person. Not the console, not
this CLI, not with a flag. If you forget what you set, set it again.
FILES
Storage for the things that do not belong in a database — an upload, a
generated PDF, a CSV somebody imported. You get opaque ids; there is no
bucket, no key, no folder and no multipart.
The important part is where the bytes go: **straight between your user and
storage, never through your app.** You ask for a link and hand it over.
POST $SAASIE_FILES_URL
{ "size": 20481, "contentType": "image/png", "name": "avatar.png" }
-> { "file": { "id": "...", "status": "pending" },
"upload": { "url": "https://...", "method": "PUT",
"contentType": "image/png" } }
Your client then PUTs the bytes to that URL with exactly that Content-Type.
From a browser, that is one fetch and your server never sees the file.
GET $SAASIE_FILES_URL what you have, newest first
GET $SAASIE_FILES_URL/<id> one file
POST $SAASIE_FILES_URL/<id>/link { "method": "GET" | "PUT" }
DELETE $SAASIE_FILES_URL/<id>
A file is "pending" until the bytes arrive. Nothing tells us when that
happens, so we ask storage: reading or listing a pending file checks it and
flips it to "available" with the size that actually arrived. Poll the file
after your upload finishes if you need to know.
"size" is what you are about to upload, and it is checked twice — against
your allowance before you get a link, and against what actually arrives.
An upload bigger than you declared and too big to fit is "rejected" and
its bytes are deleted. The row stays so you can see why.
Links are short-lived — fifteen minutes — and are the whole of the
authorization on them. Anyone holding one can read that file until it
expires, so treat a GET link like the file itself.
A provision nobody uploads to holds allowance until it is collected a day
later. Delete what you do not need.
Downloads render however the browser decides for their content type; there
is no "save as" filename yet.
QUEUES
Work that has to survive your app restarting, and the platform's own events
on the same mechanism.
POST $SAASIE_QUEUES_URL/<queue> { "payload": { ... } }
POST $SAASIE_QUEUES_URL/<queue>/consume waits, then a message or null
POST $SAASIE_QUEUES_URL/_ack { "receipt": "..." }
GET $SAASIE_QUEUES_URL what is waiting, per queue
A queue is just a name. Nothing creates one and nothing declares one — it
exists while there is a message on it.
consume long-polls for about 25 seconds and then answers
{ "message": null }
which is a normal answer, not an error. Call it again in a loop.
AT LEAST ONCE — READ THIS ONE
consume does not remove a message, it *leases* it. Ack and it is gone; crash
and it goes back on the queue for whoever asks next. So a message can be
delivered twice, and your handler has to cope with that. Every message comes
with
"deliveries": 2
which is greater than 1 when somebody had it before you and did not finish.
Ack after the work is done, not before. If your lease lapses while you were
working, the ack answers { "acked": false } — the message went back and may
already be running somewhere else. That is information, not an error.
What is deliberately not here: visibility timeouts, consumer groups,
partitions, batch sizes, topics, lease extension, delayed delivery,
dead-letter queues. If you need those you have outgrown this and want a real
broker.
EVENTS
"events" is a queue the platform writes to and you consume, with the same
calls:
POST $SAASIE_QUEUES_URL/events/consume
{ "type": "file.uploaded", "fileId": "...", "size": 20481 }
{ "type": "email.received", "messageId": "..." }
{ "type": "inference.completed", "model": "saasie/fast", "tokens": 412 }
{ "type": "inference.failed", "model": "saasie/fast", "reason": "..." }
Two of these are how you learn something happened that your own process
never saw. file.uploaded: an upload goes straight from your user to storage,
so nothing in your app sees it finish. email.received: mail arrives from
outside, and the platform does not call you.
Consuming this queue is also the closest thing to a live feed the platform
has. consume long-polls, so a loop on it costs one connection rather than a
timer — that is the mechanism to reach for when something has to happen
promptly. There is no websocket and no server-sent events from the platform,
and your app is one replica, so anything you build on an in-process event
emitter is gone at the next deploy.
You cannot enqueue to "events" — if you could, the events on it would mean
nothing. Unlike your own messages these are best effort: if we cannot write
one, the thing it describes still happened.
WHAT YOU GET, AND WHAT STOPS YOU
5 emails per day, to any address
200 received messages waiting, kept 7 days, 256 KB of body each
100,000 events per day
events kept 90 days
50,000 generation tokens per day
500,000 embedding tokens per day
25 secrets, up to 4 KB each
1 GB of files, 100 MB per file
10,000 messages waiting, kept 7 days, 64 KB each
Every count resets at midnight UTC. Past any of them, the API answers 429 and
says when it resets — handle that rather than retrying, because retrying will
not help until the next day.
Inference is counted in tokens rather than in calls, because that is what it
costs. Roughly: a token is about four characters, and a request reserves its
whole "maxTokens" up front — so a large "maxTokens" you do not use still
takes up room in the day's budget. Ask for what you need.
All of it is covered by the subscription. That is what the caps are for: a
capped service can be included, an uncapped one has to be metered.
THE TOKEN
It identifies your Saasie and nothing else — it cannot claim a name, read
another Saasie's data, or act on your account. Treat it as a secret anyway:
anything holding it can send mail as you, and there are only five a day.
It is not printed by any command and not shown in any console, because the
only thing that needs it is your container. It stops working while the Saasie
is suspended, and starts again when it resumes.
Read what your app has been doing:
saasie app <name> analytics types
saasie app <name> analytics count --type signup --since 30d
saasie app <name> mail
saasie app <name> inbox
saasie app <name> secret list
saasie app <name> files
saasie app <name> queues
The same text, on your machine: saasie docs batteries.
The machine-readable contract is
openapi.json.