# Apposters API for AI agents

Apposters turns a GitHub repository into a running SaaS. It builds the app from the repository's Dockerfile - or, without one, the way its framework builds (Vite, Next.js, Django, Go…) - and runs it at `https://<name>.apposters.com/app/`, and the AI writes the product site around it at `https://<name>.apposters.com`: a landing page, About, FAQ and a sign-in page, all about the product, with buttons that open the app. A SaaS is what to build here unless the person asks for less. A landing page or a website about the repository, without the app, is the lighter option for a project that only needs a page.

This guide is for AI agents and scripts working on Apposters for a person: sign them in (or sign them up), build and change their projects, and hand them the few steps only a person can do. What the person does: confirms who they are (opens a sign-in link and tells you a short code), pays on Stripe's page, and approves GitHub or DNS changes when a feature needs them. Everything else is yours.

## Basics

- Base URL: `https://api.apposters.com`. Every path below is relative to it.
- Send JSON with `Content-Type: application/json`.
- Authenticate every call with `Authorization: Bearer <token>`, except the two login calls and this guide.
- Errors are JSON: `{"error": "human readable", "code": "machine_readable"}` (`code` is not always there). Someone else's project, or a project that does not exist, is a `404`.
- Call the API from a server or a terminal. Browsers on other websites are blocked by CORS.
- Never put the token in a URL, a log, a commit or a message to anyone but the person.

## 1. Sign the person in and get a token

Do this once per person. The token then works for 90 days, or until the person revokes it on `https://apposters.com/account/`.

**Step 1.** Ask the person for the email of their Apposters account, or the email to create one with, and start a login:

```
POST /agent/login
{"email": "person@example.com", "client": "Name of your agent"}
```

```
201 {
  "request_id": "Qm9...",
  "verification_url": "https://apposters.com/agent/?request=Qm9...",
  "expires_in": 900,
  "email_sent": true,
  "next_step": "..."
}
```

With an email, Apposters sends the person a sign-in email. A new address gets an account: the same email confirms it. `email` is optional - without it, or when `email_sent` is `false` (`email_error` says why), give the person `verification_url` to open instead. They can sign in or sign up there.

**Step 2.** Tell the person: "Open the link in the email from Apposters (or this link: verification_url) and tell me the code you see there." The code looks like `BKDF-QRTZ`. The request lives 15 minutes.

**Step 3.** Exchange the code for a token:

```
POST /agent/token
{"request_id": "Qm9...", "code": "BKDF-QRTZ"}
```

```
200 {
  "token": "apo_...",
  "token_type": "Bearer",
  "token_id": "tok_...",
  "expires_at": "2026-12-28 10:00:00",
  "user": {"id": "...", "email": "person@example.com"}
}
```

| Answer | Meaning | What to do |
|---|---|---|
| `409` `not_opened` | The person has not opened the link yet | Wait for them, then send the code again |
| `400` `invalid_code` | Wrong code; `attempts_left` says how many tries remain | Ask the person to read it again. Case and the dash do not matter |
| `403` `denied` | The person refused | Stop. Do not start again unless they ask you to |
| `409` `token_limit` | The account already has 50 working tokens | Ask the person to revoke some on `https://apposters.com/account/`, then send the same code again |
| `410` `expired` | Expired, already used, or too many wrong codes | Start again from step 1 |
| `409` `wrong_account` | The person opened the link signed in as another account (only when you gave an email) | They sign out on that page and open the link again |
| `429` `rate_limited` | Too many logins started from your address | Wait an hour |

The person can also create a token themselves on `https://apposters.com/account/` (API tokens) and give it to you. They can revoke any token there at any time, and pressing "I did not ask for this" on the code page even after you got the token revokes it too: from then on every call answers `401`. Treat that as their decision, not as an error to work around.

**Who am I:** `GET /agent/me` -> `{"user": {"id", "email"}, "auth": "token", "token": {"id", "name", "expires_at", ...}}`.

**Sign out:** `DELETE /agent/token` revokes the token you call it with (`204`). `GET /app/tokens` lists the account's tokens; `DELETE /app/tokens/<id>` revokes one.

## 2. Build a SaaS

