# API Asender — documentação completa Este arquivo é a documentação INTEIRA em texto puro: convenções, guias e todas as 378 operações, com o que cada uma faz, o que recebe e o que devolve. Ele existe porque um agente não navega — precisa do contexto todo de uma vez. A versão navegável para pessoas está em https://docs.asender.net. Especificação OpenAPI: https://docs.asender.net/openapi.json Bases: https://api.asender.net API pública (BFF) https://auth.asender.net Identidade (OIDC / OAuth 2.1) http://asender-core:8002 Core — contas e árvore de tenants (interno — não alcançável de fora) http://asender-messages:8004 Messages — campanhas, contatos e envio (interno — não alcançável de fora) http://asender-crm:8005 CRM — jornadas, alertas e destinos (interno — não alcançável de fora) http://asender-pages:8006 Pages — páginas, formulários e mídia (interno — não alcançável de fora) http://asender-runtime:8007 Runtime — publicação e analytics (interno — não alcançável de fora) http://asender-mail:8016 Mail — e-mail corporativo (interno — não alcançável de fora) ============================================================================== # GUIA: Convenções da API ============================================================================== # API Design - Convencoes do Ecossistema Padroes de API consistentes entre o Relay PHP e o Asender Core. --- ## 1. Principios 1. **REST-ish, pragmatico** - nao precisa ser RESTfully puro, mas previsivel 2. **JSON-first** - request e response sempre JSON 3. **SES-compatible shape** - maioria das respostas segue o formato AWS SES (ja era assim no relay) 4. **Idempotencia** - endpoints POST de envio aceitam `IdempotencyKey` 5. **Versionada** - `/v1/` no Core; no relay, versionamento via action 6. **Self-documenting** - cada resposta inclui `RequestId` pra suporte --- ## 2. Autenticacao ### Header padrao ``` Authorization: Bearer ``` ### Alternativas (pra webhooks/crons) ``` ?api_key= (query param) X-Api-Key: (custom header - suportado pelo Core, nao pelo relay) ``` ### Formato de keys | Prefixo | Uso | |---------------|-----------------------------------------------| | `sk_master_` | Master key do relay (admin) | | `sk_live_` | Tenant key do relay OU API key do Core (prod) | | `sk_test_` | API key do Core em modo teste | | `pk_live_` | Publishable key (client-side, read-only) | Todas com 48 chars hex depois do prefixo. Hash SHA-256 armazenado. --- ## 3. Request Format ### Headers obrigatorios em POST/PUT ``` Content-Type: application/json; charset=utf-8 ``` ### Body JSON com chaves em **PascalCase** (alinhado com SES). Excecao: paths internos/admin podem usar snake_case se for mais conveniente. Documentado por endpoint. ### Exemplo ```json { "Source": "noreply@example.com", "Destination": { "ToAddresses": ["user@example.com"] }, "Message": { "Subject": {"Data": "Hello", "Charset": "UTF-8"}, "Body": {"Text": {"Data": "Hi there"}} } } ``` ### Idempotencia POST de envio aceita: ``` X-Idempotency-Key: uuid-or-any-string ``` ou no body: ```json {"IdempotencyKey": "..."} ``` Se a mesma key eh reutilizada dentro de 24h, retorna o mesmo MessageId sem re-enfileirar. --- ## 4. Response Format ### Sucesso ```json { "MessageId": "msg_xxx", "Status": "queued", "RequestId": "req_xxx" } ``` Sempre contem `RequestId`. ### HTTP Status Codes | Code | Uso | |------|---------------------------------------------| | 200 | Sucesso em GET/PUT/DELETE | | 201 | Recurso criado (POST) | | 204 | Sucesso sem body (OPTIONS, DELETE simples) | | 400 | Validacao falhou | | 401 | API key ausente/invalida | | 402 | Limite de plano excedido (Core) | | 403 | Sem permissao / tenant suspenso | | 404 | Recurso nao encontrado | | 405 | Metodo HTTP errado | | 409 | Conflito (ex: email ja existe na lista) | | 422 | Validacao semantica (dominio nao verificado, etc) | | 429 | Rate limit excedido | | 500 | Erro interno | | 503 | Servico indisponivel (SMTP down, etc) | ### Paginacao Listagens usam cursor-based OU offset-based. **Offset (simples, usado no relay):** ``` GET /v1/emails?limit=50&offset=100 { "Messages": [...], "Count": 50, "Total": 342, "RequestId": "..." } ``` **Cursor (recomendado no Core pra grandes volumes):** ``` GET /v1/emails?limit=50&cursor=eyJpZCI6MTAwfQ { "Messages": [...], "NextCursor": "eyJpZCI6MTUwfQ", "HasMore": true, "RequestId": "..." } ``` Cursor eh base64 de um JSON opaco com o estado (`{id: N}` ou similar). ### Headers de Response Sempre presentes: ``` X-Request-Id: req_xxx X-Content-Type-Options: nosniff X-Frame-Options: DENY ``` Quando relevante: ``` X-RateLimit-Limit: 100 X-RateLimit-Remaining: 87 X-RateLimit-Reset: 1681234567 Retry-After: 60 (em 429 ou 503) Location: /v1/emails/msg_xxx (em 201) ``` --- ## 5. Erro Format (SES-like) ### Estrutura ```json { "Error": { "Type": "Sender", "Code": "ValidationError", "Message": "Destination.ToAddresses must contain at least one address.", "Details": { "field": "Destination.ToAddresses", "constraint": "min_length", "value": 0 } }, "RequestId": "req_xxx" } ``` Campos: - `Type`: `Sender` (4xx - erro do cliente) ou `Receiver` (5xx - erro do servidor) - `Code`: enum estavel (nunca mude semantica, so adicione novos) - `Message`: descricao humana, localizavel - `Details` (opcional): contexto adicional maquina-legivel ### Codigos padronizados **Auth & Access:** - `AuthorizationError` - key ausente/invalida - `PermissionDenied` - key valida mas sem permissao - `TenantSuspended` - tenant suspenso - `PlanLimitExceeded` - limite do plano atingido (Core) **Validation:** - `ValidationError` - body invalido - `MissingParameter` - `InvalidParameter` - `InvalidJson` **Resources:** - `NotFound` - recurso nao existe - `Conflict` - duplicata - `Gone` - recurso foi deletado - `AlreadyExists` **Rate limits:** - `Throttling` - rate limit excedido - `QuotaExceeded` - quota mensal/diaria **Service:** - `InternalError` - 500 - `ServiceUnavailable` - 503 - `SmtpError` - problema com SMTP - `ProviderError` - erro do provedor externo (SES, Twilio, etc) **Specifics:** - `DomainNotVerified` - `SuppressedAddress` - destinatario na lista de supressao - `InvalidRecipient` - `NotInstalled` - relay nao instalado ainda --- ## 6. Webhooks ### Signature Outgoing webhooks assinam com HMAC-SHA256: ``` X-Asender-Signature: t=1681234567,v1=abc123def... X-Asender-Event: email.sent X-Asender-Event-Id: evt_xxx ``` Para verificar: ``` signed_payload = t + "." + body expected = hmac_sha256(webhook_secret, signed_payload) constant_time_compare(expected, v1) ``` Evita replay: rejeitar se `t` for mais antigo que 5min. ### Payload ```json { "EventId": "evt_xxx", "EventType": "email.delivered", "CreatedAt": "2026-04-12T15:30:00Z", "TenantId": "acc_xxx", "Data": { "MessageId": "msg_xxx", ... } } ``` ### Retry Se endpoint retorna nao-2xx, retry com backoff: - 1min, 5min, 15min, 1h, 6h, 24h (6 tentativas) - Apos 24h, marca delivery como `failed` --- ## 7. CORS ### Default Bloqueia cross-origin (API eh server-to-server). ### Allowlist Customer pode configurar origins permitidos (pra frontend JS usando publishable keys): ``` Access-Control-Allow-Origin: https://app.customer.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Max-Age: 3600 ``` Preflight OPTIONS sempre responde 204. --- ## 8. Rate Limits ### Camadas 1. **Global per IP:** 1000 req/min em auth endpoints (brute force protection) 2. **Per API key:** configurado por plano 3. **Per tenant send limit:** hourly/daily de envios 4. **Per resource:** ex: max 100 templates por tenant ### Resposta ao atingir ```http HTTP/1.1 429 Too Many Requests Retry-After: 45 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1681234612 { "Error": { "Type": "Sender", "Code": "Throttling", "Message": "Rate limit exceeded. Retry after 45 seconds." } } ``` --- ## 9. Versionamento ### Core URL path: `/v1/`, `/v2/`. Mudancas breaking criam nova major version. Mudancas non-breaking (campos novos opcionais, enums adicionais) nao bumpam versao. ### Relay Via `action` name. Ex: `action=send` continua sendo v1. Se precisar breaking, criar `action=v2.send`. ### Deprecacao - Aviso no header: `Sunset: Wed, 01 Jan 2027 00:00:00 GMT` - Docs marcam como deprecated - Prazo minimo: 6 meses antes de remover --- ## 10. Batch Operations Convencao pra operacoes em lote: ```json POST /v1/emails/send-batch { "Entries": [ {"Id": "client-entry-1", ...payload}, {"Id": "client-entry-2", ...payload} ] } ``` Response: ```json { "Entries": [ {"Id": "client-entry-1", "MessageId": "msg_xxx", "Status": "queued"}, {"Id": "client-entry-2", "Error": "..."} ], "SuccessCount": 1, "FailCount": 1, "RequestId": "req_xxx" } ``` Sempre retorna 200, mesmo com falhas parciais. Cliente deve inspecionar `Entries[].Error`. Limite: max 500 entries por request. --- ## 11. Filtros e Queries Listagens aceitam filtros via query params: ``` GET /v1/emails?status=sent&from=2026-04-01&to=2026-04-30&tag=campaign:welcome ``` Formato: - Igualdade simples: `status=sent` - Datas: ISO 8601 `from=2026-04-12T10:00:00Z` - Multiplos valores: `status=sent,delivered` ou `status[]=sent&status[]=delivered` - Busca: `q=welcome` (full-text nos campos relevantes) - Sort: `sort=-created_at` (prefixo `-` = desc) --- ## 12. Seguranca - **HTTPS obrigatorio** em producao (relay + core) - **HSTS header** em respostas - **Sem CORS* default** em endpoints sensitivos - **Content-Security-Policy** no dashboard - **Rate limit agressivo** em auth - **Audit log** de acoes admin - **PII masking** em logs (emails, phones aparecem como `u***@e***.com`) - **Encryption at rest** de credenciais - **Keys nunca em logs** --- ## 13. SDK Philosophy SDKs sao thin wrappers. Devem: - Setar auth automaticamente via env var - Retry em 5xx com backoff exponencial (3 tentativas) - NAO fazer retry em 4xx - Expor typed response models - Honrar `Retry-After` - Oferecer sync + async (onde aplicavel) **PHP exemplo:** ```php $asender = new Asender\Client([ 'api_key' => getenv('ASENDER_API_KEY'), 'base_url' => 'https://api.asender.io', ]); $result = $asender->emails()->send([ 'Source' => 'noreply@example.com', 'Destination' => ['ToAddresses' => ['user@example.com']], 'Message' => [...], ]); echo $result->MessageId; ``` ============================================================================== # GUIA: Arquitetura ============================================================================== # Arquitetura do Ecossistema Asender (v2 - Decentralizada) ## Stack Geral - **Backend services:** Go 1.22+ (stdlib HTTP + chi router) - **Frontends:** TypeScript + Next.js 15 (App Router) + Tailwind + shadcn/ui - **Relay edge:** PHP (unico caso PHP, ver `asender-relay-php/`) - **Database:** PostgreSQL 16 (shared instance, schema por servico OU DB por servico) - **Cache:** Redis 7 - **Async queue:** NATS JetStream (streams + key-value + object store) - **Object storage:** S3-compatible (attachments, assets) - **Analytics store:** ClickHouse (events em massa) ## Visao Geral ``` ┌─────────────────┐ ┌─────────────────┐ │ BACKOFFICE │ │ DASHBOARD │ │ (Next.js) │ │ (Next.js) │ │ Asender team │ │ Customers │ └────────┬────────┘ └────────┬────────┘ │ │ ▼ ▼ ┌────────────────────────────────────────┐ │ asender-api (Go) │ │ Public API + Dashboard BFF gateway │ │ Validates + Dispatches │ └───┬──────────┬──────────┬──────────┬──┘ │ │ │ │ ▼ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ AUTH │ │ CORE │ │ NATS │ │ REDIS │ │ (Go) │ │ (Go) │ │ JS │ │ │ └────────┘ └────────┘ └────────┘ └────────┘ │ ┌─────────────┼─────────────┬─────────────┐ ▼ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ EMAIL │ │ SMS │ │ PUSH │ │ WEBHOOK│ │ WORKER │ │ WORKER │ │ WORKER │ │ WORKER │ └───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘ │ │ │ │ ▼ ▼ ▼ ▼ ┌────────┐ ┌─────────┐ ┌────────┐ ┌────────┐ │ RELAY │ │ Twilio │ │ FCM │ │Customer│ │ PHP │ │ / SNS │ │ /APNs │ │ HTTPS │ └────────┘ └─────────┘ └────────┘ └────────┘ ``` ## Servicos (Repositorios Separados) Cada servico eh um diretorio/repo independente. Desenho baseado em perfis de carga distintos. | Servico | Linguagem | Perfil de Carga | Scaling | |---------------------------|-----------|----------------------------------|----------------| | `asender-auth` | Go | Baixo, picos em login | 2-4 replicas | | `asender-core` | Go | Baixo, business logic | 1-2 replicas | | `asender-api` | Go | **Muito alto** (ingress publico) | 5-20 replicas | | `asender-email-worker` | Go | Bursty (campanhas) | 2-10 replicas | | `asender-sms-worker` | Go | Bursty | 1-5 replicas | | `asender-push-worker` | Go | Spikey (blasts) | 1-5 replicas | | `asender-webhook-worker` | Go | Bursty (egress) | 2-5 replicas | | `asender-backoffice` | TS/Next | Baixo (team interno) | 1 replica | | `asender-dashboard` | TS/Next | Medio (customers) | 2-4 replicas | | `asender-shared` | Go lib | - | - | | `asender-relay-php` | PHP | Edge (deploy por customer) | - | ## Comunicacao entre Servicos ### Sincrona (HTTP) - **api -> auth:** validar JWT/session - **api -> core:** lookup de tenant/api_key - **backoffice/dashboard -> api:** BFF pattern - **workers -> core:** buscar config do tenant - **workers -> relay PHP:** envio real Protocolo: HTTP JSON. Auth inter-service via token service-to-service (JWT com claim `svc=`). ### Assincrona (NATS JetStream) Streams: - `emails.send` - api publica, email-worker consome - `sms.send` - api publica, sms-worker consome - `push.send` - api publica, push-worker consome - `events.*` - eventos (email.delivered, email.opened, etc) - webhook-worker + analytics consomem ### Request/Reply (NATS) Para calls sincronos sem HTTP (e.g. validacao rapida de tenant). ## Auth Flow ### Customer (dashboard) ``` 1. POST /api/auth/login (dashboard BFF) -> asender-api -> asender-auth 2. asender-auth valida credenciais, cria session, retorna JWT 3. Dashboard guarda JWT em httpOnly cookie 4. Proximas requests mandam JWT via cookie ``` ### API publica (SDK do customer) ``` 1. Customer app manda header Authorization: Bearer sk_live_xxx 2. asender-api valida API key contra asender-core 3. asender-api cria contexto de tenant + scope 4. Roteia request pro handler apropriado ``` ### Internal team (backoffice) ``` Similar ao customer flow, mas com role = asender_admin no JWT. Acesso a endpoints /api/admin/* que operam cross-tenant. ``` ### Service-to-service ``` Cada servico tem um key de servico (SERVICE_SECRET env var). Requests internas usam header X-Asender-Svc-Token: . Claims: { svc, iss, exp, iat }. ``` ## Database Strategy **Shared Postgres instance, schema por servico.** - `auth.*` - users, sessions, api_tokens, 2fa_secrets - `core.*` - tenants, tenant_members, api_keys, relays, webhooks, usage_counters - `messages.*` - email_messages, email_events, sms_messages, push_messages (acessado por workers + api) - `analytics.*` - events (ClickHouse, nao Postgres) Isolation via schema + roles Postgres. Cada servico tem role propria com acesso so ao seu schema + referencias a dados de outros services via ID. **Trade-off:** migracao pra DB-per-service se um deles precisar. ## IDs Publicos Convencao: `_<16 hex>` (usando crypto/rand). | Prefixo | Recurso | |-----------|----------------------| | `usr_` | User | | `acc_` | Tenant (account) | | `key_` | API Key | | `sk_live_`| API Key plaintext | | `sk_test_`| API Key test mode | | `sk_master_`| Relay master key | | `rel_` | Relay | | `whk_` | Webhook endpoint | | `tpl_` | Template | | `cam_` | Campaign | | `cnt_` | Contact | | `lst_` | List | | `msg_` | EmailMessage | | `sms_` | SmsMessage | | `psh_` | PushMessage | | `evt_` | Event | | `whd_` | Webhook delivery | | `req_` | Request (tracing) | ## Deployment Cada servico: - Dockerfile multi-stage (build + runtime) - Binario estatico Go (scratch ou distroless base) - Health endpoint `/healthz`, ready endpoint `/readyz` - Metrics endpoint `/metrics` (Prometheus format) - Structured logs (JSON, stdout) - Config 100% via env vars (12-factor) **Orchestracao:** Kubernetes (prod), docker-compose (dev), Fly.io ou Railway (staging). ## Convencoes Comuns (asender-shared) Lib `asender-shared` (Go) expoe: - Types: `Tenant`, `ApiKey`, `User`, `Message` (shared DTOs) - Errors: formato SES-compativel `{Error: {Type, Code, Message}}` - IDs: helper `GenerateID(prefix string) string` - Time: helpers `ISO8601Now()` etc - Auth middleware: valida JWT service-to-service - Tracing: request ID gen + propagation - Logging: factory padronizada - Config: envconfig wrapper ## Observabilidade - **Logs:** JSON pro stdout, agregados em Loki - **Metricas:** Prometheus (request rate, latency, errors, queue depth) - **Traces:** OpenTelemetry (opcional) - **Errors:** Sentry - **Uptime:** ping no `/healthz` de cada servico ## Seguranca - **TLS obrigatorio** em producao (terminado no ingress) - **Secrets:** via env vars, Vault/SOPS em prod - **API keys:** hash SHA-256 no DB - **Service tokens:** HMAC-SHA256 signed JWT, rotacao periodica - **CORS:** default deny, allowlist por tenant - **Rate limit:** por IP (gateway) + por API key (api service) + por tenant (core) - **Encryption at rest:** DKIM keys, SMTP passwords, relay master keys ## Frontend Architecture ### Backoffice (interno Asender) - Next.js 15 App Router - Auth via `asender-auth` + role `asender_admin` - Features: gestao de customers, monitoramento global, dashboards - Rotas apenas no SSR + server actions calling `asender-api` ### Dashboard (customer-facing) - Next.js 15 App Router - Auth via `asender-auth` + role usuario normal - Features: inbox de emails, campanhas, templates, contatos, relatorios, config - Rotas SSR + API routes locais que proxyam pro `asender-api` ### Shared UI Cada frontend tem seu proprio design system mas compartilham design tokens via `@asender/design-tokens` (opcional, mais tarde). ## Roadmap por Fase (Revisado) Ver [ROADMAP.md](ROADMAP.md). ============================================================================== # GUIA: Modelo de dados ============================================================================== # Data Model - Asender Ecosystem Modelos de dados de todo o ecossistema. Separado por componente. --- ## 1. Relay PHP (Multi-tenant) ### Tabela: `tenants` ``` id PK tenant_id string unique tnt_xxx name string api_key_hash string unique sha256 status enum active|suspended|deleted smtp_config json {host, port, encryption, user, pass} default_from string default_from_name string hourly_limit int nullable daily_limit int nullable batch_size int nullable send_delay_ms int nullable metadata json created_at timestamp updated_at timestamp suspended_at timestamp nullable deleted_at timestamp nullable ``` ### Tabela: `templates` ``` id PK template_id string unique tpl_xxx tenant_id string fk tnt_xxx name string description string nullable subject string body_text text body_html text variables json [{name, required, default}] metadata json created_at timestamp updated_at timestamp deleted_at timestamp nullable ``` ### Tabela: `emails` (alteracao) ``` + tenant_id string tnt_xxx + template_id string nullable tpl_xxx (se veio de template) ... (todas as outras colunas ja existentes) ``` ### Tabela: `logs` (alteracao) ``` + tenant_id string nullable ... (colunas existentes) ``` ### Tabela: `settings` (sem mudanca) ### Tabela: `rate_limits` (sem mudanca) --- ## 2. Asender Core (Laravel + PostgreSQL) ### Users & Tenants #### `users` ``` id bigint PK email string unique name string password string hashed email_verified_at timestamp nullable two_factor_secret text nullable encrypted two_factor_recovery_codes text nullable encrypted last_login_at timestamp created_at, updated_at ``` #### `tenants` ``` id bigint PK public_id string unique acc_xxx name string slug string unique (usado em subdomain) plan_id bigint fk status enum active|suspended|trial trial_ends_at timestamp nullable metadata jsonb created_at, updated_at ``` #### `tenant_user` (pivo) ``` id bigint PK tenant_id bigint fk user_id bigint fk role enum owner|admin|developer|viewer invited_by bigint fk nullable accepted_at timestamp nullable created_at unique (tenant_id, user_id) ``` #### `api_keys` ``` id bigint PK tenant_id bigint fk user_id bigint fk (quem criou) name string key_hash string unique prefix string pri 8 chars visiveis scopes json ["emails:send", "emails:read", ...] last_used_at timestamp expires_at timestamp nullable revoked_at timestamp nullable created_at ``` #### `invitations` ``` id bigint PK tenant_id bigint fk email string role enum token string unique expires_at timestamp accepted_at timestamp nullable created_at ``` ### Dominios & Reputacao #### `domains` ``` id bigint PK tenant_id bigint fk domain string example.com verified boolean verification_token string dkim_selector string default='asender' dkim_private_key text encrypted dkim_public_key text dmarc_policy enum none|quarantine|reject spf_record_detected boolean dkim_record_detected boolean dmarc_record_detected boolean last_checked_at timestamp created_at, updated_at ``` #### `suppression_list` ``` id bigint PK tenant_id bigint fk address string email ou phone type enum email|phone reason enum bounce|complaint|unsubscribe|manual source_message_id string nullable created_at unique (tenant_id, address, type) ``` ### Email / SMTP #### `email_messages` ``` id bigint PK tenant_id bigint fk message_id string unique msg_xxx domain_id bigint fk nullable template_id bigint fk nullable campaign_id bigint fk nullable relay_id bigint fk nullable (se enviou via relay) provider string ses|sendgrid|relay|mailgun|... from_email string from_name string to_addresses jsonb cc_addresses jsonb bcc_addresses jsonb reply_to jsonb subject string body_text text body_html text status enum queued|processing|sent|failed|bounced|complained attempts int last_error text nullable tags jsonb metadata jsonb scheduled_at timestamp created_at sent_at timestamp nullable indexes: (tenant_id, status), (tenant_id, created_at), (message_id) ``` #### `email_events` Em PostgreSQL (canonico) + ClickHouse (analytics). ``` id bigint PK tenant_id bigint fk message_id string fk event_type enum sent|delivered|bounced|complained|opened|clicked|unsubscribed event_data jsonb (url, ip, user agent, etc) created_at timestamp ``` #### `email_templates` ``` id bigint PK tenant_id bigint fk public_id string unique tpl_xxx name string subject string body_mjml text nullable (fonte MJML) body_html text (compilado ou direto) body_text text nullable variables jsonb tags jsonb version int created_at, updated_at ``` #### `relays` ``` id bigint PK tenant_id bigint fk public_id string unique rel_xxx name string url string https://relay.xxx.com master_key_encrypted text (encrypted at rest) status enum active|error|disabled last_health_check_at timestamp health_data jsonb tenants_count int (N tenants internos no relay) created_at, updated_at ``` ### SMS #### `sms_messages` ``` id bigint PK tenant_id bigint fk message_id string unique sms_xxx campaign_id bigint fk nullable gateway_id bigint fk from_number string to_number string body text segments int (quantos SMS foram enviados) status enum queued|sending|sent|delivered|failed|undelivered last_error text nullable metadata jsonb scheduled_at timestamp created_at, updated_at sent_at timestamp nullable delivered_at timestamp nullable ``` #### `sms_gateways` ``` id bigint PK tenant_id bigint fk provider enum twilio|sns|zenvia|plivo|custom name string config jsonb encrypted (credenciais) default boolean priority int created_at, updated_at ``` ### Push #### `push_apps` ``` id bigint PK tenant_id bigint fk public_id string unique psh_xxx name string platform enum ios|android|web fcm_server_key text encrypted nullable apns_key_id string nullable apns_team_id string nullable apns_key_p8 text encrypted nullable apns_bundle_id string nullable vapid_public text nullable vapid_private text encrypted nullable created_at, updated_at ``` #### `push_devices` ``` id bigint PK tenant_id bigint fk app_id bigint fk token text platform enum user_id_external string nullable (id do usuario no sistema do customer) topics jsonb (array) metadata jsonb last_seen_at timestamp created_at indexes: (tenant_id, token) unique ``` #### `push_messages` ``` id bigint PK tenant_id bigint fk message_id string unique psh_msg_xxx campaign_id bigint fk nullable app_id bigint fk target_type enum device|topic|segment target_value string (token ou nome do topic ou segment_id) title string body text data jsonb status enum queued|sent|delivered|failed sent_count int delivered_count int failed_count int created_at, sent_at ``` ### Campaigns #### `campaigns` ``` id bigint PK tenant_id bigint fk public_id string unique cam_xxx name string channel enum email|sms|push|multi template_id bigint fk nullable list_id bigint fk nullable segment_id bigint fk nullable status enum draft|scheduled|running|paused|completed|cancelled scheduled_at timestamp nullable started_at timestamp nullable completed_at timestamp nullable total_recipients int stats jsonb (sent, delivered, opened, clicked, etc) ab_test_config jsonb nullable created_by bigint fk (user) created_at, updated_at ``` #### `campaign_recipients` ``` id bigint PK campaign_id bigint fk contact_id bigint fk variant string (A, B, C pra A/B) status enum pending|sent|failed|skipped message_id string nullable (vinculo com email_messages/sms_messages/push_messages) processed_at timestamp nullable ``` ### Contacts #### `contacts` ``` id bigint PK tenant_id bigint fk public_id string unique cnt_xxx email string nullable phone string nullable first_name string nullable last_name string nullable attributes jsonb (campos custom) subscribed boolean default true tags jsonb source string last_activity_at timestamp created_at, updated_at unique (tenant_id, email) where email IS NOT NULL unique (tenant_id, phone) where phone IS NOT NULL ``` #### `contact_lists` ``` id bigint PK tenant_id bigint fk public_id string unique lst_xxx name string description text nullable contacts_count int created_at, updated_at ``` #### `contact_list_members` ``` id bigint PK list_id bigint fk contact_id bigint fk added_at timestamp unique (list_id, contact_id) ``` #### `segments` ``` id bigint PK tenant_id bigint fk public_id string unique seg_xxx name string rules jsonb (AST de regras: AND/OR de conditions) cached_count int nullable cached_at timestamp nullable created_at, updated_at ``` ### Billing #### `plans` ``` id bigint PK name string slug string unique price_cents int billing_cycle enum monthly|yearly features jsonb limits jsonb {emails_per_month, sms, push, contacts, etc} active boolean ``` #### `subscriptions` ``` id bigint PK tenant_id bigint fk plan_id bigint fk status enum trialing|active|past_due|canceled trial_ends_at timestamp nullable current_period_start timestamp current_period_end timestamp stripe_subscription_id string nullable cancel_at timestamp nullable created_at, updated_at ``` #### `usage_counters` ``` id bigint PK tenant_id bigint fk period string 2026-04 (YYYY-MM) emails_sent int default 0 sms_sent int default 0 push_sent int default 0 unique (tenant_id, period) ``` ### Webhooks #### `webhook_endpoints` ``` id bigint PK tenant_id bigint fk url string secret string encrypted events jsonb (array de event types) active boolean created_at, updated_at ``` #### `webhook_deliveries` ``` id bigint PK endpoint_id bigint fk event_type string payload jsonb response_status int nullable response_body text nullable attempts int next_retry_at timestamp nullable delivered_at timestamp nullable created_at ``` ### Automations #### `automations` ``` id bigint PK tenant_id bigint fk public_id string unique aut_xxx name string trigger_type string contact_created|event|scheduled|tag_added|... trigger_config jsonb status enum draft|active|paused flow jsonb (DAG de steps) created_at, updated_at ``` #### `automation_runs` ``` id bigint PK automation_id bigint fk contact_id bigint fk status enum running|completed|failed|skipped current_step string nullable context jsonb started_at timestamp completed_at timestamp nullable ``` --- ## 3. ClickHouse (Analytics Events) Schema append-only pra volumes altos. ``` CREATE TABLE events_email ( tenant_id String, message_id String, event_type Enum('sent','delivered','bounced','complained','opened','clicked','unsubscribed'), timestamp DateTime64(3), ip Nullable(String), user_agent Nullable(String), url Nullable(String), country Nullable(String), device_type Nullable(String), email_client Nullable(String) ) ENGINE = MergeTree ORDER BY (tenant_id, timestamp); -- Analogo para sms, push ``` Materialized views pra pre-aggregations de dashboards. --- ## 4. Encriptacao de Dados Sensiveis Laravel Crypt (AES-256-CBC) em campos marcados `encrypted`: - `api_keys.key_hash` (ja eh hash, nao precisa encrypt) - `domains.dkim_private_key` - `relays.master_key_encrypted` - `sms_gateways.config` - `push_apps.fcm_server_key` - `push_apps.apns_key_p8` - `push_apps.vapid_private` - `webhook_endpoints.secret` - `users.two_factor_secret` Chave mestre em env (`APP_KEY`). --- ## 5. Indices Criticos Alem das FKs, indices compostos para queries comuns: - `email_messages (tenant_id, status, created_at DESC)` - dashboard recents - `email_messages (tenant_id, campaign_id)` - campaign stats - `email_events (tenant_id, message_id)` - lookup por mensagem - `email_events (tenant_id, event_type, created_at)` - aggregates - `contacts (tenant_id, email)` - unique + lookup - `usage_counters (tenant_id, period)` - unique + lookup - Partial indexes pra soft deletes (`WHERE deleted_at IS NULL`) --- ## 6. Convencoes - IDs internos: `bigint auto-increment` - IDs publicos: `string prefix_hex` (nunca expor IDs internos na API) - Timestamps: `timestamptz` em PG, `DATETIME` em SQLite - JSON: `jsonb` em PG (indexavel), `TEXT` em SQLite - Enums: string check constraint em PG, VARCHAR em SQLite/MySQL - Soft delete: `deleted_at` nullable + index parcial - Audit: `created_at`, `updated_at` em toda tabela mutavel ============================================================================== # GUIA: Padrões de serviço ============================================================================== # Service Patterns - Asender (Go) Padroes comuns pra todos os servicos Go. Cada servico segue este layout. ## Layout de Diretorio (cada servico Go) ``` asender-/ ├── cmd/ │ └── server/ │ └── main.go # Entry point ├── internal/ │ ├── config/ │ │ └── config.go # env vars via envconfig │ ├── db/ │ │ ├── db.go # pgx pool factory │ │ ├── migrations/ # SQL files numbered │ │ │ └── 001_init.up.sql │ │ │ └── 001_init.down.sql │ │ └── queries/ # sqlc .sql files (ou query methods) │ ├── http/ │ │ ├── server.go # chi router + middleware │ │ ├── handlers/ # per-resource handlers │ │ └── middleware/ # local middleware (auth, tenant, etc) │ ├── service/ # business logic, framework-agnostic │ │ └── ... │ └── repo/ # DB access layer (wraps sqlc queries) │ └── ... ├── migrations/ # (alt location - some prefer root) ├── Dockerfile ├── Makefile ├── go.mod ├── go.sum ├── .env.example └── README.md ``` ## Dependencias Padrao (go.mod) ```go require ( github.com/go-chi/chi/v5 v5.1.0 github.com/jackc/pgx/v5 v5.7.1 github.com/kelseyhightower/envconfig v1.4.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/nats-io/nats.go v1.37.0 github.com/redis/go-redis/v9 v9.6.1 github.com/rs/xid v1.6.0 golang.org/x/crypto v0.27.0 ) ``` (Cada servico inclui so o que usa.) ## config.go (padrao) ```go package config import "github.com/kelseyhightower/envconfig" type Config struct { Env string `envconfig:"ENV" default:"development"` HTTPPort int `envconfig:"HTTP_PORT" default:"8080"` LogLevel string `envconfig:"LOG_LEVEL" default:"info"` DatabaseURL string `envconfig:"DATABASE_URL" required:"true"` RedisURL string `envconfig:"REDIS_URL" default:"redis://localhost:6379"` NatsURL string `envconfig:"NATS_URL" default:"nats://localhost:4222"` ServiceSecret string `envconfig:"SERVICE_SECRET" required:"true"` JWTSecret string `envconfig:"JWT_SECRET" required:"true"` AuthServiceURL string `envconfig:"AUTH_SERVICE_URL" default:"http://asender-auth:8080"` CoreServiceURL string `envconfig:"CORE_SERVICE_URL" default:"http://asender-core:8080"` } func Load() (*Config, error) { var c Config err := envconfig.Process("", &c) return &c, err } ``` ## main.go (padrao) ```go package main import ( "context" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "asender-/internal/config" "asender-/internal/http/server" ) func main() { cfg, err := config.Load() if err != nil { slog.Error("config load failed", "err", err) os.Exit(1) } logger := newLogger(cfg.LogLevel) slog.SetDefault(logger) srv, err := server.New(cfg, logger) if err != nil { slog.Error("server init failed", "err", err) os.Exit(1) } httpSrv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.HTTPPort), Handler: srv.Router(), ReadHeaderTimeout: 10 * time.Second, } // Graceful shutdown go func() { slog.Info("server listening", "port", cfg.HTTPPort) if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("http listen", "err", err) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit slog.Info("shutting down") ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := httpSrv.Shutdown(ctx); err != nil { slog.Error("shutdown", "err", err) } } ``` ## HTTP Server (padrao) ```go package server import ( "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) type Server struct { cfg *config.Config router *chi.Mux // ...deps } func New(cfg *config.Config, logger *slog.Logger) (*Server, error) { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(loggingMiddleware(logger)) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(30 * time.Second)) s := &Server{cfg: cfg, router: r} s.routes() return s, nil } func (s *Server) Router() http.Handler { return s.router } func (s *Server) routes() { s.router.Get("/healthz", s.handleHealth) s.router.Get("/readyz", s.handleReady) s.router.Get("/metrics", s.handleMetrics) s.router.Route("/v1", func(r chi.Router) { r.Use(s.authMiddleware) // ... }) } ``` ## Error Response Format (SES-like) ```go package httpx type ErrorBody struct { Error struct { Type string `json:"Type"` // "Sender" or "Receiver" Code string `json:"Code"` Message string `json:"Message"` Details any `json:"Details,omitempty"` } `json:"Error"` RequestId string `json:"RequestId"` } func WriteError(w http.ResponseWriter, r *http.Request, status int, code, msg string) { typ := "Sender" if status >= 500 { typ = "Receiver" } body := ErrorBody{RequestId: middleware.GetReqID(r.Context())} body.Error.Type = typ body.Error.Code = code body.Error.Message = msg writeJSON(w, status, body) } func WriteSuccess(w http.ResponseWriter, r *http.Request, status int, data map[string]any) { data["RequestId"] = middleware.GetReqID(r.Context()) writeJSON(w, status, data) } ``` ## Logging Use `log/slog` (stdlib, Go 1.21+). JSON em producao, text em dev. ```go func newLogger(level string) *slog.Logger { var lvl slog.Level _ = lvl.UnmarshalText([]byte(level)) h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) return slog.New(h) } ``` Sempre incluir `request_id`, `tenant_id` nos logs de request. ## ID Generation ```go package ids import ( "crypto/rand" "encoding/hex" ) func New(prefix string) string { b := make([]byte, 8) _, _ = rand.Read(b) return prefix + "_" + hex.EncodeToString(b) } ``` ## Graceful Shutdown Todo servico: - Responde `/healthz` (liveness - sempre 200 se processo esta up) - Responde `/readyz` (readiness - 200 so quando deps (DB, NATS) estao OK) - Trata SIGINT/SIGTERM com shutdown de 15s - Drena conexoes HTTP antes de morrer ## Testing - `internal/...` tem tests com `_test.go` ao lado - Integration tests em `tests/` usam DB real (dockertest ou testcontainers) - Minimum coverage: 70% pra service logic, 50% pra handlers ## Dockerfile Multi-stage ```dockerfile FROM golang:1.22-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /app ./cmd/server FROM gcr.io/distroless/static-debian12 COPY --from=build /app /app USER nonroot:nonroot ENTRYPOINT ["/app"] ``` ## Makefile ```makefile .PHONY: run test migrate build run: go run ./cmd/server test: go test ./... -race migrate-up: migrate -path ./internal/db/migrations -database "$(DATABASE_URL)" up migrate-down: migrate -path ./internal/db/migrations -database "$(DATABASE_URL)" down 1 build: CGO_ENABLED=0 go build -o bin/server ./cmd/server docker: docker build -t asender- . ``` ## Endpoints comuns a todos os servicos - `GET /healthz` - liveness, sempre 200 - `GET /readyz` - readiness, 200 se DB + deps OK - `GET /metrics` - Prometheus metrics - `GET /` - retorna `{service, version, commit}` ## Service-to-Service Auth Shared secret JWT. ```go // asender-shared/auth/svctoken.go type SvcClaims struct { Svc string `json:"svc"` jwt.RegisteredClaims } func SignSvcToken(secret []byte, svc string) (string, error) { claims := SvcClaims{ Svc: svc, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return tok.SignedString(secret) } func VerifySvcToken(secret []byte, tokenStr string) (*SvcClaims, error) { var claims SvcClaims _, err := jwt.ParseWithClaims(tokenStr, &claims, func(_ *jwt.Token) (any, error) { return secret, nil }) return &claims, err } ``` Requests internas setam `X-Asender-Svc-Token: `. Middleware valida. ## Frontend Patterns (Next.js) - App Router (Next 15) - TypeScript strict - Tailwind + shadcn/ui - TanStack Query para API calls client-side - Server actions + API routes proxyam pro backend - Env vars: `NEXT_PUBLIC_API_URL`, `API_URL` (server-only) - Auth via httpOnly cookie com JWT Estrutura: ``` asender-/ ├── app/ │ ├── (auth)/ │ │ ├── login/ │ │ └── register/ │ ├── (app)/ │ │ └── dashboard/ │ ├── api/ # Route handlers (proxy) │ └── layout.tsx ├── components/ │ └── ui/ # shadcn ├── lib/ │ ├── api.ts # typed client │ └── auth.ts ├── public/ ├── package.json ├── tsconfig.json ├── tailwind.config.ts └── next.config.ts ``` ============================================================================== # GUIA: Core ============================================================================== # Asender Core - Aplicacao Principal Spec da aplicacao principal multi-tenant do Asender. Este eh o SaaS que os customers acessam (dashboard + API), diferente dos relays edge. **Status:** - Fase 2 (foundation: auth, tenancy, API keys, invitations, relay management, webhooks) - IMPLEMENTADO - Fases 3+ (SMTP, templates, campanhas, SMS, push, analytics, billing) - em planejamento Codigo: [../asender-core/](../asender-core/) --- ## 1. Escopo ### Servicos Oferecidos 1. **Email / SMTP** - transacional + bulk + SMTP externo (usar como relay pra terceiros) 2. **SMS** - disparos via gateways 3. **Push** - mobile + web push ### Funcionalidades Transversais - Dashboard web de gestao - API REST publica (SES-compatible + extensoes) - Webhooks de eventos - Campanhas com agendamento e A/B - Templates visuais - Contatos e listas com segmentacao - Analytics e relatorios - Billing e quotas - Multi-tenancy completo --- ## 2. Stack ### Proposta - **Backend:** Laravel 11 (PHP 8.3) - **DB principal:** PostgreSQL 16 - **Cache/Queue:** Redis + Laravel Horizon - **Events store:** ClickHouse (eventos em massa pra analytics) - **Object storage:** S3-compatible (R2/Spaces/S3/MinIO) - **Frontend:** Inertia.js + Vue 3 + TailwindCSS - **Email editor:** MJML + component library custom - **Search:** Meilisearch (contatos, templates, logs) - **Containers:** Docker + Docker Compose (dev), Kubernetes (prod) ### Por que Laravel? - Ecossistema PHP coerente com o relay - Horizon pra filas robusto - Sanctum/Passport pra auth de API - Telescope pra debug - Inertia pra SPAs sem API separada - Comunidade grande --- ## 3. Modulos ### 3.1 Auth & Tenancy **Entidades:** - `User` (pessoa fisica que loga) - `Tenant` (empresa/conta, chamado de "organization" no UI) - `TenantUser` (pivo com role) - `ApiKey` (tokens de API, scoped ao tenant) - `Invitation` (convites pendentes) **Features:** - Registro com confirmacao de email - Login + 2FA (TOTP) - SSO via OAuth (Google, Microsoft) - SSO via SAML (enterprise) - Convites por email com role - Roles: `owner`, `admin`, `developer`, `viewer` - API keys scoped: `full_access`, `send_only`, `read_only` - Audit log de acoes sensiveis **Multi-tenant resolution:** - Por subdominio: `acme.asender.io` - Por API key (determina o tenant) - Middleware `EnsureTenant` injeta `$tenant` em request ### 3.2 Dominios e Reputacao **Entidades:** - `Domain` (dominios de envio verificados) - `DkimKey` (chaves DKIM por dominio) - `SuppressionList` (bounces + complaints + unsubscribes) **Features:** - Adicionar dominio com verificacao (TXT DNS) - Geracao automatica de DKIM (2048 bits) - Instrucoes passo-a-passo de config DNS (SPF, DKIM, DMARC) - Validacao periodica de DNS - Verificacao de reputacao (blacklists publicas) - Warm-up automatico de novos IPs ### 3.3 SMTP / Email Module **Entidades:** - `EmailMessage` (mensagem enviada ou agendada) - `EmailEvent` (open, click, bounce, complaint, etc) - `EmailTemplate` (template com editor visual) - `EmailRelay` (referencia a um relay PHP externo) - `Domain` (ja listado acima) **Features:** - API de envio SES-compatible (`/v1/emails/send`) - Envio direto via provedores SMTP parceiros (SES, SendGrid, Mailgun) - Envio via relays proprios (PHP relay) pra IPs dedicados - Tracking de opens (pixel) e clicks (redirect) - Bounce processing via webhook dos provedores - Complaint processing (FBL) - Unsubscribe automatico (link no rodape + List-Unsubscribe header) - Suppression list global por tenant - SMTP gateway externo: cliente usa `smtp.asender.io:587` como provider **Editor de templates:** - Drag-n-drop ou codigo MJML/HTML - Preview em dispositivos - Teste de envio - Variaveis Handlebars-like - Biblioteca de templates prontos - Versioning ### 3.4 SMS Module **Entidades:** - `SmsMessage` - `SmsEvent` (DLR) - `SmsGateway` (config dos gateways por tenant) - `PhoneNumber` (pool de numeros alugados) - `SuppressionList` (opt-outs) **Gateways suportados (integracao):** - Twilio - AWS SNS - Zenvia (BR) - Plivo - MessageBird - Custom HTTP gateway **Features:** - Envio 1:1 e em massa - Agendamento - DLR handling - Opt-out automatico (STOP/SAIR) - Suporte a Unicode e segmentacao automatica - Pool de numeros (rotacao) - Fallback entre gateways ### 3.5 Push Module **Entidades:** - `PushMessage` - `PushEvent` (sent, delivered, opened, failed) - `Device` (tokens FCM/APNs/WebPush) - `PushApp` (app registration com chaves do tenant) - `Topic` / `Segment` **Providers:** - FCM (Firebase Cloud Messaging) - Android - APNs (Apple Push Notification service) - iOS - Web Push (VAPID) - browsers **Features:** - Registro de devices via API (SDK cliente) - Envio individual ou em massa - Topics (publish a todos inscritos) - Segments (filtros sobre devices) - Silent push - Badge/sound customization - Analytics (delivery rate, click rate) ### 3.6 Campaigns Module **Entidades:** - `Campaign` (email, sms, push, ou multi-canal) - `CampaignRun` (execucao especifica) - `CampaignRecipient` (destinatario da execucao) - `Segment` / `List` - `AbTest` **Features:** - Builder visual de campanha - Seleciona: canal, template, lista/segmento, agendamento - Teste A/B: subject, conteudo, horario - Throttling configuravel - Rastreio individual por destinatario - Relatorio pos-campanha - Pause / resume / cancel - Copy from previous ### 3.7 Contacts Module **Entidades:** - `Contact` (pessoa alcancavel) - `ContactField` (campo custom definido pelo tenant) - `ContactValue` (valores EAV ou jsonb) - `ContactList` (listas) - `ContactListMember` (pivo) - `Segment` (consulta salva) **Features:** - CRUD de contatos - Campos custom por tenant - Listas estaticas e dinamicas (segments) - Import CSV / JSON - Export - Deduplicacao por email/telefone - Supressoes automaticas (quem deu bounce, nao eh adicionado) - API de webhook pra sincronizar com CRMs ### 3.8 Automations (Flows) **Entidades:** - `Automation` (fluxo) - `AutomationStep` (nos do fluxo) - `AutomationRun` (execucao por contato) **Features:** - Trigger: signup, tag added, event received, scheduled, etc - Steps: wait, send email/sms/push, update contact, branch (if), webhook - Visual flow builder - Histórico por contato ### 3.9 Analytics **Entidades (em ClickHouse):** - `email_events` (1 row por evento) - `sms_events` - `push_events` - `campaign_stats` (aggregates materializadas) **Features:** - Dashboard com metricas principais - Funil de engajamento (sent > delivered > opened > clicked) - Comparacao entre campanhas - Heatmaps de horarios - Geolocation (com anonimizacao) - Client breakdown (iOS/Android/Gmail/Outlook) - Export CSV / API ### 3.10 Relay Management **Entidades:** - `Relay` (instancia de asender-relay-php) - `RelayHealth` (historico de health checks) - `RelayTenant` (mapping: relay X hosts tenant Y internally) **Features:** - Cadastrar relay existente (URL + master key) - Provisionar novo relay automaticamente (via API de hosting) - Health check periodico (/health endpoint) - Sync de tenants: cada customer do Core ganha um tenant no relay apropriado - Sync de templates importantes - Agregacao de stats cross-relay - Failover: se um relay cai, route pra outro ### 3.11 Billing **Entidades:** - `Plan` (tier de assinatura) - `Subscription` - `UsageCounter` (por tenant por periodo) - `Invoice` - `PaymentMethod` **Features:** - Planos: Free, Starter, Pro, Enterprise - Metering: emails enviados, SMS, push, storage de anexos - Overages ou hard limits - Stripe pra cartao - Pix/boleto (BR) - Invoices PDF - Usage dashboard no painel ### 3.12 Webhooks & Events **Entidades:** - `WebhookEndpoint` - `WebhookDelivery` - `EventLog` **Features:** - Customer cadastra URL + secret - Eventos: email.sent, email.delivered, email.bounced, email.opened, email.clicked, sms.sent, sms.dlr, push.delivered, campaign.started, campaign.finished, etc - Retry com backoff (3, 15, 60, 360 min) - Signature HMAC-SHA256 no header - Log de deliveries - Resend manual ### 3.13 API Publica **Principios:** - REST-ish - Versionada: `/v1/` - Autenticada com API key - Formato SES-compatible onde fizer sentido - Respostas JSON com `RequestId` - Erros formato SES (`{Error: {Type, Code, Message}}`) **Principais endpoints:** ``` POST /v1/emails/send POST /v1/emails/send-batch GET /v1/emails/{id} GET /v1/emails POST /v1/emails/{id}/cancel POST /v1/sms/send GET /v1/sms/{id} POST /v1/push/send POST /v1/push/devices GET /v1/push/devices GET /v1/templates POST /v1/templates PUT /v1/templates/{id} DELETE /v1/templates/{id} GET /v1/contacts POST /v1/contacts PUT /v1/contacts/{id} GET /v1/campaigns POST /v1/campaigns POST /v1/campaigns/{id}/start GET /v1/stats GET /v1/account ``` --- ## 4. Data Model (high-level) Ver [DATA_MODEL.md](DATA_MODEL.md) pra schemas detalhados. --- ## 5. SDK Strategy Cada SDK: - Thin wrapper sobre a REST API - Auth via env var: `ASENDER_API_KEY` - Retries automaticos em 5xx - Typed models - Helpers: `$asender->emails->send([...])` **Prioridade:** PHP > Node > Python > Go > Ruby --- ## 6. Deploy & Ops ### Ambientes - `dev` - local (docker-compose) - `staging` - replica de prod, dados fake - `prod` - multi-region, multi-AZ ### CI/CD - GitHub Actions - Testes unitarios + feature - Testes E2E (Playwright) - Build de images Docker - Deploy automatico pra staging, manual pra prod ### Monitoring - Logs: Loki ou CloudWatch - Metricas: Prometheus + Grafana - Errors: Sentry - Uptime: UptimeRobot ou equivalente ### Backup - DB: daily + PITR (point-in-time recovery) - Object storage: versionado - Template/config: git --- ## 7. Segmentation do MVP Nao vai dar pra lancar tudo de uma vez. Proposta de MVP: ### MVP (3 meses) - Auth + Tenancy basico - Dominios + DKIM - SMTP module core (envio simples via provedor direto) - Templates basicos (sem editor visual, so codigo) - API publica de envio - Relay management basico - Dashboard minimo (lista de emails + stats) ### Pos-MVP (6 meses) - Campanhas + agendamento - Contatos + listas - Editor visual de templates - Webhooks de eventos - Analytics dashboard ### Expansao (12 meses) - SMS module - Push module - Automations - Billing - A/B testing - SSO enterprise ============================================================================== # GUIA: Relay multi-tenant ============================================================================== # Relay PHP - Multi-tenancy Spec Spec detalhada da evolucao do `asender-relay-php` de single-tenant para multi-tenant. **Status:** em planejamento (proxima implementacao) --- ## 1. Conceitos ### Hierarquia de Auth ``` ┌─────────────────────────────────────────┐ │ MASTER KEY │ │ ──────────── │ │ • Criada no install.php │ │ • Formato: sk_master_<48 chars hex> │ │ • Unica por instancia de relay │ │ • Usada pelo Asender Core │ │ • Acesso: admin.* + override de tenant │ └────────────┬────────────────────────────┘ │ cria / gerencia ▼ ┌─────────────────────────────────────────┐ │ TENANT KEYS (N por relay) │ │ ──────────── │ │ • Criadas via admin.tenant.create │ │ • Formato: sk_live_<48 chars hex> │ │ • 1 key por tenant │ │ • Usadas pelos apps finais │ │ • Acesso: send, list, template, etc │ │ (sempre scoped ao proprio tenant) │ └─────────────────────────────────────────┘ ``` ### Tenant Unidade de isolamento dentro do relay. Cada tenant tem: - ID publico: `tnt_<16 hex>` - Nome (human-readable) - API key propria - Config SMTP proprio (host, port, user, pass, encryption) - ou usa o default global - Limites (hourly, daily, batch size, send delay) - ou usa defaults globais - From padrao (email + nome) - Status: `active` | `suspended` | `deleted` (soft delete) - Metadata (JSON livre pra Asender Core guardar refs) ### Template Recurso reutilizavel de email, scoped por tenant: - ID publico: `tpl_<16 hex>` - Nome - Subject - Body (text + html) - Variables declaradas (array de `{name, required, default}`) - Metadata --- ## 2. Mudancas de Schema ### Nova tabela: `tenants` ```sql -- SQLite CREATE TABLE IF NOT EXISTS tenants ( id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT UNIQUE NOT NULL, name TEXT NOT NULL, api_key_hash TEXT UNIQUE NOT NULL, status TEXT NOT NULL DEFAULT 'active', smtp_config TEXT, -- JSON, nullable default_from TEXT, default_from_name TEXT, hourly_limit INTEGER, -- nullable, fallback to global daily_limit INTEGER, batch_size INTEGER, send_delay_ms INTEGER, metadata TEXT DEFAULT '{}', -- JSON created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, suspended_at DATETIME, deleted_at DATETIME ); CREATE INDEX idx_tenants_status ON tenants(status); CREATE INDEX idx_tenants_key ON tenants(api_key_hash); ``` MySQL equivalente com `VARCHAR(64)` / `VARCHAR(255)` / `JSON` / `InnoDB utf8mb4`. ### Nova tabela: `templates` ```sql CREATE TABLE IF NOT EXISTS templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id TEXT UNIQUE NOT NULL, tenant_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, subject TEXT NOT NULL, body_text TEXT, body_html TEXT, variables TEXT DEFAULT '[]', -- JSON metadata TEXT DEFAULT '{}', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME, FOREIGN KEY (tenant_id) REFERENCES tenants(tenant_id) ); CREATE INDEX idx_templates_tenant ON templates(tenant_id); ``` ### Alteracoes em `emails` ```sql ALTER TABLE emails ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ''; CREATE INDEX idx_emails_tenant ON emails(tenant_id); CREATE INDEX idx_emails_tenant_status ON emails(tenant_id, status); ``` ### Alteracoes em `logs` ```sql ALTER TABLE logs ADD COLUMN tenant_id TEXT; -- nullable for system logs CREATE INDEX idx_logs_tenant ON logs(tenant_id); ``` ### Migracao de instalacoes existentes No primeiro run pos-upgrade: 1. Cria tabelas novas se nao existirem 2. Se ja tem emails sem tenant_id: cria tenant `tnt_default` e atribui tudo a ele 3. Move `config.smtp` / `config.default_from` pro tenant default 4. Marca `data/.migrated_v2` pra nao rodar de novo --- ## 3. Configuracao ### Novo formato do `data/config.php` ```php 'sha256-hash', // Database (sem mudanca) 'database' => [ 'driver' => 'sqlite', 'path' => '/abs/path/relay.db', ], // SMTP default (opcional - tenants podem sobrescrever) 'default_smtp' => [ 'host' => 'smtp.example.com', 'port' => 587, 'encryption' => 'tls', 'username' => 'user', 'password' => 'pass', ], // Limites default (opcionais - tenants podem sobrescrever) 'default_limits' => [ 'hourly' => 100, 'daily' => 1000, 'batch_size' => 10, 'send_delay_ms' => 1000, ], // Settings globais 'cleanup_days' => 30, 'cors_origin' => '*', 'installed_at' => '2026-04-12 15:30:00', ]; ``` --- ## 4. API - Rotas Admin (Master Key) Todas as rotas abaixo requerem header `Authorization: Bearer sk_master_xxxxx`. ### 4.1 Criar Tenant ```http POST index.php?action=admin.tenant.create Content-Type: application/json { "Name": "Acme Corp", "Smtp": { "Host": "smtp.acme.com", "Port": 587, "Encryption": "tls", "Username": "mailer@acme.com", "Password": "secret" }, "DefaultFrom": "noreply@acme.com", "DefaultFromName": "Acme", "Limits": { "Hourly": 500, "Daily": 5000, "BatchSize": 20, "SendDelayMs": 500 }, "Metadata": { "asender_customer_id": "cust_abc123" } } ``` **Response:** ```json { "TenantId": "tnt_a1b2c3d4e5f67890", "Name": "Acme Corp", "ApiKey": "sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "Status": "active", "CreatedAt": "2026-04-12T15:30:00Z", "RequestId": "req_xxx" } ``` **Importante:** `ApiKey` so eh retornada nessa resposta. Em chamadas subsequentes, so `tenant_id` + hash. ### 4.2 Listar Tenants ```http GET index.php?action=admin.tenant.list&status=active&limit=50 ``` ```json { "Tenants": [ { "TenantId": "tnt_xxx", "Name": "Acme Corp", "Status": "active", "DefaultFrom": "noreply@acme.com", "Stats": { "Queued": 3, "Sent": 1247, "Failed": 2 }, "CreatedAt": "..." } ], "Count": 1, "Total": 1 } ``` ### 4.3 Get Tenant ```http GET index.php?action=admin.tenant.get&id=tnt_xxx ``` Response igual ao item acima mas com detalhes completos (Limits, Metadata, etc). NUNCA retorna a API key. ### 4.4 Update Tenant ```http PUT index.php?action=admin.tenant.update&id=tnt_xxx { "Name": "Acme Corp Renamed", "Limits": {"Hourly": 1000}, "Smtp": {"Password": "new-secret"} } ``` Campos nao enviados permanecem. ### 4.5 Delete Tenant (soft) ```http DELETE index.php?action=admin.tenant.delete&id=tnt_xxx ``` Marca `deleted_at`, `status='deleted'`. Emails historicos ficam. Query param `?purge=true` faz hard delete (apaga tudo incluindo emails). ### 4.6 Rotate API Key ```http POST index.php?action=admin.tenant.rotate-key&id=tnt_xxx ``` ```json { "TenantId": "tnt_xxx", "ApiKey": "sk_live_novaxxxxxx", "RotatedAt": "..." } ``` A key antiga deixa de funcionar imediatamente. ### 4.7 Suspend / Resume ```http POST index.php?action=admin.tenant.suspend&id=tnt_xxx POST index.php?action=admin.tenant.resume&id=tnt_xxx ``` Tenant suspenso: API key retorna 403 ate ser resumido. Emails na fila dele ficam parados. ### 4.8 Stats Globais ```http GET index.php?action=admin.stats ``` ```json { "Tenants": { "Total": 42, "Active": 40, "Suspended": 1, "Deleted": 1 }, "Stats": { "Queued": 145, "Processing": 2, "Sent": 124782, "Failed": 301 }, "Last24h": { "Sent": 8450, "Failed": 12 }, "TopTenants": [ {"TenantId": "tnt_xxx", "Name": "Acme", "Sent24h": 3200} ] } ``` ### 4.9 Stats por Tenant (via master) ```http GET index.php?action=admin.tenant.stats&id=tnt_xxx ``` Retorna estatisticas especificas do tenant. --- ## 5. API - Rotas Tenant (Tenant Key) Todas requerem `Authorization: Bearer sk_live_xxxxx`. Todas sao auto-scoped ao tenant dono da key. ### 5.1 Rotas Existentes (ja implementadas, agora tenant-scoped) - `POST action=send` - usa SMTP do tenant - `POST action=send-batch` - idem - `GET action=status&id=msg_xxx` - so emails do proprio tenant - `GET action=list` - lista so do tenant - `DELETE action=cancel&id=msg_xxx` - so proprios - `GET action=stats` - stats do tenant - `POST action=process` - processa so emails do tenant (util pra webcron por tenant) - `POST action=flush` - idem ### 5.2 Send com Template ```http POST index.php?action=send { "Template": { "Id": "tpl_xxx", "Data": {"name": "Joao", "code": "ABC123"} }, "Destination": {"ToAddresses": ["joao@email.com"]} } ``` Quando `Template.Id` eh fornecido, o relay: 1. Busca o template no tenant 2. Substitui variaveis `{{name}}` e `{{code}}` 3. Monta o email com subject/body resultantes Pode combinar com overrides diretos (Source, ReplyToAddresses, Tags, ScheduledAt). ### 5.3 Templates ```http POST action=template.create GET action=template.list GET action=template.get&id=tpl_xxx PUT action=template.update&id=tpl_xxx DELETE action=template.delete&id=tpl_xxx ``` **Create:** ```json { "Name": "Welcome Email", "Description": "Boas-vindas pos-signup", "Subject": "Bem-vindo, {{name}}!", "BodyHtml": "

