# Trazo API reference (for humans and agents) Trazo (https://gettrazo.app) is a multi-tenant contact-form backend for static websites. A site POSTs its contact-form data to one public JSON endpoint; Trazo verifies a Cap CAPTCHA token, stores the submission, classifies it for spam with an AI model, and emails the site's notification address. An authenticated Management API (and a dashboard at https://gettrazo.app/admin) manages organizations, sites, submissions, and users. This document is the complete reference for both APIs. All requests and responses are JSON; the API contract is English-only. ## How to get credentials There are two kinds of keys: - **Site API key** (`sk_live_...`): identifies one site to the public form-intake endpoint. Issued when a site is created (in the dashboard or via `POST /api/v1/sites`) and on key rotation. The plaintext is shown/returned **exactly once**; only a hash is stored. This key is embedded in public web pages by design. - **Personal API key** (`trz_pat_...`): authenticates you to the Management API with your own role and scope. Any user creates one at https://gettrazo.app/account under "API keys" (a password confirmation is required). The plaintext is shown **once** at creation; up to 10 active keys per user; revoke at any time (immediate). Keys cannot be created or revoked via the API itself, only in the account UI. No Trazo account yet? Sign up at https://gettrazo.app/signup (free tier: 1 site, 1,000 lifetime submissions) or see https://gettrazo.app/pricing. --- ## Public form-intake API ### POST https://gettrazo.app/api/v1/submissions Public, unauthenticated-by-session; authenticated by the site key in the body. No `Authorization` header. The page must render the Cap CAPTCHA widget (``, from the `cap-widget` npm package/CDN) and send the token it produces; submissions without a valid, unused Cap token are rejected. Each Cap token is single-use. Request body: ```json { "api_key": "sk_live_...", "cap_token": "", "submission": { "name": "Jane Doe", "email": "jane@example.com", "message": "I'd like a quote.", "custom_fields": { "phone": "555-867-5309" } } } ``` Field rules: `name` (required, max 200 chars), `email` (required, valid format, max 255), `message` (required, max 5000; newlines preserved), `custom_fields` (optional flat object, at most 20 pairs, string keys ≤ 100 chars, string values ≤ 1000 chars). There is no dedicated phone field, so put extras in `custom_fields`. Responses (legacy shape, **different from the Management API envelope**): - `200` `{ "status": "ok" }`: stored and queued. Delivery email is sent asynchronously after spam classification; the 200 never waits on it. - `401` `{ "status": "error", "message": "Invalid API key" }`: unknown/rotated key, inactive site or organization, or lapsed billing cancellation. - `403` `{ "status": "error", "message": "Origin not allowed" }`: the `Origin` header does not match the site's registered domain (subdomain-less and `www.` accepted; localhost allowed in Trazo's dev mode only). - `422` `{ "status": "error", "message": "Bot verification failed" }`: missing/invalid/used Cap token. - `422` `{ "status": "error", "message": "Validation failed", "errors": { "email": ["is invalid"] } }`: per-field validation errors (attribute → array of messages). - `429` `{ "status": "error", "message": "Monthly submission limit reached. Try again later." }`: the organization's submission allowance is spent (also returned by IP rate limiting). This endpoint's contract is stable; the Management API below does not create submissions. --- ## Management API Base URL: `https://gettrazo.app/api/v1` ### Authentication Send `Authorization: Bearer trz_pat_...` on every request. The key acts as its user: same role (admin / seller / owner) and same visibility as the dashboard. Missing, invalid, or revoked keys, and keys of suspended or not-yet-activated users, get a generic `401 {"error":{"code":"unauthorized",...}}`. ### Response envelope - Single resource: `{ "site": { ... } }`, 200 (201 on create). - Collection: `{ "sites": [ ... ], "pagination": { "page", "pages", "count", "limit", "prev", "next" } }`. Paginate with `?page=` and `?limit=` (default 50, clamped 1–100). - Destroy: `{ "deleted": true, "id": }`. - Member actions return the updated resource envelope. - Site create and rotate_key additionally include the plaintext key **once**: `{ "site": { ... }, "api_key": "sk_live_..." }`. ### Errors ```json { "error": { "code": "validation_failed", "message": "Validation failed", "details": { "domain": ["can't be blank"] } } } ``` | code | HTTP | when | |---|---|---| | `bad_request` | 400 | Missing/malformed parameter (e.g. unknown `label` value). | | `unauthorized` | 401 | Bad/missing/revoked key or suspended account. | | `forbidden` | 403 | Your role cannot perform this action on this record. | | `not_found` | 404 | No such record **in your scope** (out-of-scope = 404, never 403). | | `validation_failed` | 422 | Invalid record; `details` maps attributes to message arrays. | | `rate_limited` | 429 | Too many requests. | | `internal_error` | 500 | Server error. | `details` appears only on `validation_failed`. ### Rate limits - Authenticated: 120 requests/minute per API key. - No `Authorization` header on management paths: 60 requests/minute per IP. - Exceeding either returns 429 with the `rate_limited` envelope. The intake endpoint has its own separate throttles and keeps its legacy response shape. ### Roles - **admin**: everything, all tenants. - **seller**: their organizations (sites, submissions, users of those orgs). - **owner**: their own site(s) (view/limited-edit sites, read+review submissions, see only themselves in users). | Resource | admin | seller | owner | |---|---|---|---| | Organizations | full CRUD | no access | no access | | Sites | full CRUD + all actions | their orgs: create/update (cannot change org)/delete, rotate, toggle_active, send_test, billing | their sites: view, update `name`+`domain` only, rotate, send_test | | Submissions | read + review, all | read + review, their orgs | read + review, their sites | | Users | full management | list/view their orgs' users; invite/reset support actions for their owners | self only | ### GET /api/v1/me Any valid key. Returns the caller and the key in use: ```json { "user": { "id": 7, "email": "you@example.com", "role": "seller", "status": "active", "created_at": "...", "last_login_at": "...", "organizations": [{ "id": 3, "name": "Acme Agency" }], "sites": [] }, "api_key": { "id": 1, "name": "CI", "token_prefix": "trz_pat_abc12345", "created_at": "...", "last_used_at": "..." } } ``` Sellers get `organizations`, owners get `sites`, admins get empty arrays (they implicitly see everything). Use this first to verify a key and discover scope. ### Organizations (admin only) - `GET /api/v1/organizations`: paginated list. - `GET /api/v1/organizations/:id` - `POST /api/v1/organizations`: 201. - `PATCH /api/v1/organizations/:id` - `DELETE /api/v1/organizations/:id`: deletes the org and everything under it. Writable params, wrapped in `organization`: `name`, `active`, `expires_at`, `max_sites`, `monthly_submission_limit`, `notes`. Response fields: `id, name, slug, active, expires_at, max_sites, monthly_submission_limit, notes, plan_key, stripe_subscription_status, created_at, updated_at` plus computed `free_tier, subscribed, submissions_used, submission_pool_limit, sites_count`. Stripe ids and icon data are never returned. Deactivating an organization (`active: false`) makes all of its sites' intake requests 401. ### Sites - `GET /api/v1/sites`: scoped list; optional `?organization_id=`; ordered by name. - `GET /api/v1/sites/:id` - `POST /api/v1/sites`: admin/seller. 201; response includes plaintext `api_key` **once**. - `PATCH /api/v1/sites/:id`: params sliced by role (below). - `DELETE /api/v1/sites/:id`: admin/seller. An owner left with zero sites is deleted too. - `POST /api/v1/sites/:id/rotate_key`: new key returned once; the old key stops working immediately. - `PATCH /api/v1/sites/:id/toggle_active`: admin/seller. Flips `active`; suspends/reactivates an owner with no other active site. - `POST /api/v1/sites/:id/send_test`: creates a test submission and sends the notification email. - `POST /api/v1/sites/:id/cancel_billing`: admin/seller. Billing stops now, submissions accepted until end of month; 422 `validation_failed` if already canceled. - `POST /api/v1/sites/:id/uncancel_billing`: admin/seller. Undoes a pending cancellation; 422 if none pending. Params wrapped in `site`: `name`, `domain`, `notification_email`, `organization_id`. Role slicing: admin, all; seller, all except `organization_id` (create forces the seller's own organization); owner, `name` and `domain` only (other attributes are silently dropped, exactly like the dashboard). CORS for the intake endpoint follows `domain`. Owner access (admin/seller only, top level next to `site`): `owner_user_id` (attach an existing owner) or `owner_email` (invite a new owner by email). One owner per site; attaching a new owner displaces the current one. Example create: ``` POST /api/v1/sites { "site": { "name": "Acme Co", "domain": "acme.example", "notification_email": "leads@acme.example", "organization_id": 7 }, "owner_email": "owner@acme.example" } 201 → { "site": { ... }, "api_key": "sk_live_..." } ``` Response fields: `id, name, domain, notification_email, active, internal, api_key_prefix, cap_site_key, canceled_at, cancel_effective_at, created_at, updated_at`, computed `accepting_submissions, pending_cancellation, billing_lapsed`, nested `organization: {id, name}`, and `owner: {id, email, status}` (owner object only for admin/seller callers). Key hashes and Cap secrets are never returned. ### Submissions (read + review only) - `GET /api/v1/submissions`: scoped list. Filters: `site_id`, `organization_id` (ignored for owners), `from`, `to` (dates), `email_sent` (true/false), `q` (free text), `tab` (`all` | `not_spam` | `likely_spam` | `spam`). Payload includes `"counts": { "all", "not_spam", "likely_spam", "spam" }` plus `pagination`. - `GET /api/v1/submissions/:id` - `POST /api/v1/submissions/:id/label`: body `{ "label": "not_spam" }` or `"spam"` (else 400). The authoritative human correction; moves the submission between tabs, and labeling a withheld (spam-classified, never-delivered) submission `not_spam` triggers its email delivery. - `POST /api/v1/submissions/:id/rate`: body `{ "rating": "up" }` or `"down"` (else 400). Model feedback only; moves nothing. - `POST /api/v1/submissions/:id/resend_email`: re-enqueues the notification email; returns the submission. - `POST /api/v1/submissions/retry_failed_emails`: re-enqueues all failed notification emails in your scope; returns `{ "retried": }`. Response fields: `id, name, email, message, custom_fields, ip_address, user_agent, created_at, email_sent, email_sent_at, email_error, classification_status, classified_at, likely_spam, spam_confidence, classification_reasoning, owner_label, owner_rating, owner_reviewed_at`, nested `site: {id, name, organization_id}`. There is no create/update/delete; submissions only enter through the public intake endpoint. ### Users - `GET /api/v1/users`: scoped for **every** role: admins see all users, sellers see their organizations' users (their owners and org peers), owners see only themselves. - `GET /api/v1/users/:id`: admin, self, or a seller's managed owner. - `POST /api/v1/users`: admin only. 201; sends the invitation email. Params: `user: { email, role }` (role `seller` or `owner`; **`admin` is never assignable via API or UI**) plus top-level `organization_ids` / `site_ids` membership assignment. - `PATCH /api/v1/users/:id`: admin only. `email`, `role` (never `admin`) + membership reassignment. - `DELETE /api/v1/users/:id`: admin only. - `POST /api/v1/users/:id/resend_invitation`: admin or the managing seller. Re-sends the invite email with a fresh token. - `POST /api/v1/users/:id/invite_link`: admin or managing seller. Returns `{ "invite_url": "https://gettrazo.app/login/invite/..." }`; 422 if already accepted. - `POST /api/v1/users/:id/reset_password`: admin or the managing seller. Emails a reset link and revokes the user's sessions. - `POST /api/v1/users/:id/reset_2fa`: admin or the managing seller. Clears the user's second factors, revokes their sessions, and emails a two-factor reset link. - `POST /api/v1/users/:id/suspend`: admin only. Blocks sign-in and API use immediately. - `POST /api/v1/users/:id/reactivate`: admin only. Response fields: `id, email, role, status, created_at, last_login_at, invite_sent_at, two_factor_configured`, nested `organizations: [{id,name}]` and `sites: [{id,name}]`. Password digests, token hashes, and TOTP secrets never appear in any payload. ### Deliberately not in the Management API - No personal-API-key create/revoke via the API (account UI only, behind password confirmation, so a stolen key cannot mint more keys). - No admin-role creation anywhere (server CLI only). - No submission create/update/delete (intake endpoint only). - No billing checkout/portal endpoints and no white-label branding endpoints (dashboard only). --- ## Quick recipes for agents - Verify a key / discover scope: `GET /api/v1/me`. - Stand up a new client site (admin/seller): `POST /api/v1/sites` → save the once-only `api_key`, put it in the site's contact form, render the Cap widget with the returned `cap_site_key`, then `POST /api/v1/sites/:id/send_test` to confirm delivery. - Triage leads: `GET /api/v1/submissions?tab=likely_spam` → for false positives `POST /api/v1/submissions/:id/label` with `{ "label": "not_spam" }` (this also delivers a withheld lead). - Rotate a leaked site key: `POST /api/v1/sites/:id/rotate_key`, update the form with the new key.