The whole path, in order. Steps 1-3 and 6 are free; step 4 is where the person pays.

1. Create the project from the repository (2.1).
2. Generate the product site, `mode: "saas"` (2.2).
3. Check that the repository can run here (2.3).
4. The person unlocks Embed App - with more memory or disk if the app needs it (section 3).
5. Set the app up, give it its settings, deploy it (2.4).
6. Publish the site (2.5).

### 2.1 Create the project

From a public repository, or a private one the person connected (below):

```
POST /app/sites
{"github_url": "https://github.com/owner/repo", "name": "Product name"}
-> 201 {"id": "<site id>", "slug": null, "url": null, ...}
```

`name` is the product's name on every page; without it the repository name is used.

**Design (optional):** add `"design": "<id>"` to pick the look - a whole design system (colors, fonts, corners, components). `GET /app/designs` lists the 30 ids with a name, a one-line description, the project type each suits (`type`: `saas`, `website`, `landing`) and its appearance (`scheme`: `light` or `dark`) - for a dark site, pick a dark design. Leave `design` out and Apposters picks the one that matches the repository's description and topics. `"color_mode": "Dark mode"` or `"Light mode"` still asks for the other appearance on top of a design, but the app no longer offers it.

**Private repositories:** `GET /app/github/repo?url=<repo url>` says whether Apposters can read it (`exists`, `private`). If it cannot, `POST /app/github/connect {"repo": "<repo url>"}` returns `{"url": ...}`: give that link to the person to authorize GitHub, then poll `GET /app/github` until `connected` is `true`. A private repository can only be a SaaS: the product site says nothing about the code, and the other types answer `422` `private_repo`.

### 2.2 Generate the product site

```
POST /app/sites/<site id>/generate
{"kind": "template", "mode": "saas", "pages": ["about", "faq", "auth"]}
-> 202 {"job_id": "...", "status": "running", "mode": "saas", "pages": [...]}
```

The pages are about the product the repository runs as, not about the repository, and their buttons lead to the app at `/app/`. `pages` is any of `about`, `faq` and `auth` (the sign-in page); the landing page is always made. Add `"community": true` for a community forum linked from the menu. A server without app hosting answers `503`; there only a landing page or a website can be made (section 6).

**The person's own wishes (optional):** add `"instructions": "..."` (up to 2000 characters) with what they want on the pages beyond what the repository says, in their words - for example `"Add a pricing section with a free and a paid plan. On the FAQ page, answer whether the data stays private."` The landing, About and FAQ pages each take the part meant for them; a wish that names no page goes on the landing page. The sign-in page ignores it. It is not saved with the project: send it again with any later generation that should follow it.

**Wait for it.** Poll every few seconds (a page takes a minute or two):

```
GET /app/jobs/<job id>
-> {"status": "running" | "done" | "error" | "aborted" | "cancelled", "version_id": "...", "error": null, "finished_at": null, "next_job_id": null, ...}
```

The pages are generated one after another: when a job is `done` and `next_job_id` is set, follow that job the same way. A job is forgotten ten minutes after it ends. `GET /app/jobs/<job id>/stream` is the same as server-sent events, if you prefer to stream.

Once a site is a SaaS, every page generated for it later stays a product page.

### 2.3 Check that the repository can run

```
GET /app/sites/<site id>/repo-check?ref=HEAD&path=Dockerfile
-> {"dockerfile": true, "analysis": {"expose": [3000], "command": "...", "kind": "web", "warnings": []}, "setup": {...}}
```