Ola {{name}}

Seu codigo: {{code}}

", "BodyText": "Ola {{name}}\n\nSeu codigo: {{code}}", "Variables": [ {"Name": "name", "Required": true}, {"Name": "code", "Required": true} ], "Metadata": {} } ``` **Response:** ```json { "TemplateId": "tpl_xxx", "Name": "Welcome Email", ... } ``` ### 5.4 Self-Service (tenant ve proprias infos) ``` GET action=me -> detalhes do proprio tenant (sem API key, obvio) ``` Util pra apps checarem quotas, limites, etc. --- ## 6. Implementacao - Auth Refatorado ```php class Auth { private string $masterKeyHash; private Database $db; public function authenticate(): AuthContext { $key = $this->extractKey(); if (!$key) throw new AuthError('Missing API key'); $hash = hash('sha256', $key); // Master? if (hash_equals($this->masterKeyHash, $hash)) { return new AuthContext('master', null); } // Tenant? $tenant = $this->db->findTenantByKeyHash($hash); if ($tenant && $tenant['status'] === 'active') { return new AuthContext('tenant', $tenant['tenant_id'], $tenant); } if ($tenant && $tenant['status'] === 'suspended') { throw new AuthError('Tenant suspended', 403); } throw new AuthError('Invalid API key', 401); } } class AuthContext { public string $type; // 'master' | 'tenant' public ?string $tenantId; public ?array $tenant; public function requireMaster(): void { if ($this->type !== 'master') throw new AuthError('Master key required', 403); } public function requireTenant(): void { if ($this->type !== 'tenant') throw new AuthError('Tenant key required', 403); } } ``` ### Guardas por rota ```php $adminActions = ['admin.tenant.create', 'admin.tenant.list', ...]; $tenantActions = ['send', 'send-batch', 'template.create', ...]; $eitherActions = ['process', 'stats', 'health']; if (in_array($action, $adminActions)) { $ctx->requireMaster(); } if (in_array($action, $tenantActions)) { $ctx->requireTenant(); } ``` --- ## 7. Implementacao - Send Flow ``` POST send (tenant key) │ ├─ Auth resolves tenant context ├─ Parse body (may contain Template.Id) ├─ If Template.Id: fetch template from tenant, apply ReplacementData ├─ Build email payload with tenant_id ├─ Validate rate limits (tenant-scoped) ├─ Insert into emails (tenant_id = ctx.tenantId) ├─ Trigger piggyback processing └─ Return MessageId ``` ### Piggyback Processing (per tenant) ```php register_shutdown_function(function () use ($db, $tenantId) { // Lock por tenant (data/process_{tenant_id}.lock) // Fetch pending emails WHERE tenant_id = ? // Resolve SMTP config (tenant's or global default) // Send via SmtpClient // Mark sent/failed }); ``` ### SMTP Config Resolution ```php function resolveTenantSmtp(array $tenant, array $config): array { $smtp = json_decode($tenant['smtp_config'], true) ?: []; return array_merge($config['default_smtp'] ?? [], $smtp); } ``` --- ## 8. Implementacao - Database Methods (novos) ### Tenants - `createTenant(array $data): array` - `getTenant(string $tenantId): ?array` - `getTenantByKeyHash(string $hash): ?array` - `listTenants(array $filters): array` - `updateTenant(string $tenantId, array $data): bool` - `deleteTenant(string $tenantId, bool $purge = false): bool` - `suspendTenant(string $tenantId): bool` - `resumeTenant(string $tenantId): bool` - `rotateTenantKey(string $tenantId): string` // returns new plaintext key - `countTenants(?string $status = null): int` ### Templates - `createTemplate(string $tenantId, array $data): array` - `getTemplate(string $tenantId, string $templateId): ?array` - `listTemplates(string $tenantId, array $filters): array` - `updateTemplate(string $tenantId, string $templateId, array $data): bool` - `deleteTemplate(string $tenantId, string $templateId): bool` ### Updates em Email methods - Todos os metodos de email ganham parametro `$tenantId` pra scoping: - `queueEmail(string $tenantId, array $email): array` - `getByMessageId(string $tenantId, string $messageId): ?array` - `listEmails(string $tenantId, array $filters): array` - `getPending(int $limit, ?string $tenantId = null): array` // null = all tenants - `markSent/markFailed/lockEmail` - ja trabalham por id, sem mudanca - Novos scope helpers: - `getTenantStats(string $tenantId): array` --- ## 9. Install.php - Mudancas Step 4 (Settings) perde SMTP default (fica so limites e cleanup). Step 2 (SMTP) fica OPCIONAL - marca `default_smtp` se preenchido. **Nova secao na conclusao:** ``` Instalacao concluida. Master Key: sk_master_xxxxxxxxxxxxxxxxxxxxx (Use no header Authorization: Bearer pra chamar admin.*) Proximo passo: crie seu primeiro tenant: POST /index.php?action=admin.tenant.create { "Name": "Default Tenant" } Ou deixe o Asender Core provisionar automaticamente. ``` ### Opcional: auto-criar tenant default Checkbox no passo 4: "Criar tenant default com as configuracoes SMTP deste wizard" Se marcado, cria `tnt_default` com o SMTP e mostra ambas as keys no final. --- ## 10. INTEGRATION.md - Atualizacao Necessaria - Dividir em duas secoes: "Admin API" e "Tenant API" - Adicionar secao "Multi-Tenancy" explicando o modelo - Documentar todas as rotas admin - Documentar rotas de template - Atualizar exemplos pra usar tenant keys - Adicionar secao sobre como o Asender Core consome a API admin --- ## 11. Checklist de Implementacao ### Schema & Migration - [ ] Tabela `tenants` (SQLite + MySQL) - [ ] Tabela `templates` (SQLite + MySQL) - [ ] Coluna `tenant_id` em `emails` + indices - [ ] Coluna `tenant_id` em `logs` + indices - [ ] Migracao automatica pra instalacoes existentes (cria tnt_default) ### Database.php - [ ] Metodos CRUD de tenants (9 metodos) - [ ] Metodos CRUD de templates (5 metodos) - [ ] Update de queueEmail, listEmails, getStats, getByMessageId pra receber tenant_id - [ ] getTenantByKeyHash (indice usado pelo auth) - [ ] getTenantStats - [ ] Template rendering helper (substituicao {{var}}) ### Auth.php - [ ] Refatorar pra retornar AuthContext (type + tenantId + tenant) - [ ] Suporte a master key - [ ] Suporte a tenant key lookup no DB - [ ] requireMaster() / requireTenant() helpers - [ ] Tratar status suspended ### Index.php - [ ] Mapa de actions -> guards (admin/tenant/public) - [ ] Handlers admin.tenant.* (9 rotas) - [ ] Handlers template.* (5 rotas) - [ ] Handler admin.stats - [ ] Handler action=me - [ ] Atualizar handleSend pra aceitar Template.Id - [ ] Atualizar handleSend pra usar SMTP do tenant - [ ] Atualizar todos os handlers existentes pra scope por tenant_id - [ ] Piggyback processing por tenant ### Install.php - [ ] Gerar master_key_hash em vez de api_key_hash - [ ] Tornar SMTP default opcional - [ ] Checkbox "criar tenant default" - [ ] Mostrar master key + (opcional) tenant key na conclusao ### Docs - [ ] Atualizar INTEGRATION.md com novas rotas - [ ] Nova secao "Admin API" vs "Tenant API" - [ ] Exemplos de template usage - [ ] Diagrama de hierarquia de keys ### Testes - [ ] Teste manual: fluxo install → create tenant → send com tenant key - [ ] Teste: tenant A nao ve emails de tenant B - [ ] Teste: template rendering correto - [ ] Teste: rotate key invalida key antiga - [ ] Teste: tenant suspenso bloqueia send --- ## 12. Riscos e Decisoes ### Backward compat Opcao adotada: **migracao automatica**. Cria tenant `tnt_default` no primeiro run pos-upgrade, move tudo pra ele, marca flag. ### SMTP config por tenant vs global Adotado: **ambos**. Global como default, tenant override opcional. Flexivel. ### Rate limiting Por tenant OU global? Adotado: **por tenant + global como fallback**. Cada tenant tem seus limites; se null, usa global. ### Lock de processamento Global (um processo por relay) ou por tenant? **Por tenant** (arquivo `data/process_{tnt_id}.lock`) pra paralelizar. ### Template variables Simples string replacement `{{var}}` (igual batch send) ou templating engine (Mustache/Twig)? **Simples por enquanto**. Engine mais robusto pode vir dps com escape/HTML-safe. ### Pre-validacao de variaveis Se template declara variaveis required e o send nao manda, bloquear? **Sim, retornar 400 com lista de variaveis faltantes.** ============================================================================== # API pública (BFF) (https://api.asender.net) # Superfície PÚBLICA. ============================================================================== ### GET / Identidade do serviço. Respostas: 200 OK curl -X GET 'https://api.asender.net/' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/admin/blacklist Lista os bloqueios da plataforma. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/blacklist' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/admin/blacklist Acrescenta uma entrada à lista de bloqueio. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/admin/blacklist' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /api/admin/blacklist/{id} Remove uma entrada da lista de bloqueio. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X DELETE 'https://api.asender.net/api/admin/blacklist/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/admin/blacklist/suggestions Sugestões de bloqueio ainda não decididas. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/blacklist/suggestions' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/admin/blacklist/suggestions Registra uma sugestão de bloqueio. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/admin/blacklist/suggestions' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/admin/blacklist/suggestions/{id}/apply Aceita a sugestão e a promove a bloqueio. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/admin/blacklist/suggestions/id_AQUI/apply' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/admin/blacklist/suggestions/{id}/dismiss Descarta a sugestão. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/admin/blacklist/suggestions/id_AQUI/dismiss' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/admin/datacenter-asn ASNs classificados como datacenter. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/datacenter-asn' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/admin/datacenter-asn Classifica um ASN como datacenter. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/admin/datacenter-asn' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /api/admin/datacenter-asn/{asn} Remove a classificação de um ASN. Parâmetros: asn (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X DELETE 'https://api.asender.net/api/admin/datacenter-asn/asn_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/admin/impersonation/end Encerra a sessão de suporte em andamento. Onde é usada: botão "sair da conta". Efeitos: a sessão de suporte para NA HORA. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/admin/impersonation/end' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/admin/me Diz se o usuário da sessão é da plataforma. Onde é usada: o console, ao abrir — é o que decide entre mostrar o console e mostrar "não disponível". Efeitos: uma leitura na allowlist. Existe em vez de o console deduzir de outra rota: sem ela, o front descobriria que não é plataforma pelo 404 da PRIMEIRA rota que chamasse — e mostraria um erro de carregamento onde a resposta certa é "esta área não é sua". Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/me' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/admin/metrics Métricas agregadas da plataforma. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/metrics' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/admin/platform-alerts Regras de alerta da plataforma. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/platform-alerts' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/admin/platform-alerts Cria uma regra de alerta de plataforma. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/admin/platform-alerts' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PUT /api/admin/platform-alerts/{id} Substitui uma regra de alerta. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X PUT 'https://api.asender.net/api/admin/platform-alerts/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /api/admin/platform-alerts/{id} Remove uma regra de alerta. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X DELETE 'https://api.asender.net/api/admin/platform-alerts/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/admin/platform-alerts/events Disparos de alerta da plataforma. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/platform-alerts/events' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/admin/tenants Lista as contas, na visão da plataforma. Onde é usada: tela `/impersonate`. Efeitos: uma leitura no core. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/tenants' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/admin/tenants/{id} Detalhe de uma conta, na visão da plataforma. Onde é usada: console, ao abrir um cliente. Efeitos: uma leitura no core. # Por que não reusar `GET /api/tenants/{id}` Aquela rota exige PERTINÊNCIA: o usuário tem de ser membro da conta. Quem opera a plataforma não é membro de nenhuma conta de cliente — e não deve virar, porque virar membro para poder ver é exatamente o atalho que o ADR-0017 existe para impedir. A autorização aqui é a allowlist de plataforma, e a rota é outra. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/admin/tenants/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### PATCH /api/admin/tenants/{id} Altera uma conta, na visão da plataforma. Onde é usada: console. Efeitos: uma escrita no core. # Isto NÃO é a sessão de suporte A impersonação é somente leitura (ADR-0017) porque entrar na conta é ver o que o cliente vê. Isto é outra coisa: é a plataforma agindo COMO plataforma — suspender uma conta abusiva, por exemplo — e a ação fica no log com o ator. A distinção importa: se a sessão de suporte pudesse escrever, "entrar para ajudar" viraria "entrar para consertar", e o limite que o ADR desenhou desapareceria na prática. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X PATCH 'https://api.asender.net/api/admin/tenants/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/admin/tenants/{id}/enter Abre sessão de suporte na conta (somente leitura, ADR-0017). Onde é usada: tela `/impersonate`. Efeitos: uma escrita no core — e, a partir dela, LEITURA da conta alheia. O motivo é obrigatório: uma trilha sem motivo responde "alguém entrou", que é a metade inútil da pergunta que ela existe para responder. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/admin/tenants/id_AQUI/enter' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/auth/2fa Segunda etapa do login — confirma o código do segundo fator. Onde é usada: tela `/2fa`, para onde o login manda quem tem segundo fator. Efeitos: cria sessão no asender-auth. # Por que esta rota é PÚBLICA Quem chega aqui ainda não tem sessão — é justamente o que ela está tentando obter. Exigir sessão tornaria o 2FA impossível de completar: a armadilha de aplicar a mesma guarda em toda rota "porque é mais seguro". O que protege é o par (user_id, código): o `user_id` sozinho não abre nada, e o código vale 30 segundos. Sem esta rota, o login de quem tem 2FA ficava sem passo seguinte — a conta ficava inacessível pelo painel. Respostas: 200 OK curl -X POST 'https://api.asender.net/api/auth/2fa' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/auth/2fa Estado do segundo fator do usuário da sessão. Onde é usada: tela de segurança do painel. Efeitos: uma leitura no asender-auth. Responde o estado CONFIRMADO, e não "existe segredo": um setup interrompido deixa segredo gravado sem confirmação, e mostrar "2FA ligado" aí faria a pessoa acreditar numa proteção que o login não exige. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/auth/2fa' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/auth/2fa/disable Desliga o segundo fator. Onde é usada: tela de segurança. Efeitos: o login deixa de exigir o segundo fator. Exige a SENHA, e não o código: aceitar o próprio TOTP para removê-lo faria o fator se autorizar sozinho. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/auth/2fa/disable' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/auth/2fa/setup Inicia a ativação do segundo fator. Onde é usada: tela de segurança. Efeitos: grava o segredo PENDENTE de confirmação. # A senha é a invariante do piso 17, e ela para AQUI se faltar Para mexer num fator é preciso apresentar um fator diferente dele. O serviço recusa senha vazia, e o gateway recusa antes — não por desconfiança do serviço, mas porque um corpo sem senha é pedido malformado, e mandá-lo adiante gastaria uma viagem para receber a mesma recusa. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/auth/2fa/setup' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/auth/2fa/verify Confirma a ativação do segundo fator. Onde é usada: tela de segurança, depois de ler o QR. Efeitos: o login passa a EXIGIR o segundo fator. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/auth/2fa/verify' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/auth/login Autentica e devolve o token de sessão. Respostas: 200 Sessão criada, ou 2FA pendente.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 Conta bloqueada ou desabilitada.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X POST 'https://api.asender.net/api/auth/login' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/auth/logout Revoga a sessão do Bearer apresentado. IDEMPOTENTE. Onde é usada: chamado pelas rotas `/api/auth/logout` dos DOIS frontends (dashboard e backoffice), que depois apagam o próprio cookie HttpOnly. Entradas: header `Authorization: Bearer `. Saídas: 200 `{Status:"ok"}` com a sessão revogada; 401 sem Bearer; 502 `LogoutFailed` quando o asender-auth não confirmou. Efeitos: marca `auth.sessions.revoked_at` no asender-auth. DEFEITO QUE ISTO FECHA (mesma família do item 17 do _INTEGRACAO-pendente): o handler logava a falha em nível Warn e respondia **200 `{"Status":"ok"}` assim mesmo. A premissa embutida era "logout é best-effort, o cliente só precisa apagar o cookie" — errada pelo mesmo motivo que a do backoffice: com a revogação por `sid` no asender-auth, é a resposta desta rota que diz se a sessão morreu ou não. Afirmar `ok` sem revogar dá ao chamador (e à tela) a garantia de que a sessão acabou quando ela continua aceita, e ainda apaga o rastro: quem lê 200 não procura o Warn no log. Por que 502 e não 500: a falha é do upstream (asender-auth fora, ou erro de banco na revogação), não desta camada — mesmo tratamento dado a todo erro de upstream do BFF. Falta de credencial continua sendo 401 do próprio handler: esta rota fica FORA do grupo SessionAuth de propósito (ver server.go). Respostas: 200 Revogado, ou já estava — inclusive para um Bearer que nunca foi sessão.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR). curl -X POST 'https://api.asender.net/api/auth/logout' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/auth/me Principal da sessão. Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR). curl -X GET 'https://api.asender.net/api/auth/me' \ -H 'Authorization: Bearer SEU_TOKEN' ### PATCH /api/auth/me Altera o perfil do usuário da sessão. Onde é usada: tela de perfil do painel. Efeitos: escreve no asender-auth; e-mail novo desverifica a conta e dispara a verificação do endereço novo (lá). Respostas: 200 OK; 401 credencial ausente ou inválida curl -X PATCH 'https://api.asender.net/api/auth/me' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/auth/me/emails histórico paginado dos emails do tenant. Era 501; agora lê do asender_messages com o canal fixado em `email`. Onde é usada: API pública do cliente. Fluxo do dado: API key → Principal.TenantID → GET /v1/messages?channel=email. Entradas: `status`, `q`, `cursor`, `limit` (allowlist). `channel` do cliente é IGNORADO: nesta rota o canal é do contrato, não do parâmetro. Saídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada. curl -X GET 'https://api.asender.net/api/auth/me/emails' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/auth/me/emails Add: POST /api/auth/me/emails. curl -X POST 'https://api.asender.net/api/auth/me/emails' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /api/auth/me/emails/{id} Remove: DELETE /api/auth/me/emails/{id}. curl -X DELETE 'https://api.asender.net/api/auth/me/emails/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/auth/me/emails/{id}/primary Primary: POST /api/auth/me/emails/{id}/primary. curl -X POST 'https://api.asender.net/api/auth/me/emails/id_AQUI/primary' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/auth/me/emails/{id}/verify/resend Resend: POST /api/auth/me/emails/{id}/verify/resend. curl -X POST 'https://api.asender.net/api/auth/me/emails/id_AQUI/verify/resend' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/auth/me/verify/resend Reenvia o e-mail de verificação do usuário da sessão. Onde é usada: aviso "confirme seu e-mail" do painel. Efeitos: um e-mail (o guard do auth decide se ele sai fora de prd). O 429 do upstream é REPASSADO como 429: quem pediu demais precisa saber que basta esperar, e um 502 aqui mandaria a pessoa procurar defeito onde não há. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/auth/me/verify/resend' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/auth/register Cria usuário e (best-effort) o tenant raiz dele. Respostas: 201 Usuário criado.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 409 `409 Conflict` — email já registrado, slug em uso.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X POST 'https://api.asender.net/api/auth/register' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/auth/sessions Dispositivos e sessões ativas do usuário. Onde é usada: tela de segurança do painel. Efeitos: uma leitura no asender-auth. O token repassado é o da REQUISIÇÃO, e é ele que define de quem é a lista. Não existe parâmetro de usuário: um `?user_id=` seria a rota mais barata do sistema para descobrir de onde outra pessoa se conecta. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/auth/sessions' \ -H 'Authorization: Bearer SEU_TOKEN' ### DELETE /api/auth/sessions/{id} Revoga uma sessão específica. Onde é usada: botão "remover" da tela de segurança. Efeitos: a sessão para de autenticar imediatamente. # Por que a impersonação NÃO passa por aqui A sessão de suporte é somente leitura (ADR-0017), e o middleware de impersonação já recusa escrita. Isto é uma escrita — e derrubar o dispositivo de um cliente durante uma sessão de suporte é exatamente o poder que o ADR tira de quem entra na conta alheia. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X DELETE 'https://api.asender.net/api/auth/sessions/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/auth/verify Confirma o e-mail a partir do token do link. Onde é usada: tela `/verify`, com o token da URL. Efeitos: carimba `email_verified_at` no auth. # Também é PÚBLICA Quem abre o link do e-mail pode estar em outro navegador, ou nem ter sessão. Exigir login para verificar o e-mail cria a dependência circular clássica — e a pessoa que mais precisa verificar é justamente a que ainda não conseguiu entrar. Respostas: 200 OK curl -X POST 'https://api.asender.net/api/auth/verify' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/contacts Audiência do tenant. Onde é usada: tela de audiência do dashboard. Entradas: `q`, `list_id`, `cursor`, `limit` (allowlist). Saídas: 200 com `{Contacts, NextCursor, HasMore}`. Parâmetros: X-Asender-Tenant (header); q (query); list_id (query); cursor (query); limit (query) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X GET 'https://api.asender.net/api/contacts' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/contacts Cria um contato. Onde é usada: formulário de novo contato da tela de audiência. Saídas: 201 com `{Contact}`; 422 em validação (email/telefone inválidos). Efeitos: escreve em messages.contacts. Parâmetros: X-Asender-Tenant (header) Respostas: 201 Criado.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 413 `413 PayloadTooLarge` — corpo acima de 2 MiB.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X POST 'https://api.asender.net/api/contacts' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/contacts/bulk Importa até 5000 contatos, com upsert pela identidade natural. Onde é usada: importador CSV da tela de audiência. Saídas: 200 com `{Created, Updated, Errors}`; 422 quando a lista vem vazia ou acima do teto. Efeitos: escreve em messages.contacts. Parâmetros: X-Asender-Tenant (header) Respostas: 200 Lote processado.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 413 `413 PayloadTooLarge` — corpo acima de 2 MiB.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X POST 'https://api.asender.net/api/contacts/bulk' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/invitations/accept Aceita um convite e vincula o usuário à conta. Onde é usada: tela de convite. Efeitos: uma escrita no core. # Esta rota NÃO passa por contaAutorizada E não pode: quem aceita ainda NÃO pertence à conta — exigir pertinência aqui tornaria o convite impossível de aceitar. Quem autoriza é o TOKEN, que o core valida (existente, não expirado, não usado), e o usuário vem da sessão. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/invitations/accept' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/lists Listas de contatos com contagem de membros. Onde é usada: tela de audiência. Saídas: 200 com `{Lists}`. Parâmetros: X-Asender-Tenant (header); cursor (query); limit (query) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X GET 'https://api.asender.net/api/lists' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/lists Cria uma lista de contatos. Onde é usada: tela de audiência. Saídas: 201 com `{List}`; 422 em validação. Efeitos: escreve em messages.contact_lists. Parâmetros: X-Asender-Tenant (header) Respostas: 201 Criada (ou atualizada pelo slug).; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X POST 'https://api.asender.net/api/lists' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /api/lists/{id}/members Anexa contatos a uma lista. Onde é usada: tela de audiência (seleção múltipla). Saídas: 200 com `{Added}`; 404 se a lista não é do tenant; 422 se a lista de ids vem vazia. Efeitos: escreve em messages.contact_list_members. Parâmetros: X-Asender-Tenant (header); id (path, obrigatório) Respostas: 200 OK; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X POST 'https://api.asender.net/api/lists/id_AQUI/members' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/messages Histórico de envios do tenant corrente. Onde é usada: tela de histórico do dashboard. Entradas: Channel/Status/Q/Cursor/Limit são aceitos em snake_case (`channel`, `status`, `q`, `cursor`, `limit`) e repassados por allowlist — nenhum outro parâmetro atravessa. Saídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada. Parâmetros: X-Asender-Tenant (header); channel (query); status (query); q (query); cursor (query); limit (query) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X GET 'https://api.asender.net/api/messages' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/messages/{id} Detalhe do envio com timeline. Onde é usada: tela de detalhe do envio. Saídas: 200 com `{Message, Events}`; 404 quando o id não existe NO TENANT — mensagem de outro tenant também é 404, para não confirmar existência (§22.8). Parâmetros: X-Asender-Tenant (header); id (path, obrigatório) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X GET 'https://api.asender.net/api/messages/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/messages/send Composer do dashboard — envia em qualquer canal. Onde é usada: composer da tela de envio do dashboard. Fluxo do dado: cookie de sessão → SessionAuth → Principal → tenant resolvido no core → POST /v1/messages no asender_messages → outbox → NATS → worker. Saídas: 202 com `{Messages, ReusedIdempotency}`; 422 quando o asender_messages recusa a validação (canal inválido, destinatário vazio, corpo ausente). Efeitos: escreve mensagens no banco de mensageria. Parâmetros: X-Asender-Tenant (header) Respostas: 202 Enfileirado.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 413 `413 PayloadTooLarge` — corpo acima de 2 MiB.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X POST 'https://api.asender.net/api/messages/send' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/push/devices Devices de push do tenant, para o seletor da tela de disparo. Onde é usada: dashboard, tela `/send/push`. Fluxo do dado: `messages.push_devices` → asender_messages → aqui → seletor. Entradas: `cursor` e `limit`, na mesma allowlist dos outros List. Saídas: 200 com `{Devices, NextCursor, HasMore}` (PascalCase, §51). Antes desta rota o caminho respondia 404 e a tela não tinha como listar: dava para disparar digitando o token à mão, mas não escolher um device existente. Parâmetros: X-Asender-Tenant (header); cursor (query); limit (query) Respostas: 200 Página de devices, em PascalCase.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 422 curl -X GET 'https://api.asender.net/api/push/devices' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/push/site-config Pacote de integração de Web Push para o site do cliente. Onde é usada: tela de push do dashboard (botão de copiar/baixar cada arquivo). Fluxo do dado: core.vapid_keys → aqui → tela → arquivos no site do cliente → browser → `POST /v1/push/devices` → messages.push_devices. Saídas: 200 com `{PublicKey, ApiBase, Manifest, ServiceWorker, Snippet}`. Efeitos: pode criar o par VAPID do tenant na primeira chamada (no core). ## Por que o servidor gera, em vez de documentar Os três arquivos dependem de dois valores que variam por instalação: a chave pública do tenant e a base da API. Documentação com `` produz exatamente um tipo de chamado — o do integrador que esqueceu de substituir, e cujo sintoma é "não chega notificação", sem erro em lugar nenhum. Gerando aqui, o que o cliente cola já está correto. Parâmetros: X-Asender-Tenant (header) Respostas: 200 Artefatos prontos para o site.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR). curl -X GET 'https://api.asender.net/api/push/site-config' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/reports/overview Funil do período com quebra por canal. Onde é usada: dashboard de relatórios (cards do topo). Fluxo do dado: sessão → tenant resolvido → asender_messages GET /v1/reports/overview → PascalCase. Entradas: `from`/`to` em ISO-8601 UTC; ausentes = últimos 30 dias (default resolvido pelo asender_messages, não duplicado aqui). Saídas: 200 com `{Totals, Rates, ByChannel}` — as chaves de ByChannel seguem sendo `email`/`sms`/`push`, porque ali são dado e não nome de campo. Parâmetros: X-Asender-Tenant (header); from (query); to (query) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X GET 'https://api.asender.net/api/reports/overview' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/reports/timeseries Série temporal por bucket e canal. Onde é usada: dashboard de relatórios (gráfico principal). Entradas: `from`, `to`, `channel` (email|sms|push), `interval` (hour|day|week|month; default day). Valor fora da allowlist é 422 — filtro descartado em silêncio mente para quem consulta. Saídas: 200 com `{Points:[...]}`. Parâmetros: X-Asender-Tenant (header); from (query); to (query); channel (query); interval (query) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X GET 'https://api.asender.net/api/reports/timeseries' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/reports/top-templates Ranking de templates por volume, com taxas. Onde é usada: dashboard de relatórios (tabela lateral). Entradas: `from`, `to`, `limit` (1..100). Saídas: 200 com `{Templates:[...]}`. Parâmetros: X-Asender-Tenant (header); from (query); to (query); limit (query) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X GET 'https://api.asender.net/api/reports/top-templates' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/templates Templates do tenant. Onde é usada: dashboard. Saídas: 200 com `{Templates}`. Parâmetros: X-Asender-Tenant (header); limit (query) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X GET 'https://api.asender.net/api/templates' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/templates Cria ou atualiza um template pelo par (tenant, slug). Onde é usada: editor de templates do dashboard. Saídas: 200 com `{Template}`; 422 em validação do asender_messages. Efeitos: escreve em messages.templates. Parâmetros: X-Asender-Tenant (header) Respostas: 200 Gravado (upsert — 200 tanto na criação quanto na atualização).; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 413 `413 PayloadTooLarge` — corpo acima de 2 MiB.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X POST 'https://api.asender.net/api/templates' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/tenants Vínculos de tenant do usuário logado. Onde é usada: API pública do cliente. Fluxo do dado: API key → Principal.TenantID → GET /v1/messages?channel=email. Entradas: `status`, `q`, `cursor`, `limit` (allowlist). `channel` do cliente é IGNORADO: nesta rota o canal é do contrato, não do parâmetro. Saídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada. Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X GET 'https://api.asender.net/api/tenants' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/tenants Cria um tenant RAIZ pertencente ao usuário logado. Onde é usada: fluxo de cadastro (AuthHandler.Register faz o equivalente internamente) e recuperação manual quando o provisionamento automático falhou. Entradas: `{Name, Slug}` — allowlist explícita, ver tenantCreateInput. Saídas: 201 com `{Tenant:{Id,...}}` em PascalCase; 400 JSON inválido OU campo fora da allowlist; 422 nome vazio; o status 4xx do core preservado; 502 quando o core está fora. Efeitos: escreve no asender-core. O `owner_user_id` vem SEMPRE do principal verificado, nunca do corpo. Respostas: 201 Tenant criado. **O objeto vem NO TOPO, sem a chave `Tenant`** — o `asender-core` responde o tenant sem chave de recurso nesta rota e o BFF só pascaliza o que recebeu. É inconsistente com `POST /api/tenants/{id}/children` e com `POST /api/auth/register`, que devolvem `{"Tenant":{...}}`. Documentado como está no ar.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 413 `413 PayloadTooLarge` — corpo acima de 2 MiB.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X POST 'https://api.asender.net/api/tenants' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/tenants/{id}/api-keys ListKeys lista as chaves da conta. Onde é usada: GET /api/tenants/{id}/api-keys. curl -X GET 'https://api.asender.net/api/tenants/id_AQUI/api-keys' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/tenants/{id}/api-keys CreateKey emite uma chave. O texto puro volta UMA vez, no corpo do core. Onde é usada: POST /api/tenants/{id}/api-keys. curl -X POST 'https://api.asender.net/api/tenants/id_AQUI/api-keys' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /api/tenants/{id}/api-keys/{keyId} RevokeKey revoga uma chave. Onde é usada: DELETE /api/tenants/{id}/api-keys/{keyId}. curl -X DELETE 'https://api.asender.net/api/tenants/id_AQUI/api-keys/keyId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/tenants/{id}/children Cria uma sub-conta sob um tenant administrado pelo usuário. Onde é usada: tela de hierarquia de tenants. Entradas: `{Name, Slug, MonthlyQuota}` — MonthlyQuota nulo/ausente significa herdar do ancestral mais próximo com valor. Saídas: 201 com `{Tenant}`; 404 se `{id}` não é acessível; 422 quando o core recusa (slug duplicado, ciclo detectado pelo trigger de closure). Efeitos: escreve no asender-core. Parâmetros: id (path, obrigatório) Respostas: 201 Sub-conta criada.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 409 `409 Conflict` — email já registrado, slug em uso.; 413 `413 PayloadTooLarge` — corpo acima de 2 MiB.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X POST 'https://api.asender.net/api/tenants/id_AQUI/children' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/tenants/{id}/impersonations Trilha de sessões de suporte na conta. Onde é usada: tela de segurança da conta — do CLIENTE. Efeitos: uma leitura no core. # Esta rota NÃO exige plataforma Ela exige PERTINÊNCIA, como qualquer leitura de conta. Uma trilha que só a plataforma consegue ler serve para a plataforma se defender, não para o cliente se proteger (ADR-0017). Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/tenants/id_AQUI/impersonations' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/tenants/{id}/invitations Convites pendentes da conta. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/tenants/id_AQUI/invitations' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/tenants/{id}/invitations Convida alguém para a conta. Onde é usada: tela de equipe. Efeitos: uma escrita no core (e, quando houver envio, um e-mail). Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/tenants/id_AQUI/invitations' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /api/tenants/{id}/invitations/{token} Cancela um convite pendente. Onde é usada: tela de equipe. Efeitos: uma escrita no core. Parâmetros: id (path, obrigatório); token (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X DELETE 'https://api.asender.net/api/tenants/id_AQUI/invitations/token_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /api/tenants/{id}/members Membros da conta. Onde é usada: tela de equipe. Efeitos: uma leitura no core. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://api.asender.net/api/tenants/id_AQUI/members' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /api/tenants/{id}/members Vincula um usuário à conta. Onde é usada: tela de equipe. Efeitos: uma escrita no core. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://api.asender.net/api/tenants/id_AQUI/members' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /api/tenants/{id}/members/{userId} Desvincula um usuário da conta. Onde é usada: tela de equipe. Efeitos: uma escrita no core. Parâmetros: id (path, obrigatório); userId (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X DELETE 'https://api.asender.net/api/tenants/id_AQUI/members/userId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### PATCH /api/tenants/{id}/parent Move o tenant para outro pai (ou para a raiz). Onde é usada: tela de hierarquia de tenants. Segurança: autoriza os DOIS lados. Autorizar só `{id}` deixaria um usuário pendurar o tenant dele sob a árvore de outro cliente, o que é escalada de escopo — o novo pai passaria a "ver" a subárvore em rollup. Saídas: 200 com `{Tenant}`; 404 se qualquer um dos lados não é acessível; 422 quando o movimento cria ciclo (o trigger do core rejeita com check_violation). Efeitos: reescreve a closure de tenants no core. Parâmetros: id (path, obrigatório) Respostas: 200 Movido; a closure foi reescrita.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X PATCH 'https://api.asender.net/api/tenants/id_AQUI/parent' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PATCH /api/tenants/{id}/quota Define ou limpa a quota mensal do tenant. Onde é usada: tela de hierarquia de tenants. Entradas: `{MonthlyQuota}` — número define o teto; `null` volta a herdar do ancestral. A chave é OBRIGATÓRIA: aceitar corpo sem ela faria um nome de campo digitado errado limpar a quota em silêncio. Saídas: 200 com `{Tenant}`; 404 se `{id}` não é acessível; 422 em valor inválido. Efeitos: escreve no asender-core. Parâmetros: id (path, obrigatório) Respostas: 200 Quota gravada; `EffectiveQuota` já recalculada.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada. curl -X PATCH 'https://api.asender.net/api/tenants/id_AQUI/quota' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /api/tenants/tree Subárvore do tenant corrente, em pré-ordem. Onde é usada: dashboard (settings/tenants) e backoffice. Fluxo do dado: sessão → tenant resolvido → asender-core GET /v1/tenants/{id}/tree → PascalCase. Entradas: `depth` — inteiro positivo; valor não numérico é 422, não "sem limite" silencioso. Saídas: 200 com `{TenantId, Tree:[...]}`; 404 se o tenant pedido não é acessível. Parâmetros: X-Asender-Tenant (header); depth (query) Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 NoTenant` — sessão válida de um usuário sem NENHUM vínculo de tenant.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X GET 'https://api.asender.net/api/tenants/tree' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /healthz Liveness. Não toca dependência. Respostas: 200 Processo vivo. curl -X GET 'https://api.asender.net/healthz' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /metrics Métricas Prometheus. Respostas: 200 Texto no formato de exposição do Prometheus. curl -X GET 'https://api.asender.net/metrics' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /readyz Readiness. Respostas: 200 Pronto (possivelmente degradado). curl -X GET 'https://api.asender.net/readyz' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/account Conta da API key usada, mais o contexto da própria chave. Respostas: 200 OK; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X GET 'https://api.asender.net/v1/account' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/account/usage Contadores de consumo do tenant no período corrente. Onde é usada: superfície pública `/v1`, consumida por integrações de cliente. Fluxo do dado: API key verificada → Principal.TenantID → asender-core GET /v1/tenants/{id}/usage (snake_case) → PascalMap → resposta pública. Saídas: 200 com `{Usage:{Period,EmailsSent,SmsSent,PushSent,ApiCalls}}`; 404 se o tenant da API key não existe mais no core; 502 se o core está fora. DEFEITO QUE ISTO TAMBÉM FECHA (item 7 do briefing Y2, metade da borda): o core respondia 404 quando não havia linha em `core.usage_counters` — e o seed não cria nenhuma —, e este handler traduzia QUALQUER erro do upstream em 502. Ou seja: a rota respondia "serviço indisponível" para todo tenant que ainda não enviou nada, mandando o alerta para o time errado. O 404 do core agora só significa "tenant inexistente" (a correção principal está em asender-core/internal/service/usage.Get) e é traduzido como 404, não 502. DEFEITO QUE ISTO FECHA (T3 §3.6): o payload do core saía CRU sob `Usage` (`{"Usage":{"sent":42}}`), fora do contrato PascalCase da API pública (API_DESIGN.md §51). O cliente que segue o contrato lê `Usage.Sent` e recebe `undefined` — mesma classe de divergência de fronteira do GET /api/tenants. Respostas: 200 Consumo do período. Tenant sem nenhum envio devolve os contadores em zero, com `UpdatedAt: null` — ausência de linha é consumo zero, não ausência de recurso.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`.; 502 Core fora **ou** tenant sem contador de uso (ver `x-asender-divergence`). curl -X GET 'https://api.asender.net/v1/account/usage' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/emails Histórico de emails do tenant da API key. Onde é usada: API pública do cliente. Fluxo do dado: API key → Principal.TenantID → GET /v1/messages?channel=email. Entradas: `status`, `q`, `cursor`, `limit` (allowlist). `channel` do cliente é IGNORADO: nesta rota o canal é do contrato, não do parâmetro. Saídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada. Parâmetros: status (query); q (query); cursor (query); limit (query) Respostas: 200 Página de mensagens.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X GET 'https://api.asender.net/v1/emails' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/emails/{id} Um email com corpo, metadata e timeline de eventos. Parâmetros: id (path, obrigatório) Respostas: 200 Detalhe.; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 404 `404 NotFound`. **Cobre dois casos de propósito:** o recurso não existe, ou existe e não é seu. Diferenciar seria oráculo de enumeração.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`. curl -X GET 'https://api.asender.net/v1/emails/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/emails/batch Até 500 emails independentes numa chamada. Onde é usada: API pública do cliente. Fluxo do dado: igual ao Send, uma chamada ao asender_messages por item. Saídas: 202 com `{Results, Count}`; cada item traz MessageId ou Error. Efeitos: escrita no serviço de mensageria por item aceito. Respostas: 202 Lote processado (com ou sem itens rejeitados).; 400 `400 ValidationError` — JSON inválido, campo desconhecido ou tipo errado.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`. curl -X POST 'https://api.asender.net/v1/emails/batch' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/emails/send Enfileira um email (payload compatível com SES SendEmail). Onde é usada: API pública do cliente (autenticada por API key). Fluxo do dado: API key → APIKeyAuth → Principal.TenantID → POST /v1/messages (header X-Asender-Tenant) → messages + outbox no mesmo commit → NATS → worker. Saídas: 202 com `{MessageId, MessageIds, Status, ReusedIdempotency}`; 400 em validação local; 422 quando o asender_messages recusa; 502 se ele está fora. Efeitos: escrita no serviço de mensageria. Idempotente por IdempotencyKey. Parâmetros: Idempotency-Key (header) Respostas: 202 Aceito e persistido; a entrega é assíncrona.; 400 `400 ValidationError` com `Error.Details` mapeando campo → motivo.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X POST 'https://api.asender.net/v1/emails/send' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/push/devices Registra (upsert por token) um device de push. Onde é usada: SDK do cliente, na primeira abertura do app / renovação do token. Fluxo do dado: API key → Principal.TenantID → POST /v1/push/devices no asender_messages → messages.push_devices. Saídas: 200 com `{Device}` (upsert: o mesmo token duas vezes não cria dois registros); 400 em validação local; 422 quando o upstream recusa a plataforma. Efeitos: escrita no serviço de mensageria. Respostas: 200 Device registrado ou reativado.; 400 `400 ValidationError` com `Error.Details` mapeando campo → motivo.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`. curl -X POST 'https://api.asender.net/v1/push/devices' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/push/devices Lista os devices de push da conta da API key. Onde é usada: dashboard, tela `/send/push`. Fluxo do dado: `messages.push_devices` → asender_messages → aqui → seletor. Entradas: `cursor` e `limit`, na mesma allowlist dos outros List. Saídas: 200 com `{Devices, NextCursor, HasMore}` (PascalCase, §51). Antes desta rota o caminho respondia 404 e a tela não tinha como listar: dava para disparar digitando o token à mão, mas não escolher um device existente. Parâmetros: cursor (query); limit (query) Respostas: 200 Página de devices, em PascalCase.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 422 curl -X GET 'https://api.asender.net/v1/push/devices' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/push/send Enfileira um push para tokens ou para um tópico. Onde é usada: API pública do cliente (autenticada por API key). Fluxo do dado: API key → APIKeyAuth → Principal.TenantID → POST /v1/messages (header X-Asender-Tenant) → messages + outbox no mesmo commit → NATS → worker. Saídas: 202 com `{MessageId, MessageIds, Status, ReusedIdempotency}`; 400 em validação local; 422 quando o asender_messages recusa; 502 se ele está fora. Efeitos: escrita no serviço de mensageria. Idempotente por IdempotencyKey. Parâmetros: Idempotency-Key (header) Respostas: 202 Aceito e persistido; a entrega é assíncrona.; 400 `400 ValidationError` com `Error.Details` mapeando campo → motivo.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X POST 'https://api.asender.net/v1/push/send' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/sms/send Enfileira um SMS. Onde é usada: API pública do cliente (autenticada por API key). Fluxo do dado: API key → APIKeyAuth → Principal.TenantID → POST /v1/messages (header X-Asender-Tenant) → messages + outbox no mesmo commit → NATS → worker. Saídas: 202 com `{MessageId, MessageIds, Status, ReusedIdempotency}`; 400 em validação local; 422 quando o asender_messages recusa; 502 se ele está fora. Efeitos: escrita no serviço de mensageria. Idempotente por IdempotencyKey. Parâmetros: Idempotency-Key (header) Respostas: 202 Aceito e persistido; a entrega é assíncrona.; 400 `400 ValidationError` com `Error.Details` mapeando campo → motivo.; 401 `401 AuthorizationError` — credencial ausente, malformada, inválida ou expirada. **Também** quando o upstream recusa o SERVICE_SECRET (erro de configuração, logado em ERROR).; 403 `403 TenantSuspended` — a API key é válida mas o tenant não está `active`. Fail closed: status vazio também barra.; 422 `422 ValidationError` — semântica inválida: parâmetro de query fora da allowlist (nomeando o parâmetro), `from > to`, `depth` negativa, corpo de PATCH sem o campo obrigatório, ou recusa do upstream repassada.; 429 `429 Throttling` — rate limit por API key/usuário. Acompanha `Retry-After`.; 502 `502 InternalError` — upstream inalcançável ou 5xx. O motivo real fica no log com `RequestId`; o cliente recebe mensagem genérica. curl -X POST 'https://api.asender.net/v1/sms/send' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /version Versão e commit do binário em execução. Onde é usada: rota pública, usada por ops e por quem investiga incidente. Pública porque a primeira pergunta de todo incidente é "que versão está no ar?", e ela tem de ser respondível sem credencial — quem investiga muitas vezes ainda não tem uma. O que sai é só isso: nunca configuração, nunca endereço interno, que é topologia e pertence ao console de plataforma. Respostas: 200 OK curl -X GET 'https://api.asender.net/version' \ -H 'Authorization: Bearer SEU_TOKEN' ============================================================================== # Identidade (OIDC / OAuth 2.1) (https://auth.asender.net) # Superfície PÚBLICA. ============================================================================== ### GET / Identidade do serviço. Onde é usada: rota raiz. Efeitos: escreve a resposta. Serve para saber QUAL binário está rodando quando o comportamento diverge do esperado — a primeira pergunta de todo diagnóstico de deploy. Respostas: 200 OK curl -X GET 'https://auth.asender.net/' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /.well-known/jwks.json JWKS serve /.well-known/jwks.json — a(s) chave(s) pública(s) RS256. Os clients Onde é usada: verificação no client. curl -X GET 'https://auth.asender.net/.well-known/jwks.json' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /.well-known/openid-configuration anuncia os endpoints e as capacidades (só `code`, só S256, só RS256 — o perfil OAuth2.1 da casa). Onde é usada: os clients leem isto no bootstrap para se autoconfigurar. Cacheável (muda só em deploy). curl -X GET 'https://auth.asender.net/.well-known/openid-configuration' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /authorize Authorize implementa GET /authorize (OAuth2.1 authorization code + PKCE). Onde é usada: o browser é redirecionado para cá pelo client (app) que quer logar. curl -X GET 'https://auth.asender.net/authorize' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /healthz Liveness. Onde é usada: liveness probe. Efeitos: escreve a resposta. Não consulta o banco de propósito: liveness que falha por causa do Postgres faz o orquestrador REINICIAR um serviço saudável durante uma instabilidade do banco — e reiniciar o serviço de login em massa transforma degradação em queda de autenticação para todo mundo. Respostas: 200 OK curl -X GET 'https://auth.asender.net/healthz' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /logout Expects Authorization: Bearer . curl -X GET 'https://auth.asender.net/logout' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /metrics Métricas Prometheus. Respostas: 200 Texto. curl -X GET 'https://auth.asender.net/metrics' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /readyz Readiness — ping no Postgres. Onde é usada: readiness probe. Efeitos: uma consulta trivial ao Postgres. É aqui que a dependência entra — o oposto do `/healthz`. O prazo curto é deliberado: uma sonda que espera indefinidamente nunca reporta "não pronto", e a instância continua recebendo login que vai falhar. Respostas: 200 Banco respondeu.; 503 `NotReady` — o erro do ping vai no `Message`. curl -X GET 'https://auth.asender.net/readyz' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /token Token implementa POST /token. Despacha por grant_type. Onde é usada: o client troca aqui, servidor-a-servidor (ou pelo BFF), o code/refresh por tokens. curl -X POST 'https://auth.asender.net/token' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /userinfo valida o Bearer (RS256, iss, exp) e devolve sub/email/name. Onde é usada: o client chama para hidratar o perfil. Sem token válido -> 401 invalid_token. curl -X GET 'https://auth.asender.net/userinfo' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/2fa/check Segunda etapa do login — valida o código e EMITE a sessão. Respostas: 200 Código válido; sessão emitida.; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `401 TwoFactorFailed` — código TOTP inválido.; 404 `404 NotFound`. curl -X POST 'https://auth.asender.net/v1/2fa/check' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/2fa/disable Desliga o TOTP do usuário. Respostas: 200 Desligado.; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `401` — token de serviço ausente ou inválido. **Não ocorre com `ENV=development`.**; 404 `404 NotFound`. curl -X POST 'https://auth.asender.net/v1/2fa/disable' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/2fa/setup Gera o segredo TOTP e a `otpauth://` URL. Respostas: 200 Segredo gerado.; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `401` — token de serviço ausente ou inválido. **Não ocorre com `ENV=development`.**; 404 `404 NotFound`. curl -X POST 'https://auth.asender.net/v1/2fa/setup' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/2fa/verify Confirma o setup do TOTP. Respostas: 200 Confirmado.; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `401 TwoFactorFailed` — código TOTP inválido.; 404 `404 NotFound`. curl -X POST 'https://auth.asender.net/v1/2fa/verify' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/auth/login Autentica e emite sessão. Respostas: 200 Sessão emitida, ou 2FA pendente.; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `401 InvalidCredentials` — credenciais inválidas.; 500 `500 InternalError`. **DIVERGÊNCIA:** o `Message` recebe `err.Error()` CRU, como no `asender-core`. Pode vazar estrutura interna. curl -X POST 'https://auth.asender.net/v1/auth/login' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/auth/logout Revoga a sessão do Bearer apresentado. Respostas: 200 Revogada.; 401 `401` — `MissingToken` (sem `Authorization: Bearer`), `InvalidToken` (assinatura/formato) ou `ExpiredToken` (expirada **ou revogada por logout**). curl -X POST 'https://auth.asender.net/v1/auth/logout' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/auth/password-reset Solicita o token de redefinição de senha. Respostas: 200 Sempre `ok:true`; `reset_token` presente quando um token foi gerado.; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `401` — token de serviço ausente ou inválido. **Não ocorre com `ENV=development`.**; 500 `500 InternalError`. **DIVERGÊNCIA:** o `Message` recebe `err.Error()` CRU, como no `asender-core`. Pode vazar estrutura interna. curl -X POST 'https://auth.asender.net/v1/auth/password-reset' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/auth/password-reset/confirm Redefine a senha com o token emitido. Respostas: 200 Senha alterada.; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `InvalidToken` ou `ExpiredToken`. curl -X POST 'https://auth.asender.net/v1/auth/password-reset/confirm' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/auth/sessions Sessões ativas do usuário. Onde é usada: tela de segurança do painel, através do BFF. Efeitos: uma leitura. Não recebe usuário por parâmetro: a lista é sempre a do dono do token. Um `?user_id=` seria a rota mais barata do sistema para descobrir de onde outra pessoa se conecta. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X GET 'https://auth.asender.net/v1/auth/sessions' \ -H 'Authorization: Bearer SEU_TOKEN' ### DELETE /v1/auth/sessions/{id} Revoga uma sessão específica. Onde é usada: botão "remover" da tela de segurança. Efeitos: a sessão para de autenticar imediatamente. 404 tanto para sessão inexistente quanto para sessão de OUTRA pessoa: a distinção transformaria a rota num oráculo de id de sessão alheia. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X DELETE 'https://auth.asender.net/v1/auth/sessions/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/auth/validate Resolve um token de sessão no usuário dono. Respostas: 200 Sessão válida.; 401 `401` — `MissingToken` (sem `Authorization: Bearer`), `InvalidToken` (assinatura/formato) ou `ExpiredToken` (expirada **ou revogada por logout**). curl -X GET 'https://auth.asender.net/v1/auth/validate' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/auth/verify Confirma o e-mail a partir do token do link. Onde é usada: tela `/verify` do front, com o token da URL. Efeitos: duas escritas. Token inválido, expirado e já usado respondem IGUAL: distingui-los diria a quem tem um link velho se ele já foi usado por outra pessoa. Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://auth.asender.net/v1/auth/verify' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/authz/decide `{permitido, motivo, papel, nivel}`. Onde é usada: POST /v1/authz/decide. Efeitos: grava a trilha (dentro do PDP). Erro de banco vira 503, e não `{permitido:false}`: o PEP precisa distinguir "a política diz não" de "não consegui perguntar" — o primeiro ele mostra ao usuário, o segundo ele resolve com o cache que já tem (piso 10). curl -X POST 'https://auth.asender.net/v1/authz/decide' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/authz/eu `{produtos:[{slug,nome,subdominio,papel,nivel}]}`. Onde é usada: GET /v1/authz/eu?sub=&tenant= — alimenta o switcher e a home do hub. curl -X GET 'https://auth.asender.net/v1/authz/eu' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/authz/produtos Catalogo devolve as ferramentas que existem. Onde é usada: GET /v1/authz/produtos. curl -X GET 'https://auth.asender.net/v1/authz/produtos' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/tenants/{id}/acessos ListarAcessos devolve quem acessa o quê na conta. Onde é usada: GET /v1/tenants/{id}/acessos. curl -X GET 'https://auth.asender.net/v1/tenants/id_AQUI/acessos' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/acessos Conceder dá acesso de uma pessoa a uma ferramenta da conta. Onde é usada: POST /v1/tenants/{id}/acessos {user_id, produto, papel}. Papel que o produto não declara é recusado pela FK — 422 com a mensagem, e não 500: "papel inventado" é erro de quem chamou, não do servidor. curl -X POST 'https://auth.asender.net/v1/tenants/id_AQUI/acessos' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/tenants/{id}/acessos/{userId}/{produto} Revogar tira o acesso. Onde é usada: DELETE /v1/tenants/{id}/acessos/{userId}/{produto}. Idempotente: revogar duas vezes responde ok nas duas. O efeito chega às ferramentas em até 60s (TTL do cache do PEP) — contrato escrito no ADR-0025. curl -X DELETE 'https://auth.asender.net/v1/tenants/id_AQUI/acessos/userId_AQUI/produto_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/tenants/{id}/authz/decisoes Trilha devolve as decisões recentes da conta. Onde é usada: GET /v1/tenants/{id}/authz/decisoes?negadas=1&limite=50. curl -X GET 'https://auth.asender.net/v1/tenants/id_AQUI/authz/decisoes' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/tenants/{id}/produtos ListarAssinatura devolve o que a conta assina. Onde é usada: GET /v1/tenants/{id}/produtos. curl -X GET 'https://auth.asender.net/v1/tenants/id_AQUI/produtos' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/produtos Assinar liga ou suspende um produto na conta. Onde é usada: POST /v1/tenants/{id}/produtos {produto, status}. `status` é validado aqui E no CHECK da tabela: a borda dá a mensagem, o banco dá a garantia — a borda pode ser contornada por outro caminho de escrita. curl -X POST 'https://auth.asender.net/v1/tenants/id_AQUI/produtos' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/users Cria um usuário. Respostas: 201 Criado.; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `401` — token de serviço ausente ou inválido. **Não ocorre com `ENV=development`.**; 409 `409 Conflict` — email já cadastrado.; 500 `500 InternalError`. **DIVERGÊNCIA:** o `Message` recebe `err.Error()` CRU, como no `asender-core`. Pode vazar estrutura interna. curl -X POST 'https://auth.asender.net/v1/users' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/users/{id} Um usuário pelo public id. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 `401` — token de serviço ausente ou inválido. **Não ocorre com `ENV=development`.**; 404 `404 NotFound`. curl -X GET 'https://auth.asender.net/v1/users/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### PATCH /v1/users/{id} Altera nome e/ou email. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 400 `400` — `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo vazio, corpo acima de 1 MiB), `InvalidInput` (validação de domínio) ou `TwoFactorMissing` (2FA não configurado).; 401 `401` — token de serviço ausente ou inválido. **Não ocorre com `ENV=development`.**; 404 `404 NotFound`.; 409 `409 Conflict` — email já cadastrado. curl -X PATCH 'https://auth.asender.net/v1/users/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/users/{id} Remove o usuário. Parâmetros: id (path, obrigatório) Respostas: 204 Removido.; 401 `401` — token de serviço ausente ou inválido. **Não ocorre com `ENV=development`.**; 404 `404 NotFound`. curl -X DELETE 'https://auth.asender.net/v1/users/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/users/{id}/emails Listar devolve os e-mails do usuário. Onde é usada: GET /v1/users/{id}/emails. curl -X GET 'https://auth.asender.net/v1/users/id_AQUI/emails' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/users/{id}/emails Adicionar cria um e-mail secundário (não-verificado) e dispara a verificação. Onde é usada: POST /v1/users/{id}/emails {email}. curl -X POST 'https://auth.asender.net/v1/users/id_AQUI/emails' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/users/{id}/emails/{emailId} Remover apaga um e-mail secundário. Onde é usada: DELETE /v1/users/{id}/emails/{emailId}. curl -X DELETE 'https://auth.asender.net/v1/users/id_AQUI/emails/emailId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/users/{id}/emails/{emailId}/primary DefinirPrimario promove um e-mail verificado a primário. Onde é usada: POST /v1/users/{id}/emails/{emailId}/primary. curl -X POST 'https://auth.asender.net/v1/users/id_AQUI/emails/emailId_AQUI/primary' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/users/{id}/emails/{emailId}/verify/resend Reenviar redispara a verificação de um e-mail. Onde é usada: POST /v1/users/{id}/emails/{emailId}/verify/resend. curl -X POST 'https://auth.asender.net/v1/users/id_AQUI/emails/emailId_AQUI/verify/resend' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/users/{id}/verify/resend Reenvia o e-mail de verificação de um usuário. Onde é usada: POST /v1/users/{id}/emails/{emailId}/verify/resend. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 credencial ausente ou inválida curl -X POST 'https://auth.asender.net/v1/users/id_AQUI/verify/resend' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /version Versão e commit do binário em execução. Onde é usada: rota pública, usada por ops e por quem investiga incidente. Pública porque a primeira pergunta de todo incidente é "que versão está no ar?", e ela tem de ser respondível sem credencial — quem investiga muitas vezes ainda não tem uma. O que sai é só isso: nunca configuração, nunca endereço interno. Respostas: 200 OK curl -X GET 'https://auth.asender.net/version' \ -H 'Authorization: Bearer SEU_TOKEN' ============================================================================== # Core — contas e árvore de tenants (http://asender-core:8002) # Serviço INTERNO — não alcançável de fora. ============================================================================== ### GET / Identidade do serviço. Onde é usada: rota raiz. Efeitos: escreve a resposta. Serve para saber QUAL binário está rodando quando o comportamento diverge do esperado — a primeira pergunta de todo diagnóstico de deploy. Respostas: 200 OK curl -X GET 'http://asender-core:8002/' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /healthz Liveness. Onde é usada: liveness probe. Efeitos: escreve a resposta. Não consulta o banco de propósito: liveness que falha por causa do Postgres faz o orquestrador REINICIAR um serviço saudável durante uma instabilidade do banco — trocando degradação por queda. Respostas: 200 OK curl -X GET 'http://asender-core:8002/healthz' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /impersonations POST /v1/impersonations. Onde é usada: gateway, quando alguém da plataforma pede para entrar numa conta. Efeitos: uma escrita — e, a partir dela, LEITURA da conta alheia. curl -X POST 'http://asender-core:8002/impersonations' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /impersonations/{id} GET /v1/impersonations/{id}. Onde é usada: gateway, a cada requisição que se apresenta como impersonada. Efeitos: uma leitura. curl -X GET 'http://asender-core:8002/impersonations/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /impersonations/{id}/end POST /v1/impersonations/{id}/end. Onde é usada: botão "sair da conta" e logout do suporte. Efeitos: a sessão de suporte para NA HORA. curl -X POST 'http://asender-core:8002/impersonations/id_AQUI/end' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /metrics Métricas Prometheus. Respostas: 200 Texto. curl -X GET 'http://asender-core:8002/metrics' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /platform/admins/{userId} GET /v1/platform/admins/{userId}. Onde é usada: gateway, antes de mostrar ou aceitar qualquer rota de plataforma. Efeitos: uma leitura. 200 com `{"platform": false}` em vez de 404: quem pergunta é serviço, não usuário, e um 404 aqui obrigaria o chamador a distinguir "não é plataforma" de "a rota sumiu" — que é como um deny-default vira um allow por engano. curl -X GET 'http://asender-core:8002/platform/admins/userId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /platform/alerts GET /v1/platform/alerts. Onde é usada: console de plataforma. Efeitos: uma leitura. curl -X GET 'http://asender-core:8002/platform/alerts' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /platform/alerts POST /v1/platform/alerts. Onde é usada: console de plataforma. Efeitos: uma escrita. curl -X POST 'http://asender-core:8002/platform/alerts' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PUT /platform/alerts/{id} PUT /v1/platform/alerts/{id}. Onde é usada: console de plataforma. Efeitos: uma escrita. `ativo` ausente PRESERVA o valor: quem renomeia não quer ligar nem desligar, e um default implícito faria a renomeação mudar o comportamento em silêncio. curl -X PUT 'http://asender-core:8002/platform/alerts/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /platform/alerts/{id} DELETE /v1/platform/alerts/{id}. Onde é usada: console de plataforma. Efeitos: uma escrita — os EVENTOS ficam. curl -X DELETE 'http://asender-core:8002/platform/alerts/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /platform/alerts/events GET /v1/platform/alerts/events?limite=N. Onde é usada: console de plataforma. Efeitos: uma leitura. curl -X GET 'http://asender-core:8002/platform/alerts/events' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /platform/blacklist GET /v1/platform/blacklist?tipo=. Onde é usada: console de plataforma. Efeitos: uma leitura. curl -X GET 'http://asender-core:8002/platform/blacklist' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /platform/blacklist POST /v1/platform/blacklist. Onde é usada: console de plataforma. Efeitos: uma escrita — o valor deixa de ser aceito em TODAS as contas. curl -X POST 'http://asender-core:8002/platform/blacklist' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /platform/blacklist/{id} DELETE /v1/platform/blacklist/{id}. Onde é usada: console de plataforma. Efeitos: uma escrita. curl -X DELETE 'http://asender-core:8002/platform/blacklist/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /platform/blacklist/suggestions GET /v1/platform/blacklist/suggestions?todas=1. Onde é usada: console de plataforma. Efeitos: uma leitura. curl -X GET 'http://asender-core:8002/platform/blacklist/suggestions' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /platform/blacklist/suggestions POST /v1/platform/blacklist/suggestions. Onde é usada: quem detecta abuso (hoje o ops). Efeitos: uma escrita — NUNCA um bloqueio. curl -X POST 'http://asender-core:8002/platform/blacklist/suggestions' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /platform/blacklist/suggestions/{id}/apply POST /v1/platform/blacklist/suggestions/{id}/apply. Onde é usada: console de plataforma. Efeitos: duas escritas. curl -X POST 'http://asender-core:8002/platform/blacklist/suggestions/id_AQUI/apply' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /platform/blacklist/suggestions/{id}/dismiss POST /v1/platform/blacklist/suggestions/{id}/dismiss. Onde é usada: console de plataforma. Efeitos: uma escrita. curl -X POST 'http://asender-core:8002/platform/blacklist/suggestions/id_AQUI/dismiss' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /platform/datacenter-asn GET /v1/platform/datacenter-asn. Onde é usada: console de plataforma e o consumo interno da captura. Efeitos: uma leitura. curl -X GET 'http://asender-core:8002/platform/datacenter-asn' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /platform/datacenter-asn POST /v1/platform/datacenter-asn. Onde é usada: console de plataforma. Efeitos: uma escrita. curl -X POST 'http://asender-core:8002/platform/datacenter-asn' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /platform/datacenter-asn/{asn} DELETE /v1/platform/datacenter-asn/{asn}. Onde é usada: console de plataforma. Efeitos: uma escrita. curl -X DELETE 'http://asender-core:8002/platform/datacenter-asn/asn_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /platform/listas GET /v1/platform/listas. Onde é usada: `asender_runtime`, periodicamente. Efeitos: uma leitura. Sem ator, ao contrário de todas as outras deste arquivo: quem chama é serviço, não operador. O token de serviço já foi conferido no grupo `/v1`. curl -X GET 'http://asender-core:8002/platform/listas' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /platform/metrics GET /v1/platform/metrics. Onde é usada: home do console de plataforma. Efeitos: uma leitura. curl -X GET 'http://asender-core:8002/platform/metrics' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /platform/tenants GET /v1/platform/tenants?q=&limite=N. Onde é usada: a tela de suporte, para achar a conta do chamado. Efeitos: uma leitura. # Ela vive aqui, e não junto de `/v1/tenants` `GET /v1/tenants` é escopado por usuário — é a lista de quem pertence. Esta é a lista de TODAS as contas, e é o tipo de leitura que só quem pode entrar em conta alheia deveria fazer. Deixá-la ao lado da outra faria as duas parecerem a mesma coisa com um parâmetro a mais, que é como se abre uma sem querer. Quem confere a allowlist é o gateway, ANTES de chamar: aqui a superfície já exige o token de serviço. curl -X GET 'http://asender-core:8002/platform/tenants' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /readyz Readiness — ping em Postgres e Redis. Onde é usada: readiness probe. Efeitos: uma consulta trivial a cada dependência. É aqui que a dependência entra — o oposto do `/healthz`. O prazo curto é deliberado: uma sonda que espera indefinidamente nunca reporta "não pronto", e a instância segue recebendo tráfego que vai falhar. Respostas: 200 Todas as dependências responderam.; 503 Alguma dependência fora; o valor traz `down: `. curl -X GET 'http://asender-core:8002/readyz' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /tenants/{id}/impersonations GET /v1/tenants/{id}/impersonations?limite=N. Onde é usada: consulta do CLIENTE sobre a própria conta, e da plataforma. Efeitos: uma leitura. curl -X GET 'http://asender-core:8002/tenants/id_AQUI/impersonations' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/api-keys/validate Resolve uma API key em tenant + escopos. Onde é usada: `POST /internal/v1/api-keys/validate`, chamada pelo `asender-api` a cada request autenticada por chave. Efeitos: escreve a resposta; pode consultar banco e cache. Toda recusa responde o MESMO 401 "Invalid API key.", sem distinguir chave inexistente de revogada, expirada ou de outra conta: a diferença entre essas mensagens é um oráculo que diz ao atacante quando ele acertou o formato. Respostas: 200 Chave válida.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — chave inválida, revogada ou expirada, sem distinguir. curl -X POST 'http://asender-core:8002/v1/api-keys/validate' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/invitations/accept Aceita um convite e cria o vínculo de membro. Onde é usada: `POST /v1/invitations/{token}/accept`. Efeitos: escreve a resposta; cria a associação de membro. É a única rota de convite SEM a conta no caminho: quem aceita ainda não pertence a conta nenhuma, e o tenant é a RESPOSTA do token, não a entrada. Por isso a validade é conferida aqui, e não só na emissão. Respostas: 200 Aceito; devolve o membro criado.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`.; 409 `409 AlreadyUsed` — convite já aceito.; 410 `410 Expired` — convite vencido. curl -X POST 'http://asender-core:8002/v1/invitations/accept' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/relays Relays de entrega, opcionalmente filtrados por tenant. Onde é usada: `GET /v1/relays`, com filtro opcional por conta. Efeitos: escreve a resposta HTTP. Parâmetros: tenant_id (query) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder. curl -X GET 'http://asender-core:8002/v1/relays' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/relays Registra um relay. Onde é usada: `POST /v1/relays`. Efeitos: escreve a resposta HTTP. A master key chega em claro e é cifrada ANTES de qualquer escrita. Respostas: 201 Registrado.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 409 `409 Conflict` — slug já em uso, vínculo duplicado. curl -X POST 'http://asender-core:8002/v1/relays' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/relays/{id} Um relay. Onde é usada: `GET /v1/relays/{id}`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X GET 'http://asender-core:8002/v1/relays/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### DELETE /v1/relays/{id} Remove um relay. Onde é usada: `DELETE /v1/relays/{id}`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 204 Removido.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X DELETE 'http://asender-core:8002/v1/relays/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/relays/{id}/health-check Sonda o relay AGORA e grava o resultado. Onde é usada: `POST /v1/relays/{id}/health`. Efeitos: escreve a resposta HTTP. Fala com um servidor de FORA por HTTP, com prazo curto: sem o prazo, um relay que aceita a conexão e nunca responde prenderia a requisição do painel. Parâmetros: id (path, obrigatório) Respostas: 200 Sondado; veja `status`.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`.; 502 Erro repassado do relay (`relayclient.APIError` preserva o status upstream). curl -X POST 'http://asender-core:8002/v1/relays/id_AQUI/health-check' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/relays/{id}/provision Provisiona um tenant no relay e devolve a credencial dele. Onde é usada: `POST /v1/relays/{id}/provision`. Efeitos: escreve a resposta HTTP. Decifra a master key só no momento da chamada e não a devolve na resposta. Parâmetros: id (path, obrigatório) Respostas: 201 Provisionado.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X POST 'http://asender-core:8002/v1/relays/id_AQUI/provision' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/tenants Tenants de um usuário. Parâmetros: user_id (query, obrigatório) Respostas: 200 OK; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 500 `500 InternalError`. **DIVERGÊNCIA:** ao contrário do `asender_messages`, este serviço passa `err.Error()` CRU para o campo `Message`. Erro de driver pode vazar estrutura interna no corpo. Aceitável só enquanto o serviço não é exposto; é dívida registrada. curl -X GET 'http://asender-core:8002/v1/tenants' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants Cria um tenant raiz. Onde é usada: `POST /v1/tenants`. Efeitos: escreve a resposta HTTP. Respostas: 201 Criado.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 409 `409 Conflict` — slug já em uso, vínculo duplicado. curl -X POST 'http://asender-core:8002/v1/tenants' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/tenants/{id} Um tenant pelo public id. Onde é usada: `GET /v1/tenants/{id}`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### PATCH /v1/tenants/{id} Altera nome, status ou metadata. Onde é usada: `PATCH /v1/tenants/{id}`. Efeitos: escreve a resposta HTTP. Campo ausente PRESERVA o valor — não apaga. É o contrato de PATCH, e é o que permite ao painel mandar só o que mudou. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X PATCH 'http://asender-core:8002/v1/tenants/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/tenants/{id} Remoção LÓGICA do tenant. Onde é usada: `DELETE /v1/tenants/{id}`. Efeitos: escreve a resposta HTTP. Apagamento LÓGICO: mensagens, chaves e membros referenciam a conta, e um DELETE físico deixaria o histórico apontando para o nada. Parâmetros: id (path, obrigatório) Respostas: 204 Removido.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X DELETE 'http://asender-core:8002/v1/tenants/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/tenants/{id}/ancestors Breadcrumb raiz→nó, com o próprio nó como ÚLTIMO item. Onde é usada: GET /v1/tenants/{id}/ancestors. Fluxo do dado: path {id} → TreeService.Ancestors → closure → JSON. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/ancestors' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/api-keys Emite uma API key. Onde é usada: `POST /v1/tenants/{id}/api-keys`. Efeitos: escreve a resposta HTTP. A chave em claro sai UMA vez, nesta resposta: o banco guarda só o hash, e por isso chave perdida se substitui — não se recupera. Parâmetros: id (path, obrigatório) Respostas: 201 Emitida.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X POST 'http://asender-core:8002/v1/tenants/id_AQUI/api-keys' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/tenants/{id}/api-keys Chaves do tenant (sem o segredo). Onde é usada: `GET /v1/tenants/{id}/api-keys`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/api-keys' \ -H 'Authorization: Bearer SEU_TOKEN' ### DELETE /v1/tenants/{id}/api-keys/{keyId} Revoga uma chave. Onde é usada: `DELETE /v1/tenants/{id}/api-keys/{keyID}`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório); keyId (path, obrigatório) Respostas: 204 Revogada.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X DELETE 'http://asender-core:8002/v1/tenants/id_AQUI/api-keys/keyId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/children Cria uma sub-conta (`kind='leaf'`) sob o tenant do path. Onde é usada: POST /v1/tenants/{id}/children. Fluxo do dado: body {name,slug,monthly_quota} + path {id} → TreeService.CreateChild → core.tenants + closure → 201. Efeitos: cria tenant `kind='leaf'` com `parent_id` = tenant do path. Erros: 400 body inválido/nome vazio; 404 pai inexistente; 409 slug duplicado. Parâmetros: id (path, obrigatório) Respostas: 201 Criada.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`.; 409 `409 Conflict` — slug já em uso, vínculo duplicado. curl -X POST 'http://asender-core:8002/v1/tenants/id_AQUI/children' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/tenants/{id}/descendants Só a descendência — EXCLUI o próprio nó. Onde é usada: GET /v1/tenants/{id}/descendants. Fluxo do dado: path {id} + query depth → TreeService.Descendants → closure → JSON. Parâmetros: id (path, obrigatório); depth (query) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`.; 422 `422 ValidationError` — `depth` fora da faixa, pai inválido, ou **ciclo** (`repo.ErrCycle`, vindo do `check_violation` do trigger de closure). Nunca 500. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/descendants' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/invitations Cria um convite. Onde é usada: `POST /v1/tenants/{id}/invitations`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 201 Criado.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X POST 'http://asender-core:8002/v1/tenants/id_AQUI/invitations' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/tenants/{id}/invitations Convites do tenant. Onde é usada: `GET /v1/tenants/{id}/invitations`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/invitations' \ -H 'Authorization: Bearer SEU_TOKEN' ### DELETE /v1/tenants/{id}/invitations/{token} Revoga um convite pelo token. Onde é usada: `DELETE /v1/tenants/{id}/invitations/{token}`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório); token (path, obrigatório) Respostas: 204 Revogado.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X DELETE 'http://asender-core:8002/v1/tenants/id_AQUI/invitations/token_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/tenants/{id}/members Membros do tenant. Onde é usada: `GET /v1/tenants/{id}/members`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/members' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/members Vincula um usuário ao tenant. Onde é usada: `POST /v1/tenants/{id}/members`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 201 Vinculado.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`.; 409 `409 Conflict` — slug já em uso, vínculo duplicado. curl -X POST 'http://asender-core:8002/v1/tenants/id_AQUI/members' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/tenants/{id}/members/{userId} Desvincula um usuário do tenant. Onde é usada: `DELETE /v1/tenants/{id}/members/{userID}`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório); userId (path, obrigatório) Respostas: 204 Desvinculado.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X DELETE 'http://asender-core:8002/v1/tenants/id_AQUI/members/userId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### PATCH /v1/tenants/{id}/parent Move o tenant, ou o promove a raiz. Onde é usada: PATCH /v1/tenants/{id}/parent. Fluxo do dado: body {parent_id} (public id) + path {id} → TreeService.SetParent → UPDATE parent_id → trigger recalcula a closure → 200. Efeitos: reescreve closure e `depth` de toda a subárvore movida. Parâmetros: id (path, obrigatório) Respostas: 200 Movido.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`.; 422 `422 ValidationError` — `depth` fora da faixa, pai inválido, ou **ciclo** (`repo.ErrCycle`, vindo do `check_violation` do trigger de closure). Nunca 500. curl -X PATCH 'http://asender-core:8002/v1/tenants/id_AQUI/parent' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PATCH /v1/tenants/{id}/quota Grava ou limpa a quota mensal própria. Onde é usada: PATCH /v1/tenants/{id}/quota. Fluxo do dado: body {monthly_quota} + path {id} → TreeService.SetQuota → core.tenants → 200. Erros: 400 campo ausente, tipo errado (sem coerção) ou valor negativo; 404 tenant inexistente. Parâmetros: id (path, obrigatório) Respostas: 200 Gravada.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X PATCH 'http://asender-core:8002/v1/tenants/id_AQUI/quota' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/tenants/{id}/tree Subárvore em pré-ordem, INCLUINDO o próprio nó. Onde é usada: GET /v1/tenants/{id}/tree. Fluxo do dado: path {id} + query depth → TreeService.Tree → closure → JSON. Parâmetros: id (path, obrigatório); depth (query) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`.; 422 `422 ValidationError` — `depth` fora da faixa, pai inválido, ou **ciclo** (`repo.ErrCycle`, vindo do `check_violation` do trigger de closure). Nunca 500. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/tree' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/tenants/{id}/usage Contadores de consumo do período. Onde é usada: `GET /v1/tenants/{id}/usage`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório); period (query) Respostas: 200 Contadores do período (zerados quando não houve consumo).; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 O TENANT não existe. Nunca "não há contador". curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/usage' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/usage/increment Incrementa um contador de consumo. Onde é usada: `POST /internal/v1/tenants/{id}/usage`, chamada pelos serviços de envio. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 200 Contador após o incremento.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X POST 'http://asender-core:8002/v1/tenants/id_AQUI/usage/increment' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/tenants/{id}/vapid Chave pública VAPID do tenant (gera o par na primeira chamada). Onde é usada: asender-api, ao montar o artefato de integração do site (`/api/push/site-config`) e a tela de push do dashboard. Fluxo do dado: core.vapid_keys → asender-api → site do cliente → browser. Saídas: 200 com `{public_key, subject}`. Efeitos: pode criar a linha do par na primeira chamada do tenant. A chave PRIVADA nunca aparece nesta resposta nem em nenhuma outra: quem precisa assinar chama `/vapid/sign`. Parâmetros: id (path, obrigatório) Respostas: 200 Chave pública em base64url. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/vapid' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/vapid/sign Assina o JWT VAPID (RFC 8292) para um endpoint de push service. Onde é usada: chamado pelo asender-push-worker antes de entregar cada notificação. Fluxo do dado: worker → aqui → JWT ES256 → worker → push service. Saídas: 200 com `{authorization, public_key}`; 400 em endpoint inválido. ## Por que assinar aqui, em vez de mandar a chave para o worker A alternativa óbvia — o worker pedir a chave privada e assinar sozinho — coloca material secreto na rede e na memória de outro processo, e transforma cada worker num lugar de onde a chave pode vazar. Assinando aqui, a chave privada não sai deste serviço: o worker recebe um token de 12h atado a UMA audiência, inútil para qualquer outro push service. Parâmetros: id (path, obrigatório) Respostas: 200 Cabeçalho Authorization pronto.; 400 curl -X POST 'http://asender-core:8002/v1/tenants/id_AQUI/vapid/sign' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/tenants/{id}/webhooks Endpoints de webhook do tenant. Onde é usada: `GET /v1/tenants/{id}/webhooks`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório) Respostas: 200 OK; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X GET 'http://asender-core:8002/v1/tenants/id_AQUI/webhooks' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/tenants/{id}/webhooks Cria um endpoint de webhook. Onde é usada: `POST /v1/tenants/{id}/webhooks`. Efeitos: escreve a resposta HTTP. O segredo de assinatura sai em claro UMA vez, nesta resposta. Parâmetros: id (path, obrigatório) Respostas: 201 Criado.; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X POST 'http://asender-core:8002/v1/tenants/id_AQUI/webhooks' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PATCH /v1/tenants/{id}/webhooks/{whkId} Altera url, eventos ou estado ativo. Onde é usada: `PATCH /v1/tenants/{id}/webhooks/{whkID}`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório); whkId (path, obrigatório) Respostas: 200 OK; 400 `400 ValidationError` — JSON inválido, **campo desconhecido** (`DisallowUnknownFields`), corpo acima de 1 MiB, campo obrigatório ausente, ou `parent_id`/`monthly_quota` omitido no PATCH.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X PATCH 'http://asender-core:8002/v1/tenants/id_AQUI/webhooks/whkId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/tenants/{id}/webhooks/{whkId} Remove o endpoint. Onde é usada: `DELETE /v1/tenants/{id}/webhooks/{whkID}`. Efeitos: escreve a resposta HTTP. Parâmetros: id (path, obrigatório); whkId (path, obrigatório) Respostas: 204 Removido.; 401 `401 AuthorizationError` — pretendido para token de serviço ausente ou inválido. **Não ocorre hoje:** o middleware é um placeholder.; 404 `404 NotFound` — o recurso não existe. **Não significa "não é seu"**: este serviço não faz checagem de dono; quem autoriza é o `asender-api`. curl -X DELETE 'http://asender-core:8002/v1/tenants/id_AQUI/webhooks/whkId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ============================================================================== # Messages — campanhas, contatos e envio (http://asender-messages:8004) # Serviço INTERNO — não alcançável de fora. ============================================================================== ### GET /healthz Liveness. Não toca dependência. Onde é usada: probe de liveness do container. Saídas: 200 com `{"status":"ok"}`. Respostas: 200 Vivo. curl -X GET 'http://asender-messages:8004/healthz' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /metrics Métricas no formato de exposição do Prometheus (§17). Respostas: 200 Exposição em texto do Prometheus.; 503 O reader de métricas não pôde ser criado no boot. curl -X GET 'http://asender-messages:8004/metrics' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /readyz Readiness com verificação REAL (ping no Postgres). Onde é usada: probe de readiness; consumido pelo smoke test, que assere o conteúdo. Saídas: 200 quando o banco responde; 503 quando não. O broker fora **não** derruba a readiness — o serviço segue aceitando escrita e o outbox acumula (degradação graciosa, §2); isso aparece como `nats:false` e `outbox_pending` crescendo, que é o sinal correto para alerta. Efeitos: uma query de ping no Postgres. Respostas: 200 Banco respondeu.; 503 Banco fora. curl -X GET 'http://asender-messages:8004/readyz' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/contacts Audiência do tenant, com busca e filtro por lista. Onde é usada: tela de audiência do dashboard, via asender-api. Fluxo do dado: header de tenant + query → repo → `messages.contacts` → JSON. Entradas: q, list_id, cursor, limit. Query malformada é 422 (parseQuery), não listagem sem filtro. Saídas: 200 com `contacts`, `next_cursor` e `has_more`; 404 se `list_id` não existir no tenant; 422 em parâmetro inválido. Parâmetros: X-Asender-Tenant (header, obrigatório); q (query); list_id (query); cursor (query); limit (query) Respostas: 200 Página de contatos.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 404 `404 NotFound` — inexistente **ou de outro tenant**. A mensagem é sempre `resource not found`, sem distinguir os dois casos.; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X GET 'http://asender-messages:8004/v1/contacts' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/contacts Cria um contato. Onde é usada: formulário "novo contato" da tela de audiência. Fluxo do dado: asender-api → aqui → `messages.contacts` → resposta 201. Saídas: 201 com `contact`; 400 em JSON inválido ou campo desconhecido; 422 em validação (sem email nem telefone, email malformado, email já usado). Efeitos: escreve em messages.contacts. Parâmetros: X-Asender-Tenant (header, obrigatório) Respostas: 201 Criado.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto.; 500 `500 InternalError` — falha genuína de infra. O detalhe fica no log; o corpo é sempre `unexpected error`, sem SQL, driver ou stack trace. **Input do cliente nunca deve chegar aqui** (a exceção conhecida é `POST /v1/messages/{id}/status` com `sending`). curl -X POST 'http://asender-messages:8004/v1/contacts' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PATCH /v1/contacts/{id} Alteração parcial (nome, atributos, consent). Onde é usada: edição inline da tela de audiência e fluxo de descadastro. Saídas: 200 com `contact`; 404 se não existir no tenant; 422 se nada foi enviado ou algum campo for inválido; 400 em JSON inválido (email/telefone não são patcháveis, então vêm como campo desconhecido). Efeitos: escreve em messages.contacts. Parâmetros: X-Asender-Tenant (header, obrigatório); id (path, obrigatório) Respostas: 200 Atualizado.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 404 `404 NotFound` — inexistente **ou de outro tenant**. A mensagem é sempre `resource not found`, sem distinguir os dois casos.; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X PATCH 'http://asender-messages:8004/v1/contacts/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/contacts/{id} Remoção LÓGICA (`deleted_at`). Onde é usada: tela de audiência. Saídas: 204 sem corpo; 404 se não existir no tenant ou já estar removido. Efeitos: escreve em messages.contacts (soft delete). Parâmetros: X-Asender-Tenant (header, obrigatório); id (path, obrigatório) Respostas: 204 Removido.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 404 `404 NotFound` — inexistente **ou de outro tenant**. A mensagem é sempre `resource not found`, sem distinguir os dois casos. curl -X DELETE 'http://asender-messages:8004/v1/contacts/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/contacts/bulk Upsert em lote pela identidade natural (email; telefone se não houver email). Onde é usada: import da tela de audiência e seed de dados reais, que precisa poder re-rodar sem duplicar (§22.5). Fluxo do dado: asender-api → aqui → `messages.contacts`. Saídas: 200 com `created`, `updated`, `errors[]` e `contacts[]`. Um item ruim não invalida o lote — ele aparece em `errors` com seu índice. 422 só quando o lote inteiro é inválido (vazio ou acima do teto). Efeitos: escreve em messages.contacts. Parâmetros: X-Asender-Tenant (header, obrigatório) Respostas: 200 Lote processado.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X POST 'http://asender-messages:8004/v1/contacts/bulk' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/lists Listas do tenant com contagem de membros. Onde é usada: tela de audiência do dashboard, via asender-api. Fluxo do dado: header de tenant + query → repo → `messages.contacts` → JSON. Entradas: q, list_id, cursor, limit. Query malformada é 422 (parseQuery), não listagem sem filtro. Saídas: 200 com `contacts`, `next_cursor` e `has_more`; 404 se `list_id` não existir no tenant; 422 em parâmetro inválido. Parâmetros: X-Asender-Tenant (header, obrigatório); limit (query) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X GET 'http://asender-messages:8004/v1/lists' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/lists Cria (ou atualiza pelo slug) uma lista. Onde é usada: formulário "novo contato" da tela de audiência. Fluxo do dado: asender-api → aqui → `messages.contacts` → resposta 201. Saídas: 201 com `contact`; 400 em JSON inválido ou campo desconhecido; 422 em validação (sem email nem telefone, email malformado, email já usado). Efeitos: escreve em messages.contacts. Parâmetros: X-Asender-Tenant (header, obrigatório) Respostas: 200 Slug já existia; atualizada.; 201 Criada.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X POST 'http://asender-messages:8004/v1/lists' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/lists/{id}/contacts Membros paginados da lista. Onde é usada: detalhe da lista na tela de audiência e pré-visualização de audiência da campanha. Fluxo do dado: `contact_list_members` ⨝ `contacts` → asender-api → UI. Entradas: cursor, limit. Saídas: 200 com `contacts`, `next_cursor` e `has_more`; 404 se a lista não existir no tenant; 422 em parâmetro inválido. Parâmetros: X-Asender-Tenant (header, obrigatório); id (path, obrigatório); cursor (query); limit (query) Respostas: 200 Página de contatos.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 404 `404 NotFound` — inexistente **ou de outro tenant**. A mensagem é sempre `resource not found`, sem distinguir os dois casos.; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X GET 'http://asender-messages:8004/v1/lists/id_AQUI/contacts' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/lists/{id}/members Associa contatos à lista. Onde é usada: montagem da audiência de uma campanha, na tela de audiência. Fluxo do dado: asender-api → aqui → `messages.contact_list_members`. Saídas: 200 com `added` e `not_found` (ids que não existem no tenant — o servidor diz o que não pôde fazer em vez de descartar em silêncio); 404 se a lista não existir; 422 em lista vazia ou acima do teto. Efeitos: escreve em messages.contact_list_members. Idempotente. Parâmetros: X-Asender-Tenant (header, obrigatório); id (path, obrigatório) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 404 `404 NotFound` — inexistente **ou de outro tenant**. A mensagem é sempre `resource not found`, sem distinguir os dois casos.; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X POST 'http://asender-messages:8004/v1/lists/id_AQUI/members' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/lists/{id}/members Desassocia contatos da lista. Onde é usada: tela de audiência. Saídas: 200 com `removed` e `not_found`; 404 se a lista não existir; 422 em payload inválido. Efeitos: apaga linhas de messages.contact_list_members. Idempotente. Parâmetros: X-Asender-Tenant (header, obrigatório); id (path, obrigatório) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 404 `404 NotFound` — inexistente **ou de outro tenant**. A mensagem é sempre `resource not found`, sem distinguir os dois casos.; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X DELETE 'http://asender-messages:8004/v1/lists/id_AQUI/members' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/messages Enfileira uma mensagem (grava mensagem + outbox no mesmo commit). Onde é usada: chamado pelo asender-api ao servir `/v1/emails/send`, `/v1/push/send` e o composer do dashboard. Fluxo do dado: asender-api → aqui → application.SendMessage → banco (+outbox). Saídas: 201 com a lista de mensagens; 200 quando foi replay de idempotency key (nada novo criado); 422 em validação; 400 em JSON inválido. Efeitos: escrita transacional no banco. Parâmetros: X-Asender-Tenant (header, obrigatório); Idempotency-Key (header) Respostas: 200 Replay de `idempotency_key` — nada novo foi criado.; 201 Criado.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto.; 500 `500 InternalError` — falha genuína de infra. O detalhe fica no log; o corpo é sempre `unexpected error`, sem SQL, driver ou stack trace. **Input do cliente nunca deve chegar aqui** (a exceção conhecida é `POST /v1/messages/{id}/status` com `sending`). curl -X POST 'http://asender-messages:8004/v1/messages' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/messages Página de mensagens do tenant, sem corpo. Onde é usada: tela de audiência do dashboard, via asender-api. Fluxo do dado: header de tenant + query → repo → `messages.contacts` → JSON. Entradas: q, list_id, cursor, limit. Query malformada é 422 (parseQuery), não listagem sem filtro. Saídas: 200 com `contacts`, `next_cursor` e `has_more`; 404 se `list_id` não existir no tenant; 422 em parâmetro inválido. Parâmetros: X-Asender-Tenant (header, obrigatório); X-Asender-Tenant-Scope (header); channel (query); status (query); q (query); cursor (query); limit (query) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto.; 500 `500 InternalError` — falha genuína de infra. O detalhe fica no log; o corpo é sempre `unexpected error`, sem SQL, driver ou stack trace. **Input do cliente nunca deve chegar aqui** (a exceção conhecida é `POST /v1/messages/{id}/status` com `sending`). curl -X GET 'http://asender-messages:8004/v1/messages' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/messages/{id} Mensagem com corpo, metadata e trilha de eventos. Onde é usada: tela de detalhe do envio. Saídas: 200 com `message` e `events`; 404 se não existir no tenant. Mensagem de outro tenant também é 404, para não confirmar existência (§22.8). Parâmetros: X-Asender-Tenant (header, obrigatório); id (path, obrigatório) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 404 `404 NotFound` — inexistente **ou de outro tenant**. A mensagem é sempre `resource not found`, sem distinguir os dois casos.; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X GET 'http://asender-messages:8004/v1/messages/id_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/messages/{id}/status Transição de status vinda de um worker. Onde é usada: chamado pelos workers de email/sms/push depois de falar com o provedor. Fluxo do dado: worker → aqui → `messages` + `message_events` → relatório/timeline. Saídas: 204 em sucesso (não há corpo útil a devolver); 404 se a mensagem não existir no tenant; 422 em status desconhecido. Efeitos: escrita transacional. Idempotente: reprocessar não duplica evento. Parâmetros: X-Asender-Tenant (header, obrigatório); id (path, obrigatório) Respostas: 204 Aplicado. Sem corpo — não há o que devolver.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 404 `404 NotFound` — inexistente **ou de outro tenant**. A mensagem é sempre `resource not found`, sem distinguir os dois casos.; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto.; 500 Falha de infra **ou** `status:"sending"` (ver `x-asender-divergence`). curl -X POST 'http://asender-messages:8004/v1/messages/id_AQUI/status' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/push/devices Registra ou reativa um token de push (upsert por tenant+token). Onde é usada: chamado pelo asender-api ao servir `/v1/push/devices` (API key do app) e o cadastro de device do dashboard. Fluxo do dado: app → asender-api → aqui → `messages.push_devices` → o push-worker lê o token na entrega. Saídas: 200 com `device` quando o token já existia, 201 quando é novo; 422 se o token faltar ou a plataforma não for ios|android|web, ou se `contact_id` não existir no tenant; 400 em JSON inválido. Efeitos: escreve em messages.push_devices. Parâmetros: X-Asender-Tenant (header, obrigatório) Respostas: 200 Token já registrado; atualizado.; 201 Novo device.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X POST 'http://asender-messages:8004/v1/push/devices' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/push/devices Devices do tenant, mais recentes primeiro. Onde é usada: tela de audiência do dashboard, via asender-api. Fluxo do dado: header de tenant + query → repo → `messages.contacts` → JSON. Entradas: q, list_id, cursor, limit. Query malformada é 422 (parseQuery), não listagem sem filtro. Saídas: 200 com `contacts`, `next_cursor` e `has_more`; 404 se `list_id` não existir no tenant; 422 em parâmetro inválido. Parâmetros: X-Asender-Tenant (header, obrigatório); cursor (query); limit (query) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto. curl -X GET 'http://asender-messages:8004/v1/push/devices' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/reports/overview Funil do período, com os três canais sempre presentes. Onde é usada: cartões e taxas da tela de relatórios do dashboard, via asender-api `GET /api/reports/overview`. Fluxo do dado: header de tenant + query → repo.ReportsRepo.Overview → `messages.messages`/`messages.message_events` → JSON → asender-api → dashboard. Entradas: from, to (ISO-8601; default últimos 30 dias). Saídas: 200 com `totals`, `rates`, `by_channel` e `period`; 400 sem header de tenant; 422 em data inválida, `from > to` ou janela acima de 366 dias. Efeitos: só leitura. Parâmetros: X-Asender-Tenant (header, obrigatório); X-Asender-Tenant-Scope (header); from (query); to (query) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — data ilegível, `from > to`, janela acima de 366 dias (31 dias com `interval=hour`), `interval` fora de `day|hour`, `channel` desconhecido, `limit` não positivo, escopo de rollup vazio ou acima de 500 tenants.; 500 `500 InternalError` — falha genuína de infra. O detalhe fica no log; o corpo é sempre `unexpected error`, sem SQL, driver ou stack trace. **Input do cliente nunca deve chegar aqui** (a exceção conhecida é `POST /v1/messages/{id}/status` com `sending`). curl -X GET 'http://asender-messages:8004/v1/reports/overview' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/reports/timeseries Série do período, buckets vazios inclusos. Onde é usada: gráfico SVG da tela de relatórios, via asender-api `GET /api/reports/timeseries`. Fluxo do dado: query → repo.ReportsRepo.Timeseries (generate_series + LEFT JOIN) → JSON → asender-api → gráfico. Entradas: from, to, channel (email|sms|push; vazio = todos), interval (day|hour; default day). Saídas: 200 com `points` e `period`; 400 sem header de tenant; 422 em canal desconhecido, intervalo fora da allowlist, data inválida ou janela grande demais (366 dias no geral, 31 dias com interval=hour). Efeitos: só leitura. Parâmetros: X-Asender-Tenant (header, obrigatório); X-Asender-Tenant-Scope (header); from (query); to (query); channel (query); interval (query) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — data ilegível, `from > to`, janela acima de 366 dias (31 dias com `interval=hour`), `interval` fora de `day|hour`, `channel` desconhecido, `limit` não positivo, escopo de rollup vazio ou acima de 500 tenants.; 500 `500 InternalError` — falha genuína de infra. O detalhe fica no log; o corpo é sempre `unexpected error`, sem SQL, driver ou stack trace. **Input do cliente nunca deve chegar aqui** (a exceção conhecida é `POST /v1/messages/{id}/status` com `sending`). curl -X GET 'http://asender-messages:8004/v1/reports/timeseries' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/reports/top-templates Ranking de templates por volume, com taxas. Onde é usada: tabela de templates da tela de relatórios, via asender-api `GET /api/reports/top-templates`. Fluxo do dado: query → repo.ReportsRepo.TopTemplates (`messages` ⋈ `templates`) → JSON → asender-api → tabela. Entradas: from, to, limit (default 10, teto 50 aplicado no repositório). Saídas: 200 com `templates` (array, nunca null) e `period`; 400 sem header de tenant; 422 em `limit` não numérico ou não positivo e em janela inválida. Efeitos: só leitura. Parâmetros: X-Asender-Tenant (header, obrigatório); X-Asender-Tenant-Scope (header); from (query); to (query); limit (query) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — data ilegível, `from > to`, janela acima de 366 dias (31 dias com `interval=hour`), `interval` fora de `day|hour`, `channel` desconhecido, `limit` não positivo, escopo de rollup vazio ou acima de 500 tenants.; 500 `500 InternalError` — falha genuína de infra. O detalhe fica no log; o corpo é sempre `unexpected error`, sem SQL, driver ou stack trace. **Input do cliente nunca deve chegar aqui** (a exceção conhecida é `POST /v1/messages/{id}/status` com `sending`). curl -X GET 'http://asender-messages:8004/v1/reports/top-templates' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /v1/templates Templates do tenant. Onde é usada: tela de audiência do dashboard, via asender-api. Fluxo do dado: header de tenant + query → repo → `messages.contacts` → JSON. Entradas: q, list_id, cursor, limit. Query malformada é 422 (parseQuery), não listagem sem filtro. Saídas: 200 com `contacts`, `next_cursor` e `has_more`; 404 se `list_id` não existir no tenant; 422 em parâmetro inválido. Parâmetros: X-Asender-Tenant (header, obrigatório); limit (query) Respostas: 200 OK; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto.; 500 `500 InternalError` — falha genuína de infra. O detalhe fica no log; o corpo é sempre `unexpected error`, sem SQL, driver ou stack trace. **Input do cliente nunca deve chegar aqui** (a exceção conhecida é `POST /v1/messages/{id}/status` com `sending`). curl -X GET 'http://asender-messages:8004/v1/templates' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/templates Cria ou atualiza pelo par (tenant, slug). Onde é usada: chamado pelo asender-api ao servir `/v1/push/devices` (API key do app) e o cadastro de device do dashboard. Fluxo do dado: app → asender-api → aqui → `messages.push_devices` → o push-worker lê o token na entrega. Saídas: 200 com `device` quando o token já existia, 201 quando é novo; 422 se o token faltar ou a plataforma não for ios|android|web, ou se `contact_id` não existir no tenant; 400 em JSON inválido. Efeitos: escreve em messages.push_devices. Parâmetros: X-Asender-Tenant (header, obrigatório) Respostas: 200 Gravado.; 400 `400` — `MissingTenant` (header de tenant ausente) ou `InvalidRequest` (JSON inválido, **campo desconhecido**, corpo acima de 1 MiB, mais de um documento JSON no corpo).; 401 `401 Unauthorized` — `X-Asender-Svc-Token` ausente, vazio ou incorreto. Também é a resposta quando a auth está ligada e o `SERVICE_SECRET` está vazio (fail closed).; 422 `422 ValidationError` — query malformada, `cursor`/`limit` não numérico ou não positivo, canal/status fora da allowlist, contato sem email nem telefone, plataforma inválida, variável de template faltando (a mensagem NOMEIA as faltantes), lote vazio ou acima do teto.; 500 `500 InternalError` — falha genuína de infra. O detalhe fica no log; o corpo é sempre `unexpected error`, sem SQL, driver ou stack trace. **Input do cliente nunca deve chegar aqui** (a exceção conhecida é `POST /v1/messages/{id}/status` com `sending`). curl -X POST 'http://asender-messages:8004/v1/templates' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ============================================================================== # CRM — jornadas, alertas e destinos (http://asender-crm:8005) # Serviço INTERNO — não alcançável de fora. ============================================================================== ### GET /healthz responde 200 enquanto o processo está vivo. Não toca dependência: um liveness que consulta o banco derruba o pod quando o banco oscila. Onde é usada: rota pública /healthz. Efeitos: escreve JSON na resposta. curl -X GET 'http://asender-crm:8005/healthz' ### GET /readyz 503 `starting` antes do fim do boot; depois disso, CONSULTA as sondas e responde 200 `ready` ou 503 `degraded` com o nome de cada dependência. Onde é usada: rota pública /readyz. Efeitos: escreve JSON na resposta; pode tocar a dependência (com cache de 1s). # Por que ele não é mais um trinco de boot A readiness era `MarkReady()` uma vez e pronto — memória do boot, não estado atual. Num incidente real de 2026-08-27 o serviço voltou com a credencial errada do Postgres, falhou ~30 vezes por minuto ao drenar o outbox, e /healthz e /readyz responderam 200 o tempo todo: o gate de saúde do deploy passou e quem descobriu foi o pentest, depois. Readiness que só lembra do boot não enxerga a dependência que caiu DEPOIS dele. /healthz continua sem tocar em nada, e isso é de propósito: liveness que consulta o banco derruba o processo quando o banco oscila. Quem tem de dizer "não me mande tráfego" é o readiness. O que sai na resposta é o NOME da dependência e "indisponível" — nunca a mensagem do driver, que carrega usuário, host e base. Rota pública não conta topologia; o erro inteiro vai para o log. curl -X GET 'http://asender-crm:8005/readyz' ### GET /v1/alertas GET /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma leitura. curl -X GET 'http://asender-crm:8005/v1/alertas' ### POST /v1/alertas POST /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X POST 'http://asender-crm:8005/v1/alertas' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PUT /v1/alertas/{id} PUT /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria "disparado" pela antiga. curl -X PUT 'http://asender-crm:8005/v1/alertas/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/alertas/{id} DELETE /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X DELETE 'http://asender-crm:8005/v1/alertas/id_AQUI' ### GET /v1/alertas/eventos GET /v1/alertas/eventos?limite=N. Onde é usada: tela de alertas — a linha do tempo. Efeitos: uma leitura. Da CONTA, e não de um alerta: quem investiga "o que aconteceu ontem" quer a linha do tempo inteira, e paginar por alerta a obrigaria a abrir um por um. curl -X GET 'http://asender-crm:8005/v1/alertas/eventos' ### GET /v1/configuracoes/retencao GET /v1/configuracoes/retencao. Onde é usada: tela de configurações da conta. Efeitos: uma leitura. Conta sem política configurada responde 200 com `dias: null` — e não 404: "não configurei retenção" é o estado NORMAL, e a tela precisa dele para desenhar o formulário vazio. curl -X GET 'http://asender-crm:8005/v1/configuracoes/retencao' ### PUT /v1/configuracoes/retencao PUT /v1/configuracoes/retencao com `{"dias": 90}` ou `{"dias": null}`. Onde é usada: tela de configurações da conta. Efeitos: uma escrita; passa a anonimizar contato antigo em até uma hora. # `null` desliga, ausente é erro Os dois cairiam no mesmo ponteiro nil se o corpo fosse decodificado direto — e um cliente que esquecesse o campo desligaria a retenção sem querer. `null` é uma ORDEM ("guarde para sempre"); campo ausente é um pedido malformado. curl -X PUT 'http://asender-crm:8005/v1/configuracoes/retencao' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/contatos GET /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma leitura. curl -X GET 'http://asender-crm:8005/v1/contatos' ### POST /v1/contatos POST /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X POST 'http://asender-crm:8005/v1/contatos' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/contatos/{id} GET /v1/contatos/{id}/consentimento. Onde é usada: tela de contato, seção de preferências. Efeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira que ficasse desatualizada esconderia um canal do operador. curl -X GET 'http://asender-crm:8005/v1/contatos/id_AQUI' ### PATCH /v1/contatos/{id} PUT /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria "disparado" pela antiga. curl -X PATCH 'http://asender-crm:8005/v1/contatos/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/contatos/{id} DELETE /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X DELETE 'http://asender-crm:8005/v1/contatos/id_AQUI' ### GET /v1/contatos/{id}/atividades GET /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma leitura. curl -X GET 'http://asender-crm:8005/v1/contatos/id_AQUI/atividades' ### POST /v1/contatos/{id}/atividades POST /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X POST 'http://asender-crm:8005/v1/contatos/id_AQUI/atividades' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/contatos/{id}/consentimento GET /v1/contatos/{id}/consentimento. Onde é usada: tela de contato, seção de preferências. Efeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira que ficasse desatualizada esconderia um canal do operador. curl -X GET 'http://asender-crm:8005/v1/contatos/id_AQUI/consentimento' ### PUT /v1/contatos/{id}/consentimento PUT /v1/contatos/{id}/consentimento. Onde é usada: tela de contato, e o link de descadastro via BFF. Efeitos: cria ou remove a linha de opt-out. # `recebe` é ponteiro, e a ausência é ERRO Com um `bool` comum, um corpo sem o campo viria como `false` — e "esqueci de mandar o campo" seria indistinguível de "descadastra esta pessoa". O ponteiro transforma a omissão em 422 explícito. curl -X PUT 'http://asender-crm:8005/v1/contatos/id_AQUI/consentimento' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/contatos/exportar.csv GET /v1/contatos/exportar.csv?limite=N. Onde é usada: botão "exportar" da lista de contatos. Efeitos: uma leitura; decifra a PII; registra a exportação na timeline. # A PII é decifrada AQUI, e só para quem é dono O tenant vem da sessão verificada e a RLS já filtra a consulta. Este handler nunca vê contato de outra conta, e a decifragem acontece com a chave do processo — não há caminho em que o CSV carregue dado alheio. # Cada exportação vira uma ATIVIDADE, e isso não é opcional A LGPD (art. 37) exige registro das operações de tratamento, e exportar é tratamento: a partir dali o dado existe num arquivo que ninguém controla. Sem esse registro, "quem baixou a base e quando" não tem resposta — e é a primeira pergunta de todo incidente de vazamento. O registro é gravado ANTES do arquivo sair. Se a gravação falhar, a exportação não acontece: um download sem trilha é exatamente o que a trilha existe para impedir, e "o arquivo já foi" não se desfaz. curl -X GET 'http://asender-crm:8005/v1/contatos/exportar.csv' ### GET /v1/destinos GET /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma leitura. curl -X GET 'http://asender-crm:8005/v1/destinos' ### POST /v1/destinos POST /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X POST 'http://asender-crm:8005/v1/destinos' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PUT /v1/destinos/{id} PUT /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria "disparado" pela antiga. curl -X PUT 'http://asender-crm:8005/v1/destinos/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/destinos/{id} DELETE /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X DELETE 'http://asender-crm:8005/v1/destinos/id_AQUI' ### GET /v1/destinos/{id}/entregas GET /v1/destinos/{id}/entregas?limite=N. Onde é usada: diagnóstico da tela de integrações. Efeitos: uma leitura. O PAYLOAD não volta: ele carrega a PII do contato, e quem pergunta "por que não chegou" precisa do estado e do código — não do dado da pessoa repetido numa segunda tela. curl -X GET 'http://asender-crm:8005/v1/destinos/id_AQUI/entregas' ### POST /v1/destinos/{id}/testar POST /v1/destinos/{id}/testar. Onde é usada: botão "testar" da tela de integrações. Efeitos: cria uma entrega pendente. # ENFILEIRA, e não entrega na hora A entrega passa pelo MESMO dreno das entregas de verdade — e portanto pelo mesmo guard do piso 18, pela mesma guarda de rede e pela mesma assinatura. Um caminho de teste que chamasse o webhook direto seria um segundo caminho de saída, e o segundo caminho é sempre o que esquece uma guarda. O operador vê o resultado em `GET /entregas`, que é a mesma tela onde ele vê as entregas reais — então "o teste funcionou" e "a integração funciona" são a mesma leitura, e não duas afirmações que podem divergir. curl -X POST 'http://asender-crm:8005/v1/destinos/id_AQUI/testar' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/integracoes-de-conversao GET /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma leitura. curl -X GET 'http://asender-crm:8005/v1/integracoes-de-conversao' ### POST /v1/integracoes-de-conversao POST /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X POST 'http://asender-crm:8005/v1/integracoes-de-conversao' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PUT /v1/integracoes-de-conversao/{id} PUT /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria "disparado" pela antiga. curl -X PUT 'http://asender-crm:8005/v1/integracoes-de-conversao/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/integracoes-de-conversao/{id} DELETE /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X DELETE 'http://asender-crm:8005/v1/integracoes-de-conversao/id_AQUI' ### GET /v1/integracoes-de-conversao/{id}/entregas GET /v1/destinos/{id}/entregas?limite=N. Onde é usada: diagnóstico da tela de integrações. Efeitos: uma leitura. O PAYLOAD não volta: ele carrega a PII do contato, e quem pergunta "por que não chegou" precisa do estado e do código — não do dado da pessoa repetido numa segunda tela. curl -X GET 'http://asender-crm:8005/v1/integracoes-de-conversao/id_AQUI/entregas' ### POST /v1/integracoes-de-conversao/{id}/testar POST /v1/destinos/{id}/testar. Onde é usada: botão "testar" da tela de integrações. Efeitos: cria uma entrega pendente. # ENFILEIRA, e não entrega na hora A entrega passa pelo MESMO dreno das entregas de verdade — e portanto pelo mesmo guard do piso 18, pela mesma guarda de rede e pela mesma assinatura. Um caminho de teste que chamasse o webhook direto seria um segundo caminho de saída, e o segundo caminho é sempre o que esquece uma guarda. O operador vê o resultado em `GET /entregas`, que é a mesma tela onde ele vê as entregas reais — então "o teste funcionou" e "a integração funciona" são a mesma leitura, e não duas afirmações que podem divergir. curl -X POST 'http://asender-crm:8005/v1/integracoes-de-conversao/id_AQUI/testar' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/jornadas GET /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma leitura. curl -X GET 'http://asender-crm:8005/v1/jornadas' ### POST /v1/jornadas POST /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X POST 'http://asender-crm:8005/v1/jornadas' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/jornadas/{id} GET /v1/contatos/{id}/consentimento. Onde é usada: tela de contato, seção de preferências. Efeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira que ficasse desatualizada esconderia um canal do operador. curl -X GET 'http://asender-crm:8005/v1/jornadas/id_AQUI' ### PUT /v1/jornadas/{id}/ativa PUT /v1/jornadas/{id}/ativa. Onde é usada: painel, interruptor da jornada. Efeitos: muda o estado; ligada, a jornada passa a inscrever contatos. # Revalida ANTES de ligar A jornada foi validada na criação, mas pode ter sido editada depois — e uma jornada com buraco na numeração deixaria o contato preso no meio do caminho, sem erro em lugar nenhum. Validar de novo aqui custa nada e é o último ponto antes de a coisa começar a mandar mensagem. Desligar NÃO revalida: uma jornada quebrada tem de poder ser desligada, e exigir que ela esteja válida para parar seria prender o operador justamente no caso em que ele mais precisa do botão. curl -X PUT 'http://asender-crm:8005/v1/jornadas/id_AQUI/ativa' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/segmentos GET /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma leitura. curl -X GET 'http://asender-crm:8005/v1/segmentos' ### POST /v1/segmentos POST /v1/alertas. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X POST 'http://asender-crm:8005/v1/segmentos' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/segmentos/{id} GET /v1/contatos/{id}/consentimento. Onde é usada: tela de contato, seção de preferências. Efeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira que ficasse desatualizada esconderia um canal do operador. curl -X GET 'http://asender-crm:8005/v1/segmentos/id_AQUI' ### PUT /v1/segmentos/{id} PUT /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa, e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela condição NOVA, porque já estaria "disparado" pela antiga. curl -X PUT 'http://asender-crm:8005/v1/segmentos/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/segmentos/{id} DELETE /v1/alertas/{id}. Onde é usada: tela de alertas. Efeitos: uma escrita. curl -X DELETE 'http://asender-crm:8005/v1/segmentos/id_AQUI' ### POST /v1/segmentos/previa POST /v1/segmentos/previa. Onde é usada: enquanto o usuário monta os critérios na tela. Efeitos: uma consulta; NÃO grava nada. Existe para que a pergunta "isto alcança quem?" seja respondida ANTES de salvar. Sem ela, o caminho para descobrir o alcance é criar o segmento — e a tela enche de segmentos descartados chamados "teste 3". curl -X POST 'http://asender-crm:8005/v1/segmentos/previa' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/uso GET /v1/uso?periodo=YYYY-MM — números locais do CRM mais a quota e os contadores do core. Onde é usada: tela de conta/plano. Efeitos: uma leitura local e até duas chamadas ao core. # O core fora do ar NÃO derruba esta rota Os números locais (quantos contatos, quantos entraram no mês) são a metade que o CRM sabe sozinho, e são justamente a metade que a tela usa todo dia. Com o core fora, a resposta sai com `core_disponivel: false` — a tela mostra o que tem e diz o que não conseguiu saber (piso 10). curl -X GET 'http://asender-crm:8005/v1/uso' ============================================================================== # Pages — páginas, formulários e mídia (http://asender-pages:8006) # Serviço INTERNO — não alcançável de fora. ============================================================================== ### GET /healthz responde 200 enquanto o processo está vivo. Não toca dependência: um liveness que consulta o banco derruba o pod quando o banco oscila. Onde é usada: rota pública /healthz. Efeitos: escreve JSON na resposta. curl -X GET 'http://asender-pages:8006/healthz' ### GET /internal/v1/formularios/{id} GET /internal/v1/formularios/{id}. Onde é usada: chamado pelo `asender_runtime` para validar uma submissão. Efeitos: uma leitura. # Sem o tenant na rota, de propósito No modo SNIPPET o formulário está embutido no site de um terceiro e quem posta é o navegador de um visitante anônimo. O runtime não sabe de qual conta é o formulário — a conta é a RESPOSTA, tirada dele. Exigir o tenant na rota tornaria esta chamada impossível de fazer. Isto não afrouxa nada: o `public_id` é opaco e já está no HTML de quem visita o site. Quem o conhece já podia postar nele. O que protege esta rota é a credencial de serviço — sem ela, ela não responde nada. curl -X GET 'http://asender-pages:8006/internal/v1/formularios/id_AQUI' ### GET /internal/v1/paginas GET /internal/v1/paginas?dominio=&slug=. Onde é usada: chamado pelo `asender_runtime` a cada requisição de página pública. Efeitos: uma leitura. # Query string e não caminho O domínio contém pontos e o slug pode conter hífen; os dois em segmentos de rota exigiriam escape que o roteador desfaz de formas diferentes conforme a versão. Query string é literal, e o que trafega aqui é rede interna. curl -X GET 'http://asender-pages:8006/internal/v1/paginas' ### POST /internal/v1/paginas/{id}/cliques POST /internal/v1/paginas/{id}/cliques. Onde é usada: descarga periódica do contador em memória do `asender_runtime`. Efeitos: uma escrita por destino, somando ao que já existe. # Soma, e não atribui O corpo traz o DELTA do lote, nunca um total. Dois processos do runtime descarregando ao mesmo tempo somam certo; com atribuição, o último a escrever apagaria a contagem do outro — e o relatório mostraria menos visitas do que houve, sem nada indicar a perda. # Teto de destinos no corpo O mapa vem da rede. Sem teto, um lote com um milhão de chaves viraria um milhão de `INSERT` numa transação só. O teto é o mesmo do roteamento (20 destinos), porque é o máximo que uma página pode ter. curl -X POST 'http://asender-pages:8006/internal/v1/paginas/id_AQUI/cliques' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /internal/v1/paginas/publicadas GET /internal/v1/paginas/publicadas?dominio=. Onde é usada: chamado pelo `asender_runtime` para montar `/sitemap.xml`. Efeitos: uma leitura. Devolve só slug e data: o sitemap não precisa de título nem template, e cada campo a mais seria dado do cliente saindo por uma rota que qualquer visitante alcança pelo runtime. Domínio sem página responde lista VAZIA, e não 404: "este host não tem página publicada" é uma resposta sobre o mundo, e o runtime precisa dela para devolver um sitemap vazio em vez de um erro. curl -X GET 'http://asender-pages:8006/internal/v1/paginas/publicadas' ### GET /internal/v1/redirects GET /internal/v1/redirects?dominio=&slug=. Onde é usada: chamado pelo `asender_runtime` a cada clique. Efeitos: uma leitura. Não devolve o `tenant_id`: quem serve o redirect não precisa saber de quem ele é, e devolvê-lo daria a um visitante um mapa de qual conta encurta o quê. curl -X GET 'http://asender-pages:8006/internal/v1/redirects' ### POST /internal/v1/redirects/cliques POST /internal/v1/redirects/cliques. Onde é usada: descarga periódica do contador em memória do runtime. Efeitos: uma escrita por link. SOMA, e não atribui: dois processos do runtime descarregando ao mesmo tempo somam certo; com atribuição, o último a escrever apagaria a contagem do outro. curl -X POST 'http://asender-pages:8006/internal/v1/redirects/cliques' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /internal/v1/tls/authorize GET /internal/v1/tls/authorize?host=. Onde é usada: o servidor de borda, no meio do handshake TLS. Efeitos: uma consulta. # Fail-closed, e a resposta é um booleano Host desconhecido é `false` — negar é a resposta correta. Falha nossa é 503, e NÃO `false`: o servidor de borda tem de distinguir "não autorizado" de "não consegui decidir", senão um soluço de banco viraria recusa de certificado para domínio legítimo. A resposta não carrega tenant nem token. Quem monta o handshake não precisa disso, e devolvê-lo daria a quem sonda um mapa de quais domínios pertencem à plataforma. curl -X GET 'http://asender-pages:8006/internal/v1/tls/authorize' ### GET /readyz 503 `starting` antes do fim do boot; depois disso, CONSULTA as sondas e responde 200 `ready` ou 503 `degraded` com o nome de cada dependência. Onde é usada: rota pública /readyz. Efeitos: escreve JSON na resposta; pode tocar a dependência (com cache de 1s). # Por que ele não é mais um trinco de boot A readiness era `MarkReady()` uma vez e pronto — memória do boot, não estado atual. Num incidente real de 2026-08-27 o serviço voltou com a credencial errada do Postgres, falhou ~30 vezes por minuto ao drenar o outbox, e /healthz e /readyz responderam 200 o tempo todo: o gate de saúde do deploy passou e quem descobriu foi o pentest, depois. Readiness que só lembra do boot não enxerga a dependência que caiu DEPOIS dele. /healthz continua sem tocar em nada, e isso é de propósito: liveness que consulta o banco derruba o processo quando o banco oscila. Quem tem de dizer "não me mande tráfego" é o readiness. O que sai na resposta é o NOME da dependência e "indisponível" — nunca a mensagem do driver, que carrega usuário, host e base. Rota pública não conta topologia; o erro inteiro vai para o log. curl -X GET 'http://asender-pages:8006/readyz' ### GET /v1/configuracoes/marca GET /v1/paginas/{id}/experimento. Onde é usada: tela de A/B do painel. Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro. curl -X GET 'http://asender-pages:8006/v1/configuracoes/marca' ### PUT /v1/configuracoes/marca PUT /v1/configuracoes/marca — os quatro campos de uma vez. Onde é usada: tela de marca do painel. Efeitos: uma escrita; muda o que o visitante vê na próxima visita. PUT e não PATCH: são quatro campos que a tela mostra e salva juntos, e campo vazio APAGA aquele elemento. Um PATCH criaria a pergunta "como apago o logo?", cuja resposta seria um campo especial que ninguém acerta na primeira leitura. curl -X PUT 'http://asender-pages:8006/v1/configuracoes/marca' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/dominios GET /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma leitura. curl -X GET 'http://asender-pages:8006/v1/dominios' ### POST /v1/dominios POST /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde. curl -X POST 'http://asender-pages:8006/v1/dominios' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/dominios/{id} DELETE /v1/dominios/{id}. Onde é usada: tela de domínios. Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê. curl -X DELETE 'http://asender-pages:8006/v1/dominios/id_AQUI' ### POST /v1/dominios/{id}/verificar POST /v1/dominios/{id}/verificar. Onde é usada: botão "verificar" da tela de domínios. Efeitos: uma consulta DNS e uma escrita. # As duas falhas têm respostas DIFERENTES "Não consegui perguntar ao DNS" é 503 com "tente de novo"; "perguntei e o registro não está lá" é 422 com "publique o registro". Colapsar as duas faria o cliente ficar publicando um registro que já está lá. # E o resultado é GRAVADO nos dois casos Sem isso, quem tenta e falha vê a tela exatamente igual à de antes de tentar — e não tem como saber se o sistema chegou a olhar. curl -X POST 'http://asender-pages:8006/v1/dominios/id_AQUI/verificar' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/formularios GET /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma leitura. curl -X GET 'http://asender-pages:8006/v1/formularios' ### POST /v1/formularios POST /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde. curl -X POST 'http://asender-pages:8006/v1/formularios' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/formularios/{id} GET /v1/paginas/{id}/experimento. Onde é usada: tela de A/B do painel. Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro. curl -X GET 'http://asender-pages:8006/v1/formularios/id_AQUI' ### PUT /v1/formularios/{id} PUT /v1/formularios/{id}. Onde é usada: edição. Efeitos: escreve a linha. curl -X PUT 'http://asender-pages:8006/v1/formularios/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/formularios/{id} DELETE /v1/dominios/{id}. Onde é usada: tela de domínios. Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê. curl -X DELETE 'http://asender-pages:8006/v1/formularios/id_AQUI' ### POST /v1/ia/gerar-pagina POST /v1/paginas/gerar — manda o pedido ao provedor, saneia a saída e devolve o template. Onde é usada: botão "gerar com IA" do editor. Efeitos: uma chamada externa; NÃO grava nada. # Não grava: devolve o rascunho Gravar direto faria o modelo publicar na conta do cliente. O que sai daqui é uma sugestão que a pessoa vê no editor e salva se quiser — a versão continua nascendo do gesto dela. # Sem provedor, a rota DEGRADA 503 e não 500: é configuração ausente, e a distinção separa "o produto não tem essa função ligada" de "o produto quebrou". O resto do serviço segue de pé — nenhuma página deixa de ser servida porque não há chave de IA (piso 10). curl -X POST 'http://asender-pages:8006/v1/ia/gerar-pagina' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/midias GET /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma leitura. curl -X GET 'http://asender-pages:8006/v1/midias' ### POST /v1/midias POST /v1/midias (multipart, campo `arquivo`). Onde é usada: botão de upload do editor. Efeitos: escreve no disco e no banco. # A ordem é DISCO e depois BANCO Se o disco falhar, nada foi gravado e o erro é honesto. A ordem inversa deixaria uma linha apontando para um arquivo inexistente, e o visitante receberia erro numa página publicada. O caso duplicado não grava nada: a mídia já está lá, com os mesmos bytes. curl -X POST 'http://asender-pages:8006/v1/midias' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/midias/{id} DELETE /v1/dominios/{id}. Onde é usada: tela de domínios. Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê. curl -X DELETE 'http://asender-pages:8006/v1/midias/id_AQUI' ### POST /v1/midias/de-url POST /v1/midias/de-url. Onde é usada: colar o endereço de uma imagem no editor. Efeitos: uma requisição a servidor de terceiro; escreve no disco e no banco. # Esta é a rota mais perigosa do serviço Ela faz o SERVIDOR buscar um endereço que o USUÁRIO escolheu — a definição de SSRF. Sem as guardas, `http://169.254.169.254/latest/meta-data/` devolveria as credenciais da instância, e `http://redis-interno:6379/` alcançaria um serviço que nunca deveria ver tráfego de fora. Toda a defesa está em `domain/midia`: allowlist de esquema e porta, recusa de IP interno, conferência REPETIDA no momento de discar (contra DNS rebinding), zero redirecionamento e teto de tamanho durante a leitura. curl -X POST 'http://asender-pages:8006/v1/midias/de-url' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/paginas GET /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma leitura. curl -X GET 'http://asender-pages:8006/v1/paginas' ### POST /v1/paginas POST /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde. curl -X POST 'http://asender-pages:8006/v1/paginas' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/paginas/{id} GET /v1/paginas/{id}/experimento. Onde é usada: tela de A/B do painel. Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro. curl -X GET 'http://asender-pages:8006/v1/paginas/id_AQUI' ### PATCH /v1/paginas/{id} PATCH /v1/paginas/{id} — renomear, mudar o endereço público ou o domínio. Onde é usada: tela de páginas do painel (renomear) e configurações da página. Efeitos: uma escrita. # Só o que veio é tocado `nil` é "não mandado" e `&""` é "mandado vazio". Sem essa distinção, editar o título apagaria o endereço público da página — e ela sairia do ar sem ninguém ter pedido. # Validação IGUAL à da criação O slug e o título passam pelas mesmas regras do `Criar`. Um PATCH mais frouxo seria a porta dos fundos: bastaria criar válido e editar para inválido para pôr no ar um endereço que a criação recusa. curl -X PATCH 'http://asender-pages:8006/v1/paginas/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/paginas/{id}/ab/promover POST /v1/paginas/{id}/ab/promover. Onde é usada: botão "promover vencedora" e botão "iniciar teste" da tela de A/B. Efeitos: uma escrita. # Duas operações na mesma rota, e por quê O lynz expõe `POST /v1/pages/{id}/ab/promote`, sem rota de iniciar — o teste começa ao ser promovido a "rodando". Manter uma rota só preserva a superfície do lynz, e o corpo diz qual das duas transições é pedida. # Iniciar EXIGE o experimento válido É o único ponto onde o rascunho inconsistente é barrado, e é o ponto certo: pesos que não somam 100 deixariam uma faixa do tráfego sem dono, e `Escolher` devolveria a última variante para essa fatia — um viés silencioso que o número final não denuncia. curl -X POST 'http://asender-pages:8006/v1/paginas/id_AQUI/ab/promover' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/paginas/{id}/cliques GET /v1/redirects/{id}/cliques. Onde é usada: relatório da tela de links curtos. Efeitos: uma leitura. Devolve o CONTADOR, e não uma lista de eventos: a pergunta é sempre "quantos", nunca "quais". Uma linha por clique transformaria o caminho mais quente do redirect num INSERT por requisição. curl -X GET 'http://asender-pages:8006/v1/paginas/id_AQUI/cliques' ### GET /v1/paginas/{id}/experimento GET /v1/paginas/{id}/experimento. Onde é usada: tela de A/B do painel. Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro. curl -X GET 'http://asender-pages:8006/v1/paginas/id_AQUI/experimento' ### PUT /v1/paginas/{id}/experimento/auto-stop PUT /v1/paginas/{id}/experimento/auto-stop. Onde é usada: tela de A/B. Efeitos: uma escrita. Aceito com o teste RODANDO, ao contrário das variantes: apertar o critério não reatribui visitante nem invalida amostra — só muda quando o job pode concluir. curl -X PUT 'http://asender-pages:8006/v1/paginas/id_AQUI/experimento/auto-stop' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/paginas/{id}/publicar POST /v1/paginas/{id}/publicar. Onde é usada: botão publicar. Efeitos: move o ponteiro da página; o visitante passa a ver esta versão. curl -X POST 'http://asender-pages:8006/v1/paginas/id_AQUI/publicar' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/paginas/{id}/roteamento GET /v1/paginas/{id}/experimento. Onde é usada: tela de A/B do painel. Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro. curl -X GET 'http://asender-pages:8006/v1/paginas/id_AQUI/roteamento' ### PUT /v1/paginas/{id}/roteamento POST /v1/paginas/{id}/versoes. Onde é usada: botão salvar do editor. Efeitos: cria uma versão. curl -X PUT 'http://asender-pages:8006/v1/paginas/id_AQUI/roteamento' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/paginas/{id}/roteamento DELETE /v1/dominios/{id}. Onde é usada: tela de domínios. Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê. curl -X DELETE 'http://asender-pages:8006/v1/paginas/id_AQUI/roteamento' ### GET /v1/paginas/{id}/variantes GET /v1/paginas/{id}/variantes. Onde é usada: tela de A/B. Efeitos: uma leitura. curl -X GET 'http://asender-pages:8006/v1/paginas/id_AQUI/variantes' ### POST /v1/paginas/{id}/variantes POST /v1/paginas/{id}/variantes. Onde é usada: botão "nova variante" da tela de A/B. Efeitos: uma ou duas escritas. # O experimento nasce aqui, e em rascunho Exigir uma chamada de "criar experimento" antes da primeira variante daria ao operador um objeto vazio que não mede nada e que ele teria de lembrar de apagar. A primeira variante cria o teste. curl -X POST 'http://asender-pages:8006/v1/paginas/id_AQUI/variantes' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PUT /v1/paginas/{id}/variantes/{varianteID} PUT /v1/paginas/{id}/variantes/{varianteID}. Onde é usada: ajustar peso ou versão na tela de A/B. Efeitos: uma escrita. curl -X PUT 'http://asender-pages:8006/v1/paginas/id_AQUI/variantes/varianteID_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/paginas/{id}/variantes/{varianteID} DELETE /v1/paginas/{id}/variantes/{varianteID}. Onde é usada: tela de A/B. Efeitos: uma escrita. curl -X DELETE 'http://asender-pages:8006/v1/paginas/id_AQUI/variantes/varianteID_AQUI' ### POST /v1/paginas/{id}/versoes POST /v1/paginas/{id}/versoes. Onde é usada: botão salvar do editor. Efeitos: cria uma versão. curl -X POST 'http://asender-pages:8006/v1/paginas/id_AQUI/versoes' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/public/midias/{id} GET e HEAD /v1/public/midias/{id}. Onde é usada: a tag `` de uma landing publicada. Efeitos: abre e transmite o arquivo. # Sem sessão, e por isso com cuidado extra O id é o único parâmetro, e ele é resolvido no BANCO antes de tocar o disco: o caminho do arquivo nunca é montado com o que veio da URL. Path traversal não tem por onde entrar — e o `armazenamento` recusa id fora do formato de qualquer jeito, como segunda linha. # `X-Content-Type-Options: nosniff` é obrigatório aqui O tipo é o DETECTADO no upload, e a allowlist só tem imagem. Sem `nosniff`, o navegador pode reinterpretar o conteúdo e executar como outra coisa — e a allowlist de tipos perderia o sentido no último metro. curl -X GET 'http://asender-pages:8006/v1/public/midias/id_AQUI' ### GET /v1/redirects GET /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma leitura. curl -X GET 'http://asender-pages:8006/v1/redirects' ### POST /v1/redirects POST /v1/dominios. Onde é usada: tela de domínios. Efeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde. curl -X POST 'http://asender-pages:8006/v1/redirects' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/redirects/{id} GET /v1/paginas/{id}/experimento. Onde é usada: tela de A/B do painel. Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não 404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar o botão de criar. 404 mandaria a tela tratar o caso normal como erro. curl -X GET 'http://asender-pages:8006/v1/redirects/id_AQUI' ### PUT /v1/redirects/{id} PUT /v1/formularios/{id}. Onde é usada: edição. Efeitos: escreve a linha. curl -X PUT 'http://asender-pages:8006/v1/redirects/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/redirects/{id} DELETE /v1/dominios/{id}. Onde é usada: tela de domínios. Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito: um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém entender por quê. curl -X DELETE 'http://asender-pages:8006/v1/redirects/id_AQUI' ### GET /v1/redirects/{id}/cliques GET /v1/redirects/{id}/cliques. Onde é usada: relatório da tela de links curtos. Efeitos: uma leitura. Devolve o CONTADOR, e não uma lista de eventos: a pergunta é sempre "quantos", nunca "quais". Uma linha por clique transformaria o caminho mais quente do redirect num INSERT por requisição. curl -X GET 'http://asender-pages:8006/v1/redirects/id_AQUI/cliques' ============================================================================== # Runtime — publicação e analytics (http://asender-runtime:8007) # Serviço INTERNO — não alcançável de fora. ============================================================================== ### GET / resolve host+caminho na página publicada e devolve o HTML renderizado. Onde é usada: rota curinga do runtime, a de maior tráfego do sistema. Efeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento com tráfego normal de quem digitou a URL errada. curl -X GET 'http://asender-runtime:8007/' ### POST / resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e chama o MESMO pipeline dos outros dois modos. Onde é usada: POST no caminho da própria página. Efeitos: os do pipeline (banco, outbox). O tenant e o form vêm da PÁGINA resolvida no servidor, nunca do corpo: o form da página é HTML público, e qualquer um pode reenviá-lo com outro `form_id`. Resolver pelo host+caminho é o que impede postar no formulário de outro tenant. curl -X POST 'http://asender-runtime:8007/' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /{slug} resolve host+caminho na página publicada e devolve o HTML renderizado. Onde é usada: rota curinga do runtime, a de maior tráfego do sistema. Efeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento com tráfego normal de quem digitou a URL errada. curl -X GET 'http://asender-runtime:8007/slug_AQUI' ### POST /{slug} resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e chama o MESMO pipeline dos outros dois modos. Onde é usada: POST no caminho da própria página. Efeitos: os do pipeline (banco, outbox). O tenant e o form vêm da PÁGINA resolvida no servidor, nunca do corpo: o form da página é HTML público, e qualquer um pode reenviá-lo com outro `form_id`. Resolver pelo host+caminho é o que impede postar no formulário de outro tenant. curl -X POST 'http://asender-runtime:8007/slug_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /collect POST /collect — abre a credencial, normaliza cada evento e publica. Onde é usada: rota pública, a de maior volume do sistema depois da própria página. Efeitos: publica no broker; NUNCA escreve no banco. # Responde 204 quase sempre, e isso é deliberado Credencial inválida é 403 (o cliente precisa saber que aquele HTML está velho). Fora isso — evento inválido, tipo desconhecido, lote parcialmente ruim — a resposta é 204 e o que dava para aproveitar foi publicado. O pixel roda no browser de terceiros: transformar um evento malformado em erro visível não conserta nada e enche o console de quem só quer ver a página. curl -X POST 'http://asender-runtime:8007/collect' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /f/{formID} valida a ORIGEM contra os domínios verificados do tenant, ecoa os cabeçalhos de CORS e chama o mesmo pipeline. Onde é usada: rota pública, chamada de outro domínio pelo navegador. Efeitos: os do pipeline. Aqui não há API key: quem chama é o navegador do visitante, e uma key no snippet seria segredo no cliente (§13.4). A defesa é a allowlist de origem, verificada NO SERVIDOR — o header `Origin` é posto pelo navegador e não pode ser forjado por script da própria página. curl -X POST 'http://asender-runtime:8007/f/formID_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /healthz responde 200 enquanto o processo está vivo. Não toca dependência: um liveness que consulta o banco derruba o pod quando o banco oscila. Onde é usada: rota pública /healthz. Efeitos: escreve JSON na resposta. curl -X GET 'http://asender-runtime:8007/healthz' ### GET /pixel.js GET /pixel.js. Onde é usada: toda página publicada, uma vez por visita (depois é cache). Efeitos: escreve o script. Cache de 1 hora com ETag: o script muda com o deploy, e revalidar de hora em hora custa um 304. `immutable` seria errado — o conteúdo muda no mesmo caminho. curl -X GET 'http://asender-runtime:8007/pixel.js' ### GET /r/{slug} resolve host+caminho na página publicada e devolve o HTML renderizado. Onde é usada: rota curinga do runtime, a de maior tráfego do sistema. Efeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento com tráfego normal de quem digitou a URL errada. curl -X GET 'http://asender-runtime:8007/r/slug_AQUI' ### GET /readyz 503 `starting` antes do fim do boot; depois disso, CONSULTA as sondas e responde 200 `ready` ou 503 `degraded` com o nome de cada dependência. Onde é usada: rota pública /readyz. Efeitos: escreve JSON na resposta; pode tocar a dependência (com cache de 1s). # Por que ele não é mais um trinco de boot A readiness era `MarkReady()` uma vez e pronto — memória do boot, não estado atual. Num incidente real de 2026-08-27 o serviço voltou com a credencial errada do Postgres, falhou ~30 vezes por minuto ao drenar o outbox, e /healthz e /readyz responderam 200 o tempo todo: o gate de saúde do deploy passou e quem descobriu foi o pentest, depois. Readiness que só lembra do boot não enxerga a dependência que caiu DEPOIS dele. /healthz continua sem tocar em nada, e isso é de propósito: liveness que consulta o banco derruba o processo quando o banco oscila. Quem tem de dizer "não me mande tráfego" é o readiness. O que sai na resposta é o NOME da dependência e "indisponível" — nunca a mensagem do driver, que carrega usuário, host e base. Rota pública não conta topologia; o erro inteiro vai para o log. curl -X GET 'http://asender-runtime:8007/readyz' ### POST /replay POST /replay — abre a credencial, saneia e publica. Onde é usada: rota pública, chamada pelo pixel a cada poucos segundos de uma sessão gravada. Efeitos: publica no broker; NUNCA escreve no banco. Responde 204 quase sempre, como o `/collect`: credencial inválida é 403 (o HTML está velho), e o resto é aproveitado. O pixel roda no browser de terceiros, e transformar um lote parcialmente ruim em erro visível não conserta nada. curl -X POST 'http://asender-runtime:8007/replay' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /robots.txt libera a indexação e aponta o sitemap do MESMO host. Onde é usada: rota pública. Efeitos: nenhum. # Por que liberar, e não bloquear A página existe para ser achada: é landing page de campanha, e quem a publica quer tráfego. Um `Disallow: /` como padrão transformaria uma decisão de produto ("esta página não deve ser indexada") em comportamento da plataforma — e o cliente descobriria meses depois, sem entender por que o anúncio orgânico nunca apareceu. O sitemap é apontado com o HOST da requisição, e não com um domínio configurado: cada cliente tem o dele, e um endereço fixo aqui mandaria o buscador de todo mundo para o sitemap de um só. curl -X GET 'http://asender-runtime:8007/robots.txt' ### GET /sitemap.xml lista as páginas PUBLICADAS daquele host. Onde é usada: rota pública. Efeitos: uma leitura no `asender_pages`. # Host sem página responde XML VAZIO, e não 404 Um 404 no sitemap faz o buscador registrar erro e tentar de novo; um documento vazio diz "não há nada para indexar aqui", que é a verdade. E o host que ainda não tem página publicada é o caso mais comum logo depois de alguém cadastrar um domínio. # Rascunho não entra A consulta do `asender_pages` filtra por versão publicada. Listar rascunho entregaria ao buscador o endereço de uma página que o cliente ainda não quis mostrar — e o buscador não esquece. curl -X GET 'http://asender-runtime:8007/sitemap.xml' ### GET /v1/analytics/ab GET /v1/analytics/ab?pagina=. Onde é usada: tela do experimento. Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/analytics/ab' ### GET /v1/analytics/breakdown GET /v1/analytics/breakdown?dimensao=utm_source&… Onde é usada: tela de origens. Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/analytics/breakdown' ### GET /v1/analytics/events GET /v1/analytics/events?pagina=&limite=. Onde é usada: tela de depuração da instrumentação. Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/analytics/events' ### GET /v1/analytics/heatmap GET /v1/analytics/heatmap?pagina=&lado=. Onde é usada: tela de mapa de calor. Efeitos: uma leitura. # As posições são PORCENTAGEM, e não pixel Pixel absoluto é da tela de quem clicou: o mesmo botão sai em x=320 no celular e x=980 no monitor. A célula em porcentagem compara telas diferentes — que é a única pergunta que um mapa de calor responde. curl -X GET 'http://asender-runtime:8007/v1/analytics/heatmap' ### GET /v1/analytics/live GET /v1/analytics/live — sessões e eventos da janela curta. Onde é usada: cabeçalho da tela ao vivo, no primeiro carregamento. Efeitos: uma leitura. Existe além do stream porque quem abre a tela precisa ver ALGO antes do primeiro evento chegar. Sem isso, uma conta com movimento baixo mostraria tela vazia por minutos e pareceria quebrada. curl -X GET 'http://asender-runtime:8007/v1/analytics/live' ### GET /v1/analytics/live/stream GET /v1/analytics/live/stream — Server-Sent Events. Onde é usada: tela ao vivo. Efeitos: mantém uma conexão aberta e uma inscrição no hub. # SSE, e não WebSocket O fluxo é de mão única (servidor → tela) e o cliente é um navegador. SSE atravessa proxy comum, reconecta sozinho e cabe em quatro linhas de JS; WebSocket traria um protocolo inteiro para carregar dado que só desce. curl -X GET 'http://asender-runtime:8007/v1/analytics/live/stream' ### GET /v1/analytics/overview GET /v1/analytics/overview?pagina=&de=&ate=. Onde é usada: tela inicial de analytics. Efeitos: duas leituras. Funil e atribuição juntos porque a tela mostra os dois lado a lado, e duas requisições poderiam ler janelas diferentes — o total da atribuição não fecharia com o topo do funil, e o número "errado" seria o certo. curl -X GET 'http://asender-runtime:8007/v1/analytics/overview' ### GET /v1/analytics/session/{id} GET /v1/analytics/session/{id}. Onde é usada: tela de sessão — o que aquela visita fez, em ordem. Efeitos: uma leitura. Sessão de outra conta responde lista VAZIA, e não 404: o id de sessão é opaco e gerado no browser, e distinguir "não existe" de "não é sua" transformaria a rota num oráculo de existência de sessão alheia. curl -X GET 'http://asender-runtime:8007/v1/analytics/session/id_AQUI' ### GET /v1/analytics/traffic-quality GET /v1/analytics/traffic-quality. Onde é usada: tela de qualidade — "esse tráfego pago é gente?". Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/analytics/traffic-quality' ### POST /v1/forms/{formID}/submissions autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline. Onde é usada: rota autenticada do runtime. Efeitos: os do pipeline (banco, outbox). O tenant vem do CONTEXTO, posto pelo middleware que validou a API key — nunca do corpo (§37: confiar em tenant_id do cliente). É isso que faz a key de um tenant não alcançar o form de outro. curl -X POST 'http://asender-runtime:8007/v1/forms/formID_AQUI/submissions' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /v1/leads autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline. Onde é usada: rota autenticada do runtime. Efeitos: os do pipeline (banco, outbox). O tenant vem do CONTEXTO, posto pelo middleware que validou a API key — nunca do corpo (§37: confiar em tenant_id do cliente). É isso que faz a key de um tenant não alcançar o form de outro. curl -X POST 'http://asender-runtime:8007/v1/leads' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/pixels GET /v1/pixels. Onde é usada: tela de pixels. Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/pixels' ### POST /v1/pixels POST /v1/pixels. Onde é usada: tela de pixels. Efeitos: uma escrita. curl -X POST 'http://asender-runtime:8007/v1/pixels' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /v1/pixels/{id} GET /v1/pixels/{id}. Onde é usada: tela de detalhe do pixel. Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/pixels/id_AQUI' ### PATCH /v1/pixels/{id} PATCH /v1/pixels/{id}. Onde é usada: tela de detalhe. Efeitos: uma escrita. PATCH parcial de verdade: campo ausente não é tocado. Sem isso, editar só o nome DESATIVARIA o pixel — e a coleta pararia sem ninguém ter pedido. curl -X PATCH 'http://asender-runtime:8007/v1/pixels/id_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/pixels/{id} DELETE /v1/pixels/{id}. Onde é usada: tela de detalhe. Efeitos: uma escrita; a coleta daquele pixel para. curl -X DELETE 'http://asender-runtime:8007/v1/pixels/id_AQUI' ### GET /v1/pixels/{id}/conversions GET /v1/pixels/{id}/conversions. Onde é usada: tela de detalhe. Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/pixels/id_AQUI/conversions' ### POST /v1/pixels/{id}/conversions POST /v1/pixels/{id}/conversions. Onde é usada: tela de detalhe. Efeitos: uma escrita. curl -X POST 'http://asender-runtime:8007/v1/pixels/id_AQUI/conversions' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PATCH /v1/pixels/{id}/conversions/{cid} PATCH /v1/pixels/{id}/conversions/{cid}. Onde é usada: tela de detalhe. Efeitos: uma escrita. curl -X PATCH 'http://asender-runtime:8007/v1/pixels/id_AQUI/conversions/cid_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/pixels/{id}/conversions/{cid} DELETE /v1/pixels/{id}/conversions/{cid}. Onde é usada: tela de detalhe. Efeitos: uma escrita. curl -X DELETE 'http://asender-runtime:8007/v1/pixels/id_AQUI/conversions/cid_AQUI' ### GET /v1/pixels/{id}/domains GET /v1/pixels/{id}/domains. Onde é usada: tela de detalhe. Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/pixels/id_AQUI/domains' ### POST /v1/pixels/{id}/domains POST /v1/pixels/{id}/domains. Onde é usada: tela de detalhe. Efeitos: uma escrita — e, a partir dela, o `/collect` ecoa CORS para aquele host. curl -X POST 'http://asender-runtime:8007/v1/pixels/id_AQUI/domains' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PATCH /v1/pixels/{id}/domains/{domainId} PATCH /v1/pixels/{id}/domains/{domainId}. Onde é usada: tela de detalhe. Efeitos: uma escrita. curl -X PATCH 'http://asender-runtime:8007/v1/pixels/id_AQUI/domains/domainId_AQUI' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /v1/pixels/{id}/domains/{domainId} DELETE /v1/pixels/{id}/domains/{domainId}. Onde é usada: tela de detalhe. Efeitos: uma escrita — a origem para de coletar na hora. curl -X DELETE 'http://asender-runtime:8007/v1/pixels/id_AQUI/domains/domainId_AQUI' ### GET /v1/replay/sessions GET /v1/replay/sessions?limite=N. Onde é usada: tela de replay. Efeitos: uma leitura. curl -X GET 'http://asender-runtime:8007/v1/replay/sessions' ### GET /v1/replay/sessions/{id}/events GET /v1/replay/sessions/{id}/events. Onde é usada: player da tela de replay. Efeitos: uma leitura. Sessão de outra conta responde lista VAZIA, e não 404: o id é opaco e gerado no browser, e distinguir "não existe" de "não é sua" transformaria a rota num oráculo de existência de sessão alheia. curl -X GET 'http://asender-runtime:8007/v1/replay/sessions/id_AQUI/events' ### GET /version nome do serviço, versão e o commit que gerou o binário. Onde é usada: rota pública, usada por ops e por quem investiga incidente. Efeitos: escreve JSON na resposta. # Por que ela é PÚBLICA, e o que ela não conta A primeira pergunta de todo incidente é "que versão está no ar?" — e ela tem de ser respondível sem credencial, porque quem investiga muitas vezes ainda não tem uma. O que sai é o que já está no `docker inspect` de quem tem acesso ao host: nome, versão e commit. Nunca configuração, nunca dependência, nunca endereço interno — isso é topologia, e topologia é do console de plataforma. A versão vem de LDFLAGS no build, e o default é `dev`: um binário sem carimbo diz `dev` em vez de mentir uma versão que ninguém emitiu. curl -X GET 'http://asender-runtime:8007/version' ============================================================================== # Mail — e-mail corporativo (http://asender-mail:8016) # Serviço INTERNO — não alcançável de fora. ============================================================================== ### PUT /domains/{id}/autocreate confere posse do domínio, lê {enabled} e grava o flag. Onde é usada: PUT /domains/{id}/autocreate. Alternativa ao catch-all. curl -X PUT 'http://asender-mail:8016/domains/id_AQUI/autocreate' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### PUT /domains/{id}/catchall Definir/limpar catch-all Onde é usada: PUT /domains/{id}/catchall. Respostas: 200 ok curl -X PUT 'http://asender-mail:8016/domains/id_AQUI/catchall' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /domains/{id}/dns Registros de DNS a publicar (posse/MX/SPF/DKIM/DMARC) Onde é usada: GET /domains/{id}/dns. Respostas: 200 ok curl -X GET 'http://asender-mail:8016/domains/id_AQUI/dns' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /domains/{id}/mailboxes Criar caixa Onde é usada: POST /domains/{id}/mailboxes. Respostas: 201 criada curl -X POST 'http://asender-mail:8016/domains/id_AQUI/mailboxes' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /domains/{id}/mailboxes Listar caixas Onde é usada: GET /domains/{id}/mailboxes. Respostas: 200 ok curl -X GET 'http://asender-mail:8016/domains/id_AQUI/mailboxes' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /domains/{id}/verify Verificar publicação no DNS Onde é usada: POST /domains/{id}/verify. Respostas: 200 estado curl -X POST 'http://asender-mail:8016/domains/id_AQUI/verify' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /healthz prova que o serviço consegue atender (banco de pé). Degradação graciosa (piso 10): se o banco cai, o readyz avisa e o orquestrador não manda tráfego — o processo não morre. Onde é usada: GET /readyz e /healthz. curl -X GET 'http://asender-mail:8016/healthz' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /livez Livez responde 200 sempre — o processo está vivo. Onde é usada: GET /livez. curl -X GET 'http://asender-mail:8016/livez' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /mailboxes/{id}/folders Pastas da caixa, com total e não-lidas Onde é usada: GET /mailboxes/{id}/folders, que desenha o rail. Respostas: 200 ok; 404 caixa inexistente ou sem acesso curl -X GET 'http://asender-mail:8016/mailboxes/id_AQUI/folders' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /mailboxes/{id}/grants ListarGrants lista as concessões de uma caixa. Onde é usada: GET /mailboxes/{id}/grants. Admin only. curl -X GET 'http://asender-mail:8016/mailboxes/id_AQUI/grants' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /mailboxes/{id}/grants ConcederGrant concede acesso de um usuário a uma caixa. Onde é usada: POST /mailboxes/{id}/grants {user_id, role}. Admin only. curl -X POST 'http://asender-mail:8016/mailboxes/id_AQUI/grants' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### DELETE /mailboxes/{id}/grants/{userId} RevogarGrant remove o acesso de um usuário a uma caixa. Onde é usada: DELETE /mailboxes/{id}/grants/{userId}. Admin only. curl -X DELETE 'http://asender-mail:8016/mailboxes/id_AQUI/grants/userId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /mailboxes/{id}/messages Listar mensagens da caixa (por pasta, com marcadores e busca) Onde é usada: GET /mailboxes/{id}/messages?folder=&unread=1&starred=1&q=&limit=N&cursor=… O recorte vem da query string, mas o ESCOPO não: a caixa sai da rota já autorizada, e é ela que a consulta usa. Um `?mailbox=` no filtro seria a própria pessoa escolhendo o que pode ver. Parâmetros: folder (query); unread (query); starred (query); q (query); limit (query); cursor (query) Respostas: 200 ok; 404 caixa inexistente ou sem acesso (deny-default não revela existência) curl -X GET 'http://asender-mail:8016/mailboxes/id_AQUI/messages' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /mailboxes/{id}/messages/{msgId} Ler mensagem completa (marca lida) Onde é usada: GET /mailboxes/{id}/messages/{msgId}. Cross-tenant/outra caixa -> 404. Respostas: 200 ok; 404 mensagem de outra caixa curl -X GET 'http://asender-mail:8016/mailboxes/id_AQUI/messages/msgId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### PATCH /mailboxes/{id}/messages/{msgId} Marcar como lida, estrelar ou mover uma mensagem Onde é usada: PATCH /mailboxes/{id}/messages/{msgId}. Erros: patch vazio -> 400 (não é sucesso silencioso); mensagem de outra caixa -> 404, o mesmo da leitura, que não revela a existência dela. Respostas: 200 aplicado; 400 corpo inválido ou sem campo algum; 404 mensagem não é desta caixa curl -X PATCH 'http://asender-mail:8016/mailboxes/id_AQUI/messages/msgId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /mailboxes/{id}/messages/{msgId}/attachments/{attId} caixa->domínio->tenant + AcharAnexo (scopeado à mensagem/caixa); responde com o conteúdo e Content-Disposition. Onde é usada: GET /mailboxes/{id}/messages/{msgId}/attachments/{attId}. curl -X GET 'http://asender-mail:8016/mailboxes/id_AQUI/messages/msgId_AQUI/attachments/attId_AQUI' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /mailboxes/{id}/messages/{msgId}/raw authz em cadeia (caixa -> domínio -> tenant) + leitura do blob; sai como `message/rfc822` para download. Onde é usada: GET /mailboxes/{id}/messages/{msgId}/raw. Por que a rota existe: o `.eml` é o único artefato fiel do que chegou ou saiu — com cabeçalhos, ordem das partes e assinatura DKIM. É o que se abre noutro cliente, o que se anexa a uma disputa e o que se reprocessa quando o parser melhora. Sem rota, ele seria um arquivo num volume que ninguém alcança. Mensagem anterior ao ADR-0028 não tem bruto: 404, que é a verdade — e não um arquivo vazio com cara de download bem-sucedido. curl -X GET 'http://asender-mail:8016/mailboxes/id_AQUI/messages/msgId_AQUI/raw' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /mailboxes/{id}/messages/batch Aplicar a mesma alteração a várias mensagens da caixa Onde é usada: POST /mailboxes/{id}/messages/batch. # A resposta diz o que NÃO foi feito Id que não é desta caixa volta em `negados`. Não é aplicado — e também não é ignorado em silêncio: a tela precisa poder dizer "3 de 5 movidas" em vez de mostrar cinco linhas somindo e duas voltando no próximo carregamento (piso 4). O status é 200 mesmo com negados: a operação aconteceu, parcialmente, e a lista dos dois lados é a resposta — um 4xx obrigaria o cliente a adivinhar o que foi aplicado. Respostas: 200 aplicado (possivelmente em parte); 400 lista vazia; 404 caixa inexistente ou sem acesso curl -X POST 'http://asender-mail:8016/mailboxes/id_AQUI/messages/batch' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /mailboxes/{id}/send confere posse (caixa -> domínio -> org -> tenant), monta o pedido, assina com a chave do domínio e entrega pelo relay; grava o outbound_log. Onde é usada: POST /mailboxes/{id}/send. Fluxo: JSON -> outbound.Service.Enviar (assina d=domínio) -> relay /raw. curl -X POST 'http://asender-mail:8016/mailboxes/id_AQUI/send' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /mailboxes/minhas ListarMinhasCaixas devolve as caixas CONCEDIDAS ao usuário logado (visão Onde é usada: GET /mailboxes/minhas. Sem usuário (API key/admin genérico), devolve vazio — o admin usa a listagem por domínio. curl -X GET 'http://asender-mail:8016/mailboxes/minhas' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /orgs Criar organização Onde é usada: POST /orgs. Respostas: 201 criada curl -X POST 'http://asender-mail:8016/orgs' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /orgs Listar organizações da conta Onde é usada: GET /orgs. Respostas: 200 ok curl -X GET 'http://asender-mail:8016/orgs' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /orgs/{orgID}/domains Criar domínio (gera DKIM próprio) Onde é usada: POST /orgs/{orgID}/domains. Respostas: 201 criado curl -X POST 'http://asender-mail:8016/orgs/orgID_AQUI/domains' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /orgs/{orgID}/domains Listar domínios da org Onde é usada: GET /orgs/{orgID}/domains. Respostas: 200 ok curl -X GET 'http://asender-mail:8016/orgs/orgID_AQUI/domains' \ -H 'Authorization: Bearer SEU_TOKEN' ### GET /orgs/{orgID}/envios o que saiu, para quem, com que resultado e, quando falhou, por quê. Onde é usada: GET /orgs/{orgID}/envios?status=&limit=, a tela de Envios do admin. # Por que esta rota existe Porque "mandei e não sei o que aconteceu" é o pior estado possível de um serviço de e-mail. O log por destinatário já era gravado desde o F16; o que não existia era um jeito de OLHAR para ele sem abrir o banco. Um dado que só o desenvolvedor alcança não é observabilidade, é arquivo morto. O escopo é o tenant do principal verificado — nunca o `orgID` da rota sozinho, que é do cliente. O `orgID` fica no caminho por coerência com as outras rotas de org; a autorização é o tenant. curl -X GET 'http://asender-mail:8016/orgs/orgID_AQUI/envios' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /orgs/{orgID}/mailbox-accounts POST /orgs/{orgID}/mailbox-accounts {domain_id, local_part, name, password?}. Sem password, gera uma forte (CSPRNG) e devolve UMA vez para o admin repassar. Onde é usada: mail-admin. Admin only. Se o auth cair, a caixa não é criada órfã (ordem: conta primeiro, depois caixa+grant) — falha limpa. curl -X POST 'http://asender-mail:8016/orgs/orgID_AQUI/mailbox-accounts' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### POST /orgs/{orgID}/mailbox-accounts/reset ResetarSenha dispara o e-mail de redefinição para a conta da caixa. Onde é usada: POST /orgs/{orgID}/mailbox-accounts/{userId}/reset. Admin only. curl -X POST 'http://asender-mail:8016/orgs/orgID_AQUI/mailbox-accounts/reset' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ### GET /readyz prova que o serviço consegue atender (banco de pé). Degradação graciosa (piso 10): se o banco cai, o readyz avisa e o orquestrador não manda tráfego — o processo não morre. Onde é usada: GET /readyz e /healthz. curl -X GET 'http://asender-mail:8016/readyz' \ -H 'Authorization: Bearer SEU_TOKEN' ### POST /v1/emails Enviar e-mail transacional/marketing Onde é usada: POST /v1/emails. Fluxo: API key -> tenant -> caixa por endereço -> outbound -> relay /raw. Parâmetros: Idempotency-Key (header) Respostas: 200 replay idempotente (já enviado antes); 202 aceito e entregue; 400 validação; 401 API key ausente/inválida; 404 caixa remetente não é da conta; 502 envio recusado (piso 18) ou entrega falhou curl -X POST 'http://asender-mail:8016/v1/emails' \ -H 'Authorization: Bearer SEU_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: ' \ -d '{ }' ============================================================================== # SCHEMAS (OpenAPI) ============================================================================== { "api_ErrorEnvelope": { "type": "object", "required": [ "Error", "RequestId" ], "properties": { "Error": { "type": "object", "required": [ "Type", "Code", "Message" ], "properties": { "Type": { "type": "string", "enum": [ "Sender", "Receiver" ], "description": "Derivado do status: 4xx→Sender, 5xx→Receiver. O SDK decide retry por aqui." }, "Code": { "type": "string", "examples": [ "ValidationError", "AuthorizationError", "NotFound", "TenantSuspended", "NoTenant", "Conflict", "PayloadTooLarge", "Throttling", "InternalError" ] }, "Message": { "type": "string" }, "Details": { "description": "Presente só na validação SES do `/v1/*`: mapa campo → motivo. Omitido quando vazio.", "type": "object", "additionalProperties": { "type": "string" } } } }, "RequestId": { "type": "string" } } }, "api_RequestIdEnvelope": { "type": "object", "required": [ "RequestId" ], "properties": { "RequestId": { "type": "string", "description": "Presente em TODA resposta de sucesso. Espelha o header\n`X-Request-Id`. **Divergência:** um `X-Request-Id` mandado pelo\ncliente é ecoado cru e vira o correlation id do log\n(RELATORIO-FINAL §3 item 15).\n" } } }, "api_CursorPage": { "type": "object", "properties": { "NextCursor": { "type": "integer", "description": "Cursor da próxima página; 0 quando acabou." }, "HasMore": { "type": "boolean" } } }, "api_Channel": { "type": "string", "enum": [ "email", "sms", "push" ] }, "api_MessageStatus": { "type": "string", "enum": [ "queued", "sending", "sent", "delivered", "bounced", "failed", "opened", "clicked" ] }, "api_Message": { "type": "object", "description": "Projeção de listagem: SEM corpo (payload grande em lista é desperdício).", "properties": { "Id": { "type": "string" }, "TenantId": { "type": "string" }, "Channel": { "$ref": "#/components/schemas/Channel" }, "Status": { "$ref": "#/components/schemas/MessageStatus" }, "From": { "type": "string" }, "To": { "type": "string" }, "Subject": { "type": "string" }, "Provider": { "type": [ "string", "null" ] }, "ProviderMessageId": { "type": [ "string", "null" ] }, "ErrorCode": { "type": [ "string", "null" ] }, "ErrorMessage": { "type": [ "string", "null" ] }, "QueuedAt": { "type": [ "string", "null" ], "format": "date-time" }, "SentAt": { "type": [ "string", "null" ], "format": "date-time" }, "DeliveredAt": { "type": [ "string", "null" ], "format": "date-time" }, "FailedAt": { "type": [ "string", "null" ], "format": "date-time" }, "OpenedAt": { "type": [ "string", "null" ], "format": "date-time" }, "ClickedAt": { "type": [ "string", "null" ], "format": "date-time" }, "CreatedAt": { "type": "string", "format": "date-time" } } }, "api_MessagePage": { "allOf": [ { "$ref": "#/components/schemas/RequestIdEnvelope" }, { "$ref": "#/components/schemas/CursorPage" }, { "type": "object", "properties": { "Messages": { "type": "array", "items": { "$ref": "#/components/schemas/Message" } } } } ] }, "api_MessageDetail": { "allOf": [ { "$ref": "#/components/schemas/RequestIdEnvelope" }, { "type": "object", "properties": { "Message": { "allOf": [ { "$ref": "#/components/schemas/Message" }, { "type": "object", "properties": { "BodyText": { "type": "string" }, "BodyHtml": { "type": "string" }, "Metadata": { "type": "object", "additionalProperties": true, "description": "VALOR OPACO — chaves internas passam intactas (não são pascalizadas)." } } } ] }, "Events": { "type": "array", "items": { "type": "object", "properties": { "Type": { "type": "string" }, "OccurredAt": { "type": "string", "format": "date-time" }, "Payload": { "type": "object", "additionalProperties": true, "description": "Valor opaco." } } } } } } ] }, "api_SendEmailRequest": { "type": "object", "required": [ "Source", "Destination", "Message" ], "properties": { "Source": { "type": "string", "format": "email" }, "Destination": { "type": "object", "properties": { "ToAddresses": { "type": "array", "items": { "type": "string", "format": "email" } }, "CcAddresses": { "type": "array", "items": { "type": "string", "format": "email" } }, "BccAddresses": { "type": "array", "items": { "type": "string", "format": "email" } } }, "description": "Ao menos um endereço somando To+Cc+Bcc." }, "Message": { "type": "object", "required": [ "Subject", "Body" ], "properties": { "Subject": { "type": "object", "properties": { "Data": { "type": "string", "minLength": 1 }, "Charset": { "type": "string" } } }, "Body": { "type": "object", "description": "Ao menos um de Text.Data ou Html.Data.", "properties": { "Text": { "type": "object", "properties": { "Data": { "type": "string" }, "Charset": { "type": "string" } } }, "Html": { "type": "object", "properties": { "Data": { "type": "string" }, "Charset": { "type": "string" } } } } } } }, "ReplyToAddresses": { "type": "array", "items": { "type": "string", "format": "email" } }, "ReturnPath": { "type": "string", "format": "email" }, "IdempotencyKey": { "type": "string", "description": "É ESTE campo que funciona, não o header." }, "Tags": { "type": "object", "additionalProperties": { "type": "string" } } } }, "api_BatchResult": { "type": "object", "properties": { "Index": { "type": "integer" }, "Status": { "type": "string", "enum": [ "queued", "rejected", "failed" ] }, "MessageId": { "type": "string" }, "MessageIds": { "type": "array", "items": { "type": "string" } }, "Error": { "type": "object", "properties": { "Code": { "type": "string" }, "Message": { "type": "string" }, "Details": { "type": "object", "additionalProperties": { "type": "string" } } } } } }, "api_Template": { "type": "object", "properties": { "Id": { "type": "string" }, "TenantId": { "type": "string" }, "Slug": { "type": "string" }, "Name": { "type": "string" }, "Channel": { "$ref": "#/components/schemas/Channel" }, "Subject": { "type": "string" }, "BodyHtml": { "type": "string" }, "BodyText": { "type": "string" }, "Variables": { "type": "array", "items": { "type": "string" }, "description": "Valor opaco — não pascalizado." }, "CreatedAt": { "type": "string", "format": "date-time" }, "UpdatedAt": { "type": "string", "format": "date-time" } } }, "api_ContactInput": { "type": "object", "additionalProperties": false, "description": "Ao menos um de Email ou Phone.", "properties": { "Email": { "type": "string" }, "Phone": { "type": "string" }, "Name": { "type": "string" }, "Attributes": { "type": "object", "additionalProperties": true, "description": "Valor opaco." }, "Subscribed": { "type": "boolean", "default": true } } }, "api_Contact": { "type": "object", "properties": { "Id": { "type": "string" }, "TenantId": { "type": "string" }, "Email": { "type": "string" }, "Phone": { "type": "string" }, "Name": { "type": "string" }, "Attributes": { "type": "object", "additionalProperties": true }, "Subscribed": { "type": "boolean" }, "UnsubscribedAt": { "type": [ "string", "null" ], "format": "date-time" }, "CreatedAt": { "type": "string", "format": "date-time" }, "UpdatedAt": { "type": "string", "format": "date-time" } } }, "api_ContactList": { "type": "object", "properties": { "Id": { "type": "string" }, "TenantId": { "type": "string" }, "Name": { "type": "string" }, "Slug": { "type": "string" }, "Description": { "type": "string" }, "MemberCount": { "type": "integer" }, "CreatedAt": { "type": "string", "format": "date-time" }, "UpdatedAt": { "type": "string", "format": "date-time" } } }, "api_Device": { "type": "object", "properties": { "Id": { "type": "string" }, "TenantId": { "type": "string" }, "ContactId": { "type": "string" }, "Token": { "type": "string" }, "Platform": { "type": "string", "enum": [ "ios", "android", "web" ] }, "Active": { "type": "boolean" }, "LastSeenAt": { "type": [ "string", "null" ], "format": "date-time" }, "CreatedAt": { "type": "string", "format": "date-time" }, "UpdatedAt": { "type": "string", "format": "date-time" } } }, "api_Tenant": { "type": "object", "description": "Projeção do asender-core (`tenantPayload`), pascalizada.", "properties": { "Id": { "type": "string" }, "Name": { "type": "string" }, "Slug": { "type": "string" }, "Status": { "type": "string", "enum": [ "active", "suspended" ] }, "TrialEndsAt": { "type": [ "string", "null" ], "format": "date-time" }, "Metadata": { "type": "object", "additionalProperties": true, "description": "Valor opaco." }, "CreatedAt": { "type": "string", "format": "date-time" }, "UpdatedAt": { "type": "string", "format": "date-time" } } }, "api_TenantNode": { "type": "object", "description": "Nó de árvore (`tenantNodePayload` do core), pascalizado.", "properties": { "Id": { "type": "string" }, "Name": { "type": "string" }, "Slug": { "type": "string" }, "Status": { "type": "string" }, "Kind": { "type": "string", "enum": [ "root", "leaf" ] }, "Depth": { "type": "integer", "description": "Profundidade absoluta na árvore." }, "RelativeDepth": { "type": "integer", "description": "Profundidade relativa ao nó consultado — use para indentar." }, "ParentId": { "type": [ "string", "null" ] }, "MonthlyQuota": { "type": [ "integer", "null" ], "description": "`null` = herda." }, "EffectiveQuota": { "type": [ "integer", "null" ], "description": "Valor que realmente vale; `null` = sem limite em nenhum ancestral." }, "ChildrenCount": { "type": "integer" }, "CreatedAt": { "type": "string", "format": "date-time" }, "UpdatedAt": { "type": "string", "format": "date-time" } } }, "api_Usage": { "type": "object", "properties": { "Period": { "type": "string", "examples": [ "2026-07" ] }, "EmailsSent": { "type": "integer" }, "SmsSent": { "type": "integer" }, "PushSent": { "type": "integer" }, "ApiCalls": { "type": "integer" }, "UpdatedAt": { "type": "string", "format": "date-time" } } }, "api_ReportCounters": { "type": "object", "description": "`Total` é o volume do período; `Queued` é BACKLOG (o que ainda não saiu).", "properties": { "Total": { "type": "integer" }, "Queued": { "type": "integer" }, "Sent": { "type": "integer" }, "Delivered": { "type": "integer" }, "Bounced": { "type": "integer" }, "Failed": { "type": "integer" }, "Opened": { "type": "integer" }, "Clicked": { "type": "integer" } } }, "api_ReportRates": { "type": "object", "description": "`Delivery = Delivered/Sent`, `Bounce = Bounced/Sent`,\n`Open = Opened/Delivered`, `Click = Clicked/Delivered`.\nDenominador zero devolve `0` (nunca `null`, nunca `NaN`).\n", "properties": { "Delivery": { "type": "number", "minimum": 0, "maximum": 1 }, "Bounce": { "type": "number", "minimum": 0, "maximum": 1 }, "Open": { "type": "number", "minimum": 0, "maximum": 1 }, "Click": { "type": "number", "minimum": 0, "maximum": 1 } } }, "api_ReportPeriod": { "type": "object", "description": "Janela efetivamente usada, já com o default aplicado. `To` é EXCLUSIVO.", "properties": { "From": { "type": "string", "format": "date-time" }, "To": { "type": "string", "format": "date-time" } } }, "auth_ErrorEnvelope": { "type": "object", "required": [ "Error", "RequestId" ], "properties": { "Error": { "type": "object", "required": [ "Type", "Code", "Message" ], "properties": { "Type": { "type": "string", "enum": [ "Sender", "Receiver" ] }, "Code": { "type": "string", "examples": [ "InvalidRequest", "InvalidInput", "MissingToken", "InvalidCredentials", "InvalidToken", "ExpiredToken", "TwoFactorFailed", "TwoFactorMissing", "NotFound", "Conflict", "NotReady", "InternalError" ] }, "Message": { "type": "string" }, "Details": {} } }, "RequestId": { "type": "string" } } }, "auth_User": { "type": "object", "description": "Nunca traz `password_hash` nem o segredo TOTP.", "properties": { "id": { "type": "string", "examples": [ "usr_demo_owner" ] }, "email": { "type": "string", "format": "email" }, "name": { "type": "string" }, "created_at": { "type": "string", "format": "date-time" } } }, "auth_LoginResult": { "type": "object", "properties": { "token": { "type": "string", "description": "JWT de sessão. VAZIO quando `two_factor_required`." }, "expires_at": { "type": "string", "format": "date-time" }, "user_id": { "type": "string" }, "two_factor_required": { "type": "boolean" } } }, "auth_UserIdBody": { "type": "object", "additionalProperties": false, "required": [ "user_id" ], "properties": { "user_id": { "type": "string" } } }, "auth_TwoFACodeBody": { "type": "object", "additionalProperties": false, "required": [ "user_id", "code" ], "properties": { "user_id": { "type": "string" }, "code": { "type": "string", "description": "TOTP de 6 dígitos." } } } }