HTTP API
Base URL: your server, for example http://localhost:8787 in development. Machine-readable spec: /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). There are two kinds of token:
- Account token (
sdh_…): created withPOST /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 answers404, 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: theprincipalaccount, 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 |
{ "error": { "code": "unauthorized", "message": "token ausente ou inválido" } }
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.
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=.
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).
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.
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.
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
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.
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.
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.
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.
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.
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).
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.
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 |
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).
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
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).
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}
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
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).
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.
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.
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).
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=.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.