- `dockerfile: false` with `dockerfile_found: {"path": "..."}`: the repository builds another file; use that path as `dockerfile_path` below.
- No Dockerfile at all: look at `auto`. With `auto.found: true` Apposters builds the repository without one, the way its framework builds (Vite, Next.js, React, Vue, Angular, Astro, Express, Django, FastAPI, Flask, Streamlit, Go, a plain `index.html`…): `auto.name`, `auto.install`, `auto.build`, then `auto.output` (a static site) or `auto.start` (a server), and `auto.dockerfile` is the Dockerfile it writes. Deploy it with `"build_mode": "auto"` below. `auto.found: false` comes with a `reason`; then the repository needs a Dockerfile.
- `analysis.kind: "cli"` or a `not-a-server` warning: it is a command-line tool, not a web app. Tell the person before they pay for hosting.
- `analysis.expose` is the port to set below.
- `setup.vars` lists the settings the app asks for (`name`, `required`, `description`, `input`, `value`). Put the ones it needs into its environment (2.4): a `value` is ready to use (for `input: "auto"` it is the app's own address and the like), `generate` takes any long random string, and `secret` ones come from the person. A `memory` note in `setup.notes` means the app asks for more memory than the free 512 MB (section 3.2).

### 2.4 Deploy the app

Embed App has to be paid for first (section 3); until then these calls answer `402` `payment_required`.

```
PUT  /app/sites/<site id>/deployment   {"port": 3000, "dockerfile_path": "Dockerfile"}
PUT  /app/sites/<site id>/deployment   {"port": 3000, "build_mode": "auto"}   (no Dockerfile: built the way its framework builds)
PUT  /app/sites/<site id>/deployment/env   {"env": {"DATABASE_URL": "...", "OLD_KEY": null}}
POST /app/sites/<site id>/deployment/deploy   -> 202 {"job_id": "...", "run_id": "..."}
GET  /app/sites/<site id>/deployment   -> {"status": "building" | "starting" | "running" | "failed" | ..., "url": ".../app/", "last_run": {...}}
```

The first `PUT` gives the project an address if it has none yet (from the project name). Other fields: `ref` (default `HEAD`), `health_path` (default `/`), `volume_path` (where the app keeps data that must survive a deploy, e.g. `/data`). `PORT` is set for the app; environment values are never shown back to you, and `null` removes a key. With `"build_mode": "auto"` the environment reaches a Node.js build too, so `VITE_...` and `NEXT_PUBLIC_...` values end up in the pages - set them before the deploy.

Follow the build with `GET /app/jobs/<job id>`: it has ended when `finished_at` is set; then read `status` from `GET /app/sites/<site id>/deployment`. When it fails, `last_run.diagnosis` explains why (`title`, `detail`, `fix`), and `POST /app/sites/<site id>/deployment/fix` lets the AI repair the Dockerfile and try again. A failure for memory (`diagnosis.limit.kind: "memory"`, or `last_exit_oom: true`) usually means the app needs more memory, and a `409` `storage_full` on deploy means it needs more disk: both are bought for the app (section 3.2).

While it runs: `GET /app/sites/<site id>/deployment/logs` (text), `GET /app/sites/<site id>/deployment/metrics` (memory, CPU, `storage.used_mb` against `storage.limit_mb`), `POST /app/sites/<site id>/deployment/restart`, `.../stop`, `.../start`.

**Sign-in in front of the app:** with an `auth` page and the app deployed, `PATCH /app/sites/<site id> {"auth_redirect_to_app": true}` sends people who sign in on the site straight to the app, and lets only them use it.

### 2.5 Publish the site

```
GET  /app/subdomain-check?slug=my-app       -> {"available": true} or {"available": false, "reason": "invalid" | "reserved" | "taken"}
POST /app/sites/<site id>/slug   {"slug": "my-app"}   -> {"slug": "my-app", "url": "https://my-app.apposters.com"}
POST /app/sites/<site id>/publish {}                 -> {"url": "https://my-app.apposters.com", "pages": [...]}
```

A slug is 3 to 60 characters of `a-z`, `0-9` and `-`. Set it before the deployment if the person wants a particular address, or rename later: the app moves with the site, to `https://<slug>.apposters.com/app/`. Publishing is free. `POST /app/sites/<site id>/publish` puts every generated page live at once; `POST /app/sites/<site id>/publish-page {"kind": "about"}` republishes one subpage.

## 3. Payments: the person pays, you carry on

Generating, publishing, the address at `<name>.apposters.com` and the free tier of a hosted app (512 MB of memory, 1 CPU, 3 GB of disk for the app's image and data together) cost nothing. These cost money:

| Product | `<product>` | What it is | Bought for |
|---|---|---|---|
| Embed App | `embed` | Hosting the app: Apposters builds it from its Dockerfile (or without one, the way its framework builds) and runs it at `/app/`. A subscription. Needed for every SaaS | one app; another app is another purchase |
| Memory | `memory` | More memory for one app: 1, 2, 4, 8 or 16 GB instead of the free 512 MB. Charged per unit, 1 unit = 1 GB | one app (`site_id`) |
| Disk space | `disk` | More disk for one app: 5, 10, 20, 40 or 80 GB instead of the free 3 GB. Charged per unit, 1 unit = 5 GB | one app (`site_id`) |
| Custom domain | `domain` | The project on the person's own domain (section 5). A one-time payment | one domain |

Never pay yourself and never quote a price from memory: read it from the API and give the person Stripe's link.

### 3.1 Embed App and Custom domain

1. `GET /billing` -> `{"embed": {...}, "domain": {...}}`. For each: `required: true` means it has to be paid for before use, `paid` says it is, `price_label` is the price to quote. A paid route answers `402` with `code: "payment_required"` otherwise.
2. `POST /billing/<product>/checkout {"site_id": "<site id>"}` -> `{"id": "cs_...", "url": "https://checkout.stripe.com/..."}`. Give the person `url`, say what it costs, and ask them to pay there.
3. When they say they paid: `POST /billing/<product>/confirm {"session_id": "cs_..."}` (the `id` from step 2). `{"paid": false}` means not yet - wait and ask again. `GET /billing/<product>` also turns `paid: true` on its own once Stripe tells Apposters. Then continue.

Each payment covers one hosted app or one domain: the next `PUT /app/sites/<site id>/deployment` for a new app answers `409` `quota_reached` once all are in use. To buy another, send `"additional": true` with the checkout. `GET /billing/account/summary` shows, per product, `seats` (bought) and `used`, and every purchase.

### 3.2 More memory and disk space for an app

Buy more when the app does not fit the free tier:

| Sign | Buy |
|---|---|
| A deploy failed with `last_run.diagnosis.limit.kind: "memory"` (`limit.needed` is the MB it wants), or `GET .../deployment` says `last_exit_oom: true` | memory |
| `setup.notes` has a `memory` note before the first deploy | memory |
| `POST .../deployment/deploy` answered `409` `storage_full`, or `metrics.storage.over` is `true` (such an app gets stopped) or close to it | disk |

**See what the app has and what more costs:**

```
GET /app/sites/<site id>/deployment/resources
-> {"memory": {"limit_mb": 512, "units": 0, "on_sale": true, "unit_price_label": "...",
               "steps": [{"units": 2, "mb": 2048, "available": true, "add_units": 2, "price_label": "...", "add_price_label": null}, ...]},
    "disk": {...}}
```

`limit_mb` is what the app runs with now, `units` what it already pays for. A step is the size the app should END UP with, not an addition: `units: 2` of memory is 2 GB in all. Only `available: true` steps are sold; buying one charges only the units not paid for yet (`add_units`, priced `add_price_label`), so going from 2 GB to 4 GB costs 2 more units. `on_sale: false` means this server does not sell it.

**With Embed App, in one payment** (the app does not have to be set up yet):

```
POST /billing/embed/checkout   {"site_id": "<site id>", "resources": {"memory": 2, "disk": 1}}
```

The values are steps, as above; `0` leaves that resource at the free tier. `409` `not_bundleable` means that resource cannot ride along here: buy it on its own afterwards.

**On its own**, once Embed App is paid and the app is set up (`PUT .../deployment` done):

```
POST /billing/memory/checkout   {"site_id": "<site id>", "units": 4}
-> {"id": "cs_...", "url": "https://checkout.stripe.com/...", "units": 4, "quantity": 4}
POST /billing/memory/confirm    {"session_id": "cs_..."}
-> {"paid": true, "site_id": "...", "limit_mb": 4096}
```

The same with `disk`. Give the person `url` as in 3.1 and confirm the same way. Memory takes effect on the next deploy (`POST .../deployment/deploy`); disk at once.

| Answer | Meaning |
|---|---|
| `400` `invalid_units` | `units` is not one of 1, 2, 4, 8, 16 |
| `402` `payment_required` | Embed App is not paid for yet: buy it first, or together (above) |
| `409` `not_configured` | The app is not set up yet: `PUT .../deployment` first, or buy together with Embed App |
| `409` `already_has` | The app already has this much or more |
| `503` | Not sold on this server |

**Less, or cancel:** only the person, on `https://apposters.com/account/` (Manage payments, Stripe's portal). Without the purchase the app goes back to the free tier.

## 4. Change it later

- **Settings:** `PATCH /app/sites/<site id>` with any of `name`, `description`, `ga_id`, `design` (an id from `GET /app/designs`, or `""` for the automatic pick), `color_mode`, `logo_url`, `auth_redirect_to_app`. A new design shows after the next generation (below).
- **Read what is there:** `GET /app/sites` (all projects), `GET /app/sites/<site id>`, `GET /app/sites/<site id>/versions?kind=template` (kinds: `template` is the landing, `about`, `faq`, `auth`), `GET /app/versions/<version id>` (with `html`).
- **Edit the HTML yourself:** `POST /app/sites/<site id>/versions {"kind": "template", "html": "<!DOCTYPE html>...", "name": "What changed"}`. The new version becomes the current one; publish again to put it live.
- **Generate again:** `POST /app/sites/<site id>/generate {"kind": "template", "regenerate": true}` remakes the landing page and keeps the project type. To change the type, send the whole plan: `{"kind": "template", "mode": "saas", "pages": ["about", "faq"], "regenerate": true}` remakes the landing and those pages. The pages copy their look from the landing, so a new design reaches all of them through the same request with the project's current plan (`wizard` in `GET /app/sites/<site id>`). Add `"instructions"` (2.2) to change something specific; `{"kind": "about", "instructions": "..."}` remakes only the About page that way.
- **Ship a new version of the app:** push to the repository, then `POST /app/sites/<site id>/deployment/deploy` again.
- **Roll back a page:** `PUT /app/sites/<site id>/actual {"kind": "template", "version_id": "<version id>"}`, then publish.
- **Delete:** `DELETE /app/sites/<site id>` (the live page stays), or `DELETE /app/sites/<site id>?purge=1` (the live page goes too). Deleting a project removes its app and the app's data. Ask the person first.

## 5. Custom domain

`PUT /app/sites/<site id>/domain {"domain": "www.example.com"}` answers with `records`: the DNS records the person has to add at their DNS provider. After they do, `POST /app/sites/<site id>/domain/verify` checks (`dns.message` says what is missing). The certificate follows by itself; `GET /app/sites/<site id>/domain` shows `live: true` when it is done. The app then answers at `https://www.example.com/app/` too. It is a paid product (section 3).

## 6. Only a page: landing or website

When the person wants a page about the repository and no app, generate another type instead of `saas` in 2.2, then publish as in 2.5. Nothing here costs money.

```
POST /app/sites/<site id>/generate
{"kind": "template", "mode": "landing"}
```

```
POST /app/sites/<site id>/generate
{"kind": "template", "mode": "website", "pages": ["about", "faq"], "community": false}
```

`landing` is one page; `website` is a landing plus the pages in `pages` (any of `about`, `faq`, `auth`). Both are about the repository: its README, its code, a link to GitHub - which is why a private repository cannot be one. A project can become a SaaS later (section 4, "Generate again").

## Limits

- Generation: 2 at a time and 30 a day per account; `429` means wait and try later.
- App builds: one at a time per account, 20 a day. When other people's builds are running, yours waits its turn (`last_run.status: "queued"`).
- A hosted app gets 512 MB of memory, 1 CPU and 3 GB of disk for its image and data unless more was bought (section 3.2). The built image has to fit that disk next to the app's data; a bigger image needs more disk, not a smaller limit somewhere else.
- Login: a request lives 15 minutes and allows 5 wrong codes; sign-in emails are rate limited (then use `verification_url`).
- Some things only the person can do, in their browser: create another token, open the Stripe billing portal (managing or cancelling what they pay for), the admin dashboard. A token gets `403` `session_required` there; send the person to `https://apposters.com/account/` instead.
