# HTTP API

Checked against the code on 2026-10-31 (version 0.1.0).

Base URL: your server, for example `http://localhost:8787` in development. Machine-readable spec: [/openapi.json](/openapi.json) (OpenAPI 3.1).

## Authentication

Every route needs `Authorization: Bearer <token>`, except `GET /v1/health`, `POST /v1/drop` and the Asaas webhook (see [Billing](#billing)). There are two kinds of token:

- **Account token** (`sdh_…`): created with `POST /v1/account/tokens`, up to 20 active per account. It sees and changes only the sites of its own account. A site, deploy or token of another account answers `404`, the same as one that does not exist.
- **Server token** (`SDH_TOKEN`): the administrator. It sees every account, is the only one that can create accounts and change plans, and picks the account on account routes with `?conta=<id>` (default: the `principal` account, where sites created before accounts live).

These are tokens with labels, not people: there are no user identities or roles yet.

Optional header `x-sdh-actor: <name>` (up to 64 characters) sets who appears in the audit log; the default is `api`.

Errors look like this (messages are in Brazilian Portuguese today). The ones you will meet most:

| Status | `error.code` | When |
|---|---|---|
| `401` | `unauthorized` | token missing, invalid or revoked |
| `402` | `plan_limit` | creating (or claiming) a site beyond the plan's site count; `error.detail` has `plano` and `limite`. Nothing that is live is touched |
| `403` | `admin_only` | the route is for the server administrator |
| `404` | `not_found` and friends | does not exist, or belongs to another account |
| `409` | `token_limit`, `anonymous_site` | 20 active tokens already; export of an anonymous site |

```json
{ "error": { "code": "unauthorized", "message": "token ausente ou inválido" } }
```

```bash
export SDH_API_URL=http://localhost:8787
export SDH_TOKEN=dev-token
AUTH="Authorization: Bearer $SDH_TOKEN"   # the administrator
# account routes below also work with an account token (sdh_…):
export ACCOUNT_TOKEN=sdh_…
```

## Health

### GET /v1/health

No token. Version and a database check; `503` if the database does not answer.

```bash
curl "$SDH_API_URL/v1/health"
# {"ok":true,"service":"seedrophost","version":"0.1.0","db":"ok"}
```

## Sites

### GET /v1/sites

An account token lists only its own sites. The administrator sees all of them, or one account with `?conta=`.

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/sites"
```

### POST /v1/sites

Body `{"name": "..."}`. `201` with the site, created in the account of the token (the administrator may add `"conta": "<id>"`; default `principal`). Beyond the plan's site count the answer is `402 plan_limit`. Reserved names: `www`, `api`, `app`, `admin`, `edge`, `mcp`, `status`, `docs`, `mail`, and anything starting with `site-`, `dpl-`, `acc-` or `tok-` (they look like internal ids).

```bash
curl -X POST -H "$AUTH" -H "content-type: application/json" -d '{"name":"my-site"}' "$SDH_API_URL/v1/sites"
```

### GET /v1/sites/{site}

`{site}` is the name or the id. Adds `url` and `domains`.

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/sites/my-site"
```

### DELETE /v1/sites/{site}

Takes the site down at once and deletes it with its deploys, domains, forms and leads; files no other deploy uses are removed from storage. Answer `200`: `{"deleted": "my-site"}`. Recorded in the audit log as `site.delete`. There is no undo.

```bash
curl -X DELETE -H "$AUTH" "$SDH_API_URL/v1/sites/my-site"
```

## Deploys in one step

The request body is the file itself. A zip of a repository root with `netlify.toml` / `seedrop.toml` is laid out like the CLI does: only the `publish` folder goes live, with the config and functions kept private. Query options: `draft=1` (do not publish), `title=` (up to 200 characters), `source=` (up to 32 characters). Answer `201`: `{deploy, urls, files, uploaded, bytes}`, where `deploy.report` is the SEO/GEO report.

### POST /v1/sites/{site}/deploys/zip

```bash
curl --data-binary @site.zip -H "content-type: application/zip" -H "$AUTH" \
  "$SDH_API_URL/v1/sites/my-site/deploys/zip?title=first"
```

### POST /v1/sites/{site}/deploys/html

The body becomes `/index.html`.

```bash
curl --data-binary @index.html -H "content-type: text/html" -H "$AUTH" \
  "$SDH_API_URL/v1/sites/my-site/deploys/html?draft=1"
```

## Deploys by manifest (only changed files upload)

This is what the CLI uses for folders.

### POST /v1/sites/{site}/deploys

Body: `files` maps each public path to the SHA-256 (hex) of its content; optional `draft`, `title`, `source`, `git_ref`. Answer `201`: `{deploy, required, urls}`. `required` lists the hashes the server does not have yet; when it is empty, the deploy is already `ready`.

```bash
H=$(shasum -a 256 index.html | cut -d' ' -f1)
curl -X POST -H "$AUTH" -H "content-type: application/json" \
  -d "{\"files\":{\"/index.html\":\"$H\"},\"draft\":true,\"title\":\"manifest\"}" \
  "$SDH_API_URL/v1/sites/my-site/deploys"
```

### PUT /v1/deploys/{id}/blobs/{hash}

Upload one missing file; the server checks that the content matches the hash.

```bash
curl -X PUT --data-binary @index.html -H "$AUTH" "$SDH_API_URL/v1/deploys/dpl-…/blobs/$H"
# {"ok":true}
```

### POST /v1/deploys/{id}/finalize

Closes the deploy after the uploads, builds the report and, if it is not a draft, publishes it. `409 missing_files` lists what is still missing.

```bash
curl -X POST -H "$AUTH" "$SDH_API_URL/v1/deploys/dpl-…/finalize"
```

### GET /v1/sites/{site}/deploys

Latest first. `limit` from 1 to 100 (default 20). Each item has `live`, `url` and `score`.

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/sites/my-site/deploys?limit=5"
```

### GET /v1/deploys/{id}

The deploy with its full `report` (SEO/GEO, forms, rules).

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/deploys/dpl-…"
```

### POST /v1/sites/{site}/publish

Body `{"deploy_id": "..."}`. Puts a ready deploy live: approves a draft or rolls back. Takes effect on the next request. The `draft` field of a deploy keeps recording how it was created; `live` in the deploy list says what is being served.

```bash
curl -X POST -H "$AUTH" -H "content-type: application/json" -H "x-sdh-actor: release-bot" \
  -d '{"deploy_id":"dpl-…"}' "$SDH_API_URL/v1/sites/my-site/publish"
```

## Publishing without an account

### POST /v1/drop

No token. Only when the server runs with `SDH_ANON_DROP=1` (otherwise `404`). Body: a zip (`content-type: application/zip`) or an HTML file (`content-type: text/html`), up to 25 MiB; a zip may hold up to 50 MiB uncompressed. Answer `201`: `url`, `expires_at` (24 h), `claim_token` (shown once), `site`, `deploy_id`.

Refusals, each with a `Retry-After` header:

| Status | `error.code` | When |
|---|---|---|
| `429` | `rate_limited` | more than 5 per network per hour (IPv4 address or IPv6 `/64`; `Retry-After: 3600`), or the server-wide hourly cap was reached (`Retry-After: 600`) |
| `503` | `busy` | 2 drops are already being processed (`Retry-After: 10`) |
| `503` | `drop_quota` | storage for drops is full (`Retry-After: 3600`) |
| `413` | `too_large` | body above 25 MiB |

```bash
curl --data-binary @site.zip -H "content-type: application/zip" "$SDH_API_URL/v1/drop"
```

### POST /v1/drop/claim

With a token. Body `{"claim_token": "...", "name": "optional-new-name"}`. Removes the expiry and the anonymous restrictions, and the site joins the account of the token (and counts toward its site limit: `402 plan_limit` when the plan is full).

```bash
curl -X POST -H "$AUTH" -H "content-type: application/json" \
  -d '{"claim_token":"ab0e…","name":"my-site"}' "$SDH_API_URL/v1/drop/claim"
```

## Environment variables

Values are encrypted at rest (AES-256-GCM). Secret values are always masked in answers, for example `s3c••••••ue`.

### GET /v1/sites/{site}/env

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/sites/my-site/env"
```

### PUT /v1/sites/{site}/env/{key}

Key: `UPPER_CASE`, digits and `_`, up to 128 characters. Body: `value`; optional `secret` (boolean), `scopes` (`build`, `functions`, `runtime`), `contexts` (`production`, `preview`).

```bash
curl -X PUT -H "$AUTH" -H "content-type: application/json" \
  -d '{"value":"…","secret":true}' "$SDH_API_URL/v1/sites/my-site/env/API_KEY"
```

### DELETE /v1/sites/{site}/env/{key}

```bash
curl -X DELETE -H "$AUTH" "$SDH_API_URL/v1/sites/my-site/env/API_KEY"
```

## Custom domains

A custom domain is only served after its TXT record is verified.

### GET /v1/sites/{site}/domains

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/sites/my-site/domains"
```

### POST /v1/sites/{site}/domains

Body `{"hostname": "www.example.com"}`. Answer `201` with the DNS records to create: a TXT `_seedrop.<hostname>` and a CNAME (or A records for an apex domain).

```bash
curl -X POST -H "$AUTH" -H "content-type: application/json" \
  -d '{"hostname":"www.example.com"}' "$SDH_API_URL/v1/sites/my-site/domains"
```

### POST /v1/domains/{hostname}/verify

Looks up DNS and answers `verified`, `routed`, what was `found` and the `next` step.

```bash
curl -X POST -H "$AUTH" "$SDH_API_URL/v1/domains/www.example.com/verify"
```

## Forms and leads

### GET /v1/sites/{site}/forms

Forms detected in the deploys, with fields, number of submissions, spam blocked and last submission.

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/sites/my-site/forms"
```

### GET /v1/sites/{site}/submissions

Latest first. Query: `form`, `spam=1` (include spam), `limit` 1–500 (default 50).

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/sites/my-site/submissions?form=contact&limit=20"
```

## Audit

### GET /v1/audit

Latest first, `limit` 1–500 (default 50). Each entry: `actor`, `action`, `target`, `detail_json`, `at`. An account token sees only the entries of its own account; the administrator sees all, or one account with `?conta=`.

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/audit?limit=20"
```

## Accounts (administrator only)

These three routes answer `403 admin_only` to an account token.

### POST /v1/accounts

Body: `nome` (required, up to 100 characters), optional `plano` (`gratis`, `pro` or `agencia`; default `gratis`) and `rotulo` (label of the first token). Answer `201`: `{conta, token, token_id, aviso}`. The `token` is shown **once**; the server keeps only a SHA-256 of it.

```bash
curl -X POST -H "$AUTH" -H "content-type: application/json" \
  -d '{"nome":"Client Ltda","plano":"gratis","rotulo":"first token"}' "$SDH_API_URL/v1/accounts"
```

### GET /v1/accounts

Every account with its `sites` count.

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/accounts"
```

### PATCH /v1/accounts/{conta}

Body with any of `plano`, `nome`, `pagamento_falhou_em` (ISO date, or `null` when the payment is back). The plan change takes effect at once. With [billing](#billing) on, the server sets `pagamento_falhou_em` by itself when an invoice is overdue and clears it when the invoice is paid. Answers `404` for an unknown account and `400` for an invalid plan or date.

```bash
curl -X PATCH -H "$AUTH" -H "content-type: application/json" \
  -d '{"plano":"pro"}' "$SDH_API_URL/v1/accounts/acc-…"
```

## Your account, usage and tokens

With an account token these routes act on the token's own account. The administrator adds `?conta=<id>` (default `principal`).

### GET /v1/account

The account (`id`, `nome`, `plano`, dates of the warning and plan-change request, `pagamento_falhou_em`), plus `admin` (true when the caller used the server token) and the number of `sites`.

```bash
curl -H "Authorization: Bearer $ACCOUNT_TOKEN" "$SDH_API_URL/v1/account"
```

### GET /v1/account/usage

Plan, limits, usage of the current month and what to do next (texts in Brazilian Portuguese). Sites, bandwidth and leads are counted; **deploys never count**. Going over bandwidth or leads only changes `estado` to `aviso`, and two months in a row to `pedir_troca` (a plan change is requested, with 14 days): no site goes offline and every lead is kept (`leads_acima_do_plano` says how many are above the plan). Other fields: `plano`, `limites`, `historico` (two previous months), `acima_neste_mes`, `meses_seguidos_acima`, `pedir_troca`, `pagamento`, `mensagens`, `proximos_passos`.

```bash
curl -H "Authorization: Bearer $ACCOUNT_TOKEN" "$SDH_API_URL/v1/account/usage"
# shortened:
# {"conta":{"nome":"Client Ltda","plano":"gratis"},"mes":"2026-10",
#  "limites":{"sites":3,"banda_gb_mes":30,"leads_mes":100,"deploys":"ilimitados"},
#  "uso":{"sites":1,"banda_gb":0.05,"leads":4,"leads_acima_do_plano":0,"deploys":6},
#  "estado":"ok","proximos_passos":["nada a fazer: a conta está dentro do plano. Deploys são ilimitados."]}
```

### POST /v1/account/tokens

Body: optional `rotulo` (label, up to 100 characters). Answer `201`: `{id, rotulo, criado_em, token, aviso}`. The `token` (`sdh_…`) is shown **once**. Give one token to each machine or agent. At most 20 active tokens per account: the 21st answers `409 token_limit`; revoke one first.

```bash
curl -X POST -H "Authorization: Bearer $ACCOUNT_TOKEN" -H "content-type: application/json" \
  -d '{"rotulo":"ci"}' "$SDH_API_URL/v1/account/tokens"
```

### GET /v1/account/tokens

The tokens of the account without their secret: `id`, `rotulo`, `criado_em`, `ultimo_uso_em`, `revogado_em`. Revoked tokens stay in the list for 90 days.

```bash
curl -H "Authorization: Bearer $ACCOUNT_TOKEN" "$SDH_API_URL/v1/account/tokens"
```

### DELETE /v1/account/tokens/{token}

`{token}` is the token `id` (for example `tok-…`), not the secret. The token stops working at once. A token of another account answers `404 token_not_found`.

```bash
curl -X DELETE -H "Authorization: Bearer $ACCOUNT_TOKEN" "$SDH_API_URL/v1/account/tokens/tok-…"
```

## Billing

The plan subscription is paid by Pix or boleto on the invoice page of Asaas, the payment provider; a card also works on that page and never passes through the server. Billing is off until the administrator sets `SDH_ASAAS_API_KEY`. The price always comes from the plan table on the server: a value sent by the client is ignored. CPF, CNPJ and email go to Asaas and are not stored on the server. Nothing here takes a site offline or deletes anything: when an invoice is overdue the sites stay live, and only after 30 days does creating a new site follow the Free limits. The hosted plans open with the first public server; there is no date.

### GET /v1/account/billing

`estado` (`gratis`, `pendente`, `ativo`, `atrasado` or `cancelado`), `plano`, `ciclo`, `valor`, `pago_ate`, `pagamento_falhou_em`, `proxima_cobranca`, `fatura_em_aberto` (`url`, `vencimento`, `estado` `em_aberto` or `vencida`, `valor`), `troca_pendente` (a change waiting for payment), `troca_agendada` (a move down and when it starts), `precos`, `mensagens` and `proximos_passos`. `cobranca_ligada` is `false` when billing is off on the server. While a payment is awaited, the server reads Asaas again, waiting longer each time (30 s up to 1 h) and for at most 24 h. CLI: `sdh plano`; MCP: `get_billing`.

```bash
curl -H "Authorization: Bearer $ACCOUNT_TOKEN" "$SDH_API_URL/v1/account/billing"
# shortened:
# {"cobranca_ligada":true,"estado":"ativo","plano":"pro","ciclo":"mensal","valor":29,
#  "pago_ate":"2026-11-30T02:59:59.000Z","proxima_cobranca":{"data":"2026-11-30T02:59:59.000Z","valor":29},
#  "fatura_em_aberto":null,"troca_pendente":null,"troca_agendada":null,"mensagens":["plano Pro pago até 29/11/2026."]}
```

### POST /v1/account/billing/checkout

Body: `plano` (`pro` or `agencia`), `ciclo` (`mensal` or `anual`; default `mensal`), `nome` (who pays), `cpfCnpj` (check digits are validated before calling Asaas) and `email`. Send an `Idempotency-Key` header, for example a UUID: repeating it, or asking again for the same plan and cycle, returns the same subscription, and an account never has two open subscriptions. Answer `201`: `plano`, `ciclo`, `valor`, `efeito`, `invoiceUrl`, `pix` (`qr` as a base64 PNG, `copiaecola`, `expira_em`) when the invoice has Pix, `vencimento` and `proximo_passo`. CLI: `sdh assinar pro --nome "…" --cpf … --email …`.

`efeito` says when the new plan takes effect:

| `efeito` | When |
|---|---|
| `apos_pagamento` | from Free, or with no paid time left: when the invoice is paid |
| `agora` | moving up (a bigger plan, or the same plan in a cycle that costs more per day): at once; the paid time left becomes `credito_dias` days of the new plan, and its first charge is due after them |
| `agendada` | moving down: the current plan, already paid, lasts until `pago_ate` (`inicia_em`), then the new one starts. The old subscription stops charging now; nothing is refunded or charged twice |
| `retomada` | the same plan again after a cancel: charged again at the end of the paid period |

Refusals: `400` (`invalid_plan`, `invalid_cycle`, `invalid_name`, `invalid_cpf_cnpj`, `invalid_email`), `409 already_on_plan`, `409 payment_just_confirmed` (the waiting payment arrived first: check `GET /v1/account/billing` before asking again), `409 scheduled_paid`, `429 checkout_limit` (more than 5 attempts in 24 h) and `503 billing_off`.

```bash
curl -X POST -H "Authorization: Bearer $ACCOUNT_TOKEN" -H "content-type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"plano":"pro","ciclo":"mensal","nome":"Client Ltda","cpfCnpj":"11.222.333/0001-81","email":"billing@example.com"}' \
  "$SDH_API_URL/v1/account/billing/checkout"
```

### POST /v1/account/billing/cancel

Stops charging now. The plan lasts until `pago_ate` and then the account goes back to Free; nothing is deleted and no site goes offline. Before cancelling, the server reads Asaas again, so a payment that just arrived is never thrown away. Answer `200` with the same body as `GET /v1/account/billing`; `409 no_subscription` when there is nothing to cancel. CLI: `sdh cancelar-assinatura --sim`.

```bash
curl -X POST -H "Authorization: Bearer $ACCOUNT_TOKEN" "$SDH_API_URL/v1/account/billing/cancel"
```

### GET /v1/admin/billing

Administrator only. What is missing to charge: `chave` (the Asaas key: `ok`, `falta`, `recusada` or `sem_resposta`), `token_webhook`, `host_do_painel`, `webhook` (`cadastrado`, `interrompido`, `desligado` or `falta`), `pronto`, `falta` and `proximo_passo`. Changes nothing. CLI: `sdh cobranca`.

```bash
curl -H "$AUTH" "$SDH_API_URL/v1/admin/billing"
```

### POST /v1/admin/billing/webhook

Administrator only. Body: optional `email`, which gets the Asaas warnings when the webhook fails. Registers only the SeeDropHost webhook (`https://<SDH_PANEL_HOST>/v1/billing/asaas/webhook`) or turns its paused queue back on; other webhooks of the Asaas account are never touched. CLI: `sdh cobranca:webhook --email you@example.com`.

```bash
curl -X POST -H "$AUTH" -H "content-type: application/json" \
  -d '{"email":"you@example.com"}' "$SDH_API_URL/v1/admin/billing/webhook"
```

### POST /v1/billing/asaas/webhook

Called by Asaas, not by clients. No Bearer: the `asaas-access-token` header must match `SDH_ASAAS_WEBHOOK_TOKEN` (`401` otherwise; `404` when billing is off). Once the token is accepted the answer is always `200`, so Asaas never pauses the queue: events of other systems on the same Asaas account are ignored and each event is processed once. The event is never trusted: the server reads the payment or subscription again on Asaas before changing anything.

## Take everything (export)

### GET /v1/sites/{site}/export

A zip with `site/` (the files of the live deploy, including `_redirects`, `_headers` and `netlify.toml` / `seedrop.toml`), `leads.csv` (every submission, with a `spam` column; cells that start with `=`, `+`, `-` or `@` get an apostrophe so they never run as formulas), `variaveis.txt` (the **names** of the variables only: values never leave, not even masked) and `LEIA-ME.txt`. With `?resumo=1` the answer is JSON with the numbers (`arquivos`, `leads`, `variaveis`, `dominios`) and the download path instead of the zip. An anonymous drop answers `409 anonymous_site`: claim it first.

```bash
curl -H "$AUTH" -o my-site.zip "$SDH_API_URL/v1/sites/my-site/export"
curl -H "$AUTH" "$SDH_API_URL/v1/sites/my-site/export?resumo=1"
```

## Internal route

### GET /v1/internal/tls-check

Not for clients. Caddy asks it before issuing a certificate (`?domain=`). It only answers calls from the machine itself without proxy headers; anything else gets `403`, and the shipped Caddyfile answers `404` for `/v1/internal/*` from outside.

## MCP endpoint

`POST /mcp` speaks MCP (Streamable HTTP, JSON-RPC) with the same Bearer token. See [MCP for agents](/docs/mcp/).
