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.

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.