From 965697a418a000d9c0faade4b11031adf98fbe24 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Fri, 16 Oct 2026 07:27:17 -0400 Subject: [PATCH 01/26] chore(spec): vendor the by-key fire and app-emails routes The bundled spec is brew-v2 main (the spec CLI main already carries) plus exactly the spec delta of brew-v2 claude/app-emails-setup c72bb45467 (#2066, stacked on #2013 trigger keys, #2017 fire by key and GET /v1/app-emails, GET|PUT /v1/app-emails/{triggerKey}, POST /v1/app-emails/kit, `key` / `appEmail` on trigger rows, and the sandbox fields and error codes. Three-way merged against the stack's merge base 4afd0668ee; every operation and schema matches one side or carries both sides' changes. Generated types regenerated. Re-vendor from brew-v2 main once those PRs merge. Co-Authored-By: Claude Opus 5.5 --- openapi/public-api-v1.yaml | 24163 ++++++++++++++++++------------- src/generated/input-keys.ts | 46 +- src/generated/openapi-types.ts | 3116 +++- 3 files changed, 16906 insertions(+), 10419 deletions(-) diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index bbdb8e02..e6c878a0 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -39,6 +39,8 @@ tags: description: "Read-only cross-resource reports: the brand overview, windowed automation performance and the unified event feed. Send rows (with lifetime stats) live at `/v1/sends`; fired triggers at `/v1/automations/trigger-instances`." - name: Automations description: Automation graphs — deterministic create from explicit `nodes` + `connections`, update, version, publish / unpublish, test. Includes trigger event definitions + the fire endpoint (`/v1/automations/triggers`) and run history (`/v1/automations/runs`). + - name: App Emails + description: "Emails your app sends by trigger key (`brew.send(key, payload)`): one idempotent setup per key, or the auth and lifecycle starter kit, each wiring a keyed trigger, a design and a published automation." - name: Contacts description: Create, search, patch, and delete contacts. Email is the primary key. - name: Contact Fields @@ -986,7 +988,13 @@ components: domainId: type: string minLength: 1 - description: 'Owned sending-domain id — optional at authoring; required, and verified (sendable: true), at publish and on every live run. A marketing domain enforces the unsubscribe link; a sendingPurpose "transactional" domain skips it and delivers even to unsubscribed contacts, so choose one only for genuinely transactional flows (receipts, password resets).' + description: "Verified owned domain id, required at publish unless senderMode is sandbox. Transactional domains skip unsubscribe links and reach unsubscribed contacts: only for receipts, resets." + senderMode: + type: string + enum: + - domain + - sandbox + description: "sandbox: until a domain verifies, event automations email your organization members only." subject: type: string minLength: 1 @@ -1981,6 +1989,11 @@ components: type: string domainId: type: string + senderMode: + type: string + enum: + - domain + - sandbox replyTo: type: string to: {} @@ -2341,6 +2354,7 @@ components: - DRAFT_SAVED_NOT_LIVE - NODE_ID_REPLACED - TRIGGER_EVENT_NOT_RECEIVED + - ENGAGEMENT_NOT_TRACKED - ENFORCEMENT_LOOSENED_WHILE_PUBLISHED - REQUIRED_FIELD_SATISFIED_BY_FALLBACK - RESUBSCRIBE_SKIPPED @@ -2352,11 +2366,20 @@ components: - CSV_COLUMN_IGNORED - VALIDATION_SKIPPED - RECIPIENTS_EXCLUDED + - DKIM_SELECTOR_IN_USE + - SANDBOX_SENDER + - APP_EMAIL_NOT_PUBLISHED + - DESIGN_EDITED_IN_BREW - CONSENT_RECORD_MISSING message: type: string field: type: string + suggestedNames: + type: array + items: + type: string + description: "Names to use instead of the one the request sent (`DKIM_SELECTOR_IN_USE`: subdomains of the added domain)." required: - code - message @@ -2781,6 +2804,29 @@ components: type: string minLength: 1 maxLength: 256 + key: + type: string + description: Stable developer key your code names this trigger by; unique in the brand. Fire by it with `POST /v1/automations/triggers/by-key/{triggerKey}/fire`. Absent when the trigger has none. + appEmail: + type: object + properties: + automationId: + type: string + description: The automation that sends this app email. + emailId: + type: string + description: The email design it sends. + starterId: + type: string + description: The starter-kit template the design came from, if any. + starterVersion: + type: integer + description: Version of that starter template. + required: + - automationId + - emailId + additionalProperties: false + description: Present when an app-email setup wired this keyed trigger to an automation and design. title: type: string minLength: 1 @@ -5184,6 +5230,11 @@ components: enum: - ACCOUNT_SUSPENDED - API_KEY_REVOKED + - APP_EMAIL_DOMAIN_PURPOSE_MISMATCH + - APP_EMAIL_EDITED_IN_BREW + - APP_EMAIL_SETUP_IN_PROGRESS + - APP_EMAIL_STARTER_UNKNOWN + - APP_EMAIL_TRIGGER_IN_USE - AUDIENCE_BUILD_ACTIVE - AUDIENCE_BUILD_ALREADY_ACTIVE - AUDIENCE_EDIT_CONFLICT @@ -5220,6 +5271,7 @@ components: - DOMAIN_OTHER_BRAND - DOMAIN_PROVIDER_ERROR - DOMAIN_PURPOSE_NOT_ALLOWED + - DOMAIN_REQUIRED - DOMAIN_VERIFICATION_FAILED - DOMAIN_VERIFIED_ELSEWHERE - EMAIL_GENERATION_FAILED @@ -5282,6 +5334,7 @@ components: - RUN_NOT_RESUMABLE - RUN_START_FAILED - RUN_STOP_FAILED + - SANDBOX_DAILY_LIMIT - SEND_NOT_CANCELLABLE - SEND_NOT_FOUND - SEND_NOT_PAUSABLE @@ -5294,6 +5347,9 @@ components: - TRIGGER_HAS_DEPENDENT_AUTOMATIONS - TRIGGER_IMMUTABLE - TRIGGER_INSTANCE_NOT_FOUND + - TRIGGER_KEY_NOT_FOUND + - TRIGGER_KEY_RESERVED + - TRIGGER_KEY_TAKEN - TRIGGER_LIMIT_REACHED - TRIGGER_NOT_FIREABLE - UPLOAD_IN_PROGRESS @@ -5542,6 +5598,10 @@ components: - tailwind_config_unresolved - tailwind_class_dropped - foreign_component_dropped + - dynamic_prop_folded + - dynamic_control_flow_unsupported + - escaping_changed + - placeholder_unmapped - element_dropped location: type: string @@ -5570,6 +5630,20 @@ components: - stripped - warnings additionalProperties: false + variables: + type: array + items: + type: object + properties: + path: + type: string + minLength: 1 + fallback: + type: string + required: + - path + additionalProperties: false + description: Merge variables the design and subject line read (`trigger.*` from the trigger payload, `contact.*` from the recipient), with their fallbacks. required: - emailId - emailVersionId @@ -5603,6 +5677,34 @@ components: baseUrl: type: string format: uri + props: + type: string + enum: + - fold + - merge_tags + description: "`jsx` only. How the root component's props land: `fold` (default) folds each to its `PreviewProps` value or default; `merge_tags` keeps them as `{{ trigger. }}` merge tags filled from the trigger payload, with defaults and `??` literals as fallbacks." + placeholders: + type: string + enum: + - resend + description: "`resend` rewrites Resend template placeholders (`{{{NAME}}}`, `{{{NAME|fallback}}}`) to Brew merge tags (`{{ trigger.NAME }}`; `FIRST_NAME`, `LAST_NAME` and `EMAIL` read the contact)." + variables: + type: array + items: + type: object + properties: + key: + type: string + maxLength: 200 + pattern: ^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$ + fallbackValue: + type: string + maxLength: 1000 + required: + - key + additionalProperties: false + maxItems: 500 + description: "Fallback text for variables the source gives none, e.g. a Resend template's `variables`. Needs `placeholders` or `props: \"merge_tags\"`." groupId: type: string minLength: 1 @@ -8629,7 +8731,13 @@ components: domainId: type: string minLength: 1 - description: 'Owned sending-domain id — optional at authoring; required, and verified (sendable: true), at publish and on every live run. A marketing domain enforces the unsubscribe link; a sendingPurpose "transactional" domain skips it and delivers even to unsubscribed contacts, so choose one only for genuinely transactional flows (receipts, password resets).' + description: "Verified owned domain id, required at publish unless senderMode is sandbox. Transactional domains skip unsubscribe links and reach unsubscribed contacts: only for receipts, resets." + senderMode: + type: string + enum: + - domain + - sandbox + description: "sandbox: until a domain verifies, event automations email your organization members only." subject: type: string minLength: 1 @@ -9641,6 +9749,11 @@ components: type: string domainId: type: string + senderMode: + type: string + enum: + - domain + - sandbox replyTo: type: string to: {} @@ -10110,7 +10223,13 @@ components: domainId: type: string minLength: 1 - description: 'Owned sending-domain id — optional at authoring; required, and verified (sendable: true), at publish and on every live run. A marketing domain enforces the unsubscribe link; a sendingPurpose "transactional" domain skips it and delivers even to unsubscribed contacts, so choose one only for genuinely transactional flows (receipts, password resets).' + description: "Verified owned domain id, required at publish unless senderMode is sandbox. Transactional domains skip unsubscribe links and reach unsubscribed contacts: only for receipts, resets." + senderMode: + type: string + enum: + - domain + - sandbox + description: "sandbox: until a domain verifies, event automations email your organization members only." subject: type: string minLength: 1 @@ -12003,6 +12122,29 @@ components: type: string minLength: 1 maxLength: 256 + key: + type: string + description: Stable developer key your code names this trigger by; unique in the brand. Fire by it with `POST /v1/automations/triggers/by-key/{triggerKey}/fire`. Absent when the trigger has none. + appEmail: + type: object + properties: + automationId: + type: string + description: The automation that sends this app email. + emailId: + type: string + description: The email design it sends. + starterId: + type: string + description: The starter-kit template the design came from, if any. + starterVersion: + type: integer + description: Version of that starter template. + required: + - automationId + - emailId + additionalProperties: false + description: Present when an app-email setup wired this keyed trigger to an automation and design. title: type: string minLength: 1 @@ -12114,6 +12256,11 @@ components: description: type: string maxLength: 2000 + key: + type: string + maxLength: 64 + pattern: ^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$ + description: Stable developer key for identifying this trigger, unique in the brand (for example `order.paid`). A fire can name the trigger by this key or by the returned `triggerEventId`. Lowercase letters, digits, `.`, `_` and `-`, up to 64 characters. `kit`, `by-key`, `brew.*` and `auth.*` keys other than the starter-kit auth keys are reserved. payloadSchema: type: object properties: @@ -12144,6 +12291,29 @@ components: type: string minLength: 1 maxLength: 256 + key: + type: string + description: Stable developer key your code names this trigger by; unique in the brand. Fire by it with `POST /v1/automations/triggers/by-key/{triggerKey}/fire`. Absent when the trigger has none. + appEmail: + type: object + properties: + automationId: + type: string + description: The automation that sends this app email. + emailId: + type: string + description: The email design it sends. + starterId: + type: string + description: The starter-kit template the design came from, if any. + starterVersion: + type: integer + description: Version of that starter template. + required: + - automationId + - emailId + additionalProperties: false + description: Present when an app-email setup wired this keyed trigger to an automation and design. title: type: string minLength: 1 @@ -12251,6 +12421,13 @@ components: description: type: string maxLength: 2000 + key: + type: + - string + - "null" + maxLength: 64 + pattern: ^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$ + description: Set or rename the developer key; `null` clears it (and the app-email link that depends on it). Omit to keep the current key. payloadSchema: type: object properties: @@ -12396,6 +12573,9 @@ components: type: string minLength: 1 maxLength: 256 + triggerKey: + type: string + description: "Present on a by-key fire: the developer key the trigger was named by." status: type: string enum: @@ -12417,6 +12597,11 @@ components: reason: type: string description: Why its run did not start. A refusal (a merge tag or condition field the trigger does not declare, a step the run gate finds incomplete) repeats on every fire until the automation or trigger is fixed; an unexpected error is retried automatically. + code: + type: string + enum: + - DOMAIN_REQUIRED + description: "DOMAIN_REQUIRED: this automation sends from the Brew sandbox until its domain is verified, and the recipient is not a member of your organization." required: - automationId - reason @@ -12894,7 +13079,7 @@ components: required: - example additionalProperties: false - TriggerInstancesListResponse: + AppEmailsListResponse: type: object properties: data: @@ -12902,58 +13087,85 @@ components: items: type: object properties: - triggerInstanceId: + triggerKey: type: string - minLength: 1 - source: + triggerEventId: type: string - enum: - - api - - integration - - brew_event - provider: + title: type: string - triggerEventId: + emailId: type: string - minLength: 1 - maxLength: 256 - state: + automationId: type: string - enum: - - received - - verified - - matched - - partially_fired - - fired - - rejected - - dead_letter - matchedAutomationIds: - type: array - items: - type: string - automationRunIds: - type: array - items: - type: string - rejectionReason: + starterId: type: string - attempts: + starterVersion: type: integer - minimum: 0 - receivedAt: - type: string - format: date-time - processedAt: + upgradeAvailable: + type: boolean + description: The starter has a newer version; running the setup again upgrades an unedited design. + published: + type: boolean + sender: + type: object + properties: + mode: + type: string + enum: + - domain + - sandbox + - none + domainId: + type: string + domainStatus: + type: string + enum: + - not_started + - pending + - verified + - failed + - temporary_failure + - partially_verified + - partially_failed + required: + - mode + additionalProperties: false + readiness: + type: object + properties: + ready: + type: boolean + description: A fire by this key sends the email now. + teamOnly: + type: boolean + description: Sends reach organization members only until a domain verifies. + blockers: + type: array + items: + type: string + enum: + - AUTOMATION_MISSING + - AUTOMATION_NOT_PUBLISHED + - DOMAIN_REQUIRED + required: + - ready + - teamOnly + - blockers + additionalProperties: false + updatedAt: type: string format: date-time required: - - triggerInstanceId - - source - - state - - matchedAutomationIds - - automationRunIds - - attempts - - receivedAt + - triggerKey + - triggerEventId + - title + - emailId + - automationId + - upgradeAvailable + - published + - sender + - readiness + - updatedAt additionalProperties: false pagination: type: object @@ -12977,617 +13189,1043 @@ components: - data - pagination additionalProperties: false - ContactsPostBatchResponse: + AppEmailRow: type: object properties: - summary: + triggerKey: + type: string + triggerEventId: + type: string + title: + type: string + emailId: + type: string + automationId: + type: string + starterId: + type: string + starterVersion: + type: integer + upgradeAvailable: + type: boolean + description: The starter has a newer version; running the setup again upgrades an unedited design. + published: + type: boolean + sender: type: object properties: - inserted: - type: integer - minimum: 0 - updated: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 + mode: + type: string + enum: + - domain + - sandbox + - none + domainId: + type: string + domainStatus: + type: string + enum: + - not_started + - pending + - verified + - failed + - temporary_failure + - partially_verified + - partially_failed required: - - inserted - - updated - - failed + - mode additionalProperties: false - fieldsCreated: - type: array - items: - type: string - errors: - type: array - items: - type: object - properties: - email: - type: string - row: - type: integer - exclusiveMinimum: 0 - description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted)." - code: - type: string - field: - type: string - message: - type: string - required: - - email - - code - - message - additionalProperties: false - warnings: - type: array - items: - type: object - properties: - code: - type: string - minLength: 1 - email: - type: string - minLength: 1 - row: - type: integer - exclusiveMinimum: 0 - description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." - field: - type: string - minLength: 1 - message: - type: string - minLength: 1 - from: - type: string - minLength: 1 - to: - type: string - minLength: 1 - original: - type: string - minLength: 1 - normalized: - type: string - minLength: 1 - required: - - code - - message - additionalProperties: false - validation: + readiness: type: object properties: - valid: - type: integer - minimum: 0 - risky: - type: integer - minimum: 0 - invalid: - type: integer - minimum: 0 - unscored: - type: integer - minimum: 0 - persistence: - type: object - properties: - matched: - type: integer - minimum: 0 - modified: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - required: - - matched - - modified - - failed - additionalProperties: false + ready: + type: boolean + description: A fire by this key sends the email now. + teamOnly: + type: boolean + description: Sends reach organization members only until a domain verifies. + blockers: + type: array + items: + type: string + enum: + - AUTOMATION_MISSING + - AUTOMATION_NOT_PUBLISHED + - DOMAIN_REQUIRED required: - - valid - - risky - - invalid - - unscored + - ready + - teamOnly + - blockers additionalProperties: false - validationJobId: + updatedAt: type: string + format: date-time required: - - summary - - fieldsCreated - - errors - - warnings + - triggerKey + - triggerEventId + - title + - emailId + - automationId + - upgradeAvailable + - published + - sender + - readiness + - updatedAt additionalProperties: false - ContactsPostSingleResponse: + AppEmailSetupResult: type: object properties: - contact: + triggerKey: + type: string + triggerEventId: + type: string + emailId: + type: string + emailVersionId: + type: string + automationId: + type: string + published: + type: boolean + description: A fire by this key starts the automation. + created: type: object properties: + trigger: + type: boolean email: + type: boolean + automation: + type: boolean + required: + - trigger + - email + - automation + additionalProperties: false + description: What this call created; false means it already existed. + sender: + type: object + properties: + mode: type: string - firstName: + enum: + - domain + - sandbox + description: "`sandbox`: sends reach members of your organization only, under a daily cap, until a domain verifies." + from: type: string - lastName: + description: The address the email is sent from. + fromName: type: string - subscribed: - type: boolean - default: true - consent: + domainId: + type: string + description: The sending domain, or in sandbox mode the domain still waiting to verify. + domainStatus: + type: string + enum: + - not_started + - pending + - verified + - failed + - temporary_failure + - partially_verified + - partially_failed + sandbox: type: object properties: - source: - type: string - enum: - - api - - form - - import - - app - - integration - description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)." - capturedAt: - type: string - format: date-time - description: When it was captured. - policyVersion: - type: string - description: The policy or terms version in force when it was captured. - evidence: - type: string - description: "Free-text evidence: a form URL, a ticket, a checkbox label." + dailyLimit: + type: integer + minimum: 0 + remainingToday: + type: integer + minimum: 0 required: - - source - - capturedAt + - dailyLimit + - remainingToday additionalProperties: false - validationStatus: + required: + - mode + - from + additionalProperties: false + payloadSchema: + type: object + properties: + type: type: string enum: - - valid - - risky - - invalid - description: "The latest verdict. `valid` only ever comes from a deliverability check (`validate: true` on ingest, or POST /v1/contacts/validate), which also sets `lastValidatedAt`. Without a check the free format check on ingest stores `risky` (disposable or role address) or `invalid`, and leaves the field unset otherwise: not validated." - suppressed: - type: boolean - default: false - suppressedReason: - type: - - string - - "null" - unsubscribedDomains: + - object + fields: type: array items: - type: string - default: [] - lastValidatedAt: - type: string - format: date-time - validationDetails: - type: object - properties: - provider: - type: string - enum: - - brew - reason: - type: string - didYouMean: - type: string - risk: - type: string - isDisposable: - type: boolean - isRole: - type: boolean - required: - - provider - additionalProperties: false - createdAt: + $ref: "#/components/schemas/TriggerPayloadField" + required: + - type + - fields + fireExample: + type: object + properties: + method: type: string - format: date-time - updatedAt: + enum: + - POST + path: type: string - format: date-time - importId: - type: - - string - - "null" - csvFileName: - type: - - string - - "null" - customFields: + examplePayload: type: object additionalProperties: {} - default: {} + curl: + type: string + sdk: + type: string required: - - email - - createdAt - - updatedAt + - method + - path + - examplePayload + - curl + - sdk additionalProperties: false - created: - type: boolean - fieldsCreated: - type: array - items: - type: string warnings: type: array items: - type: object - properties: - code: - type: string - minLength: 1 - email: - type: string - minLength: 1 - row: - type: integer - exclusiveMinimum: 0 - description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." - field: - type: string - minLength: 1 - message: - type: string - minLength: 1 - from: - type: string - minLength: 1 - to: - type: string - minLength: 1 - original: - type: string - minLength: 1 - normalized: - type: string - minLength: 1 - required: - - code - - message - additionalProperties: false - validation: - type: object - properties: - valid: - type: integer - minimum: 0 - risky: - type: integer - minimum: 0 - invalid: - type: integer - minimum: 0 - unscored: - type: integer - minimum: 0 - persistence: - type: object - properties: - matched: - type: integer - minimum: 0 - modified: - type: integer - minimum: 0 - failed: - type: integer - minimum: 0 - required: - - matched - - modified - - failed - additionalProperties: false - required: - - valid - - risky - - invalid - - unscored - additionalProperties: false + $ref: "#/components/schemas/ApiWarning" required: - - contact + - triggerKey + - triggerEventId + - emailId + - emailVersionId + - automationId + - published - created - - fieldsCreated + - sender + - payloadSchema + - fireExample - warnings additionalProperties: false - ContactsPostRequest: - anyOf: - - $ref: "#/components/schemas/ContactsPostSingleRequest" - - $ref: "#/components/schemas/ContactsPostBatchRequest" - ContactsPostSingleRequest: + AppEmailPutRequest: type: object properties: - email: - type: string - minLength: 1 - firstName: - type: string - lastName: + starter: type: string - subscribed: - type: boolean - customFields: - type: object - additionalProperties: {} - consent: + maxLength: 64 + description: Starter-kit template key. On a kit key it must equal the key and is the default; any kit template works on your own keys. + source: type: object properties: - source: + format: type: string enum: - - api - - form - - import - description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" - capturedAt: - type: string - format: date-time - description: ISO 8601; defaults to now. - policyVersion: + - html + - mjml + - jsx + description: "`html`: an HTML email; `mjml`: MJML markup; `jsx`: React Email (a module with a default-exported component, or a bare `` element)." + content: type: string minLength: 1 - maxLength: 64 - evidence: + description: The email source. `{{ trigger. }}` merge tags read the payload. + props: type: string - minLength: 1 - maxLength: 500 + enum: + - fold + - merge_tags + description: "`jsx` only. How the root component's props land: `fold` (default) folds each to its `PreviewProps` value or default; `merge_tags` keeps them as `{{ trigger. }}` merge tags filled from the trigger payload, with defaults and `??` literals as fallbacks." + placeholders: + type: string + enum: + - resend + description: "`resend` rewrites Resend template placeholders (`{{{NAME}}}`, `{{{NAME|fallback}}}`) to Brew merge tags (`{{ trigger.NAME }}`; `FIRST_NAME`, `LAST_NAME` and `EMAIL` read the contact)." + variables: + type: array + items: + type: object + properties: + key: + type: string + maxLength: 200 + pattern: ^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$ + fallbackValue: + type: string + maxLength: 1000 + required: + - key + additionalProperties: false + maxItems: 500 + description: "Fallback text for variables the source gives none, e.g. a Resend template's `variables`. Needs `placeholders` or `props: \"merge_tags\"`." required: - - source + - format + - content additionalProperties: false - description: Marketing consent provenance to record on this contact. Optional at creation; an inline marketing send to this address later needs the contact to be subscribed and warns when no record exists. - validate: - type: boolean - description: Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. - required: - - email - additionalProperties: false - ContactsPostBatchRequest: - type: object - properties: - contacts: - type: array - items: - type: object - properties: - email: - type: string - firstName: - type: string - lastName: - type: string - subscribed: - type: boolean - customFields: - type: object - additionalProperties: {} - consent: + description: Your own design instead of a starter. + title: + type: string + minLength: 1 + maxLength: 120 + subject: + type: string + minLength: 1 + maxLength: 250 + previewText: + type: string + maxLength: 250 + payloadSchema: + type: object + properties: + fields: + type: array + items: type: object properties: - source: + key: type: string - enum: - - api - - form - - import - description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" - capturedAt: + pattern: ^[A-Za-z_][A-Za-z0-9_]{0,63}$ + description: "Field name, as templates read it: `{{ trigger. }}`." + type: type: string - format: date-time - description: ISO 8601; defaults to now. - policyVersion: + enum: + - string + - int + - boolean + - date + required: + type: boolean + description: Defaults to false. + pii: type: string - minLength: 1 - maxLength: 64 - evidence: + enum: + - none + - low + - high + description: "`high` keeps the value out of logs and stored copies." + secret: + type: boolean + description: "True for a one-time code or sign-in link: used only to render the email, redacted in stored copies, logs and analytics. Never on `email`. Omitted: `pii: high` counts as secret, and a field the starter kit marks secret stays secret." + description: type: string - minLength: 1 - maxLength: 500 + maxLength: 300 required: - - source + - key + - type additionalProperties: false - description: Marketing consent provenance to record on this contact. Optional at creation; an inline marketing send to this address later needs the contact to be subscribed and warns when no record exists. - additionalProperties: false - minItems: 1 - maxItems: 1000 - validate: - type: boolean - description: Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. - consent: + minItems: 1 + maxItems: 50 + required: + - fields + additionalProperties: false + description: Flat payload fields. `email` (the recipient) is added when missing. Every other field is template data only and never written to the contact. + domainId: + type: string + minLength: 1 + maxLength: 64 + description: Sending domain. Defaults to a verified domain whose purpose matches the email, else the team-only sandbox. + from: type: object properties: - source: - type: string - enum: - - api - - form - - import - description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" - capturedAt: - type: string - format: date-time - description: ISO 8601; defaults to now. - policyVersion: + localPart: type: string minLength: 1 maxLength: 64 - evidence: + description: Part before the @ on the sending domain, such as `hello`. + name: type: string minLength: 1 - maxLength: 500 - required: - - source + maxLength: 100 additionalProperties: false - description: Default consent record for every row that does not carry its own `consent`. - required: - - contacts + publish: + type: boolean + description: Publish the automation. Defaults to true. + overwrite: + type: boolean + description: Replace a design edited in Brew since setup. Defaults to false. additionalProperties: false - ContactsPatchResponse: + AppEmailsKitResponse: type: object properties: - contact: + results: + type: array + items: + oneOf: + - type: object + properties: + triggerKey: + type: string + triggerEventId: + type: string + emailId: + type: string + emailVersionId: + type: string + automationId: + type: string + published: + type: boolean + description: A fire by this key starts the automation. + created: + type: object + properties: + trigger: + type: boolean + email: + type: boolean + automation: + type: boolean + required: + - trigger + - email + - automation + additionalProperties: false + description: What this call created; false means it already existed. + sender: + type: object + properties: + mode: + type: string + enum: + - domain + - sandbox + description: "`sandbox`: sends reach members of your organization only, under a daily cap, until a domain verifies." + from: + type: string + description: The address the email is sent from. + fromName: + type: string + domainId: + type: string + description: The sending domain, or in sandbox mode the domain still waiting to verify. + domainStatus: + type: string + enum: + - not_started + - pending + - verified + - failed + - temporary_failure + - partially_verified + - partially_failed + sandbox: + type: object + properties: + dailyLimit: + type: integer + minimum: 0 + remainingToday: + type: integer + minimum: 0 + required: + - dailyLimit + - remainingToday + additionalProperties: false + required: + - mode + - from + additionalProperties: false + payloadSchema: + type: object + properties: + type: + type: string + enum: + - object + fields: + type: array + items: + $ref: "#/components/schemas/TriggerPayloadField" + required: + - type + - fields + fireExample: + type: object + properties: + method: + type: string + enum: + - POST + path: + type: string + examplePayload: + type: object + additionalProperties: {} + curl: + type: string + sdk: + type: string + required: + - method + - path + - examplePayload + - curl + - sdk + additionalProperties: false + warnings: + type: array + items: + $ref: "#/components/schemas/ApiWarning" + ok: + type: boolean + enum: + - true + required: + - triggerKey + - triggerEventId + - emailId + - emailVersionId + - automationId + - published + - created + - sender + - payloadSchema + - fireExample + - warnings + - ok + additionalProperties: false + - type: object + properties: + triggerKey: + type: string + ok: + type: boolean + enum: + - false + error: + type: object + properties: + code: + type: string + message: + type: string + details: + type: object + additionalProperties: {} + required: + - code + - message + additionalProperties: false + required: + - triggerKey + - ok + - error + additionalProperties: false + stack: type: object properties: - email: - type: string - firstName: - type: string - lastName: + provider: type: string - subscribed: - type: boolean - default: true - consent: - type: object - properties: - source: - type: string - enum: - - api - - form - - import - - app - - integration - description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)." - capturedAt: - type: string - format: date-time - description: When it was captured. - policyVersion: - type: string - description: The policy or terms version in force when it was captured. - evidence: - type: string - description: "Free-text evidence: a form URL, a ticket, a checkbox label." - required: - - source - - capturedAt - additionalProperties: false - validationStatus: + enum: + - better-auth + - authjs + - supabase + - clerk + - firebase + - custom + authDelivery: type: string enum: - - valid - - risky - - invalid - description: "The latest verdict. `valid` only ever comes from a deliverability check (`validate: true` on ingest, or POST /v1/contacts/validate), which also sets `lastValidatedAt`. Without a check the free format check on ingest stores `risky` (disposable or role address) or `invalid`, and leaves the field unset otherwise: not validated." - suppressed: - type: boolean - default: false - suppressedReason: - type: - - string - - "null" - unsubscribedDomains: + - code + - hosted-webhook + description: "`code`: your app, or a hook it hosts, sends the auth keys; `hosted-webhook`: the provider hands them to Brew once connected." + note: + type: string + keys: type: array items: type: string - default: [] - lastValidatedAt: - type: string - format: date-time - validationDetails: - type: object - properties: - provider: - type: string - enum: - - brew - reason: - type: string - didYouMean: - type: string - risk: - type: string - isDisposable: - type: boolean - isRole: - type: boolean - required: - - provider - additionalProperties: false - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - importId: - type: - - string - - "null" - csvFileName: - type: - - string - - "null" - customFields: - type: object - additionalProperties: {} - default: {} + snippets: + type: array + items: + type: object + properties: + title: + type: string + file: + type: string + code: + type: string + required: + - title + - file + - code + additionalProperties: false required: - - email - - createdAt - - updatedAt + - provider + - authDelivery + - note + - keys + - snippets additionalProperties: false - updated: + required: + - results + - stack + additionalProperties: false + AppEmailsKitRequest: + type: object + properties: + sets: type: array items: type: string - minLength: 1 - description: The fields whose stored value changed (`consent` included). Empty when every value sent matched what was stored; nothing is written then and `updatedAt` stays the same. - warnings: + enum: + - auth + - lifecycle + minItems: 1 + maxItems: 2 + description: Kit sets to set up. Defaults to both. + stack: + type: string + enum: + - better-auth + - authjs + - supabase + - clerk + - firebase + - custom + description: "Your auth stack: narrows the auth keys to the ones it sends and returns its code snippets. Defaults to `custom` (every auth key)." + keys: + type: array + items: + type: string + enum: + - auth.verify-email + - auth.magic-link + - auth.otp + - auth.password-reset + - auth.invite + - auth.email-change + - auth.reauthentication + - auth.security-notice + - lifecycle.welcome + - lifecycle.trial-ending + - lifecycle.payment-failed + - lifecycle.win-back + minItems: 1 + maxItems: 12 + description: Exactly these kit keys, instead of `sets` and `stack`. + domainId: + type: string + minLength: 1 + maxLength: 64 + overwrite: + type: boolean + additionalProperties: false + TriggerInstancesListResponse: + type: object + properties: + data: type: array items: type: object properties: - code: + triggerInstanceId: type: string minLength: 1 - email: + source: type: string - minLength: 1 - row: - type: integer - exclusiveMinimum: 0 - description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." - field: + enum: + - api + - integration + - brew_event + provider: type: string - minLength: 1 - message: + triggerEventId: type: string minLength: 1 - from: + maxLength: 256 + state: type: string - minLength: 1 - to: + enum: + - received + - verified + - matched + - partially_fired + - fired + - rejected + - dead_letter + matchedAutomationIds: + type: array + items: + type: string + automationRunIds: + type: array + items: + type: string + rejectionReason: type: string - minLength: 1 - original: + attempts: + type: integer + minimum: 0 + receivedAt: type: string - minLength: 1 - normalized: + format: date-time + processedAt: type: string - minLength: 1 + format: date-time required: - - code - - message + - triggerInstanceId + - source + - state + - matchedAutomationIds + - automationRunIds + - attempts + - receivedAt additionalProperties: false - required: - - contact - - updated - - warnings - additionalProperties: false - ContactsPatchRequest: + pagination: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + cursor: + type: + - string + - "null" + hasMore: + type: boolean + required: + - limit + - cursor + - hasMore + additionalProperties: false + required: + - data + - pagination + additionalProperties: false + ContactsPostBatchResponse: type: object properties: - fields: + summary: + type: object + properties: + inserted: + type: integer + minimum: 0 + updated: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - inserted + - updated + - failed + additionalProperties: false + fieldsCreated: + type: array + items: + type: string + errors: + type: array + items: + type: object + properties: + email: + type: string + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted)." + code: + type: string + field: + type: string + message: + type: string + required: + - email + - code + - message + additionalProperties: false + warnings: + type: array + items: + type: object + properties: + code: + type: string + minLength: 1 + email: + type: string + minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." + field: + type: string + minLength: 1 + message: + type: string + minLength: 1 + from: + type: string + minLength: 1 + to: + type: string + minLength: 1 + original: + type: string + minLength: 1 + normalized: + type: string + minLength: 1 + required: + - code + - message + additionalProperties: false + validation: + type: object + properties: + valid: + type: integer + minimum: 0 + risky: + type: integer + minimum: 0 + invalid: + type: integer + minimum: 0 + unscored: + type: integer + minimum: 0 + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false + required: + - valid + - risky + - invalid + - unscored + additionalProperties: false + validationJobId: + type: string + required: + - summary + - fieldsCreated + - errors + - warnings + additionalProperties: false + ContactsPostSingleResponse: + type: object + properties: + contact: + type: object + properties: + email: + type: string + firstName: + type: string + lastName: + type: string + subscribed: + type: boolean + default: true + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + - app + - integration + description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)." + capturedAt: + type: string + format: date-time + description: When it was captured. + policyVersion: + type: string + description: The policy or terms version in force when it was captured. + evidence: + type: string + description: "Free-text evidence: a form URL, a ticket, a checkbox label." + required: + - source + - capturedAt + additionalProperties: false + validationStatus: + type: string + enum: + - valid + - risky + - invalid + description: "The latest verdict. `valid` only ever comes from a deliverability check (`validate: true` on ingest, or POST /v1/contacts/validate), which also sets `lastValidatedAt`. Without a check the free format check on ingest stores `risky` (disposable or role address) or `invalid`, and leaves the field unset otherwise: not validated." + suppressed: + type: boolean + default: false + suppressedReason: + type: + - string + - "null" + unsubscribedDomains: + type: array + items: + type: string + default: [] + lastValidatedAt: + type: string + format: date-time + validationDetails: + type: object + properties: + provider: + type: string + enum: + - brew + reason: + type: string + didYouMean: + type: string + risk: + type: string + isDisposable: + type: boolean + isRole: + type: boolean + required: + - provider + additionalProperties: false + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + importId: + type: + - string + - "null" + csvFileName: + type: + - string + - "null" + customFields: + type: object + additionalProperties: {} + default: {} + required: + - email + - createdAt + - updatedAt + additionalProperties: false + created: + type: boolean + fieldsCreated: + type: array + items: + type: string + warnings: + type: array + items: + type: object + properties: + code: + type: string + minLength: 1 + email: + type: string + minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." + field: + type: string + minLength: 1 + message: + type: string + minLength: 1 + from: + type: string + minLength: 1 + to: + type: string + minLength: 1 + original: + type: string + minLength: 1 + normalized: + type: string + minLength: 1 + required: + - code + - message + additionalProperties: false + validation: + type: object + properties: + valid: + type: integer + minimum: 0 + risky: + type: integer + minimum: 0 + invalid: + type: integer + minimum: 0 + unscored: + type: integer + minimum: 0 + persistence: + type: object + properties: + matched: + type: integer + minimum: 0 + modified: + type: integer + minimum: 0 + failed: + type: integer + minimum: 0 + required: + - matched + - modified + - failed + additionalProperties: false + required: + - valid + - risky + - invalid + - unscored + additionalProperties: false + required: + - contact + - created + - fieldsCreated + - warnings + additionalProperties: false + ContactsPostRequest: + anyOf: + - $ref: "#/components/schemas/ContactsPostSingleRequest" + - $ref: "#/components/schemas/ContactsPostBatchRequest" + ContactsPostSingleRequest: + type: object + properties: + email: + type: string + minLength: 1 + firstName: + type: string + lastName: + type: string + subscribed: + type: boolean + customFields: type: object additionalProperties: {} consent: @@ -13615,23 +14253,17 @@ components: required: - source additionalProperties: false - description: Record (or replace) the marketing consent provenance. Never re-subscribes an opted-out contact. - additionalProperties: false - ContactDeleteResponse: - type: object - properties: - email: - type: string - deleted: + description: Marketing consent provenance to record on this contact. Optional at creation; an inline marketing send to this address later needs the contact to be subscribed and warns when no record exists. + validate: type: boolean + description: Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. required: - email - - deleted additionalProperties: false - ContactsListResponse: + ContactsPostBatchRequest: type: object properties: - data: + contacts: type: array items: type: object @@ -13644,7 +14276,9 @@ components: type: string subscribed: type: boolean - default: true + customFields: + type: object + additionalProperties: {} consent: type: object properties: @@ -13654,122 +14288,29 @@ components: - api - form - import - - app - - integration - description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)." + description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" capturedAt: type: string format: date-time - description: When it was captured. + description: ISO 8601; defaults to now. policyVersion: type: string - description: The policy or terms version in force when it was captured. + minLength: 1 + maxLength: 64 evidence: type: string - description: "Free-text evidence: a form URL, a ticket, a checkbox label." + minLength: 1 + maxLength: 500 required: - source - - capturedAt - additionalProperties: false - validationStatus: - type: string - enum: - - valid - - risky - - invalid - description: "The latest verdict. `valid` only ever comes from a deliverability check (`validate: true` on ingest, or POST /v1/contacts/validate), which also sets `lastValidatedAt`. Without a check the free format check on ingest stores `risky` (disposable or role address) or `invalid`, and leaves the field unset otherwise: not validated." - suppressed: - type: boolean - default: false - suppressedReason: - type: - - string - - "null" - unsubscribedDomains: - type: array - items: - type: string - default: [] - lastValidatedAt: - type: string - format: date-time - validationDetails: - type: object - properties: - provider: - type: string - enum: - - brew - reason: - type: string - didYouMean: - type: string - risk: - type: string - isDisposable: - type: boolean - isRole: - type: boolean - required: - - provider additionalProperties: false - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - importId: - type: - - string - - "null" - csvFileName: - type: - - string - - "null" - customFields: - type: object - additionalProperties: {} - default: {} - required: - - email - - createdAt - - updatedAt + description: Marketing consent provenance to record on this contact. Optional at creation; an inline marketing send to this address later needs the contact to be subscribed and warns when no record exists. additionalProperties: false - pagination: - type: object - properties: - limit: - type: integer - minimum: 1 - maximum: 100 - cursor: - type: - - string - - "null" - hasMore: - type: boolean - required: - - limit - - cursor - - hasMore - additionalProperties: false - required: - - data - - pagination - additionalProperties: false - ContactWithOpenProfile: - type: object - properties: - email: - type: string - firstName: - type: string - lastName: - type: string - subscribed: + minItems: 1 + maxItems: 1000 + validate: type: boolean - default: true + description: Optional deliverability check on ingestion. When true, each address is validated with the provider (2 credits per address, charged on success) and the verdict is saved to the contact’s `validationStatus`. Submissions above the inline cap (100 addresses) upsert first and validate as a background job, returning a `validationJobId`. consent: type: object properties: @@ -13779,26 +14320,395 @@ components: - api - form - import - - app - - integration - description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)." + description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" capturedAt: type: string format: date-time - description: When it was captured. + description: ISO 8601; defaults to now. policyVersion: type: string - description: The policy or terms version in force when it was captured. + minLength: 1 + maxLength: 64 evidence: type: string - description: "Free-text evidence: a form URL, a ticket, a checkbox label." + minLength: 1 + maxLength: 500 required: - source - - capturedAt additionalProperties: false - validationStatus: - type: string - enum: + description: Default consent record for every row that does not carry its own `consent`. + required: + - contacts + additionalProperties: false + ContactsPatchResponse: + type: object + properties: + contact: + type: object + properties: + email: + type: string + firstName: + type: string + lastName: + type: string + subscribed: + type: boolean + default: true + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + - app + - integration + description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)." + capturedAt: + type: string + format: date-time + description: When it was captured. + policyVersion: + type: string + description: The policy or terms version in force when it was captured. + evidence: + type: string + description: "Free-text evidence: a form URL, a ticket, a checkbox label." + required: + - source + - capturedAt + additionalProperties: false + validationStatus: + type: string + enum: + - valid + - risky + - invalid + description: "The latest verdict. `valid` only ever comes from a deliverability check (`validate: true` on ingest, or POST /v1/contacts/validate), which also sets `lastValidatedAt`. Without a check the free format check on ingest stores `risky` (disposable or role address) or `invalid`, and leaves the field unset otherwise: not validated." + suppressed: + type: boolean + default: false + suppressedReason: + type: + - string + - "null" + unsubscribedDomains: + type: array + items: + type: string + default: [] + lastValidatedAt: + type: string + format: date-time + validationDetails: + type: object + properties: + provider: + type: string + enum: + - brew + reason: + type: string + didYouMean: + type: string + risk: + type: string + isDisposable: + type: boolean + isRole: + type: boolean + required: + - provider + additionalProperties: false + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + importId: + type: + - string + - "null" + csvFileName: + type: + - string + - "null" + customFields: + type: object + additionalProperties: {} + default: {} + required: + - email + - createdAt + - updatedAt + additionalProperties: false + updated: + type: array + items: + type: string + minLength: 1 + description: The fields whose stored value changed (`consent` included). Empty when every value sent matched what was stored; nothing is written then and `updatedAt` stays the same. + warnings: + type: array + items: + type: object + properties: + code: + type: string + minLength: 1 + email: + type: string + minLength: 1 + row: + type: integer + exclusiveMinimum: 0 + description: "The 1-based row it is about (CSV: data row; header and blank lines not counted)." + field: + type: string + minLength: 1 + message: + type: string + minLength: 1 + from: + type: string + minLength: 1 + to: + type: string + minLength: 1 + original: + type: string + minLength: 1 + normalized: + type: string + minLength: 1 + required: + - code + - message + additionalProperties: false + required: + - contact + - updated + - warnings + additionalProperties: false + ContactsPatchRequest: + type: object + properties: + fields: + type: object + additionalProperties: {} + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + description: "Who captured it: `api`, `form`, or `import`. (`app` is reserved for the in-app CSV wizard.)" + capturedAt: + type: string + format: date-time + description: ISO 8601; defaults to now. + policyVersion: + type: string + minLength: 1 + maxLength: 64 + evidence: + type: string + minLength: 1 + maxLength: 500 + required: + - source + additionalProperties: false + description: Record (or replace) the marketing consent provenance. Never re-subscribes an opted-out contact. + additionalProperties: false + ContactDeleteResponse: + type: object + properties: + email: + type: string + deleted: + type: boolean + required: + - email + - deleted + additionalProperties: false + ContactsListResponse: + type: object + properties: + data: + type: array + items: + type: object + properties: + email: + type: string + firstName: + type: string + lastName: + type: string + subscribed: + type: boolean + default: true + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + - app + - integration + description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)." + capturedAt: + type: string + format: date-time + description: When it was captured. + policyVersion: + type: string + description: The policy or terms version in force when it was captured. + evidence: + type: string + description: "Free-text evidence: a form URL, a ticket, a checkbox label." + required: + - source + - capturedAt + additionalProperties: false + validationStatus: + type: string + enum: + - valid + - risky + - invalid + description: "The latest verdict. `valid` only ever comes from a deliverability check (`validate: true` on ingest, or POST /v1/contacts/validate), which also sets `lastValidatedAt`. Without a check the free format check on ingest stores `risky` (disposable or role address) or `invalid`, and leaves the field unset otherwise: not validated." + suppressed: + type: boolean + default: false + suppressedReason: + type: + - string + - "null" + unsubscribedDomains: + type: array + items: + type: string + default: [] + lastValidatedAt: + type: string + format: date-time + validationDetails: + type: object + properties: + provider: + type: string + enum: + - brew + reason: + type: string + didYouMean: + type: string + risk: + type: string + isDisposable: + type: boolean + isRole: + type: boolean + required: + - provider + additionalProperties: false + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + importId: + type: + - string + - "null" + csvFileName: + type: + - string + - "null" + customFields: + type: object + additionalProperties: {} + default: {} + required: + - email + - createdAt + - updatedAt + additionalProperties: false + pagination: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + cursor: + type: + - string + - "null" + hasMore: + type: boolean + required: + - limit + - cursor + - hasMore + additionalProperties: false + required: + - data + - pagination + additionalProperties: false + ContactWithOpenProfile: + type: object + properties: + email: + type: string + firstName: + type: string + lastName: + type: string + subscribed: + type: boolean + default: true + consent: + type: object + properties: + source: + type: string + enum: + - api + - form + - import + - app + - integration + description: "Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in)." + capturedAt: + type: string + format: date-time + description: When it was captured. + policyVersion: + type: string + description: The policy or terms version in force when it was captured. + evidence: + type: string + description: "Free-text evidence: a form URL, a ticket, a checkbox label." + required: + - source + - capturedAt + additionalProperties: false + validationStatus: + type: string + enum: - valid - risky - invalid @@ -15886,6 +16796,100 @@ components: - data - pagination additionalProperties: false + DomainsPostResponse: + type: object + properties: + domainId: + type: string + minLength: 1 + maxLength: 64 + domainUrl: + type: string + format: uri + name: + type: string + minLength: 1 + maxLength: 253 + region: + type: string + status: + type: string + enum: + - not_started + - pending + - verified + - failed + - temporary_failure + - partially_verified + - partially_failed + sendingEnabled: + type: boolean + sendable: + type: boolean + sendingPurpose: + type: string + enum: + - marketing + - transactional + description: marketing (default) or transactional. Transactional domains skip unsubscribe and send via automation sendEmail nodes (or a test send). + records: + type: array + items: + type: object + properties: + record: + type: string + name: + type: string + type: + type: string + ttl: + type: string + status: + type: string + value: + type: string + priority: + type: number + required: + - record + - name + - type + - ttl + - status + - value + additionalProperties: false + openTracking: + type: boolean + clickTracking: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + verifiedAt: + type: string + format: date-time + warnings: + type: array + items: + $ref: "#/components/schemas/ApiWarning" + description: "Present only with a caveat about the domain just added. `DKIM_SELECTOR_IN_USE`: the domain already publishes a different DKIM key where Brew asks for its record; `suggestedNames` lists subdomains to add instead." + required: + - domainId + - domainUrl + - name + - region + - status + - sendingEnabled + - sendable + - sendingPurpose + - records + - createdAt + - updatedAt + additionalProperties: false DomainsPostRequest: type: object properties: @@ -18135,6 +19139,7 @@ components: - stripe - stytch - supabase + - supabase_auth - workos - shopify - revenuecat @@ -18206,238 +19211,105 @@ components: - data - pagination additionalProperties: false - ApiKeysListResponse: + IntegrationConnection: type: object properties: - data: - type: array - items: - type: object - properties: - keyId: - type: string - minLength: 1 - name: - type: string - minLength: 1 - keyPreview: - type: string - minLength: 1 - createdAt: - type: string - format: date-time - lastUsedAt: - type: string - format: date-time - status: - type: string - enum: - - active - - revoked - brandId: - type: string - minLength: 1 - permissions: - type: array - items: - type: string - minLength: 1 - required: - - keyId - - name - - keyPreview - - createdAt - - status - - permissions - additionalProperties: false - pagination: - type: object - properties: - limit: - type: integer - minimum: 1 - maximum: 100 - cursor: - type: - - string - - "null" - hasMore: - type: boolean - required: - - limit - - cursor - - hasMore - additionalProperties: false - required: - - data - - pagination - additionalProperties: false - ApiKeysCreateResponse: - type: object - properties: - key: + provider: type: string - minLength: 1 - keyId: + enum: + - supabase_auth + - clerk + connectionId: type: string - minLength: 1 - message: + description: Server-derived, stable per brand and provider. + webhookUrl: type: string - minLength: 1 + description: "Where the provider delivers: `https://brew.new/api/integrations/{provider}/webhook/{connectionId}`. Paste it as the Supabase hook URL or the Clerk endpoint URL." + status: + type: string + enum: + - pending_verification + - active + description: "`pending_verification` until the first signed delivery arrives, then `active`." required: - - key - - keyId - - message + - provider + - connectionId + - webhookUrl + - status additionalProperties: false - ApiKeysCreateRequest: + IntegrationConnectionPutResponse: type: object properties: - name: - type: string - minLength: 1 - maxLength: 80 - permissions: - type: array - items: - type: string - enum: - - all - - contacts - - emails - - automations - - transactional - - domains - - sends - - audiences - - brands - minItems: 1 - brandId: + provider: type: string - minLength: 1 - maxLength: 64 - replacesKeyId: + enum: + - supabase_auth + - clerk + connectionId: type: string - minLength: 1 - maxLength: 64 - additionalProperties: false - ApiKeysDeleteResponse: - type: object - properties: - keyId: + description: Server-derived, stable per brand and provider. + webhookUrl: type: string - minLength: 1 - deleted: - type: boolean - revokedAt: - type: string - format: date-time - required: - - keyId - - deleted - additionalProperties: false - HealthResponse: - type: object - properties: + description: "Where the provider delivers: `https://brew.new/api/integrations/{provider}/webhook/{connectionId}`. Paste it as the Supabase hook URL or the Clerk endpoint URL." status: type: string enum: - - ok - version: - type: string - required: - - status - - version - additionalProperties: false - HelpResponse: - type: object - additionalProperties: {} - ChatList: - type: object - properties: - data: + - pending_verification + - active + description: "`pending_verification` until the first signed delivery arrives, then `active`." + authEmails: type: array items: type: object properties: - chatId: - type: string - description: Resume the chat with the chat-context read. - title: - type: - - string - - "null" - firstUserPrompt: - type: - - string - - "null" - description: The opening prompt, cut at 80 characters. - lastAssistantPreview: - type: - - string - - "null" - description: Brew's latest reply, cut at 140 characters. - status: - type: - - string - - "null" - enum: - - idle - - streaming - - background_finalizing - - completed - - failed - - interrupted - - stopped - - persistence_failed - - null - description: "`streaming` or `background_finalizing` while a run is still going; null before the first run." - origin: - type: - - string - - "null" - enum: - - slack - - null - description: The chat app the conversation started in; null for the Brew web app. - updatedAt: + triggerKey: type: string - format: date-time - url: - type: string - description: Opens the chat in the Brew app. + ok: + type: boolean + error: + type: object + properties: + code: + type: string + message: + type: string + required: + - code + - message + additionalProperties: false required: - - chatId - - title - - firstUserPrompt - - lastAssistantPreview - - status - - origin - - updatedAt - - url + - triggerKey + - ok additionalProperties: false - pagination: + description: The auth app emails this provider sends, each set up from the starter kit or kept as it is. A key that failed carries its `error`; connecting again finishes it. + required: + - provider + - connectionId + - webhookUrl + - status + - authEmails + additionalProperties: false + IntegrationConnectionPutRequest: + type: object + properties: + signingSecret: + type: string + minLength: 1 + maxLength: 4096 + description: "The secret the provider signs deliveries with: Supabase shows `v1,whsec_…` for the hook, Clerk `whsec_…` for the endpoint." + settings: type: object properties: - limit: - type: integer - minimum: 1 - maximum: 100 - cursor: - type: - - string - - "null" - hasMore: - type: boolean - required: - - limit - - cursor - - hasMore + projectUrl: + type: string + maxLength: 2048 + format: uri + description: "Supabase only: the project URL confirmation links point at (`https://.supabase.co` or a custom auth domain). Omitted: the URL Supabase sends with each email." additionalProperties: false required: - - data - - pagination + - signingSecret additionalProperties: false - NotificationList: + ApiKeysListResponse: type: object properties: data: @@ -18445,87 +19317,318 @@ components: items: type: object properties: - notificationId: - type: string - pattern: ^ntf_[0-9a-f]{20}$ - description: "`ntf_` + 20 hex characters: a one-way hash, stable for the row’s life." - type: - type: string - enum: - - chat_stream - - import_job - - brand_image_import - - validation_job - - email_sent - - email_scheduled - - preview_email - - brand_extracted - - domain_status - - domain_score_run - - api_key_created - - email_send_failed - - gradual_send_paused - - gradual_send_completed - - send_limit_reached - - automation_pause_window_closed - - send_review_rejected - - comment_mention - - comment_reply - - integration_attention - status: - type: string - enum: - - uploading - - preparing - - processing - - finalizing - - streaming - - background_finalizing - - completed - - partial - - failed - - interrupted - - stopped - - persistence_failed - title: - type: string - subtitle: - type: string - progressPercent: - type: number - description: 0-100 while work is running, where it reports progress. - url: - type: string - description: Where the bell opens it in the Brew app. - chatId: - type: string - emailId: + keyId: type: string - domainId: + minLength: 1 + name: type: string - domainName: + minLength: 1 + keyPreview: type: string - isPersonal: - type: boolean - description: "Addressed to the caller alone: a comment mention or reply, shown only on a personal connection." + minLength: 1 createdAt: type: string format: date-time - updatedAt: + lastUsedAt: type: string format: date-time - completedAt: + status: type: string - format: date-time + enum: + - active + - revoked + brandId: + type: string + minLength: 1 + permissions: + type: array + items: + type: string + minLength: 1 required: - - notificationId - - type - - status - - title - - subtitle - - isPersonal + - keyId + - name + - keyPreview - createdAt - - updatedAt + - status + - permissions + additionalProperties: false + pagination: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + cursor: + type: + - string + - "null" + hasMore: + type: boolean + required: + - limit + - cursor + - hasMore + additionalProperties: false + required: + - data + - pagination + additionalProperties: false + ApiKeysCreateResponse: + type: object + properties: + key: + type: string + minLength: 1 + keyId: + type: string + minLength: 1 + message: + type: string + minLength: 1 + required: + - key + - keyId + - message + additionalProperties: false + ApiKeysCreateRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 80 + permissions: + type: array + items: + type: string + enum: + - all + - contacts + - emails + - automations + - transactional + - domains + - sends + - audiences + - brands + minItems: 1 + brandId: + type: string + minLength: 1 + maxLength: 64 + replacesKeyId: + type: string + minLength: 1 + maxLength: 64 + additionalProperties: false + ApiKeysDeleteResponse: + type: object + properties: + keyId: + type: string + minLength: 1 + deleted: + type: boolean + revokedAt: + type: string + format: date-time + required: + - keyId + - deleted + additionalProperties: false + HealthResponse: + type: object + properties: + status: + type: string + enum: + - ok + version: + type: string + required: + - status + - version + additionalProperties: false + HelpResponse: + type: object + additionalProperties: {} + ChatList: + type: object + properties: + data: + type: array + items: + type: object + properties: + chatId: + type: string + description: Resume the chat with the chat-context read. + title: + type: + - string + - "null" + firstUserPrompt: + type: + - string + - "null" + description: The opening prompt, cut at 80 characters. + lastAssistantPreview: + type: + - string + - "null" + description: Brew's latest reply, cut at 140 characters. + status: + type: + - string + - "null" + enum: + - idle + - streaming + - background_finalizing + - completed + - failed + - interrupted + - stopped + - persistence_failed + - null + description: "`streaming` or `background_finalizing` while a run is still going; null before the first run." + origin: + type: + - string + - "null" + enum: + - slack + - null + description: The chat app the conversation started in; null for the Brew web app. + updatedAt: + type: string + format: date-time + url: + type: string + description: Opens the chat in the Brew app. + required: + - chatId + - title + - firstUserPrompt + - lastAssistantPreview + - status + - origin + - updatedAt + - url + additionalProperties: false + pagination: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + cursor: + type: + - string + - "null" + hasMore: + type: boolean + required: + - limit + - cursor + - hasMore + additionalProperties: false + required: + - data + - pagination + additionalProperties: false + NotificationList: + type: object + properties: + data: + type: array + items: + type: object + properties: + notificationId: + type: string + pattern: ^ntf_[0-9a-f]{20}$ + description: "`ntf_` + 20 hex characters: a one-way hash, stable for the row’s life." + type: + type: string + enum: + - chat_stream + - import_job + - brand_image_import + - validation_job + - email_sent + - email_scheduled + - preview_email + - brand_extracted + - domain_status + - domain_score_run + - api_key_created + - email_send_failed + - gradual_send_paused + - gradual_send_completed + - send_limit_reached + - automation_pause_window_closed + - send_review_rejected + - comment_mention + - comment_reply + - integration_attention + status: + type: string + enum: + - uploading + - preparing + - processing + - finalizing + - streaming + - background_finalizing + - completed + - partial + - failed + - interrupted + - stopped + - persistence_failed + title: + type: string + subtitle: + type: string + progressPercent: + type: number + description: 0-100 while work is running, where it reports progress. + url: + type: string + description: Where the bell opens it in the Brew app. + chatId: + type: string + emailId: + type: string + domainId: + type: string + domainName: + type: string + isPersonal: + type: boolean + description: "Addressed to the caller alone: a comment mention or reply, shown only on a personal connection." + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + completedAt: + type: string + format: date-time + required: + - notificationId + - type + - status + - title + - subtitle + - isPersonal + - createdAt + - updatedAt additionalProperties: false pagination: type: object @@ -21054,7 +22157,9 @@ paths: **Input** `content` (the raw source as a string) and `format`: `html` (a document or fragment), `mjml`, `jsx` (a module with a default-exported component or a bare `` element; static values, `PreviewProps` and local sub-components fold, anything executable is rejected with the offending line) or `eml` (the HTML body is imported, inline `cid:` images are rehosted, other attachments are dropped, and the Subject becomes the title). HTML, MJML and EML land as source-preserved designs with an editable projection; JSX lands as a native React Email design. Optional `baseUrl` resolves relative image paths; optional `title` and `subjectLine` (the design-default inbox subject; an EML Subject is the default); optional `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Limits: 5,000,000 UTF-8 bytes, 256 remote resources, 8 concurrent fetches, 10 MB per resource and 50 MB aggregate downloads. - **Returns** `201 { emailId, emailVersionId, html, previewImage?, assetReport, group, subjectLine? }`. Each source element with content that a design could not keep is listed once in `assetReport.warnings` as `{ code: "element_dropped", location: "markup", host: "" }`: inline `svg`, `math` and `canvas`, and `button`, `select`, `textarea` or table `caption` holding text are left out of the editable projection (the original source keeps them); `audio`, `video`, `iframe`, `object` and `embed` are removed from both, with `audio`/`video` fallback content kept. + **Migrating from Resend** `props: "merge_tags"` (`jsx` only) keeps the root component's props as `{{ trigger. }}` merge tags filled from the trigger payload: member reads extend the path (`props.user.name` is `trigger.user.name`), default values and `??` / `||` literals become fallbacks, template literals and `href` / `src` / `alt` / `title` carry tags, and `{prop && }` becomes a `{% if %}` block. A prop used where a tag cannot go (arithmetic, a style, a `.map`) takes its `PreviewProps` or default value with a `dynamic_prop_folded` or `dynamic_control_flow_unsupported` warning; one with neither is refused. `placeholders: "resend"` rewrites Resend `{{{NAME}}}` / `{{{NAME|fallback}}}` to `{{ trigger.NAME }}` in the body and `subjectLine` (`FIRST_NAME`, `LAST_NAME`, `EMAIL` read the contact; `href="{{{RESEND_UNSUBSCRIBE_URL}}}"` becomes Brew's unsubscribe link). Resend printed those values raw and Brew escapes them (`escaping_changed`): end a tag with `| raw` only for HTML you trust. Optional `variables: [{ key, fallbackValue? }]` supplies fallbacks the source lacks (a Resend template's `variables`) and needs one of the two modes. + + **Returns** `201 { emailId, emailVersionId, html, previewImage?, assetReport, variables, group, subjectLine? }`. `variables` lists every merge path the design and subject read, `{ path, fallback? }`, ready to declare as the trigger payload. Each source element with content that a design could not keep is listed once in `assetReport.warnings` as `{ code: "element_dropped", location: "markup", host: "" }`: inline `svg`, `math` and `canvas`, and `button`, `select`, `textarea` or table `caption` holding text are left out of the editable projection (the original source keeps them); `audio`, `video`, `iframe`, `object` and `embed` are removed from both, with `audio`/`video` fallback content kept. **Errors** `422 EMAIL_IMPORT_FAILED` when the markup cannot be compiled into a design or its editable model is over the size budget (nothing is persisted); `404 EMAIL_GROUP_NOT_FOUND` for an unknown `groupId`; `413 PAYLOAD_TOO_LARGE` over the byte cap; `422 BRAND_NOT_READY` while the brand is still extracting. @@ -21101,6 +22206,26 @@ paths: value: format: jsx content: WelcomeThanks for joining. + jsxMergeTags: + summary: Import React Email with props as merge tags + value: + format: jsx + props: merge_tags + content: |- + import { Html, Body, Text, Button } from 'react-email' + export default function Invite({ name = 'there', inviteLink }) { + return Hi {name}, + } + resendTemplate: + summary: Import a Resend template + value: + format: html + placeholders: resend + subjectLine: Order {{{ORDER_ID}}} confirmed + content:

Hi {{{FIRST_NAME|there}}}, order {{{ORDER_ID}}} ships {{{ETA}}}.

+ variables: + - key: ETA + fallbackValue: soon eml: summary: Import a saved .eml message value: @@ -21165,6 +22290,10 @@ paths: retained: 0 stripped: 0 warnings: [] + variables: + - path: trigger.name + fallback: there + - path: trigger.inviteLink group: null "400": description: |- @@ -26682,9 +27811,2523 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUDIT_NOT_FOUND`: No saved audit matches that id in this brand, or its seven day retention has expired. - + `AUDIT_NOT_FOUND`: No saved audit matches that id in this brand, or its seven day retention has expired. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + auditNotFound: + summary: AUDIT_NOT_FOUND + value: + error: + code: AUDIT_NOT_FOUND + type: not_found + message: No saved audit matches that id in this brand, or its seven day retention has expired. + suggestion: Use an auditId returned by POST /v1/emails/audit for this brand, or run a new audit. + docs: https://docs.brew.new/api-reference/api/errors + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 100 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: emails.read + /v1/emails/client-previews/{previewId}: + get: + operationId: getEmailRendering + tags: + - Emails + summary: Read email rendering progress + description: Reads the existing rendering job and per-client results without submitting another provider test or charging again. Returns successful full-size image links, pending or failed clients with reasons and retryability, expiry, and credit reservation status. Poll the same previewId after nextPollAfterMs while the job is `queued` or `running`. + parameters: + - schema: + type: string + minLength: 1 + maxLength: 100 + required: true + name: previewId + in: path + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + responses: + "200": + description: Rendering progress for the pinned email version and requested clients. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + type: object + properties: + previewId: + type: string + emailId: + type: string + emailVersionId: + type: string + description: Portable version ID when present. previewId pins the saved snapshot. + status: + type: string + enum: + - queued + - running + - completed + - partially_completed + - failed + description: "The job: `queued` or `running` until every client settles, then `completed`, `partially_completed` (some screenshots failed) or `failed`." + previews: + type: array + items: + type: object + properties: + id: + type: string + label: + type: string + category: + type: string + enum: + - gmail + - outlook + - apple + - yahoo + - other + os: + type: string + dark: + type: boolean + status: + type: string + enum: + - running + - completed + - failed + imageUrl: + type: + - string + - "null" + format: uri + reason: + type: string + enum: + - pending + - upstream_unavailable + - unsupported_client + - render_failed + - rehost_failed + - expired + - submission_unknown + - authorization_changed + - snapshot_unavailable + retryable: + type: boolean + required: + - id + - label + - category + - os + - dark + - status + - imageUrl + - retryable + additionalProperties: false + pending: + type: array + items: + type: string + createdAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + nextPollAfterMs: + type: integer + minimum: 0 + credits: + type: object + properties: + cost: + type: number + status: + type: string + enum: + - reserved + - settled + - released + required: + - cost + - status + additionalProperties: false + required: + - previewId + - emailId + - status + - previews + - pending + - createdAt + - expiresAt + - nextPollAfterMs + - credits + additionalProperties: false + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `PREVIEW_NOT_FOUND`: No rendering job matches that id in this brand, or its results have expired. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + previewNotFound: + summary: PREVIEW_NOT_FOUND + value: + error: + code: PREVIEW_NOT_FOUND + type: not_found + message: No rendering job matches that id in this brand, or its results have expired. + suggestion: Use the previewId returned by POST /v1/emails/{emailId}/client-previews, or start a new job. + docs: https://docs.brew.new/api-reference/api/errors + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 100 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: emails.read + /v1/canvas/sessions: + post: + operationId: createCanvasSession + tags: + - Emails + summary: Create an authenticated email canvas embed session + description: Create a short-lived grant for exactly one group or email. Call from your backend after authenticating your user and authorizing the target. Credentials are returned once; response replay is disabled. API keys remain server-side. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + target: + oneOf: + - type: object + properties: + type: + type: string + enum: + - group + groupId: + type: string + minLength: 1 + maxLength: 200 + required: + - type + - groupId + - type: object + properties: + type: + type: string + enum: + - email + emailId: + type: string + minLength: 1 + maxLength: 200 + required: + - type + - emailId + user: + type: object + properties: + externalId: + type: string + minLength: 1 + maxLength: 200 + name: + type: string + minLength: 1 + maxLength: 100 + required: + - externalId + - name + parentOrigin: + type: string + maxLength: 2000 + permissions: + type: array + items: + type: string + enum: + - read + - edit + - presence + minItems: 1 + maxItems: 3 + default: + - read + - edit + - presence + required: + - target + - user + - parentOrigin + responses: + "201": + description: A scoped session and single-use bootstrap credential. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/CanvasSession" + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `NOT_FOUND`: No v1 resource lives at this path. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + notFound: + summary: NOT_FOUND + value: + error: + code: NOT_FOUND + type: not_found + message: No v1 resource lives at this path. + suggestion: Check the request path and HTTP method against the API reference. + docs: https://docs.brew.new/api-reference/api/api-introduction + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 20 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 20 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + parameters: + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: disabled + x-brew-credited: false + x-brew-rate-policy: emails.edit + /v1/canvas/sessions/{sessionId}/refresh: + post: + operationId: refreshCanvasSession + tags: + - Emails + summary: Refresh a canvas session + description: Issue a new bootstrap credential without changing the target or external user. Requires the original issuing authority. Response replay is disabled. + parameters: + - schema: + type: string + minLength: 1 + maxLength: 200 + required: true + name: sessionId + in: path + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + responses: + "200": + description: The same scoped session with a new bootstrap credential. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/CanvasSession" + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `NOT_FOUND`: No v1 resource lives at this path. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + notFound: + summary: NOT_FOUND + value: + error: + code: NOT_FOUND + type: not_found + message: No v1 resource lives at this path. + suggestion: Check the request path and HTTP method against the API reference. + docs: https://docs.brew.new/api-reference/api/api-introduction + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 20 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 20 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: disabled + x-brew-credited: false + x-brew-rate-policy: emails.edit + /v1/canvas/sessions/{sessionId}: + delete: + operationId: revokeCanvasSession + tags: + - Emails + summary: Revoke a canvas session + description: Stop reads and writes through an existing session, including active subscriptions. + parameters: + - schema: + type: string + minLength: 1 + maxLength: 200 + required: true + name: sessionId + in: path + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + responses: + "200": + description: The session is revoked. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + type: object + properties: + revoked: + type: boolean + enum: + - true + required: + - revoked + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `NOT_FOUND`: No v1 resource lives at this path. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + notFound: + summary: NOT_FOUND + value: + error: + code: NOT_FOUND + type: not_found + message: No v1 resource lives at this path. + suggestion: Check the request path and HTTP method against the API reference. + docs: https://docs.brew.new/api-reference/api/api-introduction + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 20 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 20 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: emails.edit + /v1/email-groups: + get: + operationId: listEmailGroups + tags: + - Emails + summary: List email groups + description: |- + Lists the brand’s email folders under `{ data, pagination }`. Named groups use `grp_*` ids; Ungrouped is always included as `{ groupId: "ungrouped", groupName: "Ungrouped" }`. `emailCount` is capped at 100. A named group a person created carries `createdByUserId` and, when they can be named, `createdBy` (their name or "Former member"); older and org-key groups have neither. + + **Use when** picking the `groupId` to file a design under or to filter `listEmails` by. + + **Input** `limit` and `cursor`. + + **Returns** `200` with a page of `EmailGroupSummary` rows. + + **Errors** `400 INVALID_REQUEST` for an unknown query key. + + **See also** `getEmailGroup`, `createEmailGroup`, `listEmails` (`?groupId=`). + parameters: + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 512 + required: false + name: cursor + in: query + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + responses: + "200": + description: A page of email groups. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/EmailGroupsListResponse" + example: + data: + - groupId: grp_welcome + groupName: Welcome + emailCount: 3 + createdBy: Sam Rivera + createdByUserId: user_2xK9mPq4Rt7Vw1Yb + - groupId: ungrouped + groupName: Ungrouped + emailCount: 12 + pagination: + limit: 100 + cursor: null + hasMore: false + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 100 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: emails.read + post: + operationId: createEmailGroup + tags: + - Emails + summary: Create an email group + description: Creates a named email folder. Returns `201` with `{ groupId, groupName, emailCount }`. Pass `emailIds` (up to 50) to move existing designs in, in the same transaction as the create; the response then adds `moved` and `notMoved` (`not_found`, `generating`, `not_movable`, `folder_full` or `retry`; resend `retry` ids in a follow-up `PATCH`). Reserved names (`Ungrouped` / `ungrouped` / `__ungrouped__`) are `400`. Duplicate names are `409 EMAIL_GROUP_NAME_CONFLICT`. Pass the returned `groupId` as `groupId` on create/import/clone. + parameters: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailGroupCreateRequest" + example: + name: Welcome + emailIds: + - V1StGXR8_Z5jdHi6B-myT + - qkE3pWv2xN9dLmR4tYbZ7 + responses: + "201": + description: Created, with any designs moved in. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/EmailGroupWriteResponse" + example: + groupId: grp_welcome + groupName: Welcome + emailCount: 2 + moved: 2 + notMoved: [] + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `EMAIL_GROUP_NOT_FOUND`: No email group with that id exists in the brand. + + `NOT_FOUND`: No v1 resource lives at this path. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + emailGroupNotFound: + summary: EMAIL_GROUP_NOT_FOUND + value: + error: + code: EMAIL_GROUP_NOT_FOUND + type: not_found + message: No email group with that id exists in the brand. + suggestion: List groups with GET /v1/email-groups. + docs: https://docs.brew.new/api-reference/api/errors + notFound: + summary: NOT_FOUND + value: + error: + code: NOT_FOUND + type: not_found + message: No v1 resource lives at this path. + suggestion: Check the request path and HTTP method against the API reference. + docs: https://docs.brew.new/api-reference/api/api-introduction + "409": + description: |- + `EMAIL_GROUP_NAME_CONFLICT`: A group with that name already exists in the brand. + + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + emailGroupNameConflict: + summary: EMAIL_GROUP_NAME_CONFLICT + value: + error: + code: EMAIL_GROUP_NAME_CONFLICT + type: conflict + message: A group with that name already exists in the brand. + suggestion: Pick a different name, or rename the existing group. + docs: https://docs.brew.new/api-reference/api/errors + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + "429": + description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 20 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 20 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: replay + x-brew-credited: false + x-brew-rate-policy: emails.edit + /v1/email-groups/{groupId}: + get: + operationId: getEmailGroup + tags: + - Emails + summary: Get an email group + description: |- + Reads one email folder as the bare `EmailGroupSummary` (`groupId`, `groupName`, `emailCount`, plus `createdByUserId` when a person created it, and `createdBy` when they can be named). + + **Use when** confirming a group before filing designs under it, or reading the `ungrouped` catalog row. + + **Input** `groupId` in the path: a `grp_*` id or the literal `ungrouped`. + + **Returns** `200` with the row. + + **Errors** `404 EMAIL_GROUP_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for a non-public Ungrouped sentinel. + + **See also** `listEmailGroups`, `updateEmailGroup`, `deleteEmailGroup`. + parameters: + - schema: + type: string + minLength: 1 + maxLength: 64 + description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. + example: grp_welcome + required: true + description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. + name: groupId + in: path + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + responses: + "200": + description: The group row. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/EmailGroupSummary" + example: + groupId: grp_welcome + groupName: Welcome + emailCount: 3 + createdBy: Sam Rivera + createdByUserId: user_2xK9mPq4Rt7Vw1Yb + "400": + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication + invalidRequest: + summary: INVALID_REQUEST + value: + error: + code: INVALID_REQUEST + type: invalid_request + message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + suggestion: Fix the field reported in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + "401": + description: |- + `API_KEY_REVOKED`: The API key was revoked. + + `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + + `INVALID_API_KEY`: The API key is malformed or unknown. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + apiKeyRevoked: + summary: API_KEY_REVOKED + value: + error: + code: API_KEY_REVOKED + type: authentication_error + message: The API key was revoked. + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED + value: + error: + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication + "403": + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `EMAIL_GROUP_NOT_FOUND`: No email group with that id exists in the brand. headers: x-request-id: schema: @@ -26698,15 +30341,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - auditNotFound: - summary: AUDIT_NOT_FOUND - value: - error: - code: AUDIT_NOT_FOUND - type: not_found - message: No saved audit matches that id in this brand, or its seven day retention has expired. - suggestion: Use an auditId returned by POST /v1/emails/audit for this brand, or run a new audit. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -26716,6 +30350,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + emailGroupNotFound: + summary: EMAIL_GROUP_NOT_FOUND + value: + error: + code: EMAIL_GROUP_NOT_FOUND + type: not_found + message: No email group with that id exists in the brand. + suggestion: List groups with GET /v1/email-groups. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -26804,20 +30447,22 @@ paths: x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: emails.read - /v1/emails/client-previews/{previewId}: - get: - operationId: getEmailRendering + patch: + operationId: updateEmailGroup tags: - Emails - summary: Read email rendering progress - description: Reads the existing rendering job and per-client results without submitting another provider test or charging again. Returns successful full-size image links, pending or failed clients with reasons and retryability, expiry, and credit reservation status. Poll the same previewId after nextPollAfterMs while the job is `queued` or `running`. + summary: Update an email group + description: Renames a named folder (`name`), moves existing designs into it (`emailIds`, up to 50, one request against the rate limit), or both, in one transaction. A move adds `moved` and `notMoved` (`not_found`, `generating`, `not_movable`, `folder_full` or `retry`; resend `retry` ids) to the response. Ungrouped is not a target (`400`). Unknown / cross-brand ids are `404`. Duplicate names are `409 EMAIL_GROUP_NAME_CONFLICT`. parameters: - schema: type: string minLength: 1 - maxLength: 100 + maxLength: 64 + description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. + example: grp_welcome required: true - name: previewId + description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. + name: groupId in: path - name: X-Brand-Id in: header @@ -26828,9 +30473,17 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailGroupPatchRequest" + example: + name: Welcome series responses: "200": - description: Rendering progress for the pinned email version and requested clients. + description: Updated. headers: x-request-id: schema: @@ -26863,119 +30516,11 @@ paths: content: application/json: schema: - type: object - properties: - previewId: - type: string - emailId: - type: string - emailVersionId: - type: string - description: Portable version ID when present. previewId pins the saved snapshot. - status: - type: string - enum: - - queued - - running - - completed - - partially_completed - - failed - description: "The job: `queued` or `running` until every client settles, then `completed`, `partially_completed` (some screenshots failed) or `failed`." - previews: - type: array - items: - type: object - properties: - id: - type: string - label: - type: string - category: - type: string - enum: - - gmail - - outlook - - apple - - yahoo - - other - os: - type: string - dark: - type: boolean - status: - type: string - enum: - - running - - completed - - failed - imageUrl: - type: - - string - - "null" - format: uri - reason: - type: string - enum: - - pending - - upstream_unavailable - - unsupported_client - - render_failed - - rehost_failed - - expired - - submission_unknown - - authorization_changed - - snapshot_unavailable - retryable: - type: boolean - required: - - id - - label - - category - - os - - dark - - status - - imageUrl - - retryable - additionalProperties: false - pending: - type: array - items: - type: string - createdAt: - type: string - format: date-time - expiresAt: - type: string - format: date-time - nextPollAfterMs: - type: integer - minimum: 0 - credits: - type: object - properties: - cost: - type: number - status: - type: string - enum: - - reserved - - settled - - released - required: - - cost - - status - additionalProperties: false - required: - - previewId - - emailId - - status - - previews - - pending - - createdAt - - expiresAt - - nextPollAfterMs - - credits - additionalProperties: false + $ref: "#/components/schemas/EmailGroupWriteResponse" + example: + groupId: grp_welcome + groupName: Welcome series + emailCount: 3 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -27121,7 +30666,9 @@ paths: description: |- `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - `PREVIEW_NOT_FOUND`: No rendering job matches that id in this brand, or its results have expired. + `EMAIL_GROUP_NOT_FOUND`: No email group with that id exists in the brand. + + `NOT_FOUND`: No v1 resource lives at this path. headers: x-request-id: schema: @@ -27144,14 +30691,47 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - previewNotFound: - summary: PREVIEW_NOT_FOUND + emailGroupNotFound: + summary: EMAIL_GROUP_NOT_FOUND value: error: - code: PREVIEW_NOT_FOUND + code: EMAIL_GROUP_NOT_FOUND type: not_found - message: No rendering job matches that id in this brand, or its results have expired. - suggestion: Use the previewId returned by POST /v1/emails/{emailId}/client-previews, or start a new job. + message: No email group with that id exists in the brand. + suggestion: List groups with GET /v1/email-groups. + docs: https://docs.brew.new/api-reference/api/errors + notFound: + summary: NOT_FOUND + value: + error: + code: NOT_FOUND + type: not_found + message: No v1 resource lives at this path. + suggestion: Check the request path and HTTP method against the API reference. + docs: https://docs.brew.new/api-reference/api/api-introduction + "409": + description: "`EMAIL_GROUP_NAME_CONFLICT`: A group with that name already exists in the brand." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + emailGroupNameConflict: + summary: EMAIL_GROUP_NAME_CONFLICT + value: + error: + code: EMAIL_GROUP_NAME_CONFLICT + type: conflict + message: A group with that name already exists in the brand. + suggestion: Pick a different name, or rename the existing group. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -27167,7 +30747,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 20 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -27200,127 +30780,76 @@ paths: summary: RATE_LIMITED value: error: - code: RATE_LIMITED - type: rate_limit - message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. - suggestion: Wait the Retry-After time, then retry the same request. - docs: https://docs.brew.new/api-reference/api/rate-limits - retryAfter: 42 - details: - scope: credential - limit: 100 - remaining: 0 - reset: 1712592360 - consumed: false - "500": - description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - internalError: - summary: INTERNAL_ERROR - value: - error: - code: INTERNAL_ERROR - type: internal_error - message: An unexpected failure; the x-request-id header identifies it. - suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. - docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails - x-brew-brand-scope: brand - x-brew-idempotency: none - x-brew-credited: false - x-brew-rate-policy: emails.read - /v1/canvas/sessions: - post: - operationId: createCanvasSession - tags: - - Emails - summary: Create an authenticated email canvas embed session - description: Create a short-lived grant for exactly one group or email. Call from your backend after authenticating your user and authorizing the target. Credentials are returned once; response replay is disabled. API keys remain server-side. - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - target: - oneOf: - - type: object - properties: - type: - type: string - enum: - - group - groupId: - type: string - minLength: 1 - maxLength: 200 - required: - - type - - groupId - - type: object - properties: - type: - type: string - enum: - - email - emailId: - type: string - minLength: 1 - maxLength: 200 - required: - - type - - emailId - user: - type: object - properties: - externalId: - type: string - minLength: 1 - maxLength: 200 - name: - type: string - minLength: 1 - maxLength: 100 - required: - - externalId - - name - parentOrigin: - type: string - maxLength: 2000 - permissions: - type: array - items: - type: string - enum: - - read - - edit - - presence - minItems: 1 - maxItems: 3 - default: - - read - - edit - - presence - required: - - target - - user - - parentOrigin + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 20 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: emails.edit + delete: + operationId: deleteEmailGroup + tags: + - Emails + summary: Delete an email group + description: "Deletes a named folder. Member emails move to Ungrouped. Ungrouped cannot be deleted (`400`). Idempotent — an unknown / cross-brand id resolves with `{ deleted: false }`." + parameters: + - schema: + type: string + minLength: 1 + maxLength: 64 + description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. + example: grp_welcome + required: true + description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. + name: groupId + in: path + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: - "201": - description: A scoped session and single-use bootstrap credential. + "200": + description: Deleted (or already gone). headers: x-request-id: schema: @@ -27329,10 +30858,34 @@ paths: example: req_8cac13fd94e6420cacdd75a1aa403a28 required: true description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. content: application/json: schema: - $ref: "#/components/schemas/CanvasSession" + $ref: "#/components/schemas/EmailGroupDeleteResponse" + example: + groupId: grp_welcome + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -27478,6 +31031,8 @@ paths: description: |- `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + `EMAIL_GROUP_NOT_FOUND`: No email group with that id exists in the brand. + `NOT_FOUND`: No v1 resource lives at this path. headers: x-request-id: @@ -27501,6 +31056,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + emailGroupNotFound: + summary: EMAIL_GROUP_NOT_FOUND + value: + error: + code: EMAIL_GROUP_NOT_FOUND + type: not_found + message: No email group with that id exists in the brand. + suggestion: List groups with GET /v1/email-groups. + docs: https://docs.brew.new/api-reference/api/errors notFound: summary: NOT_FOUND value: @@ -27510,6 +31074,30 @@ paths: message: No v1 resource lives at this path. suggestion: Check the request path and HTTP method against the API reference. docs: https://docs.brew.new/api-reference/api/api-introduction + "409": + description: "`EMAIL_GROUP_NAME_CONFLICT`: A group with that name already exists in the brand." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + emailGroupNameConflict: + summary: EMAIL_GROUP_NAME_CONFLICT + value: + error: + code: EMAIL_GROUP_NAME_CONFLICT + type: conflict + message: A group with that name already exists in the brand. + suggestion: Pick a different name, or rename the existing group. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -27593,36 +31181,131 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - parameters: - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg x-brew-scope: emails x-brew-brand-scope: brand - x-brew-idempotency: disabled + x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: emails.edit - /v1/canvas/sessions/{sessionId}/refresh: - post: - operationId: refreshCanvasSession + /v1/sends: + get: + operationId: listSends tags: - - Emails - summary: Refresh a canvas session - description: Issue a new bootstrap credential without changing the target or external user. Requires the original issuing authority. Response replay is disabled. + - Sends + summary: List sends + description: |- + Lists sends, newest first, under `{ data, pagination }`. A send is the unit of delivery and analytics: one design delivered to a target through one domain. Rows carry the sender and subject snapshot, `status`, delivery accounting and, once delivered, lifetime `stats`. + + **Use when** auditing what a design was sent to, reading campaign performance, or joining an automation run to its deliveries (`listAutomationRuns`, then `?automationRunId=` here, then `getEventsAnalytics` with the same id). + + **Input** the default list is campaign sends: `emailId` narrows to one design; `status`, `messageClass` (`marketing` or `transactional`) and an inclusive `from`/`to` window on `updatedAt` filter it. A join filter (`automationId`, `automationRunId`, `audienceRunId` or `triggerInstanceId`) switches to the automation grain, one row per recipient; `kind=automation` is implied and per-recipient rows are never listed brand-wide. `limit` and `cursor` page (an opaque native cursor; a `triggerInstanceId` read merges the runs of one fire into a single bounded page). + + **Returns** `200` with a page of `Send` rows. + + **Errors** `404 AUTOMATION_RUN_NOT_FOUND` or `AUDIENCE_RUN_NOT_FOUND` when a join filter names a run outside the brand; `400 INVALID_REQUEST` for `kind=campaign` with a join filter, `emailId` with a join filter, `kind=automation` without one, or a malformed cursor. + + **See also** `getSend` (`?include=events`), `createSend`, `getEventsAnalytics`, `getAnalyticsOverview`. parameters: - schema: type: string minLength: 1 - maxLength: 200 - required: true - name: sessionId - in: path + maxLength: 64 + description: "Campaign grain: only sends of this design." + required: false + description: "Campaign grain: only sends of this design." + name: emailId + in: query + - schema: + type: string + enum: + - campaign + - automation + required: false + name: kind + in: query + - schema: + type: string + minLength: 1 + maxLength: 64 + required: false + name: automationId + in: query + - schema: + type: string + minLength: 1 + maxLength: 64 + required: false + name: automationRunId + in: query + - schema: + type: string + minLength: 1 + maxLength: 128 + required: false + name: audienceRunId + in: query + - schema: + type: string + minLength: 1 + maxLength: 64 + required: false + name: triggerInstanceId + in: query + - schema: + type: string + enum: + - scheduled + - queued + - running + - paused + - completed + - partially_completed + - failed + - canceled + required: false + name: status + in: query + - schema: + type: string + enum: + - marketing + - transactional + required: false + name: messageClass + in: query + - schema: + type: string + format: date-time + description: Inclusive lower bound on `updatedAt` (ISO 8601). + required: false + description: Inclusive lower bound on `updatedAt` (ISO 8601). + name: from + in: query + - schema: + type: string + format: date-time + description: Inclusive upper bound on `updatedAt` (ISO 8601). + required: false + description: Inclusive upper bound on `updatedAt` (ISO 8601). + name: to + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 8192 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -27634,7 +31317,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The same scoped session with a new bootstrap credential. + description: A page of sends. headers: x-request-id: schema: @@ -27643,10 +31326,57 @@ paths: example: req_8cac13fd94e6420cacdd75a1aa403a28 required: true description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. content: application/json: schema: - $ref: "#/components/schemas/CanvasSession" + $ref: "#/components/schemas/SendsListResponse" + example: + data: + - sendId: r4Kq8ZxL2mW7nT1vPb9Yc + kind: campaign + messageClass: marketing + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: Yc5Kz8Sq2Wn7Lp4Tm1RvX + status: completed + audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg + audienceName: Subscribers + startedAt: '2026-04-08T12:00:05.000Z' + completedAt: '2026-04-08T12:34:56.000Z' + stats: + sent: 1200 + delivered: 1180 + opened: 540 + clicked: 96 + bounced: 20 + complained: 1 + unsubscribed: 4 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:34:56.000Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -27790,9 +31520,11 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. - `NOT_FOUND`: No v1 resource lives at this path. + `AUTOMATION_RUN_NOT_FOUND`: No automation run with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -27806,6 +31538,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + audienceRunNotFound: + summary: AUDIENCE_RUN_NOT_FOUND + value: + error: + code: AUDIENCE_RUN_NOT_FOUND + type: not_found + message: No manual-audience run with that id exists in the brand. + suggestion: List runs with GET /v1/automations/audience-runs. + docs: https://docs.brew.new/api-reference/api/errors + automationRunNotFound: + summary: AUTOMATION_RUN_NOT_FOUND + value: + error: + code: AUTOMATION_RUN_NOT_FOUND + type: not_found + message: No automation run with that id exists in the brand. + suggestion: List runs with GET /v1/automations/runs. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -27815,15 +31565,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - notFound: - summary: NOT_FOUND - value: - error: - code: NOT_FOUND - type: not_found - message: No v1 resource lives at this path. - suggestion: Check the request path and HTTP method against the API reference. - docs: https://docs.brew.new/api-reference/api/api-introduction "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -27838,14 +31579,202 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 0 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + rateLimited: + summary: RATE_LIMITED + value: + error: + code: RATE_LIMITED + type: rate_limit + message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + suggestion: Wait the Retry-After time, then retry the same request. + docs: https://docs.brew.new/api-reference/api/rate-limits + retryAfter: 42 + details: + scope: credential + limit: 100 + remaining: 0 + reset: 1712592360 + consumed: false + "500": + description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + internalError: + summary: INTERNAL_ERROR + value: + error: + code: INTERNAL_ERROR + type: internal_error + message: An unexpected failure; the x-request-id header identifies it. + suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: sends + x-brew-brand-scope: brand + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: sends.read + post: + operationId: createSend + tags: + - Emails + summary: Send an email + description: |- + Sends an email design. Polymorphic on `test`: a campaign send mints a new `Send` (the unit of delivery and analytics) and delivers to a saved audience or an inline list through a verified domain; `test: true` delivers one synchronous QA copy to a single address and writes no send row. + + **Use when** a design is ready to go out. For per-recipient event-driven delivery, publish an automation and fire its trigger (`fireTrigger`). + + **Input** Campaign (`test` omitted or `false`): `emailId` (optionally pinned to `emailVersionId`), a verified `domainId`, `subject`, and exactly one of `audienceId` (a saved audience, or `"all"` for every contact in the brand) or `to` (one address or an array of up to 50). Optional `from` (`{ email, name? }`, an address on the send domain), `previewText`, `replyTo`, `scheduledAt`, one delivery mode (`gradualSend`, or `smartSend: true` for Intelligent Send: each recipient with past opens or clicks gets it at the upcoming hour, within 24 hours, their own history makes them most likely to open, and the rest right away; it composes with `scheduledAt`), and `consent` provenance (`source`, optional `capturedAt`, `policyVersion`, `evidence`) for inline recipients that are not yet contacts: each is created as a subscribed contact carrying that record. An existing opt-out is never re-subscribed. Inline recipients face the same unsubscribe and suppression gate and per-recipient quota as audience sends. + + Test (`test: true`): `emailId`, `subject`, one `to` address, optional `emailVersionId`, `previewText`, `replyTo`, `domainId` (a verified org-owned domain; otherwise the Brew default sender, and an unverified or foreign domain is rejected, never downgraded), `from` (`{ email, name? }`, an address on that domain), `variables` (example values for `{{ var | fallback }}` merge tags; a value wins over the fallback) and `payload` (template data; nested JSON renders via Liquid as `trigger.*`); the subject carries a `[TEST]` prefix. The same design can be sent unlimited times; every campaign call mints a new send. + + **Returns** campaign `202 { status: 'queued' | 'scheduled', sendId, scheduledAt?, warnings? }` (poll `getSend`; `warnings[]` carries one `CONSENT_RECORD_MISSING` per inline recipient that is a subscribed contact with no consent record, and one `RECIPIENTS_EXCLUDED` with the counts when unsubscribed, suppressed, undeliverable or permission-less recipients will be skipped); test `200 { status: 'completed', recipient }`. + + **Errors** `400 INVALID_REQUEST` (`param: subject`) when `subject` has a line break or runs over 1000 characters (993 on a test send, for the `[TEST]` prefix), judged on the text every recipient receives; `404 EMAIL_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `AUDIENCE_NOT_FOUND` (a resource in another brand also surfaces as `404`); `422 EMAIL_NOT_READY`, `DOMAIN_NOT_READY`, `DOMAIN_PURPOSE_NOT_ALLOWED`, `LIQUID_RENDER_ERROR`; `422 CONSENT_REQUIRED` when an inline marketing recipient has no contact record with marketing consent and no `consent` was supplied (`details.recipients`); `422 RECIPIENT_UNSUBSCRIBED` when an inline recipient is an existing contact who opted out (`details.recipients`, never re-subscribed); `422 NO_ELIGIBLE_RECIPIENTS` when an immediate send would reach nobody because every contact it targets is excluded (`details.eligibility` = `{ total, eligible, excluded: { unsubscribed, suppressed, invalidEmail, consentRequired? } }`, each address counted once; no send is created, while a scheduled send is accepted instead); `422 EMAIL_IMAGES_MISSING` when the email shows a cdn.brew.new image that does not exist (`details.missingImages[]`; a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access); `402 SEND_QUOTA_EXCEEDED` when the plan's monthly volume would be exceeded (no `Retry-After`). + + **See also** `listSends`, `cancelSend`, `fireTrigger`. + parameters: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SendEmailRequest" + examples: + test: + summary: One-off test to a single address (sync 200) + value: + test: true + emailId: V1StGXR8_Z5jdHi6B-myT + subject: Welcome to Brew + to: qa@acme.com + testVerifiedDomain: + summary: Test from your verified domain with example merge-tag values + value: + test: true + emailId: V1StGXR8_Z5jdHi6B-myT + subject: Welcome {{ firstName | there }} + to: qa@acme.com + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + from: + email: vanessa@acme.com + name: Vanessa + variables: + firstName: Sarah + now: + summary: Send the latest version to an audience now + value: + emailId: V1StGXR8_Z5jdHi6B-myT + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew + audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg + scheduledPinned: + summary: Schedule a pinned version for later + value: + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: Gm2Tq7Xp4Lw9Rn1Vc5KzS + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew + previewText: Get started in 2 minutes. + audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg + scheduledAt: '2099-01-01T00:00:00.000Z' + intelligent: + summary: "Intelligent Send: each contact at their likeliest hour within 24 hours" + value: + emailId: V1StGXR8_Z5jdHi6B-myT + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew + audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg + smartSend: true + inline: + summary: Send to inline recipients + value: + emailId: V1StGXR8_Z5jdHi6B-myT + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew + to: + - founder@acme.com + - design@acme.com + responses: + "200": + description: "The TEST send (`test: true`) was delivered synchronously to the single recipient." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: schema: type: integer description: Requests remaining in the current rolling rate limit window. - example: 0 + example: 99 required: true description: Requests remaining in the current rolling rate limit window. X-RateLimit-Reset: @@ -27855,36 +31784,15 @@ paths: example: 1712592360 required: true description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. content: application/json: schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - rateLimited: - summary: RATE_LIMITED - value: - error: - code: RATE_LIMITED - type: rate_limit - message: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. - suggestion: Wait the Retry-After time, then retry the same request. - docs: https://docs.brew.new/api-reference/api/rate-limits - retryAfter: 42 - details: - scope: credential - limit: 20 - remaining: 0 - reset: 1712592360 - consumed: false - "500": - description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." + $ref: "#/components/schemas/SendEmailTestResponse" + example: + status: completed + recipient: qa@acme.com + "202": + description: The campaign send was accepted, queued or scheduled. headers: x-request-id: schema: @@ -27893,71 +31801,34 @@ paths: example: req_8cac13fd94e6420cacdd75a1aa403a28 required: true description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: + X-RateLimit-Limit: schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - internalError: - summary: INTERNAL_ERROR - value: - error: - code: INTERNAL_ERROR - type: internal_error - message: An unexpected failure; the x-request-id header identifies it. - suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. - docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails - x-brew-brand-scope: brand - x-brew-idempotency: disabled - x-brew-credited: false - x-brew-rate-policy: emails.edit - /v1/canvas/sessions/{sessionId}: - delete: - operationId: revokeCanvasSession - tags: - - Emails - summary: Revoke a canvas session - description: Stop reads and writes through an existing session, including active subscriptions. - parameters: - - schema: - type: string - minLength: 1 - maxLength: 200 - required: true - name: sessionId - in: path - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - responses: - "200": - description: The session is revoked. - headers: - x-request-id: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 required: true - description: Unique request identifier. Share this with support when debugging a request. + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. content: application/json: schema: - type: object - properties: - revoked: - type: boolean - enum: - - true - required: - - revoked + $ref: "#/components/schemas/SendsPostResponse" + example: + status: queued + sendId: r4Kq8ZxL2mW7nT1vPb9Yc "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -28041,6 +31912,30 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`SEND_QUOTA_EXCEEDED`: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + sendQuotaExceeded: + summary: SEND_QUOTA_EXCEEDED + value: + error: + code: SEND_QUOTA_EXCEEDED + type: payment_required + message: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period. + suggestion: Upgrade your plan for a higher monthly send volume, send to fewer recipients, or wait for the billing period to reset. + docs: https://docs.brew.new/api-reference/api/credits "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -28101,9 +31996,13 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- + `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - `NOT_FOUND`: No v1 resource lives at this path. + `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. + + `EMAIL_NOT_FOUND`: No email design with that id exists in the brand (cross-brand ids surface as 404). headers: x-request-id: schema: @@ -28117,6 +32016,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + audienceNotFound: + summary: AUDIENCE_NOT_FOUND + value: + error: + code: AUDIENCE_NOT_FOUND + type: not_found + message: No audience with that id exists in the brand. + suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -28126,15 +32034,190 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - notFound: - summary: NOT_FOUND + domainNotFound: + summary: DOMAIN_NOT_FOUND value: error: - code: NOT_FOUND + code: DOMAIN_NOT_FOUND type: not_found - message: No v1 resource lives at this path. - suggestion: Check the request path and HTTP method against the API reference. - docs: https://docs.brew.new/api-reference/api/api-introduction + message: No domain with that id exists in the brand. + suggestion: Use GET /v1/domains to choose a verified domain. + docs: https://docs.brew.new/api-reference/api/errors + emailNotFound: + summary: EMAIL_NOT_FOUND + value: + error: + code: EMAIL_NOT_FOUND + type: not_found + message: No email design with that id exists in the brand (cross-brand ids surface as 404). + suggestion: List designs with GET /v1/emails. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + "413": + description: "`PAYLOAD_TOO_LARGE`: The request body exceeds the route cap." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + payloadTooLarge: + summary: PAYLOAD_TOO_LARGE + value: + error: + code: PAYLOAD_TOO_LARGE + type: invalid_request + message: The request body exceeds the route cap. + suggestion: Reduce the payload size and retry. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: |- + `CONSENT_REQUIRED`: An inline marketing recipient has no contact record with marketing consent and the send carried no consent provenance; details.recipients lists the addresses. + + `DOMAIN_NOT_READY`: The domain is not verified for sending. + + `DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional). + + `EMAIL_IMAGES_MISSING`: The email shows an image on cdn.brew.new that does not exist, so it was not sent; `details.missingImages[]` lists each URL and the email it is in. + + `EMAIL_NOT_READY`: The design is still generating or failed to generate, so it cannot be sent or cloned. + + `LIQUID_RENDER_ERROR`: A Liquid template failed to parse or render. + + `NO_ELIGIBLE_RECIPIENTS`: Every contact the send targets is unsubscribed, suppressed or undeliverable (or there are none), so an immediate send would deliver nothing; `details.eligibility` carries the counts. + + `RECIPIENT_UNSUBSCRIBED`: An inline recipient is an existing contact who unsubscribed from marketing email; details.recipients lists the addresses and Brew never re-subscribes an opt-out. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + consentRequired: + summary: CONSENT_REQUIRED + value: + error: + code: CONSENT_REQUIRED + type: invalid_request + message: An inline marketing recipient has no contact record with marketing consent and the send carried no consent provenance; details.recipients lists the addresses. + suggestion: Create the contacts first with POST /v1/contacts carrying a consent record, or include consent provenance on the send to create them as subscribed contacts. + docs: https://docs.brew.new/api-reference/api/errors + domainNotReady: + summary: DOMAIN_NOT_READY + value: + error: + code: DOMAIN_NOT_READY + type: invalid_request + message: The domain is not verified for sending. + suggestion: Use a verified domain, or finish domain verification before sending. + docs: https://docs.brew.new/api-reference/api/errors + domainPurposeNotAllowed: + summary: DOMAIN_PURPOSE_NOT_ALLOWED + value: + error: + code: DOMAIN_PURPOSE_NOT_ALLOWED + type: invalid_request + message: The sending domain is admitted for a different purpose than this send (marketing vs transactional). + suggestion: Use a marketing domain for campaigns and audience sends. For transactional flows, wire the design into a published automation with a trigger and fire POST /v1/automations/triggers/{triggerEventId}/fire. + docs: https://docs.brew.new/api-reference/api/errors + emailImagesMissing: + summary: EMAIL_IMAGES_MISSING + value: + error: + code: EMAIL_IMAGES_MISSING + type: invalid_request + message: The email shows an image on cdn.brew.new that does not exist, so it was not sent; `details.missingImages[]` lists each URL and the email it is in. + suggestion: Replace or remove each image listed in `details.missingImages` in the email, then send again. + docs: https://docs.brew.new/api-reference/api/errors + details: + missingImages: + - emailId: em_8b1f0c2a + url: https://cdn.brew.new/generated/b1/hero-16x9.png + emailNotReady: + summary: EMAIL_NOT_READY + value: + error: + code: EMAIL_NOT_READY + type: invalid_request + message: The design is still generating or failed to generate, so it cannot be sent or cloned. + suggestion: Use a completed design, or wait for generation to finish. + docs: https://docs.brew.new/api-reference/api/errors + liquidRenderError: + summary: LIQUID_RENDER_ERROR + value: + error: + code: LIQUID_RENDER_ERROR + type: invalid_request + message: A Liquid template failed to parse or render. + suggestion: "Fix the template syntax, or supply the payload data the template references. Strict-mode fires fail on any unresolved variable; | default: is the sanctioned fallback." + docs: https://docs.brew.new/api-reference/api/errors + noEligibleRecipients: + summary: NO_ELIGIBLE_RECIPIENTS + value: + error: + code: NO_ELIGIBLE_RECIPIENTS + type: invalid_request + message: Every contact the send targets is unsubscribed, suppressed or undeliverable (or there are none), so an immediate send would deliver nothing; `details.eligibility` carries the counts. + suggestion: Target an audience with subscribed, deliverable contacts, or schedule the send for a time when the audience will have them. + docs: https://docs.brew.new/api-reference/api/errors + recipientUnsubscribed: + summary: RECIPIENT_UNSUBSCRIBED + value: + error: + code: RECIPIENT_UNSUBSCRIBED + type: invalid_request + message: An inline recipient is an existing contact who unsubscribed from marketing email; details.recipients lists the addresses and Brew never re-subscribes an opt-out. + suggestion: Remove the unsubscribed addresses from the recipients; a contact re-subscribes only through their own action. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -28149,7 +32232,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -28190,7 +32273,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -28218,47 +32301,79 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: sends x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: fail_closed x-brew-credited: false - x-brew-rate-policy: emails.edit - /v1/email-groups: + x-brew-rate-policy: sends.write + /v1/sends/{sendId}: get: - operationId: listEmailGroups + operationId: getSend tags: - - Emails - summary: List email groups + - Sends + summary: Get a send description: |- - Lists the brand’s email folders under `{ data, pagination }`. Named groups use `grp_*` ids; Ungrouped is always included as `{ groupId: "ungrouped", groupName: "Ungrouped" }`. `emailCount` is capped at 100. A named group a person created carries `createdByUserId` and, when they can be named, `createdBy` (their name or "Former member"); older and org-key groups have neither. + Reads one send (campaign or automation) as the bare `Send` row: identity and join keys, the sender and subject snapshot, `status`, `approvalState`, delivery accounting, gradual-send progress and lifetime `stats`. - **Use when** picking the `groupId` to file a design under or to filter `listEmails` by. + **Use when** polling a send accepted by `createSend`, or inspecting one automation delivery. - **Input** `limit` and `cursor`. + **Input** `sendId` in the path; `include=events` attaches a bounded first page of the send’s analytics events (recipient, type, click URL). - **Returns** `200` with a page of `EmailGroupSummary` rows. + **Returns** `200` with the row; `events` is present only when requested. - **Errors** `400 INVALID_REQUEST` for an unknown query key. + **Errors** `404 SEND_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. - **See also** `getEmailGroup`, `createEmailGroup`, `listEmails` (`?groupId=`). + **See also** `listSends`, `cancelSend`, `pauseSend`, `resumeSend`, `getEventsAnalytics` (`?sendId=`). parameters: - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - schema: type: string minLength: 1 - maxLength: 512 + maxLength: 64 + description: Send id returned by `POST /v1/sends`. + example: r4Kq8ZxL2mW7nT1vPb9Yc + required: true + description: Send id returned by `POST /v1/sends`. + name: sendId + in: path + - schema: + type: string + description: "Expansions: `events` attaches a bounded first page of the send’s analytics events." + x-brew-include-tokens: + - events required: false - name: cursor + description: "Expansions: `events` attaches a bounded first page of the send’s analytics events." + name: include in: query - name: X-Brand-Id in: header @@ -28271,7 +32386,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of email groups. + description: The send row. headers: x-request-id: schema: @@ -28304,21 +32419,28 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EmailGroupsListResponse" + $ref: "#/components/schemas/Send" example: - data: - - groupId: grp_welcome - groupName: Welcome - emailCount: 3 - createdBy: Sam Rivera - createdByUserId: user_2xK9mPq4Rt7Vw1Yb - - groupId: ungrouped - groupName: Ungrouped - emailCount: 12 - pagination: - limit: 100 - cursor: null - hasMore: false + sendId: r4Kq8ZxL2mW7nT1vPb9Yc + kind: campaign + messageClass: marketing + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: Yc5Kz8Sq2Wn7Lp4Tm1RvX + status: completed + audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg + audienceName: Subscribers + startedAt: '2026-04-08T12:00:05.000Z' + completedAt: '2026-04-08T12:34:56.000Z' + stats: + sent: 1200 + delivered: 1180 + opened: 540 + clicked: 96 + bounced: 20 + complained: 1 + unsubscribed: 4 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:34:56.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -28461,7 +32583,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `SEND_NOT_FOUND`: No send with that id exists in the brand. headers: x-request-id: schema: @@ -28484,6 +32609,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + sendNotFound: + summary: SEND_NOT_FOUND + value: + error: + code: SEND_NOT_FOUND + type: not_found + message: No send with that id exists in the brand. + suggestion: Use the sendId returned by POST /v1/sends, or list sends with GET /v1/sends. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -28567,17 +32701,18 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails + x-brew-scope: sends x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: emails.read + x-brew-rate-policy: sends.read + /v1/sends/{sendId}/cancel: post: - operationId: createEmailGroup + operationId: cancelSend tags: - Emails - summary: Create an email group - description: Creates a named email folder. Returns `201` with `{ groupId, groupName, emailCount }`. Pass `emailIds` (up to 50) to move existing designs in, in the same transaction as the create; the response then adds `moved` and `notMoved` (`not_found`, `generating`, `not_movable`, `folder_full` or `retry`; resend `retry` ids in a follow-up `PATCH`). Reserved names (`Ungrouped` / `ungrouped` / `__ungrouped__`) are `400`. Duplicate names are `409 EMAIL_GROUP_NAME_CONFLICT`. Pass the returned `groupId` as `groupId` on create/import/clone. + summary: Cancel a send + description: "Cancels a scheduled or queued send before it goes out, or STOPS any in-flight campaign send. A campaign that is `running` or `paused` is canceled the same way the in-app Stop button does it: the remaining recipients are never delivered while already-sent ones stay sent (every shape re-checks liveness at regular points mid-flight — smart per time bucket, gradual and plain blast at bounded chunk intervals — so a small tail may still deliver after the cancel lands). Idempotent — a send already `canceled` returns `200`. A send already `completed`, `partially_completed` or `failed`, or a non-campaign (automation) send, returns `409 SEND_NOT_CANCELLABLE`. Brand-scoped: an unknown / cross-brand `sendId` is `404`." parameters: - name: Idempotency-Key in: header @@ -28588,6 +32723,16 @@ paths: minLength: 1 maxLength: 100 example: api-request-2026-04-08-001 + - schema: + type: string + minLength: 1 + maxLength: 64 + description: Send id returned by `POST /v1/sends`. + example: r4Kq8ZxL2mW7nT1vPb9Yc + required: true + description: Send id returned by `POST /v1/sends`. + name: sendId + in: path - name: X-Brand-Id in: header required: false @@ -28597,20 +32742,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EmailGroupCreateRequest" - example: - name: Welcome - emailIds: - - V1StGXR8_Z5jdHi6B-myT - - qkE3pWv2xN9dLmR4tYbZ7 responses: - "201": - description: Created, with any designs moved in. + "200": + description: The send is canceled (or was already canceled). headers: x-request-id: schema: @@ -28643,13 +32777,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EmailGroupWriteResponse" + $ref: "#/components/schemas/SendCancelResponse" example: - groupId: grp_welcome - groupName: Welcome - emailCount: 2 - moved: 2 - notMoved: [] + sendId: r4Kq8ZxL2mW7nT1vPb9Yc + status: canceled "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -28795,9 +32926,7 @@ paths: description: |- `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - `EMAIL_GROUP_NOT_FOUND`: No email group with that id exists in the brand. - - `NOT_FOUND`: No v1 resource lives at this path. + `SEND_NOT_FOUND`: No send with that id exists in the brand. headers: x-request-id: schema: @@ -28820,31 +32949,22 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - emailGroupNotFound: - summary: EMAIL_GROUP_NOT_FOUND + sendNotFound: + summary: SEND_NOT_FOUND value: error: - code: EMAIL_GROUP_NOT_FOUND + code: SEND_NOT_FOUND type: not_found - message: No email group with that id exists in the brand. - suggestion: List groups with GET /v1/email-groups. + message: No send with that id exists in the brand. + suggestion: Use the sendId returned by POST /v1/sends, or list sends with GET /v1/sends. docs: https://docs.brew.new/api-reference/api/errors - notFound: - summary: NOT_FOUND - value: - error: - code: NOT_FOUND - type: not_found - message: No v1 resource lives at this path. - suggestion: Check the request path and HTTP method against the API reference. - docs: https://docs.brew.new/api-reference/api/api-introduction "409": description: |- - `EMAIL_GROUP_NAME_CONFLICT`: A group with that name already exists in the brand. - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + + `SEND_NOT_CANCELLABLE`: The send already finished, so there is nothing left to cancel. headers: x-request-id: schema: @@ -28858,15 +32978,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - emailGroupNameConflict: - summary: EMAIL_GROUP_NAME_CONFLICT - value: - error: - code: EMAIL_GROUP_NAME_CONFLICT - type: conflict - message: A group with that name already exists in the brand. - suggestion: Pick a different name, or rename the existing group. - docs: https://docs.brew.new/api-reference/api/errors idempotencyConflict: summary: IDEMPOTENCY_CONFLICT value: @@ -28885,6 +32996,15 @@ paths: message: A request with this Idempotency-Key is still executing. suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency + sendNotCancellable: + summary: SEND_NOT_CANCELLABLE + value: + error: + code: SEND_NOT_CANCELLABLE + type: conflict + message: The send already finished, so there is nothing left to cancel. + suggestion: Check the send status with GET /v1/sends/{sendId} before canceling. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -28899,7 +33019,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -28940,7 +33060,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -28968,39 +33088,37 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails + x-brew-scope: sends x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: emails.edit - /v1/email-groups/{groupId}: - get: - operationId: getEmailGroup + x-brew-rate-policy: sends.write + /v1/sends/{sendId}/pause: + post: + operationId: pauseSend tags: - Emails - summary: Get an email group - description: |- - Reads one email folder as the bare `EmailGroupSummary` (`groupId`, `groupName`, `emailCount`, plus `createdByUserId` when a person created it, and `createdBy` when they can be named). - - **Use when** confirming a group before filing designs under it, or reading the `ungrouped` catalog row. - - **Input** `groupId` in the path: a `grp_*` id or the literal `ungrouped`. - - **Returns** `200` with the row. - - **Errors** `404 EMAIL_GROUP_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for a non-public Ungrouped sentinel. - - **See also** `listEmailGroups`, `updateEmailGroup`, `deleteEmailGroup`. + summary: Pause a gradual send + description: "Manually pauses an in-flight GRADUAL (domain-warmup) send. The ramp stops before its next chunk and holds its current batch until `POST /v1/sends/{sendId}/resume` (or a cancel). A send its operator already paused returns the same 200 and changes nothing; any other send that is not a `running` gradual send (including one held at the plan limit or for its domain) returns `409 SEND_NOT_PAUSABLE`. Brand-scoped: an unknown / cross-brand `sendId` is `404`." parameters: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 - schema: type: string minLength: 1 maxLength: 64 - description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. - example: grp_welcome + description: Send id returned by `POST /v1/sends`. + example: r4Kq8ZxL2mW7nT1vPb9Yc required: true - description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. - name: groupId + description: Send id returned by `POST /v1/sends`. + name: sendId in: path - name: X-Brand-Id in: header @@ -29013,7 +33131,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The group row. + description: The send is paused. headers: x-request-id: schema: @@ -29046,13 +33164,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EmailGroupSummary" + $ref: "#/components/schemas/SendPauseResponse" example: - groupId: grp_welcome - groupName: Welcome - emailCount: 3 - createdBy: Sam Rivera - createdByUserId: user_2xK9mPq4Rt7Vw1Yb + sendId: r4Kq8ZxL2mW7nT1vPb9Yc + status: paused "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -29198,7 +33313,7 @@ paths: description: |- `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - `EMAIL_GROUP_NOT_FOUND`: No email group with that id exists in the brand. + `SEND_NOT_FOUND`: No send with that id exists in the brand. headers: x-request-id: schema: @@ -29221,14 +33336,61 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - emailGroupNotFound: - summary: EMAIL_GROUP_NOT_FOUND + sendNotFound: + summary: SEND_NOT_FOUND value: error: - code: EMAIL_GROUP_NOT_FOUND + code: SEND_NOT_FOUND type: not_found - message: No email group with that id exists in the brand. - suggestion: List groups with GET /v1/email-groups. + message: No send with that id exists in the brand. + suggestion: Use the sendId returned by POST /v1/sends, or list sends with GET /v1/sends. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + + `SEND_NOT_PAUSABLE`: Only a delivering gradual send can be paused. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + sendNotPausable: + summary: SEND_NOT_PAUSABLE + value: + error: + code: SEND_NOT_PAUSABLE + type: conflict + message: Only a delivering gradual send can be paused. + suggestion: Check the send status with GET /v1/sends/{sendId}. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -29244,7 +33406,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -29285,7 +33447,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -29313,27 +33475,37 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails + x-brew-scope: sends x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: emails.read - patch: - operationId: updateEmailGroup + x-brew-rate-policy: sends.write + /v1/sends/{sendId}/resume: + post: + operationId: resumeSend tags: - Emails - summary: Update an email group - description: Renames a named folder (`name`), moves existing designs into it (`emailIds`, up to 50, one request against the rate limit), or both, in one transaction. A move adds `moved` and `notMoved` (`not_found`, `generating`, `not_movable`, `folder_full` or `retry`; resend `retry` ids) to the response. Ungrouped is not a target (`400`). Unknown / cross-brand ids are `404`. Duplicate names are `409 EMAIL_GROUP_NAME_CONFLICT`. + summary: Resume a paused gradual send + description: "Resumes a manually paused GRADUAL send. The ramp picks the resume up at its next hold check (seconds after a short pause, up to an hour after a long one); the unsent tail is re-spread and later batches shift so missed intervals do not compress into a burst. A send held at the plan limit resumes on its own once the plan allowance is available; any other send returns `409 SEND_NOT_RESUMABLE`. Brand-scoped: an unknown / cross-brand `sendId` is `404`." parameters: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 - schema: type: string minLength: 1 maxLength: 64 - description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. - example: grp_welcome + description: Send id returned by `POST /v1/sends`. + example: r4Kq8ZxL2mW7nT1vPb9Yc required: true - description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. - name: groupId + description: Send id returned by `POST /v1/sends`. + name: sendId in: path - name: X-Brand-Id in: header @@ -29344,17 +33516,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/EmailGroupPatchRequest" - example: - name: Welcome series responses: "200": - description: Updated. + description: The send is delivering again. headers: x-request-id: schema: @@ -29387,11 +33551,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EmailGroupWriteResponse" + $ref: "#/components/schemas/SendResumeResponse" example: - groupId: grp_welcome - groupName: Welcome series - emailCount: 3 + sendId: r4Kq8ZxL2mW7nT1vPb9Yc + status: running "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -29537,9 +33700,7 @@ paths: description: |- `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - `EMAIL_GROUP_NOT_FOUND`: No email group with that id exists in the brand. - - `NOT_FOUND`: No v1 resource lives at this path. + `SEND_NOT_FOUND`: No send with that id exists in the brand. headers: x-request-id: schema: @@ -29562,26 +33723,22 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - emailGroupNotFound: - summary: EMAIL_GROUP_NOT_FOUND + sendNotFound: + summary: SEND_NOT_FOUND value: error: - code: EMAIL_GROUP_NOT_FOUND + code: SEND_NOT_FOUND type: not_found - message: No email group with that id exists in the brand. - suggestion: List groups with GET /v1/email-groups. + message: No send with that id exists in the brand. + suggestion: Use the sendId returned by POST /v1/sends, or list sends with GET /v1/sends. docs: https://docs.brew.new/api-reference/api/errors - notFound: - summary: NOT_FOUND - value: - error: - code: NOT_FOUND - type: not_found - message: No v1 resource lives at this path. - suggestion: Check the request path and HTTP method against the API reference. - docs: https://docs.brew.new/api-reference/api/api-introduction "409": - description: "`EMAIL_GROUP_NAME_CONFLICT`: A group with that name already exists in the brand." + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + + `SEND_NOT_RESUMABLE`: Only a manually paused gradual send can be resumed. headers: x-request-id: schema: @@ -29595,14 +33752,32 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - emailGroupNameConflict: - summary: EMAIL_GROUP_NAME_CONFLICT + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT value: error: - code: EMAIL_GROUP_NAME_CONFLICT + code: IDEMPOTENCY_CONFLICT type: conflict - message: A group with that name already exists in the brand. - suggestion: Pick a different name, or rename the existing group. + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + sendNotResumable: + summary: SEND_NOT_RESUMABLE + value: + error: + code: SEND_NOT_RESUMABLE + type: conflict + message: Only a manually paused gradual send can be resumed. + suggestion: Check the send status with GET /v1/sends/{sendId}. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -29618,7 +33793,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -29659,7 +33834,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -29687,28 +33862,98 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails + x-brew-scope: sends x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: emails.edit - delete: - operationId: deleteEmailGroup + x-brew-rate-policy: sends.write + /v1/analytics/overview: + get: + operationId: getAnalyticsOverview tags: - - Emails - summary: Delete an email group - description: "Deletes a named folder. Member emails move to Ungrouped. Ungrouped cannot be deleted (`400`). Idempotent — an unknown / cross-brand id resolves with `{ deleted: false }`." + - Analytics + summary: Brand overview (totals, rates, timeseries) + description: |- + Windowed brand overview: the exact read behind the app's Analytics metric cards and chart (same per-recipient dedup and machine open and click exclusion), so API, MCP and page numbers always agree. Defaults to the last 7 days. + + **Use when** you need totals, rates and a time series for the brand or for one automation, design, audience, source, domain or recipient set. For individual rows use `getEventsAnalytics`. + + **Input** Filters compose freely: `source` (csv of send sources), `automationId`, `emailId`, `audienceId` (csv, max 20), `triggerEventId` (csv, max 10, resolved to the automations they wire), `domain` (sending domain) and `recipient` (csv of recipient rules, same grammar as `getEventsAnalytics`). A single filter is answered from pre-aggregated rollups; any combination, and anything with `domain` or `recipient`, aggregates raw events instead: exact but capped, so watch `truncated` on wide windows. + + **Returns** `200 { totals, rates, buckets, granularity, timeZone, range, truncated }`; `truncated: true` means the window exceeded the scan budget, so narrow the range. Requires the `emails` scope. + + **Errors** `400 INVALID_REQUEST` for an inverted window or too many csv values. + + **See also** `getEventsAnalytics`, `getAutomationAnalytics`, `listSends` (campaign rows carry their own `stats`). parameters: - - schema: + - name: from + in: query + required: false + schema: + type: string + format: date-time + - name: to + in: query + required: false + schema: + type: string + format: date-time + - name: source + in: query + required: false + description: "CSV of send sources to include (e.g. `audience,api`; valid values: audience, api, automation_manual, automation_integration, automation_custom). Composes with every other filter." + schema: + type: string + minLength: 1 + maxLength: 256 + - name: automationId + in: query + required: false + description: CSV of automation ids (max 20) — a single id is still valid. Composes with every other filter; one id reads a pre-aggregated rollup partition, several take the raw-event path. + schema: + type: string + minLength: 1 + maxLength: 2048 + - name: emailId + in: query + required: false + description: Scope to one email design. Composes with every other filter — a design used by several automations can be narrowed with `automationId`. + schema: type: string minLength: 1 maxLength: 64 - description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. - example: grp_welcome - required: true - description: Named group id (`grp_*`) returned by `POST /v1/email-groups` and listed by `GET /v1/email-groups`. Ungrouped is not a writable id. - name: groupId - in: path + - name: audienceId + in: query + required: false + description: CSV of audience ids (max 20). Composes with every other filter. + schema: + type: string + minLength: 1 + maxLength: 2048 + - name: triggerEventId + in: query + required: false + description: CSV of integration trigger-event ids (max 10), resolved to their wired automations. Composes with every other filter. + schema: + type: string + minLength: 1 + maxLength: 2048 + - name: domain + in: query + required: false + description: Sending domain (`fromEmail` match). Has no rollup dimension, so requests carrying it are answered from raw events — check `truncated` on wide windows. + schema: + type: string + minLength: 1 + maxLength: 255 + - name: recipient + in: query + required: false + description: CSV of recipient rules (max 10) matching who RECEIVED the email — the SAME grammar and the same parameter name as `GET /v1/analytics/events`, so one filter string moves between the two and the totals here describe exactly the rows that feed lists. A full address matches exactly, `@domain` matches the domain, any other text matches as a substring; prefix `!` to exclude (e.g. `@clay.com,!ceo@clay.com`). Includes OR together; excludes always apply. Like `domain` this has no rollup dimension (stored rows aggregate ACROSS recipients), so requests carrying it are answered from raw events — check `truncated` on wide windows. + schema: + type: string + minLength: 1 + maxLength: 2048 - name: X-Brand-Id in: header required: false @@ -29720,7 +33965,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: Deleted (or already gone). + description: Brand-wide totals, rates, and a zero-filled timeseries for the window. headers: x-request-id: schema: @@ -29753,10 +33998,44 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EmailGroupDeleteResponse" + $ref: "#/components/schemas/AnalyticsOverviewResponse" example: - groupId: grp_welcome - deleted: true + totals: + sent: 5000 + delivered: 4920 + opened: 2110 + clicked: 540 + bounced: 80 + complained: 3 + unsubscribed: 12 + failed: 0 + suppressed: 14 + deliveryDelayed: 2 + rates: + deliveryRate: 0.984 + openRate: 0.4289 + clickRate: 0.1098 + bounceRate: 0.016 + complaintRate: 0.0006 + unsubscribeRate: 0.0024 + buckets: + - at: '2026-04-08T00:00:00.000Z' + sent: 5000 + delivered: 4920 + deliveryDelayed: 2 + opened: 2110 + clicked: 540 + bounced: 80 + complained: 3 + failed: 0 + suppressed: 14 + unsubscribed: 12 + granularity: 1d + timeZone: America/New_York + range: + from: '2026-04-01T12:34:56.789Z' + to: '2026-04-08T12:34:56.789Z' + truncated: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -29899,12 +34178,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `EMAIL_GROUP_NOT_FOUND`: No email group with that id exists in the brand. - - `NOT_FOUND`: No v1 resource lives at this path. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -29927,48 +34201,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - emailGroupNotFound: - summary: EMAIL_GROUP_NOT_FOUND - value: - error: - code: EMAIL_GROUP_NOT_FOUND - type: not_found - message: No email group with that id exists in the brand. - suggestion: List groups with GET /v1/email-groups. - docs: https://docs.brew.new/api-reference/api/errors - notFound: - summary: NOT_FOUND - value: - error: - code: NOT_FOUND - type: not_found - message: No v1 resource lives at this path. - suggestion: Check the request path and HTTP method against the API reference. - docs: https://docs.brew.new/api-reference/api/api-introduction - "409": - description: "`EMAIL_GROUP_NAME_CONFLICT`: A group with that name already exists in the brand." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - emailGroupNameConflict: - summary: EMAIL_GROUP_NAME_CONFLICT - value: - error: - code: EMAIL_GROUP_NAME_CONFLICT - type: conflict - message: A group with that name already exists in the brand. - suggestion: Pick a different name, or rename the existing group. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -29983,7 +34215,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -30024,7 +34256,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -30056,127 +34288,42 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: emails.edit - /v1/sends: + /v1/analytics/automations: get: - operationId: listSends + operationId: getAutomationAnalytics tags: - - Sends - summary: List sends - description: |- - Lists sends, newest first, under `{ data, pagination }`. A send is the unit of delivery and analytics: one design delivered to a target through one domain. Rows carry the sender and subject snapshot, `status`, delivery accounting and, once delivered, lifetime `stats`. - - **Use when** auditing what a design was sent to, reading campaign performance, or joining an automation run to its deliveries (`listAutomationRuns`, then `?automationRunId=` here, then `getEventsAnalytics` with the same id). - - **Input** the default list is campaign sends: `emailId` narrows to one design; `status`, `messageClass` (`marketing` or `transactional`) and an inclusive `from`/`to` window on `updatedAt` filter it. A join filter (`automationId`, `automationRunId`, `audienceRunId` or `triggerInstanceId`) switches to the automation grain, one row per recipient; `kind=automation` is implied and per-recipient rows are never listed brand-wide. `limit` and `cursor` page (an opaque native cursor; a `triggerInstanceId` read merges the runs of one fire into a single bounded page). - - **Returns** `200` with a page of `Send` rows. - - **Errors** `404 AUTOMATION_RUN_NOT_FOUND` or `AUDIENCE_RUN_NOT_FOUND` when a join filter names a run outside the brand; `400 INVALID_REQUEST` for `kind=campaign` with a join filter, `emailId` with a join filter, `kind=automation` without one, or a malformed cursor. - - **See also** `getSend` (`?include=events`), `createSend`, `getEventsAnalytics`, `getAnalyticsOverview`. + - Analytics + summary: Automation performance + description: "Windowed per-automation performance + scoped `totals` + the resolved `range`. Defaults to the last 30 days. Reflects LIVE runs only (test runs never contribute). Totals include every matching automation independently from the displayed row `limit`. Narrow with the `automationId` filter; bound the window with `from` / `to`. Check `truncated`: when true, scan budgets clipped the requested window and the metrics are partial. Requires the `automations` scope." parameters: - - schema: - type: string - minLength: 1 - maxLength: 64 - description: "Campaign grain: only sends of this design." - required: false - description: "Campaign grain: only sends of this design." - name: emailId - in: query - - schema: - type: string - enum: - - campaign - - automation - required: false - name: kind - in: query - - schema: - type: string - minLength: 1 - maxLength: 64 - required: false - name: automationId - in: query - - schema: - type: string - minLength: 1 - maxLength: 64 - required: false - name: automationRunId - in: query - - schema: - type: string - minLength: 1 - maxLength: 128 - required: false - name: audienceRunId - in: query - - schema: - type: string - minLength: 1 - maxLength: 64 - required: false - name: triggerInstanceId - in: query - - schema: - type: string - enum: - - scheduled - - queued - - running - - paused - - completed - - partially_completed - - failed - - canceled - required: false - name: status + - name: from in: query - - schema: - type: string - enum: - - marketing - - transactional required: false - name: messageClass - in: query - - schema: + schema: type: string format: date-time - description: Inclusive lower bound on `updatedAt` (ISO 8601). - required: false - description: Inclusive lower bound on `updatedAt` (ISO 8601). - name: from + - name: to in: query - - schema: + required: false + schema: type: string format: date-time - description: Inclusive upper bound on `updatedAt` (ISO 8601). + - name: automationId + in: query required: false - description: Inclusive upper bound on `updatedAt` (ISO 8601). - name: to + schema: + type: string + minLength: 1 + maxLength: 64 + - name: limit in: query - - schema: + required: false + schema: type: integer minimum: 1 maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false description: Page size (1-100). Defaults to 100. - name: limit - in: query example: 50 - - schema: - type: string - minLength: 1 - maxLength: 8192 - required: false - name: cursor - in: query - name: X-Brand-Id in: header required: false @@ -30188,7 +34335,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of sends. + description: Per-automation performance + totals for the window. headers: x-request-id: schema: @@ -30221,33 +34368,40 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SendsListResponse" + $ref: "#/components/schemas/AutomationAnalyticsResponse" example: data: - - sendId: r4Kq8ZxL2mW7nT1vPb9Yc - kind: campaign - messageClass: marketing - emailId: V1StGXR8_Z5jdHi6B-myT - emailVersionId: Yc5Kz8Sq2Wn7Lp4Tm1RvX - status: completed - audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg - audienceName: Subscribers - startedAt: '2026-04-08T12:00:05.000Z' - completedAt: '2026-04-08T12:34:56.000Z' - stats: - sent: 1200 - delivered: 1180 - opened: 540 - clicked: 96 - bounced: 20 - complained: 1 - unsubscribed: 4 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:34:56.000Z' - pagination: - limit: 100 - cursor: null - hasMore: false + - automationId: Zq7Lm2Nx8Rt4Vw1KyPb6H + name: Welcome flow + published: true + triggerEventId: user_signed_up + runs: 1200 + completed: 1180 + failed: 5 + sent: 1180 + delivered: 1150 + opened: 690 + clicked: 210 + bounced: 18 + successRate: 0.983 + openRate: 0.6 + clickRate: 0.182 + clickThroughRate: 0.304 + lastRunAt: '2026-04-08T12:34:56.789Z' + lastRunStatus: completed + totals: + runs: 1200 + completed: 1180 + failed: 5 + sent: 1180 + delivered: 1150 + opened: 690 + clicked: 210 + bounced: 18 + range: + from: '2026-03-09T12:34:56.789Z' + to: '2026-04-08T12:34:56.789Z' + truncated: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -30390,12 +34544,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. - - `AUTOMATION_RUN_NOT_FOUND`: No automation run with that id exists in the brand. - - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -30409,24 +34558,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceRunNotFound: - summary: AUDIENCE_RUN_NOT_FOUND - value: - error: - code: AUDIENCE_RUN_NOT_FOUND - type: not_found - message: No manual-audience run with that id exists in the brand. - suggestion: List runs with GET /v1/automations/audience-runs. - docs: https://docs.brew.new/api-reference/api/errors - automationRunNotFound: - summary: AUTOMATION_RUN_NOT_FOUND - value: - error: - code: AUTOMATION_RUN_NOT_FOUND - type: not_found - message: No automation run with that id exists in the brand. - suggestion: List runs with GET /v1/automations/runs. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -30450,7 +34581,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -30491,7 +34622,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -30519,40 +34650,215 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: sends + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: automations x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: sends.read - post: - operationId: createSend + /v1/analytics/events: + get: + operationId: getEventsAnalytics tags: - - Emails - summary: Send an email + - Analytics + summary: Unified events feed description: |- - Sends an email design. Polymorphic on `test`: a campaign send mints a new `Send` (the unit of delivery and analytics) and delivers to a saved audience or an inline list through a verified domain; `test: true` delivers one synchronous QA copy to a single address and writes no send row. + Read-only feed of the brand's analytics events (email, automation, trigger, inbound), newest first, over a `from`/`to` window that defaults to the last 7 days. Email rows carry the send behind them (`sendId`, `emailId`), so a page joins back to `GET /v1/sends/{sendId}`. - **Use when** a design is ready to go out. For per-recipient event-driven delivery, publish an automation and fire its trigger (`fireTrigger`). + **Use when** you need per-event or per-recipient detail. For windowed totals and rates use `getAnalyticsOverview`; for one send's events in one bounded page use `GET /v1/sends/{sendId}?include=events` (`getSend`). - **Input** Campaign (`test` omitted or `false`): `emailId` (optionally pinned to `emailVersionId`), a verified `domainId`, `subject`, and exactly one of `audienceId` (a saved audience, or `"all"` for every contact in the brand) or `to` (one address or an array of up to 50). Optional `from` (`{ email, name? }`, an address on the send domain), `previewText`, `replyTo`, `scheduledAt`, one delivery mode (`gradualSend`, or `smartSend: true` for Intelligent Send: each recipient with past opens or clicks gets it at the upcoming hour, within 24 hours, their own history makes them most likely to open, and the rest right away; it composes with `scheduledAt`), and `consent` provenance (`source`, optional `capturedAt`, `policyVersion`, `evidence`) for inline recipients that are not yet contacts: each is created as a subscribed contact carrying that record. An existing opt-out is never re-subscribed. Inline recipients face the same unsubscribe and suppression gate and per-recipient quota as audience sends. + **Input** Equality filters: `eventType`, `sendId`, `emailId`, `automationId`, `messageClass` (`marketing` or `transactional`; absent stamps count as marketing). Run scope: `automationRunId` returns one run’s events across every domain (its lifecycle rows and the deliveries it produced), newest first and not windowed unless `from`/`to` are given; only `eventType` combines with it. Recipient rules `recipient` (csv, max 10): a full address matches exactly, `@domain` matches everyone on the domain, other text matches as a substring, a `!` prefix excludes (`recipient=@clay.com,!ceo@clay.com`); includes OR together, excludes always apply. Send facets `source` (csv), `audienceId` (csv, max 20), `domain` (sending domain), `triggerEventId` (csv, max 10, resolved to the automations they wire). Any facet or recipient rule narrows the feed to email events and adds `sendSource`, `sendContext` and `messageClass` to each row (plus `triggerProvider` and `triggerTitle` on triggered rows). Machine-classified opens and clicks (scanner detonation, Apple proxy prefetch) are excluded unless `includeMachineOpens=true` or `includeMachineClicks=true` (audit only). - Test (`test: true`): `emailId`, `subject`, one `to` address, optional `emailVersionId`, `previewText`, `replyTo`, `domainId` (a verified org-owned domain; otherwise the Brew default sender, and an unverified or foreign domain is rejected, never downgraded), `from` (`{ email, name? }`, an address on that domain), `variables` (example values for `{{ var | fallback }}` merge tags; a value wins over the fallback) and `payload` (template data; nested JSON renders via Liquid as `trigger.*`); the subject carries a `[TEST]` prefix. The same design can be sent unlimited times; every campaign call mints a new send. + **Grouped counts** `groupBy` (one or two csv fields: `eventType`, `emailId`, `automationId`, `sendId`, `source`, `link`, `recipientDomain`, `unsubscribeReason`) and/or `bucket` (`day`, `week` or `month`, UTC, Monday weeks) count the email events the same filters list instead of returning them: `link` with `eventType=clicked` is clicks per link, `unsubscribeReason` counts an unsubscribe once per reason it gave. The count reads the newest 20,000 events; a busier window answers `truncated: true` with `coveredFrom` (narrow `from`/`to` or add `eventType`). Events, not unique recipients: `getAnalyticsOverview` `opened`/`clicked` count unique human recipients. - **Returns** campaign `202 { status: 'queued' | 'scheduled', sendId, scheduledAt?, warnings? }` (poll `getSend`; `warnings[]` carries one `CONSENT_RECORD_MISSING` per inline recipient that is a subscribed contact with no consent record, and one `RECIPIENTS_EXCLUDED` with the counts when unsubscribed, suppressed, undeliverable or permission-less recipients will be skipped); test `200 { status: 'completed', recipient }`. + **Returns** `200 { data: Event[], pagination: { limit, cursor, hasMore }, range }`; page with `cursor` until it is `null`. Rows carry the run join keys when stamped: `automationRunId`, `triggerInstanceId`, `audienceRunId` (plus `sendId`, `nodeId`). With `groupBy`/`bucket`: `200 { count, groups: [{ key, bucket?, count }], otherCount, range, truncated, coveredFrom? }`, the largest 200 groups first. Requires the `emails` scope. - **Errors** `400 INVALID_REQUEST` (`param: subject`) when `subject` has a line break or runs over 1000 characters (993 on a test send, for the `[TEST]` prefix), judged on the text every recipient receives; `404 EMAIL_NOT_FOUND`, `DOMAIN_NOT_FOUND`, `AUDIENCE_NOT_FOUND` (a resource in another brand also surfaces as `404`); `422 EMAIL_NOT_READY`, `DOMAIN_NOT_READY`, `DOMAIN_PURPOSE_NOT_ALLOWED`, `LIQUID_RENDER_ERROR`; `422 CONSENT_REQUIRED` when an inline marketing recipient has no contact record with marketing consent and no `consent` was supplied (`details.recipients`); `422 RECIPIENT_UNSUBSCRIBED` when an inline recipient is an existing contact who opted out (`details.recipients`, never re-subscribed); `422 NO_ELIGIBLE_RECIPIENTS` when an immediate send would reach nobody because every contact it targets is excluded (`details.eligibility` = `{ total, eligible, excluded: { unsubscribed, suppressed, invalidEmail, consentRequired? } }`, each address counted once; no send is created, while a scheduled send is accepted instead); `422 EMAIL_IMAGES_MISSING` when the email shows a cdn.brew.new image that does not exist (`details.missingImages[]`; a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access); `402 SEND_QUOTA_EXCEEDED` when the plan's monthly volume would be exceeded (no `Retry-After`). + **Errors** `400 INVALID_REQUEST` for an inverted window, an unknown `eventType`, or more than 10 recipient rules or 20 audience ids; with `groupBy`/`bucket`, for an unknown or repeated field, more than two fields, a non-email `eventType`, `cursor` or `automationRunId` (`limit` is ignored). - **See also** `listSends`, `cancelSend`, `fireTrigger`. + **See also** `getAnalyticsOverview`, `listSends`, `listAutomationRuns`. parameters: - - name: Idempotency-Key - in: header + - name: from + in: query required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + format: date-time + - name: to + in: query + required: false + schema: + type: string + format: date-time + - name: recipient + in: query + required: false + description: "CSV of recipient rules (max 10) matching who RECEIVED the email: a full address matches exactly, `@domain` matches the domain, any other text matches as a substring; prefix `!` to exclude (e.g. `@clay.com,!ceo@clay.com`). Includes OR together; excludes always apply. Narrows the feed to email events." schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + maxLength: 2048 + - name: eventType + in: query + required: false + schema: + type: string + minLength: 1 + maxLength: 64 + - name: automationId + in: query + required: false + schema: + type: string + minLength: 1 + maxLength: 64 + - name: sendId + in: query + required: false + schema: + type: string + minLength: 1 + maxLength: 64 + - name: automationRunId + in: query + required: false + description: One automation run’s events across every domain, newest first. Not windowed unless `from`/`to` are given; only `eventType` combines with it. + schema: + type: string + minLength: 1 + maxLength: 64 + - name: messageClass + in: query + required: false + description: Admission snapshot on the send behind the event (`marketing` | `transactional`). Same event object for both classes; absent stamps match as marketing. Narrows the feed to email events. + schema: + type: string + enum: + - marketing + - transactional + - name: source + in: query + required: false + description: "Send-object facet: CSV of send sources (valid values: audience, api, automation_manual, automation_integration, automation_custom). Narrows the feed to email events." + schema: + type: string + minLength: 1 + maxLength: 256 + - name: audienceId + in: query + required: false + description: "Send-object facet: CSV of audience ids (max 20) — events from the audiences' sends. Narrows the feed to email events." + schema: + type: string + minLength: 1 + maxLength: 2048 + - name: emailId + in: query + required: false + description: "Send-object facet: one email design id. Narrows the feed to email events." + schema: + type: string + minLength: 1 + maxLength: 64 + - name: domain + in: query + required: false + description: "Send-object facet: a sending domain (`fromEmail` match). Narrows the feed to email events." + schema: + type: string + minLength: 1 + maxLength: 255 + - name: triggerEventId + in: query + required: false + description: "Send-object facet: CSV of integration trigger-event ids (max 10), resolved to their wired automations. Narrows the feed to email events." + schema: + type: string + minLength: 1 + maxLength: 2048 + - name: includeMachineClicks + in: query + required: false + description: "Machine/bot-classified `clicked` rows are excluded by default. Pass `true` to include the raw rows (they carry `machineGenerated: true` + a `clickBotReason`; audit/debug only)." + schema: + type: boolean + - name: includeMachineOpens + in: query + required: false + description: Machine/bot-classified `opened` rows (security-scanner pixel detonation, Apple-proxy prefetch) and still-classifying opens are excluded by default. Pass `true` to include the raw rows (they carry `machineGenerated` + an `openBotReason` and `openFetchSource`; audit/debug only). + schema: + type: boolean + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + - name: cursor + in: query + required: false + schema: + type: string + minLength: 1 + maxLength: 8192 + - name: groupBy + in: query + required: false + description: "Count the email events these filters list per value of one or two comma-separated fields instead of returning them: eventType, emailId, automationId, sendId, source, link, recipientDomain, unsubscribeReason." + schema: + type: string + minLength: 1 + maxLength: 200 + x-brew-group-by-tokens: + - eventType + - emailId + - automationId + - sendId + - source + - link + - recipientDomain + - unsubscribeReason + - name: bucket + in: query + required: false + description: Count the email events these filters list per UTC day, week (Monday start) or month; combines with `groupBy`. + schema: + type: string + enum: + - day + - week + - month - name: X-Brand-Id in: header required: false @@ -30562,108 +34868,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SendEmailRequest" - examples: - test: - summary: One-off test to a single address (sync 200) - value: - test: true - emailId: V1StGXR8_Z5jdHi6B-myT - subject: Welcome to Brew - to: qa@acme.com - testVerifiedDomain: - summary: Test from your verified domain with example merge-tag values - value: - test: true - emailId: V1StGXR8_Z5jdHi6B-myT - subject: Welcome {{ firstName | there }} - to: qa@acme.com - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - from: - email: vanessa@acme.com - name: Vanessa - variables: - firstName: Sarah - now: - summary: Send the latest version to an audience now - value: - emailId: V1StGXR8_Z5jdHi6B-myT - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew - audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg - scheduledPinned: - summary: Schedule a pinned version for later - value: - emailId: V1StGXR8_Z5jdHi6B-myT - emailVersionId: Gm2Tq7Xp4Lw9Rn1Vc5KzS - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew - previewText: Get started in 2 minutes. - audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg - scheduledAt: '2099-01-01T00:00:00.000Z' - intelligent: - summary: "Intelligent Send: each contact at their likeliest hour within 24 hours" - value: - emailId: V1StGXR8_Z5jdHi6B-myT - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew - audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg - smartSend: true - inline: - summary: Send to inline recipients - value: - emailId: V1StGXR8_Z5jdHi6B-myT - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew - to: - - founder@acme.com - - design@acme.com responses: "200": - description: "The TEST send (`test: true`) was delivered synchronously to the single recipient." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - X-RateLimit-Limit: - schema: - type: integer - description: Requests allowed in the current rolling rate limit window. - example: 100 - required: true - description: Requests allowed in the current rolling rate limit window. - X-RateLimit-Remaining: - schema: - type: integer - description: Requests remaining in the current rolling rate limit window. - example: 99 - required: true - description: Requests remaining in the current rolling rate limit window. - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - example: 1712592360 - required: true - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - content: - application/json: - schema: - $ref: "#/components/schemas/SendEmailTestResponse" - example: - status: completed - recipient: qa@acme.com - "202": - description: The campaign send was accepted, queued or scheduled. + description: A page of unified analytics events, or grouped counts with `groupBy`/`bucket`. headers: x-request-id: schema: @@ -30696,10 +34903,43 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SendsPostResponse" - example: - status: queued - sendId: r4Kq8ZxL2mW7nT1vPb9Yc + $ref: "#/components/schemas/EventsAnalyticsSuccessResponse" + examples: + page: + summary: Event page + value: + data: + - id: evt_abc + occurredAt: '2026-04-08T12:34:56.789Z' + domain: email + eventType: opened + recipientEmail: jane@example.com + sendId: r4Kq8ZxL2mW7nT1vPb9Yc + emailId: qkE3pWv2xN9dLmR4tYbZ7 + emailName: Spring Launch + pagination: + limit: 50 + cursor: null + hasMore: false + range: + from: '2026-04-01T12:34:56.789Z' + to: '2026-04-08T12:34:56.789Z' + grouped: + summary: Clicks per link (groupBy=link, eventType=clicked) + value: + count: 412 + groups: + - key: + link: https://example.com/spring + count: 301 + - key: + link: https://example.com/pricing + count: 111 + otherCount: 0 + range: + from: '2026-04-01T12:34:56.789Z' + to: '2026-04-08T12:34:56.789Z' + truncated: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -30763,50 +35003,26 @@ paths: code: API_KEY_REVOKED type: authentication_error message: The API key was revoked. - suggestion: Create a new active API key and retry. - docs: https://docs.brew.new/api-reference/api/authentication - authenticationRequired: - summary: AUTHENTICATION_REQUIRED - value: - error: - code: AUTHENTICATION_REQUIRED - type: authentication_error - message: No API key or session accompanied the request. - suggestion: Provide a valid API key or sign in with an organization session. - docs: https://docs.brew.new/api-reference/api/authentication - invalidApiKey: - summary: INVALID_API_KEY - value: - error: - code: INVALID_API_KEY - type: authentication_error - message: The API key is malformed or unknown. - suggestion: Check the API key format and retry with a valid active key. - docs: https://docs.brew.new/api-reference/api/authentication - "402": - description: "`SEND_QUOTA_EXCEEDED`: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - sendQuotaExceeded: - summary: SEND_QUOTA_EXCEEDED + suggestion: Create a new active API key and retry. + docs: https://docs.brew.new/api-reference/api/authentication + authenticationRequired: + summary: AUTHENTICATION_REQUIRED value: error: - code: SEND_QUOTA_EXCEEDED - type: payment_required - message: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period. - suggestion: Upgrade your plan for a higher monthly send volume, send to fewer recipients, or wait for the billing period to reset. - docs: https://docs.brew.new/api-reference/api/credits + code: AUTHENTICATION_REQUIRED + type: authentication_error + message: No API key or session accompanied the request. + suggestion: Provide a valid API key or sign in with an organization session. + docs: https://docs.brew.new/api-reference/api/authentication + invalidApiKey: + summary: INVALID_API_KEY + value: + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -30866,14 +35082,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. - - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. - - `EMAIL_NOT_FOUND`: No email design with that id exists in the brand (cross-brand ids surface as 404). + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -30887,15 +35096,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceNotFound: - summary: AUDIENCE_NOT_FOUND - value: - error: - code: AUDIENCE_NOT_FOUND - type: not_found - message: No audience with that id exists in the brand. - suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -30905,190 +35105,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - domainNotFound: - summary: DOMAIN_NOT_FOUND - value: - error: - code: DOMAIN_NOT_FOUND - type: not_found - message: No domain with that id exists in the brand. - suggestion: Use GET /v1/domains to choose a verified domain. - docs: https://docs.brew.new/api-reference/api/errors - emailNotFound: - summary: EMAIL_NOT_FOUND - value: - error: - code: EMAIL_NOT_FOUND - type: not_found - message: No email design with that id exists in the brand (cross-brand ids surface as 404). - suggestion: List designs with GET /v1/emails. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - "413": - description: "`PAYLOAD_TOO_LARGE`: The request body exceeds the route cap." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - payloadTooLarge: - summary: PAYLOAD_TOO_LARGE - value: - error: - code: PAYLOAD_TOO_LARGE - type: invalid_request - message: The request body exceeds the route cap. - suggestion: Reduce the payload size and retry. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: |- - `CONSENT_REQUIRED`: An inline marketing recipient has no contact record with marketing consent and the send carried no consent provenance; details.recipients lists the addresses. - - `DOMAIN_NOT_READY`: The domain is not verified for sending. - - `DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional). - - `EMAIL_IMAGES_MISSING`: The email shows an image on cdn.brew.new that does not exist, so it was not sent; `details.missingImages[]` lists each URL and the email it is in. - - `EMAIL_NOT_READY`: The design is still generating or failed to generate, so it cannot be sent or cloned. - - `LIQUID_RENDER_ERROR`: A Liquid template failed to parse or render. - - `NO_ELIGIBLE_RECIPIENTS`: Every contact the send targets is unsubscribed, suppressed or undeliverable (or there are none), so an immediate send would deliver nothing; `details.eligibility` carries the counts. - - `RECIPIENT_UNSUBSCRIBED`: An inline recipient is an existing contact who unsubscribed from marketing email; details.recipients lists the addresses and Brew never re-subscribes an opt-out. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - consentRequired: - summary: CONSENT_REQUIRED - value: - error: - code: CONSENT_REQUIRED - type: invalid_request - message: An inline marketing recipient has no contact record with marketing consent and the send carried no consent provenance; details.recipients lists the addresses. - suggestion: Create the contacts first with POST /v1/contacts carrying a consent record, or include consent provenance on the send to create them as subscribed contacts. - docs: https://docs.brew.new/api-reference/api/errors - domainNotReady: - summary: DOMAIN_NOT_READY - value: - error: - code: DOMAIN_NOT_READY - type: invalid_request - message: The domain is not verified for sending. - suggestion: Use a verified domain, or finish domain verification before sending. - docs: https://docs.brew.new/api-reference/api/errors - domainPurposeNotAllowed: - summary: DOMAIN_PURPOSE_NOT_ALLOWED - value: - error: - code: DOMAIN_PURPOSE_NOT_ALLOWED - type: invalid_request - message: The sending domain is admitted for a different purpose than this send (marketing vs transactional). - suggestion: Use a marketing domain for campaigns and audience sends. For transactional flows, wire the design into a published automation with a trigger and fire POST /v1/automations/triggers/{triggerEventId}/fire. - docs: https://docs.brew.new/api-reference/api/errors - emailImagesMissing: - summary: EMAIL_IMAGES_MISSING - value: - error: - code: EMAIL_IMAGES_MISSING - type: invalid_request - message: The email shows an image on cdn.brew.new that does not exist, so it was not sent; `details.missingImages[]` lists each URL and the email it is in. - suggestion: Replace or remove each image listed in `details.missingImages` in the email, then send again. - docs: https://docs.brew.new/api-reference/api/errors - details: - missingImages: - - emailId: em_8b1f0c2a - url: https://cdn.brew.new/generated/b1/hero-16x9.png - emailNotReady: - summary: EMAIL_NOT_READY - value: - error: - code: EMAIL_NOT_READY - type: invalid_request - message: The design is still generating or failed to generate, so it cannot be sent or cloned. - suggestion: Use a completed design, or wait for generation to finish. - docs: https://docs.brew.new/api-reference/api/errors - liquidRenderError: - summary: LIQUID_RENDER_ERROR - value: - error: - code: LIQUID_RENDER_ERROR - type: invalid_request - message: A Liquid template failed to parse or render. - suggestion: "Fix the template syntax, or supply the payload data the template references. Strict-mode fires fail on any unresolved variable; | default: is the sanctioned fallback." - docs: https://docs.brew.new/api-reference/api/errors - noEligibleRecipients: - summary: NO_ELIGIBLE_RECIPIENTS - value: - error: - code: NO_ELIGIBLE_RECIPIENTS - type: invalid_request - message: Every contact the send targets is unsubscribed, suppressed or undeliverable (or there are none), so an immediate send would deliver nothing; `details.eligibility` carries the counts. - suggestion: Target an audience with subscribed, deliverable contacts, or schedule the send for a time when the audience will have them. - docs: https://docs.brew.new/api-reference/api/errors - recipientUnsubscribed: - summary: RECIPIENT_UNSUBSCRIBED - value: - error: - code: RECIPIENT_UNSUBSCRIBED - type: invalid_request - message: An inline recipient is an existing contact who unsubscribed from marketing email; details.recipients lists the addresses and Brew never re-subscribes an opt-out. - suggestion: Remove the unsubscribed addresses from the recipients; a contact re-subscribes only through their own action. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -31172,80 +35188,38 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: sends + x-brew-scope: emails x-brew-brand-scope: brand - x-brew-idempotency: fail_closed + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: sends.write - /v1/sends/{sendId}: - get: - operationId: getSend + /v1/automations: + post: + operationId: createAutomation tags: - - Sends - summary: Get a send + - Automations + summary: Create an automation description: |- - Reads one send (campaign or automation) as the bare `Send` row: identity and join keys, the sender and subject snapshot, `status`, `approvalState`, delivery accounting, gradual-send progress and lifetime `stats`. + Deterministic create: the body carries the full graph (`{ name, triggerEventId?, nodes, connections }`). - **Use when** polling a send accepted by `createSend`, or inspecting one automation delivery. + **Use when** you have minted every design a `sendEmail` node references (`generateEmail` first). Publish afterwards with `updateAutomation` (`{ "published": true }`), or launch a manual-audience automation with `runAutomation`. - **Input** `sendId` in the path; `include=events` attaches a bounded first page of the send’s analytics events (recipient, type, click URL). + **Input** Trigger binding, exactly one: an EVENT automation passes `triggerEventId`; a MANUAL-AUDIENCE automation omits it and gives the trigger node `config: { "mode": "manualAudience", "audienceId": "…" }`. Typed conditions: every filter and condition-mode split uses a non-empty `conditions` array; each condition needs `field`, `type` (`string`, `number`, `date` or `bool`) and a canonical snake_case `operator`; unary operators omit `value`, comparisons require a type-correct scalar, a non-empty array, or an exact two-value `between` tuple (see `AutomationNode`). Every `sendEmail` node carries `emailId`, `emailVersionId` and `subject` (`previewText` is optional; the design's `` is the source of truth). `domainId` is optional at create time; publishing or running requires a verified one from `listDomains`. There is no `messageClass` key: the delivery class derives from the sending domain's `sendingPurpose` (a transactional-purpose domain sends with no unsubscribe link and delivers to unsubscribed contacts), so pick the class by picking the domain. Node ids are stored as sent, except the two shapes `NODE_ID_REPLACED` reports. `dryRun: true` validates without persisting. - **Returns** `200` with the row; `events` is present only when requested. + **Returns** `201` with the bare `AutomationRow`; with `dryRun: true`, `200` with an `AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }` (`blockers[]` fail publish, `warnings[]` are advisory, `blockingIssues[]` lists per-node references the bound trigger or contact catalog cannot provide, each of which also fails publish; `valid` is false when any blocker or blocking issue is present). - **Errors** `404 SEND_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. + **Errors** `400 AUTOMATION_GRAPH_INVALID` with `details.issues[]` (`kind`, `nodeId`, `message` per problem); `404 TRIGGER_EVENT_NOT_FOUND` for an unknown `triggerEventId`. - **See also** `listSends`, `cancelSend`, `pauseSend`, `resumeSend`, `getEventsAnalytics` (`?sendId=`). + **See also** `updateAutomation`, `testAutomation`, `runAutomation`, `createTrigger`. parameters: - - schema: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: type: string minLength: 1 - maxLength: 64 - description: Send id returned by `POST /v1/sends`. - example: r4Kq8ZxL2mW7nT1vPb9Yc - required: true - description: Send id returned by `POST /v1/sends`. - name: sendId - in: path - - schema: - type: string - description: "Expansions: `events` attaches a bounded first page of the send’s analytics events." - x-brew-include-tokens: - - events - required: false - description: "Expansions: `events` attaches a bounded first page of the send’s analytics events." - name: include - in: query + maxLength: 100 + example: api-request-2026-04-08-001 - name: X-Brand-Id in: header required: false @@ -31255,9 +35229,56 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AutomationsPostRequest" + examples: + deterministic: + summary: Create a trigger → sendEmail flow + value: + name: Welcome flow + description: Welcome new signups + triggerEventId: user_signed_up + nodes: + - id: trg + label: On signup + type: trigger + config: + actionType: trigger + - id: send_welcome + label: Welcome + type: sendEmail + config: + actionType: sendEmail + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: Ux8nW3qL1mP9zRv6TkYcB + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew, {{firstName | there}}! + previewText: Thanks for signing up — get started in 2 minutes. + fromName: Brew + replyTo: support@example.com + connections: + - from: trg + to: send_welcome + dryRun: + summary: Validate without persisting + value: + name: preview + triggerEventId: user_signed_up + nodes: + - id: trg + label: On signup + type: trigger + config: + actionType: trigger + connections: [] + dryRun: true responses: "200": - description: The send row. + description: "Dry-run result (returned only for `dryRun: true`; no writes). `valid` is false when any `blockers[]` entry or any `blockingIssues[]` entry is present. Create and update both return `blockingIssues[]` (possibly empty)." headers: x-request-id: schema: @@ -31290,30 +35311,93 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Send" + $ref: "#/components/schemas/AutomationDryRunReport" example: - sendId: r4Kq8ZxL2mW7nT1vPb9Yc - kind: campaign - messageClass: marketing - emailId: V1StGXR8_Z5jdHi6B-myT - emailVersionId: Yc5Kz8Sq2Wn7Lp4Tm1RvX - status: completed - audienceId: jn79k2x4apqz8mjm12ekz1kxdx87yceg - audienceName: Subscribers - startedAt: '2026-04-08T12:00:05.000Z' - completedAt: '2026-04-08T12:34:56.000Z' - stats: - sent: 1200 - delivered: 1180 - opened: 540 - clicked: 96 - bounced: 20 - complained: 1 - unsubscribed: 4 + valid: false + blockers: + - severity: error + message: Add at least one Send Email action + warnings: [] + blockingIssues: [] + nodeCounts: + sendEmail: 0 + wait: 0 + filter: 0 + split: 0 + "201": + description: Created. The bare automation row (draft, full graph). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/AutomationRow" + example: + automationId: M3nRk8LqW2xT5vBp9YcZd + automationVersionId: Rt4Vw1KyPb6HZq7Lm2Nx8 + triggerEventId: user_signed_up + name: Welcome flow + version: latest + published: false + isLive: false + nodes: + - id: trg + label: On signup + type: trigger + config: + actionType: trigger + - id: send_welcome + label: Welcome + type: sendEmail + config: + actionType: sendEmail + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: Ux8nW3qL1mP9zRv6TkYcB + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew, {{firstName | there}}! + previewText: Thanks for signing up — get started in 2 minutes. + fromName: Brew + replyTo: support@example.com + connections: + - from: trg + to: send_welcome + emailIds: + - V1StGXR8_Z5jdHi6B-myT + createdBy: Sam Rivera + createdByUserId: user_2xK9mPq4Rt7Vw1Yb createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:34:56.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' "400": description: |- + `AUTOMATION_GRAPH_INVALID`: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. @@ -31330,6 +35414,20 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + automationGraphInvalid: + summary: AUTOMATION_GRAPH_INVALID + value: + error: + code: AUTOMATION_GRAPH_INVALID + type: invalid_request + message: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. + suggestion: Fix every issue reported in `details.issues` then resubmit. + docs: https://docs.brew.new/api-reference/api/errors + details: + issues: + - kind: missing_send_email_domain + nodeId: send-1 + message: sendEmail node send-1 names no domainId. brandIdRequired: summary: BRAND_ID_REQUIRED value: @@ -31457,7 +35555,7 @@ paths: description: |- `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - `SEND_NOT_FOUND`: No send with that id exists in the brand. + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -31480,15 +35578,51 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - sendNotFound: - summary: SEND_NOT_FOUND + triggerEventNotFound: + summary: TRIGGER_EVENT_NOT_FOUND + value: + error: + code: TRIGGER_EVENT_NOT_FOUND + type: not_found + message: No trigger with that id exists in the brand. + suggestion: List triggers with GET /v1/automations/triggers. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS value: error: - code: SEND_NOT_FOUND - type: not_found - message: No send with that id exists in the brand. - suggestion: Use the sendId returned by POST /v1/sends, or list sends with GET /v1/sends. - docs: https://docs.brew.new/api-reference/api/errors + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -31503,7 +35637,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -31544,7 +35678,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -31572,38 +35706,55 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: sends + x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: sends.read - /v1/sends/{sendId}/cancel: - post: - operationId: cancelSend + get: + operationId: listAutomations tags: - - Emails - summary: Cancel a send - description: "Cancels a scheduled or queued send before it goes out, or STOPS any in-flight campaign send. A campaign that is `running` or `paused` is canceled the same way the in-app Stop button does it: the remaining recipients are never delivered while already-sent ones stay sent (every shape re-checks liveness at regular points mid-flight — smart per time bucket, gradual and plain blast at bounded chunk intervals — so a small tail may still deliver after the cancel lands). Idempotent — a send already `canceled` returns `200`. A send already `completed`, `partially_completed` or `failed`, or a non-campaign (automation) send, returns `409 SEND_NOT_CANCELLABLE`. Brand-scoped: an unknown / cross-brand `sendId` is `404`." + - Automations + summary: List automations + description: |- + Lists the brand’s automations under `{ data, pagination }` as LEAN rows: identity, `name`, `version`, `published`, `paused` and live-version state, `emailIds`, `updatedAt` and attribution; the graph is omitted. + + **Use when** browsing automations or finding the `automationId` to update, run or inspect. + + **Input** `limit` and `cursor` (an opaque native cursor). + + **Returns** `200` with a page of lean automation rows. `createdBy` / `createdByUserId` name the creator (`createdBy: "API"` when an organization API key made it, `Former member` once a person leaves the brand, absent when the creator cannot be named); while live, `publishedBy` / `publishedByUserId` name whoever published the live version. + + **Errors** `400 INVALID_REQUEST` for an unknown query key or a malformed cursor. + + **See also** `getAutomation` (with `include=graph,versions`), `createAutomation`, `listAutomationRuns`. parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: + - schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + maxLength: 200 + description: Only automations whose name matches these words (full-text), still newest first. + required: false + description: Only automations whose name matches these words (full-text), still newest first. + name: search + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 - schema: type: string minLength: 1 - maxLength: 64 - description: Send id returned by `POST /v1/sends`. - example: r4Kq8ZxL2mW7nT1vPb9Yc - required: true - description: Send id returned by `POST /v1/sends`. - name: sendId - in: path + maxLength: 512 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -31615,7 +35766,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The send is canceled (or was already canceled). + description: A page of lean automation rows. headers: x-request-id: schema: @@ -31648,10 +35799,22 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SendCancelResponse" + $ref: "#/components/schemas/AutomationsListResponse" example: - sendId: r4Kq8ZxL2mW7nT1vPb9Yc - status: canceled + data: + - automationId: M3nRk8LqW2xT5vBp9YcZd + automationVersionId: Rt4Vw1KyPb6HZq7Lm2Nx8 + triggerEventId: user_signed_up + name: Welcome flow + version: latest + published: true + emailIds: + - V1StGXR8_Z5jdHi6B-myT + updatedAt: '2026-04-08T12:00:00.000Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -31794,10 +35957,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `SEND_NOT_FOUND`: No send with that id exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -31820,62 +35980,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - sendNotFound: - summary: SEND_NOT_FOUND - value: - error: - code: SEND_NOT_FOUND - type: not_found - message: No send with that id exists in the brand. - suggestion: Use the sendId returned by POST /v1/sends, or list sends with GET /v1/sends. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - - `SEND_NOT_CANCELLABLE`: The send already finished, so there is nothing left to cancel. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - sendNotCancellable: - summary: SEND_NOT_CANCELLABLE - value: - error: - code: SEND_NOT_CANCELLABLE - type: conflict - message: The send already finished, so there is nothing left to cancel. - suggestion: Check the send status with GET /v1/sends/{sendId} before canceling. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -31890,7 +35994,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -31931,7 +36035,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -31959,38 +36063,50 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: sends + x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: sends.write - /v1/sends/{sendId}/pause: - post: - operationId: pauseSend + x-brew-rate-policy: automations.read + /v1/automations/{automationId}: + get: + operationId: getAutomation tags: - - Emails - summary: Pause a gradual send - description: "Manually pauses an in-flight GRADUAL (domain-warmup) send. The ramp stops before its next chunk and holds its current batch until `POST /v1/sends/{sendId}/resume` (or a cancel). A send its operator already paused returns the same 200 and changes nothing; any other send that is not a `running` gradual send (including one held at the plan limit or for its domain) returns `409 SEND_NOT_PAUSABLE`. Brand-scoped: an unknown / cross-brand `sendId` is `404`." + - Automations + summary: Get an automation + description: |- + Reads one automation as the bare row, LEAN by default (graph omitted), with `published`, `paused`, `pausedAt`, the live version it serves, its creator (`createdBy`, `createdByUserId`) and, while live, the live version’s publisher (`publishedBy`, `publishedByUserId`). + + **Use when** reading a flow before editing or publishing it, pinning `liveAutomationVersionId`, or inspecting its graph. + + **Input** `automationId` in the path; `include=graph` inlines `nodes` and `connections` and `include=versions` the lean `{ version, automationVersionId }` history (comma-separate both). + + **Returns** `200` with the row; the `include` fields are present only when requested. + + **Errors** `404 AUTOMATION_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. + + **See also** `listAutomations`, `updateAutomation`, `testAutomation`, `runAutomation`. parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: - type: string - minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 - schema: type: string minLength: 1 maxLength: 64 - description: Send id returned by `POST /v1/sends`. - example: r4Kq8ZxL2mW7nT1vPb9Yc + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + example: M3nRk8LqW2xT5vBp9YcZd required: true - description: Send id returned by `POST /v1/sends`. - name: sendId + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + name: automationId in: path + - schema: + type: string + description: "Comma-separated expansions: `graph` inlines `nodes`/`connections`; `versions` inlines the lean version history." + x-brew-include-tokens: + - versions + - graph + required: false + description: "Comma-separated expansions: `graph` inlines `nodes`/`connections`; `versions` inlines the lean version history." + name: include + in: query - name: X-Brand-Id in: header required: false @@ -32002,7 +36118,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The send is paused. + description: The automation row. headers: x-request-id: schema: @@ -32035,10 +36151,17 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SendPauseResponse" + $ref: "#/components/schemas/AutomationRow" example: - sendId: r4Kq8ZxL2mW7nT1vPb9Yc - status: paused + automationId: M3nRk8LqW2xT5vBp9YcZd + automationVersionId: Rt4Vw1KyPb6HZq7Lm2Nx8 + triggerEventId: user_signed_up + name: Welcome flow + version: latest + published: true + emailIds: + - V1StGXR8_Z5jdHi6B-myT + updatedAt: '2026-04-08T12:00:00.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -32182,9 +36305,9 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. - `SEND_NOT_FOUND`: No send with that id exists in the brand. + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -32198,6 +36321,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + automationNotFound: + summary: AUTOMATION_NOT_FOUND + value: + error: + code: AUTOMATION_NOT_FOUND + type: not_found + message: No automation with that id exists in the brand. + suggestion: List automations with GET /v1/automations. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -32207,62 +36339,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - sendNotFound: - summary: SEND_NOT_FOUND - value: - error: - code: SEND_NOT_FOUND - type: not_found - message: No send with that id exists in the brand. - suggestion: Use the sendId returned by POST /v1/sends, or list sends with GET /v1/sends. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - - `SEND_NOT_PAUSABLE`: Only a delivering gradual send can be paused. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - sendNotPausable: - summary: SEND_NOT_PAUSABLE - value: - error: - code: SEND_NOT_PAUSABLE - type: conflict - message: Only a delivering gradual send can be paused. - suggestion: Check the send status with GET /v1/sends/{sendId}. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -32277,7 +36353,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -32318,7 +36394,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -32346,37 +36422,38 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: sends + x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: sends.write - /v1/sends/{sendId}/resume: - post: - operationId: resumeSend + x-brew-rate-policy: automations.read + patch: + operationId: updateAutomation tags: - - Emails - summary: Resume a paused gradual send - description: "Resumes a manually paused GRADUAL send. The ramp picks the resume up at its next hold check (seconds after a short pause, up to an hour after a long one); the unsent tail is re-spread and later batches shift so missed intervals do not compress into a burst. A send held at the plan limit resumes on its own once the plan allowance is available; any other send returns `409 SEND_NOT_RESUMABLE`. Brand-scoped: an unknown / cross-brand `sendId` is `404`." + - Automations + summary: Update or publish an automation + description: |- + One endpoint, two mutually exclusive modes: update the graph (a new `automationVersionId` on the same `automationId`) or change lifecycle state (`published`, `paused`). The modes cannot be combined: update first, then `PATCH { "published": true }`. + + **Use when** editing a graph or changing runtime state of an automation created with `createAutomation`. Manual-audience runs are controlled with `pauseAudienceRun`, `resumeAudienceRun` and `cancelAudienceRun`, not here. + + **Input** Update: one or more of `name`, `description`, `nodes`, `connections`, `triggerEventId`, under the same typed condition contract as create (explicit `type`, canonical snake_case `operator`, no `value` for unary operators, type-correct values elsewhere); `dryRun: true` validates without persisting. Node ids are stored as sent, so renamed nodes keep their new ids, except the two shapes `NODE_ID_REPLACED` reports. Lifecycle: `published: true` promotes the stored latest version live (optionally pin `automationVersionId`; the graph is validated first), `published: false` unpublishes, `paused: true` or `false` freezes or resumes future send steps (`stopInFlight` also stops runs already in progress). + + **Returns** the bare automation row, or with `dryRun: true` a `200 AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }`: `blockingIssues[]` lists per-node references the effective trigger or contact catalog cannot provide, and publish refuses every one of them, fallback or not (`fatal` says what the reference would do: `true` for filter or split conditions and triple-brace `{{{ }}}` body tokens would break the run, `false` for subject, previewText, fromName, replyTo and double-brace body tags would render empty); `valid` is false when any blocker or blocking issue is present. Updating a LIVE automation saves a draft and does not go live: the previously published version keeps serving until you republish, `published` describes the returned row, `isLive`, `liveVersion` and `liveAutomationVersionId` describe the automation, and `warnings[]` carries `DRAFT_SAVED_NOT_LIVE`. Publishing an automation whose integration trigger has not received its event on the current connection (a Shopify topic with no webhook) still succeeds, and `warnings[]` carries `TRIGGER_EVENT_NOT_RECEIVED` saying what to set up. An engagement filter that watches an email sent from a transactional domain (no open or click tracking) publishes with `ENGAGEMENT_NOT_TRACKED`. + + **Errors** `404 AUTOMATION_NOT_FOUND`, `AUTOMATION_VERSION_NOT_FOUND` (pin `liveAutomationVersionId`, a `versions[]` id, or a run row's `automationVersionId`), `TRIGGER_EVENT_NOT_FOUND`; `400 AUTOMATION_GRAPH_INVALID` (`details.issues[]`); `409 AUTOMATION_VERSION_CONFLICT` (re-read, reapply, retry); `422 PUBLISH_VALIDATION_FAILED` (`details.blockers[]`; a cdn.brew.new image a step would send that does not exist is a blocker, listed in `details.missingImages[]`, and a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access), `AUTOMATION_NOT_PUBLISHED` (nothing to unpublish; with `stopInFlight: true` on a draft, also no contacts in flight to stop), `AUTOMATION_NOT_PAUSABLE` (a manual-audience automation pauses through its runs). + + **See also** `createAutomation`, `testAutomation`, `runAutomation`, `listAutomationRuns`. parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: - type: string - minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 - schema: type: string minLength: 1 maxLength: 64 - description: Send id returned by `POST /v1/sends`. - example: r4Kq8ZxL2mW7nT1vPb9Yc + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + example: M3nRk8LqW2xT5vBp9YcZd required: true - description: Send id returned by `POST /v1/sends`. - name: sendId + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + name: automationId in: path - name: X-Brand-Id in: header @@ -32387,9 +36464,82 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AutomationsUpdateRequest" + examples: + rename: + summary: Rename + value: + name: Welcome flow v2 + graph: + summary: Replace the graph + value: + nodes: + - id: trg + label: On signup + type: trigger + config: + actionType: trigger + - id: send_welcome + label: Welcome + type: sendEmail + config: + actionType: sendEmail + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: Ux8nW3qL1mP9zRv6TkYcB + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew, {{firstName | there}}! + previewText: Thanks for signing up — get started in 2 minutes. + fromName: Brew + replyTo: support@example.com + connections: + - from: trg + to: send_welcome + dryRun: + summary: Validate a graph update without persisting + value: + nodes: + - id: trg + label: On signup + type: trigger + config: + actionType: trigger + - id: send_welcome + label: Welcome + type: sendEmail + config: + actionType: sendEmail + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: Ux8nW3qL1mP9zRv6TkYcB + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew, {{firstName | there}}! + previewText: Thanks for signing up — get started in 2 minutes. + fromName: Brew + replyTo: support@example.com + connections: + - from: trg + to: send_welcome + dryRun: true + publish: + summary: Publish (promote live) + value: + published: true + publishVersion: + summary: Publish a pinned version + value: + published: true + automationVersionId: Kx9Pb2Nt6Wq4Ym8Lz1Rv3 + unpublish: + summary: Unpublish + value: + published: false responses: "200": - description: The send is delivering again. + description: "Updated / published / unpublished — the bare automation row. For `dryRun: true` update requests, an `AutomationDryRunReport` instead (no writes)." headers: x-request-id: schema: @@ -32422,12 +36572,74 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SendResumeResponse" - example: - sendId: r4Kq8ZxL2mW7nT1vPb9Yc - status: running + anyOf: + - $ref: "#/components/schemas/AutomationRow" + - $ref: "#/components/schemas/AutomationDryRunReport" + examples: + updated: + summary: Updated / published — the automation row + value: + automationId: M3nRk8LqW2xT5vBp9YcZd + automationVersionId: Rt4Vw1KyPb6HZq7Lm2Nx8 + triggerEventId: user_signed_up + name: Welcome flow + version: latest + published: false + isLive: true + liveVersion: 3 + liveAutomationVersionId: Nt6Wq4Ym8Lz1Rv3Kx9Pb2 + nodes: + - id: trg + label: On signup + type: trigger + config: + actionType: trigger + - id: send_welcome + label: Welcome + type: sendEmail + config: + actionType: sendEmail + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: Ux8nW3qL1mP9zRv6TkYcB + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + subject: Welcome to Brew, {{firstName | there}}! + previewText: Thanks for signing up — get started in 2 minutes. + fromName: Brew + replyTo: support@example.com + connections: + - from: trg + to: send_welcome + emailIds: + - V1StGXR8_Z5jdHi6B-myT + createdBy: Sam Rivera + createdByUserId: user_2xK9mPq4Rt7Vw1Yb + publishedBy: Sam Rivera + publishedByUserId: user_2xK9mPq4Rt7Vw1Yb + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' + dryRun: + summary: "Dry-run validation report (`dryRun: true`)" + value: + valid: false + blockers: [] + warnings: [] + blockingIssues: + - nodeId: filter_a1b2 + nodeLabel: Paid plan + surface: filterCondition + variable: plan + reason: Condition on "plan", which the trigger doesn't provide + fatal: true + hasFallback: false + nodeCounts: + sendEmail: 1 + wait: 0 + filter: 1 + split: 0 "400": description: |- + `AUTOMATION_GRAPH_INVALID`: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. @@ -32444,6 +36656,20 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + automationGraphInvalid: + summary: AUTOMATION_GRAPH_INVALID + value: + error: + code: AUTOMATION_GRAPH_INVALID + type: invalid_request + message: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. + suggestion: Fix every issue reported in `details.issues` then resubmit. + docs: https://docs.brew.new/api-reference/api/errors + details: + issues: + - kind: missing_send_email_domain + nodeId: send-1 + message: sendEmail node send-1 names no domainId. brandIdRequired: summary: BRAND_ID_REQUIRED value: @@ -32569,9 +36795,13 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- + `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + + `AUTOMATION_VERSION_NOT_FOUND`: The automation has no version with that id. + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - `SEND_NOT_FOUND`: No send with that id exists in the brand. + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -32585,6 +36815,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + automationNotFound: + summary: AUTOMATION_NOT_FOUND + value: + error: + code: AUTOMATION_NOT_FOUND + type: not_found + message: No automation with that id exists in the brand. + suggestion: List automations with GET /v1/automations. + docs: https://docs.brew.new/api-reference/api/errors + automationVersionNotFound: + summary: AUTOMATION_VERSION_NOT_FOUND + value: + error: + code: AUTOMATION_VERSION_NOT_FOUND + type: not_found + message: The automation has no version with that id. + suggestion: List versions with GET /v1/automations/{automationId}?include=versions. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -32594,22 +36842,46 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - sendNotFound: - summary: SEND_NOT_FOUND + triggerEventNotFound: + summary: TRIGGER_EVENT_NOT_FOUND value: error: - code: SEND_NOT_FOUND + code: TRIGGER_EVENT_NOT_FOUND type: not_found - message: No send with that id exists in the brand. - suggestion: Use the sendId returned by POST /v1/sends, or list sends with GET /v1/sends. + message: No trigger with that id exists in the brand. + suggestion: List triggers with GET /v1/automations/triggers. docs: https://docs.brew.new/api-reference/api/errors "409": + description: "`AUTOMATION_VERSION_CONFLICT`: The automation changed since the version you edited was read." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + automationVersionConflict: + summary: AUTOMATION_VERSION_CONFLICT + value: + error: + code: AUTOMATION_VERSION_CONFLICT + type: conflict + message: The automation changed since the version you edited was read. + suggestion: Read the latest automation version, reapply the intended changes, and retry the update. + docs: https://docs.brew.new/api-reference/api/errors + "422": description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + `AUTOMATION_NOT_PAUSABLE`: The automation runs against a manual audience, so it is paused through its runs, not the automation. - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + `AUTOMATION_NOT_PUBLISHED`: The automation is not live, so there is nothing to unpublish; with `stopInFlight: true` it also has no contacts in flight to stop. - `SEND_NOT_RESUMABLE`: Only a manually paused gradual send can be resumed. + `PUBLISH_VALIDATION_FAILED`: The saved graph cannot go live; `details.blockers[]` lists what publish requires. headers: x-request-id: schema: @@ -32623,33 +36895,39 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT + automationNotPausable: + summary: AUTOMATION_NOT_PAUSABLE value: error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS + code: AUTOMATION_NOT_PAUSABLE + type: invalid_request + message: The automation runs against a manual audience, so it is paused through its runs, not the automation. + suggestion: Use the audience-run control operations to pause or cancel an in-flight run. + docs: https://docs.brew.new/api-reference/api/errors + automationNotPublished: + summary: AUTOMATION_NOT_PUBLISHED value: error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - sendNotResumable: - summary: SEND_NOT_RESUMABLE + code: AUTOMATION_NOT_PUBLISHED + type: invalid_request + message: "The automation is not live, so there is nothing to unpublish; with `stopInFlight: true` it also has no contacts in flight to stop." + suggestion: Check the live state with GET /v1/automations/{automationId}. + docs: https://docs.brew.new/api-reference/api/errors + publishValidationFailed: + summary: PUBLISH_VALIDATION_FAILED value: error: - code: SEND_NOT_RESUMABLE - type: conflict - message: Only a manually paused gradual send can be resumed. - suggestion: Check the send status with GET /v1/sends/{sendId}. + code: PUBLISH_VALIDATION_FAILED + type: invalid_request + message: The saved graph cannot go live; `details.blockers[]` lists what publish requires. + suggestion: "Fix every blocker reported in `details.blockers` then PATCH again with { published: true }." docs: https://docs.brew.new/api-reference/api/errors + details: + blockers: + - nodeId: send-1 + nodeLabel: Welcome email + code: domain_not_verified + message: The sending domain is not verified. "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -32664,7 +36942,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -32705,7 +36983,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -32733,98 +37011,28 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: sends + x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: sends.write - /v1/analytics/overview: - get: - operationId: getAnalyticsOverview + x-brew-rate-policy: automations.write + delete: + operationId: deleteAutomation tags: - - Analytics - summary: Brand overview (totals, rates, timeseries) - description: |- - Windowed brand overview: the exact read behind the app's Analytics metric cards and chart (same per-recipient dedup and machine open and click exclusion), so API, MCP and page numbers always agree. Defaults to the last 7 days. - - **Use when** you need totals, rates and a time series for the brand or for one automation, design, audience, source, domain or recipient set. For individual rows use `getEventsAnalytics`. - - **Input** Filters compose freely: `source` (csv of send sources), `automationId`, `emailId`, `audienceId` (csv, max 20), `triggerEventId` (csv, max 10, resolved to the automations they wire), `domain` (sending domain) and `recipient` (csv of recipient rules, same grammar as `getEventsAnalytics`). A single filter is answered from pre-aggregated rollups; any combination, and anything with `domain` or `recipient`, aggregates raw events instead: exact but capped, so watch `truncated` on wide windows. - - **Returns** `200 { totals, rates, buckets, granularity, timeZone, range, truncated }`; `truncated: true` means the window exceeded the scan budget, so narrow the range. Requires the `emails` scope. - - **Errors** `400 INVALID_REQUEST` for an inverted window or too many csv values. - - **See also** `getEventsAnalytics`, `getAutomationAnalytics`, `listSends` (campaign rows carry their own `stats`). + - Automations + summary: Delete an automation + description: "Cascade-deletes the automation: every version row, runs, and logs. Referenced email DESIGNS are shared resources and always survive. Idempotent — an unknown id resolves with `deleted: false`." parameters: - - name: from - in: query - required: false - schema: - type: string - format: date-time - - name: to - in: query - required: false - schema: - type: string - format: date-time - - name: source - in: query - required: false - description: "CSV of send sources to include (e.g. `audience,api`; valid values: audience, api, automation_manual, automation_integration, automation_custom). Composes with every other filter." - schema: - type: string - minLength: 1 - maxLength: 256 - - name: automationId - in: query - required: false - description: CSV of automation ids (max 20) — a single id is still valid. Composes with every other filter; one id reads a pre-aggregated rollup partition, several take the raw-event path. - schema: - type: string - minLength: 1 - maxLength: 2048 - - name: emailId - in: query - required: false - description: Scope to one email design. Composes with every other filter — a design used by several automations can be narrowed with `automationId`. - schema: + - schema: type: string minLength: 1 maxLength: 64 - - name: audienceId - in: query - required: false - description: CSV of audience ids (max 20). Composes with every other filter. - schema: - type: string - minLength: 1 - maxLength: 2048 - - name: triggerEventId - in: query - required: false - description: CSV of integration trigger-event ids (max 10), resolved to their wired automations. Composes with every other filter. - schema: - type: string - minLength: 1 - maxLength: 2048 - - name: domain - in: query - required: false - description: Sending domain (`fromEmail` match). Has no rollup dimension, so requests carrying it are answered from raw events — check `truncated` on wide windows. - schema: - type: string - minLength: 1 - maxLength: 255 - - name: recipient - in: query - required: false - description: CSV of recipient rules (max 10) matching who RECEIVED the email — the SAME grammar and the same parameter name as `GET /v1/analytics/events`, so one filter string moves between the two and the totals here describe exactly the rows that feed lists. A full address matches exactly, `@domain` matches the domain, any other text matches as a substring; prefix `!` to exclude (e.g. `@clay.com,!ceo@clay.com`). Includes OR together; excludes always apply. Like `domain` this has no rollup dimension (stored rows aggregate ACROSS recipients), so requests carrying it are answered from raw events — check `truncated` on wide windows. - schema: - type: string - minLength: 1 - maxLength: 2048 + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + example: M3nRk8LqW2xT5vBp9YcZd + required: true + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + name: automationId + in: path - name: X-Brand-Id in: header required: false @@ -32836,7 +37044,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: Brand-wide totals, rates, and a zero-filled timeseries for the window. + description: Deleted (or already gone). headers: x-request-id: schema: @@ -32869,44 +37077,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AnalyticsOverviewResponse" + $ref: "#/components/schemas/AutomationsDeleteResponse" example: - totals: - sent: 5000 - delivered: 4920 - opened: 2110 - clicked: 540 - bounced: 80 - complained: 3 - unsubscribed: 12 - failed: 0 - suppressed: 14 - deliveryDelayed: 2 - rates: - deliveryRate: 0.984 - openRate: 0.4289 - clickRate: 0.1098 - bounceRate: 0.016 - complaintRate: 0.0006 - unsubscribeRate: 0.0024 - buckets: - - at: '2026-04-08T00:00:00.000Z' - sent: 5000 - delivered: 4920 - deliveryDelayed: 2 - opened: 2110 - clicked: 540 - bounced: 80 - complained: 3 - failed: 0 - suppressed: 14 - unsubscribed: 12 - granularity: 1d - timeZone: America/New_York - range: - from: '2026-04-01T12:34:56.789Z' - to: '2026-04-08T12:34:56.789Z' - truncated: false + automationId: M3nRk8LqW2xT5vBp9YcZd + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -33049,7 +37223,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -33063,6 +37240,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + automationNotFound: + summary: AUTOMATION_NOT_FOUND + value: + error: + code: AUTOMATION_NOT_FOUND + type: not_found + message: No automation with that id exists in the brand. + suggestion: List automations with GET /v1/automations. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -33086,7 +37272,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -33127,7 +37313,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -33155,46 +37341,38 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails + x-brew-scope: automations x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - /v1/analytics/automations: - get: - operationId: getAutomationAnalytics + x-brew-rate-policy: automations.write + /v1/automations/{automationId}/test: + post: + operationId: testAutomation tags: - - Analytics - summary: Automation performance - description: "Windowed per-automation performance + scoped `totals` + the resolved `range`. Defaults to the last 30 days. Reflects LIVE runs only (test runs never contribute). Totals include every matching automation independently from the displayed row `limit`. Narrow with the `automationId` filter; bound the window with `from` / `to`. Check `truncated`: when true, scan budgets clipped the requested window and the metrics are partial. Requires the `automations` scope." + - Automations + summary: Test an automation + description: Runs the saved automation END-TO-END through the real workflow in TEST mode — works on drafts and for both event and manual-audience automations; every node executes (filters/splits evaluate against `payload`, and `contact.` conditions read the test recipient’s contact unless `payload` supplies that field — a flat `contact.` key or a `contact` object — and the trigger’s contract keeps extra keys; wait nodes fast-forward). Pass `testRecipient` to DELIVER each send-email node’s email for real to that address (real subject/preview/sender name; verified configured sender domains are preserved, otherwise delivery uses the Brew test domain; customer addresses are never hit) — omit it for a silent dry-run with no mail. Test runs are excluded from live analytics rollups. Real test deliveries consume send usage; dry runs do not. Test payloads never update real contact records. Returns `202` with the started run id; follow per-node status via `GET /v1/automations/runs/{automationRunId}?include=logs`. Optional `scenario` controls simulate timed opens/clicks at engagement-filter nodes and force left/right percentage-split choices (waits use a virtual clock; controls stay separate from `payload`, and simulated events never enter live analytics or send quotas); the run row’s `testCoverage` reports visited nodes, traversed connections, evaluated or forced decisions, uncovered paths and simulation limitations. On a Brew-event (unsubscribe) trigger, omit payload to use Brew’s sample event; supplied fields replace the sample’s. The run never creates or edits a contact. parameters: - - name: from - in: query - required: false - schema: - type: string - format: date-time - - name: to - in: query + - name: Idempotency-Key + in: header required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. schema: type: string - format: date-time - - name: automationId - in: query - required: false - schema: + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 + - schema: type: string minLength: 1 maxLength: 64 - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + example: M3nRk8LqW2xT5vBp9YcZd + required: true + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + name: automationId + in: path - name: X-Brand-Id in: header required: false @@ -33204,9 +37382,20 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/AutomationTestRequest" + example: + payload: + email: qa@example.com + plan: pro + testRecipient: qa@example.com responses: - "200": - description: Per-automation performance + totals for the window. + "202": + description: Test run started. headers: x-request-id: schema: @@ -33239,44 +37428,18 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationAnalyticsResponse" + $ref: "#/components/schemas/AutomationRunStartedResponse" example: - data: - - automationId: Zq7Lm2Nx8Rt4Vw1KyPb6H - name: Welcome flow - published: true - triggerEventId: user_signed_up - runs: 1200 - completed: 1180 - failed: 5 - sent: 1180 - delivered: 1150 - opened: 690 - clicked: 210 - bounced: 18 - successRate: 0.983 - openRate: 0.6 - clickRate: 0.182 - clickThroughRate: 0.304 - lastRunAt: '2026-04-08T12:34:56.789Z' - lastRunStatus: completed - totals: - runs: 1200 - completed: 1180 - failed: 5 - sent: 1180 - delivered: 1150 - opened: 690 - clicked: 210 - bounced: 18 - range: - from: '2026-03-09T12:34:56.789Z' - to: '2026-04-08T12:34:56.789Z' - truncated: false + automationRunIds: + - jh7acthhe99pq4448xh83gn54x8eennb + status: test_started + receivedAt: '2026-04-08T12:34:56.789Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + `INVALID_PAYLOAD`: The test payload does not match the trigger’s payload schema. + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: @@ -33300,6 +37463,15 @@ paths: message: An organization-scoped credential called a brand-scoped operation without naming the brand. suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. docs: https://docs.brew.new/api-reference/api/authentication + invalidPayload: + summary: INVALID_PAYLOAD + value: + error: + code: INVALID_PAYLOAD + type: invalid_request + message: The test payload does not match the trigger’s payload schema. + suggestion: Send fields and types matching the trigger’s payloadSchema (see GET /v1/automations/triggers/{triggerEventId}). + docs: https://docs.brew.new/api-reference/api/errors invalidRequest: summary: INVALID_REQUEST value: @@ -33415,7 +37587,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -33429,6 +37604,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + automationNotFound: + summary: AUTOMATION_NOT_FOUND + value: + error: + code: AUTOMATION_NOT_FOUND + type: not_found + message: No automation with that id exists in the brand. + suggestion: List automations with GET /v1/automations. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -33438,6 +37622,42 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -33452,7 +37672,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -33493,7 +37713,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -33554,182 +37774,36 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: fail_closed x-brew-credited: false - /v1/analytics/events: - get: - operationId: getEventsAnalytics + x-brew-rate-policy: automation.runs.write + /v1/automations/{automationId}/run: + post: + operationId: runAutomation tags: - - Analytics - summary: Unified events feed - description: |- - Read-only feed of the brand's analytics events (email, automation, trigger, inbound), newest first, over a `from`/`to` window that defaults to the last 7 days. Email rows carry the send behind them (`sendId`, `emailId`), so a page joins back to `GET /v1/sends/{sendId}`. - - **Use when** you need per-event or per-recipient detail. For windowed totals and rates use `getAnalyticsOverview`; for one send's events in one bounded page use `GET /v1/sends/{sendId}?include=events` (`getSend`). - - **Input** Equality filters: `eventType`, `sendId`, `emailId`, `automationId`, `messageClass` (`marketing` or `transactional`; absent stamps count as marketing). Run scope: `automationRunId` returns one run’s events across every domain (its lifecycle rows and the deliveries it produced), newest first and not windowed unless `from`/`to` are given; only `eventType` combines with it. Recipient rules `recipient` (csv, max 10): a full address matches exactly, `@domain` matches everyone on the domain, other text matches as a substring, a `!` prefix excludes (`recipient=@clay.com,!ceo@clay.com`); includes OR together, excludes always apply. Send facets `source` (csv), `audienceId` (csv, max 20), `domain` (sending domain), `triggerEventId` (csv, max 10, resolved to the automations they wire). Any facet or recipient rule narrows the feed to email events and adds `sendSource`, `sendContext` and `messageClass` to each row (plus `triggerProvider` and `triggerTitle` on triggered rows). Machine-classified opens and clicks (scanner detonation, Apple proxy prefetch) are excluded unless `includeMachineOpens=true` or `includeMachineClicks=true` (audit only). - - **Grouped counts** `groupBy` (one or two csv fields: `eventType`, `emailId`, `automationId`, `sendId`, `source`, `link`, `recipientDomain`, `unsubscribeReason`) and/or `bucket` (`day`, `week` or `month`, UTC, Monday weeks) count the email events the same filters list instead of returning them: `link` with `eventType=clicked` is clicks per link, `unsubscribeReason` counts an unsubscribe once per reason it gave. The count reads the newest 20,000 events; a busier window answers `truncated: true` with `coveredFrom` (narrow `from`/`to` or add `eventType`). Events, not unique recipients: `getAnalyticsOverview` `opened`/`clicked` count unique human recipients. - - **Returns** `200 { data: Event[], pagination: { limit, cursor, hasMore }, range }`; page with `cursor` until it is `null`. Rows carry the run join keys when stamped: `automationRunId`, `triggerInstanceId`, `audienceRunId` (plus `sendId`, `nodeId`). With `groupBy`/`bucket`: `200 { count, groups: [{ key, bucket?, count }], otherCount, range, truncated, coveredFrom? }`, the largest 200 groups first. Requires the `emails` scope. - - **Errors** `400 INVALID_REQUEST` for an inverted window, an unknown `eventType`, or more than 10 recipient rules or 20 audience ids; with `groupBy`/`bucket`, for an unknown or repeated field, more than two fields, a non-email `eventType`, `cursor` or `automationRunId` (`limit` is ignored). - - **See also** `getAnalyticsOverview`, `listSends`, `listAutomationRuns`. + - Automations + summary: Run a manual-audience automation + description: "Launches a MANUAL-AUDIENCE automation against the audience bound to its trigger node. `dryRun: true` previews without sending; `scheduledAt` launches later, at most 1 year ahead. Percentage-based `gradualSend` delivers each send step in custom hour or calendar-day batches and supports manual pause/resume/cancel. `400` when `scheduledAt` is more than 1 year ahead, or a `gradualSend` plan exceeds 50,000 recipients, 30 batches, or 30 elapsed days. `422 EMAIL_IMAGES_MISSING` when a step would send a cdn.brew.new image that does not exist (`details.missingImages[]`). Returns `202` with the `audienceRunId`." parameters: - - name: from - in: query - required: false - schema: - type: string - format: date-time - - name: to - in: query - required: false - schema: - type: string - format: date-time - - name: recipient - in: query - required: false - description: "CSV of recipient rules (max 10) matching who RECEIVED the email: a full address matches exactly, `@domain` matches the domain, any other text matches as a substring; prefix `!` to exclude (e.g. `@clay.com,!ceo@clay.com`). Includes OR together; excludes always apply. Narrows the feed to email events." - schema: - type: string - minLength: 1 - maxLength: 2048 - - name: eventType - in: query - required: false - schema: - type: string - minLength: 1 - maxLength: 64 - - name: automationId - in: query - required: false - schema: - type: string - minLength: 1 - maxLength: 64 - - name: sendId - in: query - required: false - schema: - type: string - minLength: 1 - maxLength: 64 - - name: automationRunId - in: query - required: false - description: One automation run’s events across every domain, newest first. Not windowed unless `from`/`to` are given; only `eventType` combines with it. - schema: - type: string - minLength: 1 - maxLength: 64 - - name: messageClass - in: query - required: false - description: Admission snapshot on the send behind the event (`marketing` | `transactional`). Same event object for both classes; absent stamps match as marketing. Narrows the feed to email events. - schema: - type: string - enum: - - marketing - - transactional - - name: source - in: query - required: false - description: "Send-object facet: CSV of send sources (valid values: audience, api, automation_manual, automation_integration, automation_custom). Narrows the feed to email events." - schema: - type: string - minLength: 1 - maxLength: 256 - - name: audienceId - in: query + - name: Idempotency-Key + in: header required: false - description: "Send-object facet: CSV of audience ids (max 20) — events from the audiences' sends. Narrows the feed to email events." + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. schema: type: string minLength: 1 - maxLength: 2048 - - name: emailId - in: query - required: false - description: "Send-object facet: one email design id. Narrows the feed to email events." - schema: + maxLength: 100 + example: api-request-2026-04-08-001 + - schema: type: string minLength: 1 maxLength: 64 - - name: domain - in: query - required: false - description: "Send-object facet: a sending domain (`fromEmail` match). Narrows the feed to email events." - schema: - type: string - minLength: 1 - maxLength: 255 - - name: triggerEventId - in: query - required: false - description: "Send-object facet: CSV of integration trigger-event ids (max 10), resolved to their wired automations. Narrows the feed to email events." - schema: - type: string - minLength: 1 - maxLength: 2048 - - name: includeMachineClicks - in: query - required: false - description: "Machine/bot-classified `clicked` rows are excluded by default. Pass `true` to include the raw rows (they carry `machineGenerated: true` + a `clickBotReason`; audit/debug only)." - schema: - type: boolean - - name: includeMachineOpens - in: query - required: false - description: Machine/bot-classified `opened` rows (security-scanner pixel detonation, Apple-proxy prefetch) and still-classifying opens are excluded by default. Pass `true` to include the raw rows (they carry `machineGenerated` + an `openBotReason` and `openFetchSource`; audit/debug only). - schema: - type: boolean - - name: limit - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - - name: cursor - in: query - required: false - schema: - type: string - minLength: 1 - maxLength: 8192 - - name: groupBy - in: query - required: false - description: "Count the email events these filters list per value of one or two comma-separated fields instead of returning them: eventType, emailId, automationId, sendId, source, link, recipientDomain, unsubscribeReason." - schema: - type: string - minLength: 1 - maxLength: 200 - x-brew-group-by-tokens: - - eventType - - emailId - - automationId - - sendId - - source - - link - - recipientDomain - - unsubscribeReason - - name: bucket - in: query - required: false - description: Count the email events these filters list per UTC day, week (Monday start) or month; combines with `groupBy`. - schema: - type: string - enum: - - day - - week - - month + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + example: M3nRk8LqW2xT5vBp9YcZd + required: true + description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. + name: automationId + in: path - name: X-Brand-Id in: header required: false @@ -33739,9 +37813,17 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/AutomationRunRequest" + example: + scheduledAt: '2026-07-01T15:00:00.000Z' responses: "200": - description: A page of unified analytics events, or grouped counts with `groupBy`/`bucket`. + description: Dry-run preview (no run started). recipientCount is who will receive the email. warnings carries RECIPIENTS_EXCLUDED when unsubscribed, suppressed or undeliverable contacts are skipped. headers: x-request-id: schema: @@ -33774,43 +37856,55 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/EventsAnalyticsSuccessResponse" - examples: - page: - summary: Event page - value: - data: - - id: evt_abc - occurredAt: '2026-04-08T12:34:56.789Z' - domain: email - eventType: opened - recipientEmail: jane@example.com - sendId: r4Kq8ZxL2mW7nT1vPb9Yc - emailId: qkE3pWv2xN9dLmR4tYbZ7 - emailName: Spring Launch - pagination: - limit: 50 - cursor: null - hasMore: false - range: - from: '2026-04-01T12:34:56.789Z' - to: '2026-04-08T12:34:56.789Z' - grouped: - summary: Clicks per link (groupBy=link, eventType=clicked) - value: - count: 412 - groups: - - key: - link: https://example.com/spring - count: 301 - - key: - link: https://example.com/pricing - count: 111 - otherCount: 0 - range: - from: '2026-04-01T12:34:56.789Z' - to: '2026-04-08T12:34:56.789Z' - truncated: false + $ref: "#/components/schemas/AutomationRunDryRunResponse" + example: + dryRun: true + automationId: M3nRk8LqW2xT5vBp9YcZd + audienceId: jn7b3s7fapqz8mjm12ekz1kxdx87yceg + audienceName: Active trial users + recipientCount: 12400 + sendNodeCount: 2 + "202": + description: 'Run launched (or scheduled). When org-level send approval is enabled the body additionally carries `approvalState: "pending"`.' + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/AudienceAutomationRunStartedResponse" + example: + audienceRunId: ajh7acthhe99pq4448xh83gn54x8eennb + automationId: M3nRk8LqW2xT5vBp9YcZd + status: queued + totalRecipients: 12400 + receivedAt: '2026-04-08T12:34:56.789Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -33894,15 +37988,142 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`SEND_QUOTA_EXCEEDED`: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + sendQuotaExceeded: + summary: SEND_QUOTA_EXCEEDED + value: + error: + code: SEND_QUOTA_EXCEEDED + type: payment_required + message: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period. + suggestion: Upgrade your plan for a higher monthly send volume, send to fewer recipients, or wait for the billing period to reset. + docs: https://docs.brew.new/api-reference/api/credits "403": description: |- - `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. - - `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + accountSuspended: + summary: ACCOUNT_SUSPENDED + value: + error: + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- + `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. + + `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + audienceNotFound: + summary: AUDIENCE_NOT_FOUND + value: + error: + code: AUDIENCE_NOT_FOUND + type: not_found + message: No audience with that id exists in the brand. + suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. + docs: https://docs.brew.new/api-reference/api/errors + automationNotFound: + summary: AUTOMATION_NOT_FOUND + value: + error: + code: AUTOMATION_NOT_FOUND + type: not_found + message: No automation with that id exists in the brand. + suggestion: List automations with GET /v1/automations. + docs: https://docs.brew.new/api-reference/api/errors + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + `RUN_IN_PROGRESS`: A manual-audience run is already in progress for this automation. headers: x-request-id: schema: @@ -33916,44 +38137,35 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - accountSuspended: - summary: ACCOUNT_SUSPENDED - value: - error: - code: ACCOUNT_SUSPENDED - type: authorization_error - message: The organization behind the credential is suspended. - suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. - docs: https://docs.brew.new/api-reference/api/authentication - brandScopeMismatch: - summary: BRAND_SCOPE_MISMATCH + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT value: error: - code: BRAND_SCOPE_MISMATCH - type: authorization_error - message: A brand-scoped credential named a brand other than the one it is bound to. - suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientPermissions: - summary: INSUFFICIENT_PERMISSIONS + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS value: error: - code: INSUFFICIENT_PERMISSIONS - type: authorization_error - message: The credential lacks the permission scope the operation needs. - suggestion: Use an API key or session with the required permission. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientRole: - summary: INSUFFICIENT_ROLE + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + runInProgress: + summary: RUN_IN_PROGRESS value: error: - code: INSUFFICIENT_ROLE - type: authorization_error - message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." - suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. - docs: https://docs.brew.new/api-reference/api/authentication - "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + code: RUN_IN_PROGRESS + type: conflict + message: A manual-audience run is already in progress for this automation. + suggestion: Wait for the in-progress run to finish (or cancel it) before running again. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: "`EMAIL_IMAGES_MISSING`: The email shows an image on cdn.brew.new that does not exist, so it was not sent; `details.missingImages[]` lists each URL and the email it is in." headers: x-request-id: schema: @@ -33967,15 +38179,19 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandNotFound: - summary: BRAND_NOT_FOUND + emailImagesMissing: + summary: EMAIL_IMAGES_MISSING value: error: - code: BRAND_NOT_FOUND - type: not_found - message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - suggestion: List the brands this credential can reach with GET /v1/brands. + code: EMAIL_IMAGES_MISSING + type: invalid_request + message: The email shows an image on cdn.brew.new that does not exist, so it was not sent; `details.missingImages[]` lists each URL and the email it is in. + suggestion: Replace or remove each image listed in `details.missingImages` in the email, then send again. docs: https://docs.brew.new/api-reference/api/errors + details: + missingImages: + - emailId: em_8b1f0c2a + url: https://cdn.brew.new/generated/b1/hero-16x9.png "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -33990,7 +38206,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -34031,7 +38247,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -34059,38 +38275,115 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails + "503": + description: |- + `RUN_START_FAILED`: The workflow engine could not start the run right now. + + `SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + runStartFailed: + summary: RUN_START_FAILED + value: + error: + code: RUN_START_FAILED + type: service_unavailable + message: The workflow engine could not start the run right now. + suggestion: The workflow engine was momentarily busy; retry the run in a few seconds. + docs: https://docs.brew.new/api-reference/api/errors + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: fail_closed x-brew-credited: false - /v1/automations: - post: - operationId: createAutomation + x-brew-rate-policy: automation.runs.write + /v1/automations/audience-runs: + get: + operationId: listAudienceRuns tags: - Automations - summary: Create an automation + summary: List manual-audience runs description: |- - Deterministic create: the body carries the full graph (`{ name, triggerEventId?, nodes, connections }`). + Lists manual-audience launches (each "Run" of a manual-audience automation), newest first, under `{ data, pagination }`: status, recipient totals and the per-node funnel. - **Use when** you have minted every design a `sendEmail` node references (`generateEmail` first). Publish afterwards with `updateAutomation` (`{ "published": true }`), or launch a manual-audience automation with `runAutomation`. + **Use when** watching launches started with `runAutomation` or auditing an automation’s manual sends. - **Input** Trigger binding, exactly one: an EVENT automation passes `triggerEventId`; a MANUAL-AUDIENCE automation omits it and gives the trigger node `config: { "mode": "manualAudience", "audienceId": "…" }`. Typed conditions: every filter and condition-mode split uses a non-empty `conditions` array; each condition needs `field`, `type` (`string`, `number`, `date` or `bool`) and a canonical snake_case `operator`; unary operators omit `value`, comparisons require a type-correct scalar, a non-empty array, or an exact two-value `between` tuple (see `AutomationNode`). Every `sendEmail` node carries `emailId`, `emailVersionId` and `subject` (`previewText` is optional; the design's `` is the source of truth). `domainId` is optional at create time; publishing or running requires a verified one from `listDomains`. There is no `messageClass` key: the delivery class derives from the sending domain's `sendingPurpose` (a transactional-purpose domain sends with no unsubscribe link and delivers to unsubscribed contacts), so pick the class by picking the domain. Node ids are stored as sent, except the two shapes `NODE_ID_REPLACED` reports. `dryRun: true` validates without persisting. + **Input** `automationId` and `status` to narrow; `limit` and `cursor`. The page is a slice of the newest 200 launches. - **Returns** `201` with the bare `AutomationRow`; with `dryRun: true`, `200` with an `AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }` (`blockers[]` fail publish, `warnings[]` are advisory, `blockingIssues[]` lists per-node references the bound trigger or contact catalog cannot provide, each of which also fails publish; `valid` is false when any blocker or blocking issue is present). + **Returns** `200` with a page of `AudienceRun` rows. - **Errors** `400 AUTOMATION_GRAPH_INVALID` with `details.issues[]` (`kind`, `nodeId`, `message` per problem); `404 TRIGGER_EVENT_NOT_FOUND` for an unknown `triggerEventId`. + **Errors** `400 INVALID_REQUEST` for an unknown query key or status. - **See also** `updateAutomation`, `testAutomation`, `runAutomation`, `createTrigger`. + **See also** `getAudienceRun`, `runAutomation`, `pauseAudienceRun`, `listAutomationRuns`. parameters: - - name: Idempotency-Key - in: header + - schema: + type: string + minLength: 1 + maxLength: 64 + description: Only runs of this automation. required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: + description: Only runs of this automation. + name: automationId + in: query + - schema: + type: string + enum: + - queued + - scheduled + - running + - paused + - completed + - failed + - canceled + description: Only runs in this status. + required: false + description: Only runs in this status. + name: status + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + maxLength: 8192 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -34100,103 +38393,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AutomationsPostRequest" - examples: - deterministic: - summary: Create a trigger → sendEmail flow - value: - name: Welcome flow - description: Welcome new signups - triggerEventId: user_signed_up - nodes: - - id: trg - label: On signup - type: trigger - config: - actionType: trigger - - id: send_welcome - label: Welcome - type: sendEmail - config: - actionType: sendEmail - emailId: V1StGXR8_Z5jdHi6B-myT - emailVersionId: Ux8nW3qL1mP9zRv6TkYcB - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew, {{firstName | there}}! - previewText: Thanks for signing up — get started in 2 minutes. - fromName: Brew - replyTo: support@example.com - connections: - - from: trg - to: send_welcome - dryRun: - summary: Validate without persisting - value: - name: preview - triggerEventId: user_signed_up - nodes: - - id: trg - label: On signup - type: trigger - config: - actionType: trigger - connections: [] - dryRun: true responses: "200": - description: "Dry-run result (returned only for `dryRun: true`; no writes). `valid` is false when any `blockers[]` entry or any `blockingIssues[]` entry is present. Create and update both return `blockingIssues[]` (possibly empty)." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - X-RateLimit-Limit: - schema: - type: integer - description: Requests allowed in the current rolling rate limit window. - example: 100 - required: true - description: Requests allowed in the current rolling rate limit window. - X-RateLimit-Remaining: - schema: - type: integer - description: Requests remaining in the current rolling rate limit window. - example: 99 - required: true - description: Requests remaining in the current rolling rate limit window. - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - example: 1712592360 - required: true - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - content: - application/json: - schema: - $ref: "#/components/schemas/AutomationDryRunReport" - example: - valid: false - blockers: - - severity: error - message: Add at least one Send Email action - warnings: [] - blockingIssues: [] - nodeCounts: - sendEmail: 0 - wait: 0 - filter: 0 - split: 0 - "201": - description: Created. The bare automation row (draft, full graph). + description: A page of manual-audience runs. headers: x-request-id: schema: @@ -34229,46 +38428,26 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationRow" + $ref: "#/components/schemas/AudienceRunsListResponse" example: - automationId: M3nRk8LqW2xT5vBp9YcZd - automationVersionId: Rt4Vw1KyPb6HZq7Lm2Nx8 - triggerEventId: user_signed_up - name: Welcome flow - version: latest - published: false - isLive: false - nodes: - - id: trg - label: On signup - type: trigger - config: - actionType: trigger - - id: send_welcome - label: Welcome - type: sendEmail - config: - actionType: sendEmail - emailId: V1StGXR8_Z5jdHi6B-myT - emailVersionId: Ux8nW3qL1mP9zRv6TkYcB - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew, {{firstName | there}}! - previewText: Thanks for signing up — get started in 2 minutes. - fromName: Brew - replyTo: support@example.com - connections: - - from: trg - to: send_welcome - emailIds: - - V1StGXR8_Z5jdHi6B-myT - createdBy: Sam Rivera - createdByUserId: user_2xK9mPq4Rt7Vw1Yb - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' + data: + - audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb + automationId: M3nRk8LqW2xT5vBp9YcZd + audienceId: jn7b3s7fapqz8mjm12ekz1kxdx87yceg + audienceName: Active trial users + status: completed + totalRecipients: 12400 + sentCount: 12180 + failedCount: 0 + skippedCount: 220 + createdAt: '2026-04-08T12:34:56.789Z' + updatedAt: '2026-04-08T12:41:10.000Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- - `AUTOMATION_GRAPH_INVALID`: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. - `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. @@ -34285,20 +38464,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationGraphInvalid: - summary: AUTOMATION_GRAPH_INVALID - value: - error: - code: AUTOMATION_GRAPH_INVALID - type: invalid_request - message: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. - suggestion: Fix every issue reported in `details.issues` then resubmit. - docs: https://docs.brew.new/api-reference/api/errors - details: - issues: - - kind: missing_send_email_domain - nodeId: send-1 - message: sendEmail node send-1 names no domainId. brandIdRequired: summary: BRAND_ID_REQUIRED value: @@ -34423,10 +38588,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -34449,51 +38611,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - triggerEventNotFound: - summary: TRIGGER_EVENT_NOT_FOUND - value: - error: - code: TRIGGER_EVENT_NOT_FOUND - type: not_found - message: No trigger with that id exists in the brand. - suggestion: List triggers with GET /v1/automations/triggers. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -34508,7 +38625,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -34549,7 +38666,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -34579,53 +38696,38 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false + x-brew-rate-policy: automation.runs.read + /v1/automations/audience-runs/{audienceRunId}: get: - operationId: listAutomations + operationId: getAudienceRun tags: - Automations - summary: List automations + summary: Get a manual-audience run description: |- - Lists the brand’s automations under `{ data, pagination }` as LEAN rows: identity, `name`, `version`, `published`, `paused` and live-version state, `emailIds`, `updatedAt` and attribution; the graph is omitted. + Reads one manual-audience launch as the bare `AudienceRun` row: `status`, `approvalState`, `pauseReason` while paused, recipient totals, per-node `nodeStats`. A gradual run carries its authored ramp; the batch progress of each send step is on its own send (`nodeStats[].sendId`). - **Use when** browsing automations or finding the `automationId` to update, run or inspect. + **Use when** polling a launch to completion or reading its funnel. - **Input** `limit` and `cursor` (an opaque native cursor). + **Input** `audienceRunId` in the path. - **Returns** `200` with a page of lean automation rows. `createdBy` / `createdByUserId` name the creator (`createdBy: "API"` when an organization API key made it, `Former member` once a person leaves the brand, absent when the creator cannot be named); while live, `publishedBy` / `publishedByUserId` name whoever published the live version. + **Returns** `200` with the row. - **Errors** `400 INVALID_REQUEST` for an unknown query key or a malformed cursor. + **Errors** `404 AUDIENCE_RUN_NOT_FOUND` for an unknown or cross-brand id. - **See also** `getAutomation` (with `include=graph,versions`), `createAutomation`, `listAutomationRuns`. + **See also** `listAudienceRuns`, `pauseAudienceRun`, `resumeAudienceRun`, `cancelAudienceRun`, `listSends` (`?audienceRunId=`). parameters: - schema: type: string minLength: 1 - maxLength: 200 - description: Only automations whose name matches these words (full-text), still newest first. - required: false - description: Only automations whose name matches these words (full-text), still newest first. - name: search - in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - - schema: - type: string - minLength: 1 - maxLength: 512 - required: false - name: cursor - in: query + maxLength: 128 + description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). + example: arun_7acthhe99pq4448xh83gn54x8eennb + required: true + description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). + name: audienceRunId + in: path - name: X-Brand-Id in: header required: false @@ -34637,7 +38739,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of lean automation rows. + description: The manual-audience run. headers: x-request-id: schema: @@ -34670,22 +38772,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationsListResponse" + $ref: "#/components/schemas/AudienceRun" example: - data: - - automationId: M3nRk8LqW2xT5vBp9YcZd - automationVersionId: Rt4Vw1KyPb6HZq7Lm2Nx8 - triggerEventId: user_signed_up - name: Welcome flow - version: latest - published: true - emailIds: - - V1StGXR8_Z5jdHi6B-myT - updatedAt: '2026-04-08T12:00:00.000Z' - pagination: - limit: 100 - cursor: null - hasMore: false + audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb + automationId: M3nRk8LqW2xT5vBp9YcZd + audienceId: jn7b3s7fapqz8mjm12ekz1kxdx87yceg + audienceName: Active trial users + status: completed + totalRecipients: 12400 + sentCount: 12180 + failedCount: 0 + skippedCount: 220 + createdAt: '2026-04-08T12:34:56.789Z' + updatedAt: '2026-04-08T12:41:10.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -34828,7 +38927,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -34842,6 +38944,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + audienceRunNotFound: + summary: AUDIENCE_RUN_NOT_FOUND + value: + error: + code: AUDIENCE_RUN_NOT_FOUND + type: not_found + message: No manual-audience run with that id exists in the brand. + suggestion: List runs with GET /v1/automations/audience-runs. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -34938,46 +39049,36 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automations.read - /v1/automations/{automationId}: - get: - operationId: getAutomation + x-brew-rate-policy: automation.runs.read + /v1/automations/audience-runs/{audienceRunId}/pause: + post: + operationId: pauseAudienceRun tags: - Automations - summary: Get an automation + summary: Pause a manual-audience run description: |- - Reads one automation as the bare row, LEAN by default (graph omitted), with `published`, `paused`, `pausedAt`, the live version it serves, its creator (`createdBy`, `createdByUserId`) and, while live, the live version’s publisher (`publishedBy`, `publishedByUserId`). + Holds a running manual-audience launch at its next step boundary. Delivery already in flight for the current step completes; nothing further goes out until `resumeAudienceRun`. - **Use when** reading a flow before editing or publishing it, pinning `liveAutomationVersionId`, or inspecting its graph. + **Use when** a launch must stop temporarily, for example to fix a downstream step. - **Input** `automationId` in the path; `include=graph` inlines `nodes` and `connections` and `include=versions` the lean `{ version, automationVersionId }` history (comma-separate both). + **Input** `audienceRunId` in the path; no body. - **Returns** `200` with the row; the `include` fields are present only when requested. + **Returns** `200` with the run’s new `status` (`paused`). - **Errors** `404 AUTOMATION_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. + **Errors** `409 RUN_NOT_PAUSABLE` unless the run is running; `404 AUDIENCE_RUN_NOT_FOUND` for an unknown or cross-brand id. - **See also** `listAutomations`, `updateAutomation`, `testAutomation`, `runAutomation`. + **See also** `resumeAudienceRun`, `cancelAudienceRun`, `getAudienceRun`. parameters: - schema: type: string minLength: 1 - maxLength: 64 - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - example: M3nRk8LqW2xT5vBp9YcZd + maxLength: 128 + description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). + example: arun_7acthhe99pq4448xh83gn54x8eennb required: true - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - name: automationId + description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). + name: audienceRunId in: path - - schema: - type: string - description: "Comma-separated expansions: `graph` inlines `nodes`/`connections`; `versions` inlines the lean version history." - x-brew-include-tokens: - - versions - - graph - required: false - description: "Comma-separated expansions: `graph` inlines `nodes`/`connections`; `versions` inlines the lean version history." - name: include - in: query - name: X-Brand-Id in: header required: false @@ -34989,7 +39090,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The automation row. + description: The run's new status. headers: x-request-id: schema: @@ -35022,17 +39123,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationRow" + $ref: "#/components/schemas/AudienceRunActionResponse" example: - automationId: M3nRk8LqW2xT5vBp9YcZd - automationVersionId: Rt4Vw1KyPb6HZq7Lm2Nx8 - triggerEventId: user_signed_up - name: Welcome flow - version: latest - published: true - emailIds: - - V1StGXR8_Z5jdHi6B-myT - updatedAt: '2026-04-08T12:00:00.000Z' + audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb + status: paused "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -35176,7 +39270,7 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: @@ -35192,14 +39286,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationNotFound: - summary: AUTOMATION_NOT_FOUND + audienceRunNotFound: + summary: AUDIENCE_RUN_NOT_FOUND value: error: - code: AUTOMATION_NOT_FOUND + code: AUDIENCE_RUN_NOT_FOUND type: not_found - message: No automation with that id exists in the brand. - suggestion: List automations with GET /v1/automations. + message: No manual-audience run with that id exists in the brand. + suggestion: List runs with GET /v1/automations/audience-runs. docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND @@ -35210,6 +39304,53 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + + `RUN_NOT_PAUSABLE`: Only a running manual-audience run can be paused. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + runNotPausable: + summary: RUN_NOT_PAUSABLE + value: + error: + code: RUN_NOT_PAUSABLE + type: conflict + message: Only a running manual-audience run can be paused. + suggestion: Only a running run can be paused. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -35224,7 +39365,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -35265,7 +39406,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -35295,36 +39436,37 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: automations.read - patch: - operationId: updateAutomation + x-brew-rate-policy: automation.runs.write + /v1/automations/audience-runs/{audienceRunId}/resume: + post: + operationId: resumeAudienceRun tags: - Automations - summary: Update or publish an automation + summary: Resume a manual-audience run description: |- - One endpoint, two mutually exclusive modes: update the graph (a new `automationVersionId` on the same `automationId`) or change lifecycle state (`published`, `paused`). The modes cannot be combined: update first, then `PATCH { "published": true }`. + Continues a paused launch, or restarts a FAILED one under a fresh workflow run: it picks up at the first send step that never delivered and skips every step the failed run finished, so nothing is resent. `resumedFrom` reports which case ran. A launch held at the plan limit (`pauseReason: plan_limit`) resumes on its own once the monthly send allowance has room. - **Use when** editing a graph or changing runtime state of an automation created with `createAutomation`. Manual-audience runs are controlled with `pauseAudienceRun`, `resumeAudienceRun` and `cancelAudienceRun`, not here. + **Use when** a paused launch may continue, or a failed launch should finish its remaining steps. - **Input** Update: one or more of `name`, `description`, `nodes`, `connections`, `triggerEventId`, under the same typed condition contract as create (explicit `type`, canonical snake_case `operator`, no `value` for unary operators, type-correct values elsewhere); `dryRun: true` validates without persisting. Node ids are stored as sent, so renamed nodes keep their new ids, except the two shapes `NODE_ID_REPLACED` reports. Lifecycle: `published: true` promotes the stored latest version live (optionally pin `automationVersionId`; the graph is validated first), `published: false` unpublishes, `paused: true` or `false` freezes or resumes future send steps (`stopInFlight` also stops runs already in progress). + **Input** `audienceRunId` in the path; no body. - **Returns** the bare automation row, or with `dryRun: true` a `200 AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }`: `blockingIssues[]` lists per-node references the effective trigger or contact catalog cannot provide, and publish refuses every one of them, fallback or not (`fatal` says what the reference would do: `true` for filter or split conditions and triple-brace `{{{ }}}` body tokens would break the run, `false` for subject, previewText, fromName, replyTo and double-brace body tags would render empty); `valid` is false when any blocker or blocking issue is present. Updating a LIVE automation saves a draft and does not go live: the previously published version keeps serving until you republish, `published` describes the returned row, `isLive`, `liveVersion` and `liveAutomationVersionId` describe the automation, and `warnings[]` carries `DRAFT_SAVED_NOT_LIVE`. Publishing an automation whose integration trigger has not received its event on the current connection (a Shopify topic with no webhook) still succeeds, and `warnings[]` carries `TRIGGER_EVENT_NOT_RECEIVED` saying what to set up. + **Returns** `200` with the run’s new `status` and `resumedFrom` (`paused` or `failed`). - **Errors** `404 AUTOMATION_NOT_FOUND`, `AUTOMATION_VERSION_NOT_FOUND` (pin `liveAutomationVersionId`, a `versions[]` id, or a run row's `automationVersionId`), `TRIGGER_EVENT_NOT_FOUND`; `400 AUTOMATION_GRAPH_INVALID` (`details.issues[]`); `409 AUTOMATION_VERSION_CONFLICT` (re-read, reapply, retry); `422 PUBLISH_VALIDATION_FAILED` (`details.blockers[]`; a cdn.brew.new image a step would send that does not exist is a blocker, listed in `details.missingImages[]`, and a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access), `AUTOMATION_NOT_PUBLISHED` (nothing to unpublish; with `stopInFlight: true` on a draft, also no contacts in flight to stop), `AUTOMATION_NOT_PAUSABLE` (a manual-audience automation pauses through its runs). + **Errors** `409 RUN_NOT_PAUSED` when a running run is resumed; `409 RUN_NOT_RESUMABLE` when the run is held at the plan limit, or a failed run has no undelivered send step or a send step delivered to only part of its segment; `409 RUN_IN_PROGRESS` when another run of the automation is active; `402 SEND_QUOTA_EXCEEDED` when resuming would exceed the monthly send limit; `422 INVALID_REQUEST` when a send step is no longer valid; `503 RUN_STOP_FAILED` / `RUN_START_FAILED` when the workflow engine could not stop the previous run or start the new one (retry); `404 AUDIENCE_RUN_NOT_FOUND` for an unknown or cross-brand id. - **See also** `createAutomation`, `testAutomation`, `runAutomation`, `listAutomationRuns`. + **See also** `pauseAudienceRun`, `cancelAudienceRun`, `getAudienceRun`. parameters: - schema: type: string minLength: 1 - maxLength: 64 - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - example: M3nRk8LqW2xT5vBp9YcZd + maxLength: 128 + description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). + example: arun_7acthhe99pq4448xh83gn54x8eennb required: true - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - name: automationId + description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). + name: audienceRunId in: path - name: X-Brand-Id in: header @@ -35335,82 +39477,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AutomationsUpdateRequest" - examples: - rename: - summary: Rename - value: - name: Welcome flow v2 - graph: - summary: Replace the graph - value: - nodes: - - id: trg - label: On signup - type: trigger - config: - actionType: trigger - - id: send_welcome - label: Welcome - type: sendEmail - config: - actionType: sendEmail - emailId: V1StGXR8_Z5jdHi6B-myT - emailVersionId: Ux8nW3qL1mP9zRv6TkYcB - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew, {{firstName | there}}! - previewText: Thanks for signing up — get started in 2 minutes. - fromName: Brew - replyTo: support@example.com - connections: - - from: trg - to: send_welcome - dryRun: - summary: Validate a graph update without persisting - value: - nodes: - - id: trg - label: On signup - type: trigger - config: - actionType: trigger - - id: send_welcome - label: Welcome - type: sendEmail - config: - actionType: sendEmail - emailId: V1StGXR8_Z5jdHi6B-myT - emailVersionId: Ux8nW3qL1mP9zRv6TkYcB - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew, {{firstName | there}}! - previewText: Thanks for signing up — get started in 2 minutes. - fromName: Brew - replyTo: support@example.com - connections: - - from: trg - to: send_welcome - dryRun: true - publish: - summary: Publish (promote live) - value: - published: true - publishVersion: - summary: Publish a pinned version - value: - published: true - automationVersionId: Kx9Pb2Nt6Wq4Ym8Lz1Rv3 - unpublish: - summary: Unpublish - value: - published: false responses: "200": - description: "Updated / published / unpublished — the bare automation row. For `dryRun: true` update requests, an `AutomationDryRunReport` instead (no writes)." + description: The run's new status and what it resumed from. headers: x-request-id: schema: @@ -35443,74 +39512,13 @@ paths: content: application/json: schema: - anyOf: - - $ref: "#/components/schemas/AutomationRow" - - $ref: "#/components/schemas/AutomationDryRunReport" - examples: - updated: - summary: Updated / published — the automation row - value: - automationId: M3nRk8LqW2xT5vBp9YcZd - automationVersionId: Rt4Vw1KyPb6HZq7Lm2Nx8 - triggerEventId: user_signed_up - name: Welcome flow - version: latest - published: false - isLive: true - liveVersion: 3 - liveAutomationVersionId: Nt6Wq4Ym8Lz1Rv3Kx9Pb2 - nodes: - - id: trg - label: On signup - type: trigger - config: - actionType: trigger - - id: send_welcome - label: Welcome - type: sendEmail - config: - actionType: sendEmail - emailId: V1StGXR8_Z5jdHi6B-myT - emailVersionId: Ux8nW3qL1mP9zRv6TkYcB - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - subject: Welcome to Brew, {{firstName | there}}! - previewText: Thanks for signing up — get started in 2 minutes. - fromName: Brew - replyTo: support@example.com - connections: - - from: trg - to: send_welcome - emailIds: - - V1StGXR8_Z5jdHi6B-myT - createdBy: Sam Rivera - createdByUserId: user_2xK9mPq4Rt7Vw1Yb - publishedBy: Sam Rivera - publishedByUserId: user_2xK9mPq4Rt7Vw1Yb - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' - dryRun: - summary: "Dry-run validation report (`dryRun: true`)" - value: - valid: false - blockers: [] - warnings: [] - blockingIssues: - - nodeId: filter_a1b2 - nodeLabel: Paid plan - surface: filterCondition - variable: plan - reason: Condition on "plan", which the trigger doesn't provide - fatal: true - hasFallback: false - nodeCounts: - sendEmail: 1 - wait: 0 - filter: 1 - split: 0 + $ref: "#/components/schemas/AudienceRunActionResponse" + example: + audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb + status: running + resumedFrom: paused "400": description: |- - `AUTOMATION_GRAPH_INVALID`: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. - `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. @@ -35527,20 +39535,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationGraphInvalid: - summary: AUTOMATION_GRAPH_INVALID - value: - error: - code: AUTOMATION_GRAPH_INVALID - type: invalid_request - message: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. - suggestion: Fix every issue reported in `details.issues` then resubmit. - docs: https://docs.brew.new/api-reference/api/errors - details: - issues: - - kind: missing_send_email_domain - nodeId: send-1 - message: sendEmail node send-1 names no domainId. brandIdRequired: summary: BRAND_ID_REQUIRED value: @@ -35606,6 +39600,30 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`SEND_QUOTA_EXCEEDED`: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + sendQuotaExceeded: + summary: SEND_QUOTA_EXCEEDED + value: + error: + code: SEND_QUOTA_EXCEEDED + type: payment_required + message: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period. + suggestion: Upgrade your plan for a higher monthly send volume, send to fewer recipients, or wait for the billing period to reset. + docs: https://docs.brew.new/api-reference/api/credits "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -35666,13 +39684,9 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. - - `AUTOMATION_VERSION_NOT_FOUND`: The automation has no version with that id. + `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -35686,23 +39700,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationNotFound: - summary: AUTOMATION_NOT_FOUND - value: - error: - code: AUTOMATION_NOT_FOUND - type: not_found - message: No automation with that id exists in the brand. - suggestion: List automations with GET /v1/automations. - docs: https://docs.brew.new/api-reference/api/errors - automationVersionNotFound: - summary: AUTOMATION_VERSION_NOT_FOUND + audienceRunNotFound: + summary: AUDIENCE_RUN_NOT_FOUND value: error: - code: AUTOMATION_VERSION_NOT_FOUND + code: AUDIENCE_RUN_NOT_FOUND type: not_found - message: The automation has no version with that id. - suggestion: List versions with GET /v1/automations/{automationId}?include=versions. + message: No manual-audience run with that id exists in the brand. + suggestion: List runs with GET /v1/automations/audience-runs. docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND @@ -35713,46 +39718,17 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - triggerEventNotFound: - summary: TRIGGER_EVENT_NOT_FOUND - value: - error: - code: TRIGGER_EVENT_NOT_FOUND - type: not_found - message: No trigger with that id exists in the brand. - suggestion: List triggers with GET /v1/automations/triggers. - docs: https://docs.brew.new/api-reference/api/errors "409": - description: "`AUTOMATION_VERSION_CONFLICT`: The automation changed since the version you edited was read." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - automationVersionConflict: - summary: AUTOMATION_VERSION_CONFLICT - value: - error: - code: AUTOMATION_VERSION_CONFLICT - type: conflict - message: The automation changed since the version you edited was read. - suggestion: Read the latest automation version, reapply the intended changes, and retry the update. - docs: https://docs.brew.new/api-reference/api/errors - "422": description: |- - `AUTOMATION_NOT_PAUSABLE`: The automation runs against a manual audience, so it is paused through its runs, not the automation. + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - `AUTOMATION_NOT_PUBLISHED`: The automation is not live, so there is nothing to unpublish; with `stopInFlight: true` it also has no contacts in flight to stop. + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - `PUBLISH_VALIDATION_FAILED`: The saved graph cannot go live; `details.blockers[]` lists what publish requires. + `RUN_IN_PROGRESS`: A manual-audience run is already in progress for this automation. + + `RUN_NOT_PAUSED`: Only a paused manual-audience run can be resumed. + + `RUN_NOT_RESUMABLE`: The run cannot be resumed: a failed run needs an undelivered send step, and a run held at the plan limit resumes on its own. headers: x-request-id: schema: @@ -35766,39 +39742,51 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationNotPausable: - summary: AUTOMATION_NOT_PAUSABLE + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT value: error: - code: AUTOMATION_NOT_PAUSABLE - type: invalid_request - message: The automation runs against a manual audience, so it is paused through its runs, not the automation. - suggestion: Use the audience-run control operations to pause or cancel an in-flight run. + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + runInProgress: + summary: RUN_IN_PROGRESS + value: + error: + code: RUN_IN_PROGRESS + type: conflict + message: A manual-audience run is already in progress for this automation. + suggestion: Wait for the in-progress run to finish (or cancel it) before running again. docs: https://docs.brew.new/api-reference/api/errors - automationNotPublished: - summary: AUTOMATION_NOT_PUBLISHED + runNotPaused: + summary: RUN_NOT_PAUSED value: error: - code: AUTOMATION_NOT_PUBLISHED - type: invalid_request - message: "The automation is not live, so there is nothing to unpublish; with `stopInFlight: true` it also has no contacts in flight to stop." - suggestion: Check the live state with GET /v1/automations/{automationId}. + code: RUN_NOT_PAUSED + type: conflict + message: Only a paused manual-audience run can be resumed. + suggestion: Only a paused run can be resumed. docs: https://docs.brew.new/api-reference/api/errors - publishValidationFailed: - summary: PUBLISH_VALIDATION_FAILED + runNotResumable: + summary: RUN_NOT_RESUMABLE value: error: - code: PUBLISH_VALIDATION_FAILED - type: invalid_request - message: The saved graph cannot go live; `details.blockers[]` lists what publish requires. - suggestion: "Fix every blocker reported in `details.blockers` then PATCH again with { published: true }." + code: RUN_NOT_RESUMABLE + type: conflict + message: "The run cannot be resumed: a failed run needs an undelivered send step, and a run held at the plan limit resumes on its own." + suggestion: "For a failed run, start a new run for the remaining steps instead. A run held at the plan limit needs no call: it resumes once the monthly send allowance has room, or you can cancel it." docs: https://docs.brew.new/api-reference/api/errors - details: - blockers: - - nodeId: send-1 - nodeLabel: Welcome email - code: domain_not_verified - message: The sending domain is not verified. "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -35882,27 +39870,75 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + "503": + description: |- + `RUN_START_FAILED`: The workflow engine could not start the run right now. + + `RUN_STOP_FAILED`: The previous run of this cohort could not be stopped. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + runStartFailed: + summary: RUN_START_FAILED + value: + error: + code: RUN_START_FAILED + type: service_unavailable + message: The workflow engine could not start the run right now. + suggestion: The workflow engine was momentarily busy; retry the run in a few seconds. + docs: https://docs.brew.new/api-reference/api/errors + runStopFailed: + summary: RUN_STOP_FAILED + value: + error: + code: RUN_STOP_FAILED + type: service_unavailable + message: The previous run of this cohort could not be stopped. + suggestion: Retry the resume in a few seconds, or cancel the previous run first. + docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: automations.write - delete: - operationId: deleteAutomation + x-brew-rate-policy: automation.runs.write + /v1/automations/audience-runs/{audienceRunId}/cancel: + post: + operationId: cancelAudienceRun tags: - Automations - summary: Delete an automation - description: "Cascade-deletes the automation: every version row, runs, and logs. Referenced email DESIGNS are shared resources and always survive. Idempotent — an unknown id resolves with `deleted: false`." + summary: Cancel a manual-audience run + description: |- + Stops a manual-audience launch for good. Emails already sent are NOT recalled and a canceled run cannot be resumed. + + **Use when** a launch must not deliver its remaining steps. + + **Input** `audienceRunId` in the path; no body. + + **Returns** `200` with the run’s new `status` (`canceled`). + + **Errors** `409 RUN_NOT_CANCELLABLE` when the run already finished; `503 RUN_STOP_FAILED` when the workflow could not be stopped (retry); `404 AUDIENCE_RUN_NOT_FOUND` for an unknown or cross-brand id. + + **See also** `pauseAudienceRun`, `resumeAudienceRun`, `cancelAutomationRun` (one event or test run). parameters: - schema: type: string minLength: 1 - maxLength: 64 - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - example: M3nRk8LqW2xT5vBp9YcZd + maxLength: 128 + description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). + example: arun_7acthhe99pq4448xh83gn54x8eennb required: true - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - name: automationId + description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). + name: audienceRunId in: path - name: X-Brand-Id in: header @@ -35915,7 +39951,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: Deleted (or already gone). + description: The run's new status. headers: x-request-id: schema: @@ -35948,10 +39984,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationsDeleteResponse" + $ref: "#/components/schemas/AudienceRunActionResponse" example: - automationId: M3nRk8LqW2xT5vBp9YcZd - deleted: true + audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb + status: canceled "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -36095,7 +40131,7 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: @@ -36111,14 +40147,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationNotFound: - summary: AUTOMATION_NOT_FOUND + audienceRunNotFound: + summary: AUDIENCE_RUN_NOT_FOUND value: error: - code: AUTOMATION_NOT_FOUND + code: AUDIENCE_RUN_NOT_FOUND type: not_found - message: No automation with that id exists in the brand. - suggestion: List automations with GET /v1/automations. + message: No manual-audience run with that id exists in the brand. + suggestion: List runs with GET /v1/automations/audience-runs. docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND @@ -36129,6 +40165,53 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + + `RUN_NOT_CANCELLABLE`: The run already reached a terminal state. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + runNotCancellable: + summary: RUN_NOT_CANCELLABLE + value: + error: + code: RUN_NOT_CANCELLABLE + type: conflict + message: The run already reached a terminal state. + suggestion: The run has already finished and can no longer be canceled. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -36212,38 +40295,134 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`RUN_STOP_FAILED`: The previous run of this cohort could not be stopped." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + runStopFailed: + summary: RUN_STOP_FAILED + value: + error: + code: RUN_STOP_FAILED + type: service_unavailable + message: The previous run of this cohort could not be stopped. + suggestion: Retry the resume in a few seconds, or cancel the previous run first. + docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: automations.write - /v1/automations/{automationId}/test: - post: - operationId: testAutomation + x-brew-rate-policy: automation.runs.write + /v1/automations/runs: + get: + operationId: listAutomationRuns tags: - Automations - summary: Test an automation - description: Runs the saved automation END-TO-END through the real workflow in TEST mode — works on drafts and for both event and manual-audience automations; every node executes (filters/splits evaluate against `payload`, and `contact.` conditions read the test recipient’s contact unless `payload` supplies that field — a flat `contact.` key or a `contact` object — and the trigger’s contract keeps extra keys; wait nodes fast-forward). Pass `testRecipient` to DELIVER each send-email node’s email for real to that address (real subject/preview/sender name; verified configured sender domains are preserved, otherwise delivery uses the Brew test domain; customer addresses are never hit) — omit it for a silent dry-run with no mail. Test runs are excluded from live analytics rollups. Real test deliveries consume send usage; dry runs do not. Test payloads never update real contact records. Returns `202` with the started run id; follow per-node status via `GET /v1/automations/runs/{automationRunId}?include=logs`. Optional `scenario` controls simulate timed opens/clicks at engagement-filter nodes and force left/right percentage-split choices (waits use a virtual clock; controls stay separate from `payload`, and simulated events never enter live analytics or send quotas); the run row’s `testCoverage` reports visited nodes, traversed connections, evaluated or forced decisions, uncovered paths and simulation limitations. On a Brew-event (unsubscribe) trigger, omit payload to use Brew’s sample event; supplied fields replace the sample’s. The run never creates or edits a contact. + summary: List automation runs + description: |- + Lists recent runs (event executions and test runs), newest first, under `{ data, pagination }`. Rows are lean: identity, `automationVersionId`, `definitionHash`, `triggerInstanceId`, `mode`, `status` and timestamps. + + **Use when** auditing what an automation did, following a fire (`triggerInstanceId`) to its runs, or watching a test run. + + **Input** `automationId`, `triggerEventId`, `triggerInstanceId`, `recipientEmail` (one contact's run history, matched case-insensitively), `status`, `mode` (`live` | `test`), an inclusive `from`/`to` window on `startedAt`, `limit` and `cursor` (an opaque native cursor). + + **Returns** `200` with a page of run rows. + + **Errors** `400 INVALID_REQUEST` for an unknown query key, a reversed window or a malformed cursor. + + **See also** `getAutomationRun` (with `include=logs`), `cancelAutomationRun`, `listTriggerInstances`, `listSends`. parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: + - schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + maxLength: 64 + required: false + name: automationId + in: query - schema: type: string minLength: 1 + maxLength: 256 + required: false + name: triggerEventId + in: query + - schema: + type: string + minLength: 4 maxLength: 64 - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - example: M3nRk8LqW2xT5vBp9YcZd - required: true - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - name: automationId - in: path + required: false + name: triggerInstanceId + in: query + - schema: + type: string + maxLength: 254 + pattern: ^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$ + description: "Only runs for this recipient, matched case-insensitively against each run's stored `recipientEmail` column. A run written before that column existed carries the address only inside its trigger payload: the row still REPORTS `recipientEmail` (the projection reads the payload as a fallback), but the filter cannot reach it until `migration_mutations:backfillRecipientEmail` has run on the deployment." + required: false + description: "Only runs for this recipient, matched case-insensitively against each run's stored `recipientEmail` column. A run written before that column existed carries the address only inside its trigger payload: the row still REPORTS `recipientEmail` (the projection reads the payload as a fallback), but the filter cannot reach it until `migration_mutations:backfillRecipientEmail` has run on the deployment." + name: recipientEmail + in: query + - schema: + type: string + enum: + - queued + - running + - completed + - failed + - canceled + required: false + name: status + in: query + - schema: + type: string + enum: + - live + - test + required: false + name: mode + in: query + - schema: + type: string + format: date-time + required: false + name: from + in: query + - schema: + type: string + format: date-time + required: false + name: to + in: query + - schema: + anyOf: + - type: number + - type: string + type: integer + minimum: 1 + maximum: 100 + required: false + name: limit + in: query + description: Page size (1-100). Defaults to 100. + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 8192 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -36253,20 +40432,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: false - content: - application/json: - schema: - $ref: "#/components/schemas/AutomationTestRequest" - example: - payload: - email: qa@example.com - plan: pro - testRecipient: qa@example.com responses: - "202": - description: Test run started. + "200": + description: A page of runs. headers: x-request-id: schema: @@ -36299,18 +40467,27 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationRunStartedResponse" + $ref: "#/components/schemas/AutomationRunsListResponse" example: - automationRunIds: - - jh7acthhe99pq4448xh83gn54x8eennb - status: test_started - receivedAt: '2026-04-08T12:34:56.789Z' + data: + - automationRunId: jh7acthhe99pq4448xh83gn54x8eennb + automationId: M3nRk8LqW2xT5vBp9YcZd + automationVersionId: Kx9Pb2Nt6Wq4Ym8Lz1Rv3 + definitionHash: 9c1f3a6e2b7d4c8f0a5e1b9d3c7f2a6e4b8d0c1f5a9e3b7d2c6f0a4e8b1d5c9f + triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD + mode: live + status: completed + recipientEmail: jane@example.com + startedAt: '2026-04-08T12:34:56.789Z' + completedAt: '2026-04-08T12:35:10.120Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. - `INVALID_PAYLOAD`: The test payload does not match the trigger’s payload schema. - `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: @@ -36334,15 +40511,6 @@ paths: message: An organization-scoped credential called a brand-scoped operation without naming the brand. suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. docs: https://docs.brew.new/api-reference/api/authentication - invalidPayload: - summary: INVALID_PAYLOAD - value: - error: - code: INVALID_PAYLOAD - type: invalid_request - message: The test payload does not match the trigger’s payload schema. - suggestion: Send fields and types matching the trigger’s payloadSchema (see GET /v1/automations/triggers/{triggerEventId}). - docs: https://docs.brew.new/api-reference/api/errors invalidRequest: summary: INVALID_REQUEST value: @@ -36458,10 +40626,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. - - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -36475,15 +40640,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationNotFound: - summary: AUTOMATION_NOT_FOUND - value: - error: - code: AUTOMATION_NOT_FOUND - type: not_found - message: No automation with that id exists in the brand. - suggestion: List automations with GET /v1/automations. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -36493,42 +40649,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -36543,7 +40663,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -36584,7 +40704,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -36612,69 +40732,46 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: fail_closed + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.write - /v1/automations/{automationId}/run: - post: - operationId: runAutomation + x-brew-rate-policy: automation.runs.read + /v1/automations/runs/{automationRunId}: + get: + operationId: getAutomationRun tags: - Automations - summary: Run a manual-audience automation - description: "Launches a MANUAL-AUDIENCE automation against the audience bound to its trigger node. `dryRun: true` previews without sending; `scheduledAt` launches later, at most 1 year ahead. Percentage-based `gradualSend` delivers each send step in custom hour or calendar-day batches and supports manual pause/resume/cancel. `400` when `scheduledAt` is more than 1 year ahead, or a `gradualSend` plan exceeds 50,000 recipients, 30 batches, or 30 elapsed days. `422 EMAIL_IMAGES_MISSING` when a step would send a cdn.brew.new image that does not exist (`details.missingImages[]`). Returns `202` with the `audienceRunId`." + summary: Get an automation run + description: |- + Reads one run as the bare row: the automation and version it executed (`automationVersionId` is a valid publish pin, `definitionHash` the graph fingerprint), the fire behind it (`triggerInstanceId`), `mode`, `status` and timestamps. + + **Use when** debugging one contact’s journey or confirming a test run finished. + + **Input** `automationRunId` in the path; `include=logs` inlines the newest 100 execution log rows in `orderIndex` order and sets `logsTruncated` when older rows exist. Each node status change is its own row: a node that runs has a `running` row (no `completedAt`), then its terminal `completed` or `failed` row, or `skipped` when the run is canceled while it runs; a node a branch rules out has only a `skipped` row. A node’s outcome is its last row. + + **Returns** `200` with the row; `logs` is present only when requested. + + **Errors** `404 AUTOMATION_RUN_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. + + **See also** `listAutomationRuns`, `cancelAutomationRun`, `listSends` (`?automationRunId=`), `getEventsAnalytics` (`?automationRunId=`). parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: - type: string - minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 - schema: type: string minLength: 1 maxLength: 64 - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - example: M3nRk8LqW2xT5vBp9YcZd required: true - description: Automation id returned by `POST /v1/automations` and listed by `GET /v1/automations`. - name: automationId + name: automationRunId in: path + - schema: + type: string + description: "Expansions: `logs` inlines the newest 100 execution log rows, one per node status change (a node's outcome is its last row by `orderIndex`), and reports `logsTruncated`." + x-brew-include-tokens: + - logs + required: false + description: "Expansions: `logs` inlines the newest 100 execution log rows, one per node status change (a node's outcome is its last row by `orderIndex`), and reports `logsTruncated`." + name: include + in: query - name: X-Brand-Id in: header required: false @@ -36684,59 +40781,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: false - content: - application/json: - schema: - $ref: "#/components/schemas/AutomationRunRequest" - example: - scheduledAt: '2026-07-01T15:00:00.000Z' responses: "200": - description: Dry-run preview (no run started). recipientCount is who will receive the email. warnings carries RECIPIENTS_EXCLUDED when unsubscribed, suppressed or undeliverable contacts are skipped. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - X-RateLimit-Limit: - schema: - type: integer - description: Requests allowed in the current rolling rate limit window. - example: 100 - required: true - description: Requests allowed in the current rolling rate limit window. - X-RateLimit-Remaining: - schema: - type: integer - description: Requests remaining in the current rolling rate limit window. - example: 99 - required: true - description: Requests remaining in the current rolling rate limit window. - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - example: 1712592360 - required: true - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - content: - application/json: - schema: - $ref: "#/components/schemas/AutomationRunDryRunResponse" - example: - dryRun: true - automationId: M3nRk8LqW2xT5vBp9YcZd - audienceId: jn7b3s7fapqz8mjm12ekz1kxdx87yceg - audienceName: Active trial users - recipientCount: 12400 - sendNodeCount: 2 - "202": - description: 'Run launched (or scheduled). When org-level send approval is enabled the body additionally carries `approvalState: "pending"`.' + description: The run row. headers: x-request-id: schema: @@ -36769,13 +40816,28 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudienceAutomationRunStartedResponse" + $ref: "#/components/schemas/AutomationRun" example: - audienceRunId: ajh7acthhe99pq4448xh83gn54x8eennb + automationRunId: jh7acthhe99pq4448xh83gn54x8eennb automationId: M3nRk8LqW2xT5vBp9YcZd - status: queued - totalRecipients: 12400 - receivedAt: '2026-04-08T12:34:56.789Z' + automationVersionId: Kx9Pb2Nt6Wq4Ym8Lz1Rv3 + definitionHash: 9c1f3a6e2b7d4c8f0a5e1b9d3c7f2a6e4b8d0c1f5a9e3b7d2c6f0a4e8b1d5c9f + triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD + mode: live + status: completed + recipientEmail: jane@example.com + startedAt: '2026-04-08T12:34:56.789Z' + completedAt: '2026-04-08T12:35:10.120Z' + logs: + - automationRunId: jh7acthhe99pq4448xh83gn54x8eennb + nodeId: node_send_welcome + nodeName: Send welcome + nodeType: sendEmail + status: completed + orderIndex: 0 + startedAt: '2026-04-08T12:34:57.000Z' + completedAt: '2026-04-08T12:35:10.000Z' + logsTruncated: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -36853,36 +40915,12 @@ paths: invalidApiKey: summary: INVALID_API_KEY value: - error: - code: INVALID_API_KEY - type: authentication_error - message: The API key is malformed or unknown. - suggestion: Check the API key format and retry with a valid active key. - docs: https://docs.brew.new/api-reference/api/authentication - "402": - description: "`SEND_QUOTA_EXCEEDED`: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - sendQuotaExceeded: - summary: SEND_QUOTA_EXCEEDED - value: - error: - code: SEND_QUOTA_EXCEEDED - type: payment_required - message: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period. - suggestion: Upgrade your plan for a higher monthly send volume, send to fewer recipients, or wait for the billing period to reset. - docs: https://docs.brew.new/api-reference/api/credits + error: + code: INVALID_API_KEY + type: authentication_error + message: The API key is malformed or unknown. + suggestion: Check the API key format and retry with a valid active key. + docs: https://docs.brew.new/api-reference/api/authentication "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -36943,9 +40981,7 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. - - `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + `AUTOMATION_RUN_NOT_FOUND`: No automation run with that id exists in the brand. `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: @@ -36961,23 +40997,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceNotFound: - summary: AUDIENCE_NOT_FOUND - value: - error: - code: AUDIENCE_NOT_FOUND - type: not_found - message: No audience with that id exists in the brand. - suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. - docs: https://docs.brew.new/api-reference/api/errors - automationNotFound: - summary: AUTOMATION_NOT_FOUND + automationRunNotFound: + summary: AUTOMATION_RUN_NOT_FOUND value: error: - code: AUTOMATION_NOT_FOUND + code: AUTOMATION_RUN_NOT_FOUND type: not_found - message: No automation with that id exists in the brand. - suggestion: List automations with GET /v1/automations. + message: No automation run with that id exists in the brand. + suggestion: List runs with GET /v1/automations/runs. docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND @@ -36988,81 +41015,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - - `RUN_IN_PROGRESS`: A manual-audience run is already in progress for this automation. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - runInProgress: - summary: RUN_IN_PROGRESS - value: - error: - code: RUN_IN_PROGRESS - type: conflict - message: A manual-audience run is already in progress for this automation. - suggestion: Wait for the in-progress run to finish (or cancel it) before running again. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: "`EMAIL_IMAGES_MISSING`: The email shows an image on cdn.brew.new that does not exist, so it was not sent; `details.missingImages[]` lists each URL and the email it is in." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - emailImagesMissing: - summary: EMAIL_IMAGES_MISSING - value: - error: - code: EMAIL_IMAGES_MISSING - type: invalid_request - message: The email shows an image on cdn.brew.new that does not exist, so it was not sent; `details.missingImages[]` lists each URL and the email it is in. - suggestion: Replace or remove each image listed in `details.missingImages` in the email, then send again. - docs: https://docs.brew.new/api-reference/api/errors - details: - missingImages: - - emailId: em_8b1f0c2a - url: https://cdn.brew.new/generated/b1/hero-16x9.png "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -37077,7 +41029,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -37118,7 +41070,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -37146,115 +41098,37 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: |- - `RUN_START_FAILED`: The workflow engine could not start the run right now. - - `SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - runStartFailed: - summary: RUN_START_FAILED - value: - error: - code: RUN_START_FAILED - type: service_unavailable - message: The workflow engine could not start the run right now. - suggestion: The workflow engine was momentarily busy; retry the run in a few seconds. - docs: https://docs.brew.new/api-reference/api/errors - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: fail_closed + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.write - /v1/automations/audience-runs: - get: - operationId: listAudienceRuns + x-brew-rate-policy: automation.runs.read + /v1/automations/runs/{automationRunId}/cancel: + post: + operationId: cancelAutomationRun tags: - Automations - summary: List manual-audience runs + summary: Cancel an automation run description: |- - Lists manual-audience launches (each "Run" of a manual-audience automation), newest first, under `{ data, pagination }`: status, recipient totals and the per-node funnel. + Stops ONE run (an event execution or a test run). Marks it `canceled` (first-terminal-wins: a late completion from the dying workflow can never overwrite it), wakes a run parked on a wait node so it observes the cancel now, and terminates the durable workflow run so a step stuck retrying is freed too. - **Use when** watching launches started with `runAutomation` or auditing an automation’s manual sends. + **Use when** a contact must not receive the remaining steps of a journey that already started. - **Input** `automationId` and `status` to narrow; `limit` and `cursor`. The page is a slice of the newest 200 launches. + **Input** `automationRunId` in the path; an optional `reason` for the audit trail. Nothing further is sent; emails already delivered are NOT recalled and a canceled run cannot be resumed. - **Returns** `200` with a page of `AudienceRun` rows. + **Returns** `200` with the run’s new `status` and the `previousStatus` it held. - **Errors** `400 INVALID_REQUEST` for an unknown query key or status. + **Errors** `409 RUN_NOT_CANCELLABLE` when the run already finished (`completed`, `failed` or `canceled`); `404 AUTOMATION_RUN_NOT_FOUND` for an unknown or cross-brand id. - **See also** `getAudienceRun`, `runAutomation`, `pauseAudienceRun`, `listAutomationRuns`. + **See also** `getAutomationRun`, `cancelAudienceRun` (manual-audience launches), `updateAutomation` (`paused` / `published`). parameters: - schema: type: string minLength: 1 maxLength: 64 - description: Only runs of this automation. - required: false - description: Only runs of this automation. - name: automationId - in: query - - schema: - type: string - enum: - - queued - - scheduled - - running - - paused - - completed - - failed - - canceled - description: Only runs in this status. - required: false - description: Only runs in this status. - name: status - in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - - schema: - type: string - minLength: 1 - maxLength: 8192 - required: false - name: cursor - in: query + required: true + name: automationRunId + in: path - name: X-Brand-Id in: header required: false @@ -37264,9 +41138,17 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/AutomationRunCancelRequest" + example: + reason: Wrong segment — stopping before the second email. responses: "200": - description: A page of manual-audience runs. + description: The run's new status plus the status it held before the cancel. headers: x-request-id: schema: @@ -37299,24 +41181,11 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudienceRunsListResponse" + $ref: "#/components/schemas/AutomationRunCancelResponse" example: - data: - - audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb - automationId: M3nRk8LqW2xT5vBp9YcZd - audienceId: jn7b3s7fapqz8mjm12ekz1kxdx87yceg - audienceName: Active trial users - status: completed - totalRecipients: 12400 - sentCount: 12180 - failedCount: 0 - skippedCount: 220 - createdAt: '2026-04-08T12:34:56.789Z' - updatedAt: '2026-04-08T12:41:10.000Z' - pagination: - limit: 100 - cursor: null - hasMore: false + automationRunId: jh7acthhe99pq4448xh83gn54x8eennb + status: canceled + previousStatus: running "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -37459,7 +41328,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `AUTOMATION_RUN_NOT_FOUND`: No automation run with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -37473,6 +41345,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + automationRunNotFound: + summary: AUTOMATION_RUN_NOT_FOUND + value: + error: + code: AUTOMATION_RUN_NOT_FOUND + type: not_found + message: No automation run with that id exists in the brand. + suggestion: List runs with GET /v1/automations/runs. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -37482,6 +41363,53 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + + `RUN_NOT_CANCELLABLE`: The run already reached a terminal state. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + runNotCancellable: + summary: RUN_NOT_CANCELLABLE + value: + error: + code: RUN_NOT_CANCELLABLE + type: conflict + message: The run already reached a terminal state. + suggestion: The run has already finished and can no longer be canceled. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -37496,7 +41424,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -37537,7 +41465,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -37567,38 +41495,37 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: automation.runs.read - /v1/automations/audience-runs/{audienceRunId}: - get: - operationId: getAudienceRun + x-brew-rate-policy: automation.runs.write + /v1/automations/triggers/brew-events: + post: + operationId: ensureBrewEventTrigger tags: - Automations - summary: Get a manual-audience run + summary: Get or create an unsubscribe trigger description: |- - Reads one manual-audience launch as the bare `AudienceRun` row: `status`, `approvalState`, `pauseReason` while paused, recipient totals, per-node `nodeStats`. A gradual run carries its authored ramp; the batch progress of each send step is on its own send (`nodeStats[].sendId`). + Returns the brand’s trigger for one Brew event scope, creating it the first time. Brew events start automations from things that happen inside Brew. The first is `contact.unsubscribed`: a contact unsubscribed with an email’s unsubscribe link or their mail app’s one-click button (never a bounce, complaint, import or API edit). - **Use when** polling a launch to completion or reading its funnel. + **Use when** an automation should start when a contact unsubscribes, from one marketing domain’s list or from every email the brand sends. - **Input** `audienceRunId` in the path. + **Input** `{ event: "contact.unsubscribed", scope: "domain", domainId }` for one marketing domain’s list (ids from `listDomains`), or `{ event: "contact.unsubscribed", scope: "all" }` for every email. An unsubscribe from all clicked in one of a domain’s emails also starts that domain’s trigger, while the contact was still on its list. - **Returns** `200` with the row. + **Returns** `201` with the new trigger row, or `200` with the existing one (`created: false`). Brew owns its title and payload, so the row is immutable and never fires over the API. The contact has just opted out, so an automation on this trigger sends only a message they need (such as confirming the unsubscribe) from a transactional domain, or mail from another marketing domain they are still on; publish refuses a flow none of whose emails could reach them. - **Errors** `404 AUDIENCE_RUN_NOT_FOUND` for an unknown or cross-brand id. + **Errors** `400 INVALID_REQUEST` when `domainId` is missing for `scope: "domain"` or sent for `scope: "all"`; `404 DOMAIN_NOT_FOUND` for an unknown or cross-brand domain; `422 DOMAIN_PURPOSE_NOT_ALLOWED` for a transactional domain, which owns no list; `409 TRIGGER_LIMIT_REACHED` when the brand already holds the maximum number of triggers. - **See also** `listAudienceRuns`, `pauseAudienceRun`, `resumeAudienceRun`, `cancelAudienceRun`, `listSends` (`?audienceRunId=`). + **See also** `createAutomation`, `fireTrigger`, `listDomains`. parameters: - - schema: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: type: string minLength: 1 - maxLength: 128 - description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). - example: arun_7acthhe99pq4448xh83gn54x8eennb - required: true - description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). - name: audienceRunId - in: path + maxLength: 100 + example: api-request-2026-04-08-001 - name: X-Brand-Id in: header required: false @@ -37608,9 +41535,19 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BrewEventTriggersPostRequest" + example: + event: contact.unsubscribed + scope: domain + domainId: jd7f0c2a8w1x9m4n6p3q5r8s2t responses: "200": - description: The manual-audience run. + description: The trigger already existed for this scope. headers: x-request-id: schema: @@ -37643,19 +41580,144 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudienceRun" + $ref: "#/components/schemas/BrewEventTriggerSaved" example: - audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb - automationId: M3nRk8LqW2xT5vBp9YcZd - audienceId: jn7b3s7fapqz8mjm12ekz1kxdx87yceg - audienceName: Active trial users - status: completed - totalRecipients: 12400 - sentCount: 12180 - failedCount: 0 - skippedCount: 220 - createdAt: '2026-04-08T12:34:56.789Z' - updatedAt: '2026-04-08T12:41:10.000Z' + triggerEventId: k57abc123unsub + title: Unsubscribed from news.acme.com + description: Starts when a contact unsubscribes from emails sent from news.acme.com, from an email's unsubscribe link or the mail app's unsubscribe button. Unsubscribing from all emails in one of those emails starts it too. + provider: brew_event + providerEventKey: contact.unsubscribed + brewEvent: + event: contact.unsubscribed + scope: domain + domainHost: news.acme.com + payloadSchema: + fields: + - key: email + required: true + type: string + - children: + - key: scope + required: true + type: string + - key: domain + required: false + type: string + - key: method + required: true + type: string + - key: reasons + required: true + type: array + itemType: string + - key: reasonText + required: false + type: string + - key: emailId + required: false + type: string + - key: emailTitle + required: false + type: string + - key: sendId + required: false + type: string + - key: at + required: true + type: string + key: unsubscribe + required: true + type: object + type: object + createdAt: '2026-10-13T12:00:00.000Z' + updatedAt: '2026-10-13T12:00:00.000Z' + created: false + "201": + description: Created. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/BrewEventTriggerSaved" + example: + triggerEventId: k57abc123unsub + title: Unsubscribed from news.acme.com + description: Starts when a contact unsubscribes from emails sent from news.acme.com, from an email's unsubscribe link or the mail app's unsubscribe button. Unsubscribing from all emails in one of those emails starts it too. + provider: brew_event + providerEventKey: contact.unsubscribed + brewEvent: + event: contact.unsubscribed + scope: domain + domainHost: news.acme.com + payloadSchema: + fields: + - key: email + required: true + type: string + - children: + - key: scope + required: true + type: string + - key: domain + required: false + type: string + - key: method + required: true + type: string + - key: reasons + required: true + type: array + itemType: string + - key: reasonText + required: false + type: string + - key: emailId + required: false + type: string + - key: emailTitle + required: false + type: string + - key: sendId + required: false + type: string + - key: at + required: true + type: string + key: unsubscribe + required: true + type: object + type: object + createdAt: '2026-10-13T12:00:00.000Z' + updatedAt: '2026-10-13T12:00:00.000Z' + created: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -37799,9 +41861,9 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. headers: x-request-id: schema: @@ -37815,15 +41877,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceRunNotFound: - summary: AUDIENCE_RUN_NOT_FOUND - value: - error: - code: AUDIENCE_RUN_NOT_FOUND - type: not_found - message: No manual-audience run with that id exists in the brand. - suggestion: List runs with GET /v1/automations/audience-runs. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -37833,6 +41886,86 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + domainNotFound: + summary: DOMAIN_NOT_FOUND + value: + error: + code: DOMAIN_NOT_FOUND + type: not_found + message: No domain with that id exists in the brand. + suggestion: Use GET /v1/domains to choose a verified domain. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + + `TRIGGER_LIMIT_REACHED`: The brand already holds the maximum number of trigger events. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + triggerLimitReached: + summary: TRIGGER_LIMIT_REACHED + value: + error: + code: TRIGGER_LIMIT_REACHED + type: conflict + message: The brand already holds the maximum number of trigger events. + suggestion: Delete an unused trigger with `DELETE /v1/automations/triggers/{triggerEventId}`, then retry. Each brand holds at most 100. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: "`DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional)." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + domainPurposeNotAllowed: + summary: DOMAIN_PURPOSE_NOT_ALLOWED + value: + error: + code: DOMAIN_PURPOSE_NOT_ALLOWED + type: invalid_request + message: The sending domain is admitted for a different purpose than this send (marketing vs transactional). + suggestion: Use a marketing domain for campaigns and audience sends. For transactional flows, wire the design into a published automation with a trigger and fire POST /v1/automations/triggers/{triggerEventId}/fire. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -37847,7 +41980,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -37888,7 +42021,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -37918,38 +42051,31 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: automation.runs.read - /v1/automations/audience-runs/{audienceRunId}/pause: + x-brew-rate-policy: triggers.write + /v1/automations/triggers: post: - operationId: pauseAudienceRun + operationId: createTrigger tags: - Automations - summary: Pause a manual-audience run + summary: Create a trigger description: |- - Holds a running manual-audience launch at its next step boundary. Delivery already in flight for the current step completes; nothing further goes out until `resumeAudienceRun`. - - **Use when** a launch must stop temporarily, for example to fix a downstream step. - - **Input** `audienceRunId` in the path; no body. - - **Returns** `200` with the run’s new `status` (`paused`). + Creates a custom trigger event definition — the contract automations subscribe to. Body carries `{ title, description?, key?, payloadSchema }`; the server mints `triggerEventId` and hardcodes `provider: "brew_api"`. Integration triggers (clerk, stripe, shopify, …) are provisioned by the corresponding integration only. - **Errors** `409 RUN_NOT_PAUSABLE` unless the run is running; `404 AUDIENCE_RUN_NOT_FOUND` for an unknown or cross-brand id. + `key` is an optional stable developer name (`order.paid`) for identifying the trigger; `POST /v1/automations/triggers/by-key/{triggerKey}/fire` fires by it. The key is unique in the brand (`409 TRIGGER_KEY_TAKEN`), and `kit`, `by-key`, `brew.*` and non-starter-kit `auth.*` keys are reserved (`400 TRIGGER_KEY_RESERVED`). - **See also** `resumeAudienceRun`, `cancelAudienceRun`, `getAudienceRun`. + `payloadSchema.fields` MUST declare `{ key: "email", type: "string", required: true }` so downstream automations can resolve a recipient. Returns `201` with the bare trigger row. parameters: - - schema: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: type: string minLength: 1 - maxLength: 128 - description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). - example: arun_7acthhe99pq4448xh83gn54x8eennb - required: true - description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). - name: audienceRunId - in: path + maxLength: 100 + example: api-request-2026-04-08-001 - name: X-Brand-Id in: header required: false @@ -37959,9 +42085,27 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TriggersPostRequest" + example: + title: User Signed Up + description: Fires when a user completes signup. + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + - key: firstName + type: string + required: false responses: - "200": - description: The run's new status. + "201": + description: Created. The bare trigger row. headers: x-request-id: schema: @@ -37994,15 +42138,32 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudienceRunActionResponse" + $ref: "#/components/schemas/TriggerRow" example: - audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb - status: paused + triggerEventId: user_signed_up + title: User Signed Up + description: Fires when a user completes signup. + provider: brew_api + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + - key: firstName + type: string + required: false + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + + `PAYLOAD_SCHEMA_EMAIL_REQUIRED`: A trigger payload schema must declare a required string `email` field. + + `TRIGGER_KEY_RESERVED`: That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit. headers: x-request-id: schema: @@ -38034,6 +42195,24 @@ paths: message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." suggestion: Fix the field reported in `param` and retry. docs: https://docs.brew.new/api-reference/api/errors + payloadSchemaEmailRequired: + summary: PAYLOAD_SCHEMA_EMAIL_REQUIRED + value: + error: + code: PAYLOAD_SCHEMA_EMAIL_REQUIRED + type: invalid_request + message: A trigger payload schema must declare a required string `email` field. + suggestion: 'Add { key: "email", type: "string", required: true } to payloadSchema.fields before retrying.' + docs: https://docs.brew.new/api-reference/api/errors + triggerKeyReserved: + summary: TRIGGER_KEY_RESERVED + value: + error: + code: TRIGGER_KEY_RESERVED + type: invalid_request + message: "That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit." + suggestion: Pick a key outside `brew.*`, `kit` and `by-key`. Under `auth.*` only the starter-kit auth keys are allowed. + docs: https://docs.brew.new/api-reference/api/errors "401": description: |- `API_KEY_REVOKED`: The API key was revoked. @@ -38140,10 +42319,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. - - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -38157,15 +42333,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceRunNotFound: - summary: AUDIENCE_RUN_NOT_FOUND - value: - error: - code: AUDIENCE_RUN_NOT_FOUND - type: not_found - message: No manual-audience run with that id exists in the brand. - suggestion: List runs with GET /v1/automations/audience-runs. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -38181,7 +42348,11 @@ paths: `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - `RUN_NOT_PAUSABLE`: Only a running manual-audience run can be paused. + `TRIGGER_ALREADY_EXISTS`: A trigger with that id already exists in the brand. + + `TRIGGER_KEY_TAKEN`: Another trigger in the brand already uses that key. + + `TRIGGER_LIMIT_REACHED`: The brand already holds the maximum number of trigger events. headers: x-request-id: schema: @@ -38213,14 +42384,56 @@ paths: message: A request with this Idempotency-Key is still executing. suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency - runNotPausable: - summary: RUN_NOT_PAUSABLE + triggerAlreadyExists: + summary: TRIGGER_ALREADY_EXISTS value: error: - code: RUN_NOT_PAUSABLE + code: TRIGGER_ALREADY_EXISTS type: conflict - message: Only a running manual-audience run can be paused. - suggestion: Only a running run can be paused. + message: A trigger with that id already exists in the brand. + suggestion: Reuse the existing trigger, or choose a different triggerEventId. + docs: https://docs.brew.new/api-reference/api/errors + triggerKeyTaken: + summary: TRIGGER_KEY_TAKEN + value: + error: + code: TRIGGER_KEY_TAKEN + type: conflict + message: Another trigger in the brand already uses that key. + suggestion: "Choose another key, or clear it on the trigger that holds it with PATCH /v1/automations/triggers/{triggerEventId} and `key: null`." + docs: https://docs.brew.new/api-reference/api/errors + triggerLimitReached: + summary: TRIGGER_LIMIT_REACHED + value: + error: + code: TRIGGER_LIMIT_REACHED + type: conflict + message: The brand already holds the maximum number of trigger events. + suggestion: Delete an unused trigger with `DELETE /v1/automations/triggers/{triggerEventId}`, then retry. Each brand holds at most 100. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: "`PAYLOAD_SCHEMA_EMAIL_REQUIRED`: A trigger payload schema must declare a required string `email` field." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + payloadSchemaEmailRequired: + summary: PAYLOAD_SCHEMA_EMAIL_REQUIRED + value: + error: + code: PAYLOAD_SCHEMA_EMAIL_REQUIRED + type: invalid_request + message: A trigger payload schema must declare a required string `email` field. + suggestion: 'Add { key: "email", type: "string", required: true } to payloadSchema.fields before retrying.' docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -38236,7 +42449,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -38277,7 +42490,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -38309,36 +42522,42 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: automation.runs.write - /v1/automations/audience-runs/{audienceRunId}/resume: - post: - operationId: resumeAudienceRun + get: + operationId: listTriggers tags: - Automations - summary: Resume a manual-audience run + summary: List triggers description: |- - Continues a paused launch, or restarts a FAILED one under a fresh workflow run: it picks up at the first send step that never delivered and skips every step the failed run finished, so nothing is resent. `resumedFrom` reports which case ran. A launch held at the plan limit (`pauseReason: plan_limit`) resumes on its own once the monthly send allowance has room. + Lists every trigger the brand has under `{ data, pagination }`: `provider: "brew_api"` for API-created customs, `provider: "clerk" | "stripe" | …` for integration-provisioned triggers, and `provider: "brew_event"` for the unsubscribe triggers Brew starts itself. Each row carries its `payloadSchema`. - **Use when** a paused launch may continue, or a failed launch should finish its remaining steps. + **Use when** finding the `triggerEventId` to fire, to wire an automation to, or to read the typed contract of. - **Input** `audienceRunId` in the path; no body. + **Input** `limit` and `cursor`. - **Returns** `200` with the run’s new `status` and `resumedFrom` (`paused` or `failed`). + **Returns** `200` with a page of trigger rows. - **Errors** `409 RUN_NOT_PAUSED` when a running run is resumed; `409 RUN_NOT_RESUMABLE` when the run is held at the plan limit, or a failed run has no undelivered send step or a send step delivered to only part of its segment; `409 RUN_IN_PROGRESS` when another run of the automation is active; `402 SEND_QUOTA_EXCEEDED` when resuming would exceed the monthly send limit; `422 INVALID_REQUEST` when a send step is no longer valid; `503 RUN_STOP_FAILED` / `RUN_START_FAILED` when the workflow engine could not stop the previous run or start the new one (retry); `404 AUDIENCE_RUN_NOT_FOUND` for an unknown or cross-brand id. + **Errors** `400 INVALID_REQUEST` for an unknown query key. - **See also** `pauseAudienceRun`, `cancelAudienceRun`, `getAudienceRun`. + **See also** `getTrigger` (with `include=skill`), `createTrigger`, `fireTrigger`, `listTriggerInstances`. parameters: + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 - schema: type: string minLength: 1 - maxLength: 128 - description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). - example: arun_7acthhe99pq4448xh83gn54x8eennb - required: true - description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). - name: audienceRunId - in: path + maxLength: 512 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -38350,7 +42569,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The run's new status and what it resumed from. + description: A page of trigger rows. headers: x-request-id: schema: @@ -38383,11 +42602,28 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudienceRunActionResponse" + $ref: "#/components/schemas/TriggersListResponse" example: - audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb - status: running - resumedFrom: paused + data: + - triggerEventId: user_signed_up + title: User Signed Up + description: Fires when a user completes signup. + provider: brew_api + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + - key: firstName + type: string + required: false + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -38471,30 +42707,6 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication - "402": - description: "`SEND_QUOTA_EXCEEDED`: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - sendQuotaExceeded: - summary: SEND_QUOTA_EXCEEDED - value: - error: - code: SEND_QUOTA_EXCEEDED - type: payment_required - message: The send or manual-audience run would exceed the plan’s monthly email volume. A send must fit at launch and draws from the allowance chunk by chunk as it delivers; `reserved` is only the batch going out right now (`details` carries `used`, `reserved`, `remaining`, `limit`, `planKey` and `requested`, and `periodEnd` when known). The window resets with the billing period. - suggestion: Upgrade your plan for a higher monthly send volume, send to fewer recipients, or wait for the billing period to reset. - docs: https://docs.brew.new/api-reference/api/credits "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -38554,10 +42766,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. - - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -38571,15 +42780,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceRunNotFound: - summary: AUDIENCE_RUN_NOT_FOUND - value: - error: - code: AUDIENCE_RUN_NOT_FOUND - type: not_found - message: No manual-audience run with that id exists in the brand. - suggestion: List runs with GET /v1/automations/audience-runs. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -38589,75 +42789,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - - `RUN_IN_PROGRESS`: A manual-audience run is already in progress for this automation. - - `RUN_NOT_PAUSED`: Only a paused manual-audience run can be resumed. - - `RUN_NOT_RESUMABLE`: The run cannot be resumed: a failed run needs an undelivered send step, and a run held at the plan limit resumes on its own. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - runInProgress: - summary: RUN_IN_PROGRESS - value: - error: - code: RUN_IN_PROGRESS - type: conflict - message: A manual-audience run is already in progress for this automation. - suggestion: Wait for the in-progress run to finish (or cancel it) before running again. - docs: https://docs.brew.new/api-reference/api/errors - runNotPaused: - summary: RUN_NOT_PAUSED - value: - error: - code: RUN_NOT_PAUSED - type: conflict - message: Only a paused manual-audience run can be resumed. - suggestion: Only a paused run can be resumed. - docs: https://docs.brew.new/api-reference/api/errors - runNotResumable: - summary: RUN_NOT_RESUMABLE - value: - error: - code: RUN_NOT_RESUMABLE - type: conflict - message: "The run cannot be resumed: a failed run needs an undelivered send step, and a run held at the plan limit resumes on its own." - suggestion: "For a failed run, start a new run for the remaining steps instead. A run held at the plan limit needs no call: it resumes once the monthly send allowance has room, or you can cancel it." - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -38672,7 +42803,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -38713,7 +42844,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -38741,76 +42872,49 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: |- - `RUN_START_FAILED`: The workflow engine could not start the run right now. - - `RUN_STOP_FAILED`: The previous run of this cohort could not be stopped. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - runStartFailed: - summary: RUN_START_FAILED - value: - error: - code: RUN_START_FAILED - type: service_unavailable - message: The workflow engine could not start the run right now. - suggestion: The workflow engine was momentarily busy; retry the run in a few seconds. - docs: https://docs.brew.new/api-reference/api/errors - runStopFailed: - summary: RUN_STOP_FAILED - value: - error: - code: RUN_STOP_FAILED - type: service_unavailable - message: The previous run of this cohort could not be stopped. - suggestion: Retry the resume in a few seconds, or cancel the previous run first. - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.write - /v1/automations/audience-runs/{audienceRunId}/cancel: - post: - operationId: cancelAudienceRun + x-brew-rate-policy: triggers.read + /v1/automations/triggers/{triggerEventId}: + get: + operationId: getTrigger tags: - Automations - summary: Cancel a manual-audience run + summary: Get a trigger description: |- - Stops a manual-audience launch for good. Emails already sent are NOT recalled and a canceled run cannot be resumed. + Reads one trigger as the bare row: `triggerEventId`, `title`, `provider`, `payloadSchema` and timestamps. - **Use when** a launch must not deliver its remaining steps. + **Use when** inspecting the payload shape before firing, or generating an integration brief for the fire endpoint. - **Input** `audienceRunId` in the path; no body. + **Input** `triggerEventId` in the path (URL-encode composite integration ids); `include=skill` adds `skill`, a SKILL.md-shaped wiring brief built from the stored contract when one exists. - **Returns** `200` with the run’s new `status` (`canceled`). + **Returns** `200` with the row; `skill` is present only when requested. - **Errors** `409 RUN_NOT_CANCELLABLE` when the run already finished; `503 RUN_STOP_FAILED` when the workflow could not be stopped (retry); `404 AUDIENCE_RUN_NOT_FOUND` for an unknown or cross-brand id. + **Errors** `404 TRIGGER_EVENT_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. - **See also** `pauseAudienceRun`, `resumeAudienceRun`, `cancelAutomationRun` (one event or test run). + **See also** `listTriggers`, `getTriggerContract`, `fireTrigger`, `checkTriggerReady`. parameters: - schema: type: string minLength: 1 - maxLength: 128 - description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). - example: arun_7acthhe99pq4448xh83gn54x8eennb + maxLength: 256 + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + example: user_signed_up required: true - description: The audience run id (from `POST /v1/automations/{automationId}/run` or `GET /v1/automations/audience-runs`), passed exactly as returned. A launch with an `Idempotency-Key` returns a longer id (`arun_` plus 64 hex characters). - name: audienceRunId + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + name: triggerEventId in: path + - schema: + type: string + description: "Expansions: `skill` adds a SKILL.md-shaped wiring brief for the fire endpoint." + x-brew-include-tokens: + - skill + required: false + description: "Expansions: `skill` adds a SKILL.md-shaped wiring brief for the fire endpoint." + name: include + in: query - name: X-Brand-Id in: header required: false @@ -38822,7 +42926,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The run's new status. + description: The trigger row. headers: x-request-id: schema: @@ -38855,10 +42959,23 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudienceRunActionResponse" + $ref: "#/components/schemas/TriggerRow" example: - audienceRunId: arun_7acthhe99pq4448xh83gn54x8eennb - status: canceled + triggerEventId: user_signed_up + title: User Signed Up + description: Fires when a user completes signup. + provider: brew_api + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + - key: firstName + type: string + required: false + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -39002,9 +43119,9 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUDIENCE_RUN_NOT_FOUND`: No manual-audience run with that id exists in the brand. - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -39018,15 +43135,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceRunNotFound: - summary: AUDIENCE_RUN_NOT_FOUND - value: - error: - code: AUDIENCE_RUN_NOT_FOUND - type: not_found - message: No manual-audience run with that id exists in the brand. - suggestion: List runs with GET /v1/automations/audience-runs. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -39036,52 +43144,14 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - - `RUN_NOT_CANCELLABLE`: The run already reached a terminal state. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - runNotCancellable: - summary: RUN_NOT_CANCELLABLE + triggerEventNotFound: + summary: TRIGGER_EVENT_NOT_FOUND value: error: - code: RUN_NOT_CANCELLABLE - type: conflict - message: The run already reached a terminal state. - suggestion: The run has already finished and can no longer be canceled. + code: TRIGGER_EVENT_NOT_FOUND + type: not_found + message: No trigger with that id exists in the brand. + suggestion: List triggers with GET /v1/automations/triggers. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -39097,7 +43167,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -39138,7 +43208,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -39166,134 +43236,28 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`RUN_STOP_FAILED`: The previous run of this cohort could not be stopped." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - runStopFailed: - summary: RUN_STOP_FAILED - value: - error: - code: RUN_STOP_FAILED - type: service_unavailable - message: The previous run of this cohort could not be stopped. - suggestion: Retry the resume in a few seconds, or cancel the previous run first. - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.write - /v1/automations/runs: - get: - operationId: listAutomationRuns + x-brew-rate-policy: triggers.read + patch: + operationId: updateTrigger tags: - Automations - summary: List automation runs - description: |- - Lists recent runs (event executions and test runs), newest first, under `{ data, pagination }`. Rows are lean: identity, `automationVersionId`, `definitionHash`, `triggerInstanceId`, `mode`, `status` and timestamps. - - **Use when** auditing what an automation did, following a fire (`triggerInstanceId`) to its runs, or watching a test run. - - **Input** `automationId`, `triggerEventId`, `triggerInstanceId`, `recipientEmail` (one contact's run history, matched case-insensitively), `status`, `mode` (`live` | `test`), an inclusive `from`/`to` window on `startedAt`, `limit` and `cursor` (an opaque native cursor). - - **Returns** `200` with a page of run rows. - - **Errors** `400 INVALID_REQUEST` for an unknown query key, a reversed window or a malformed cursor. - - **See also** `getAutomationRun` (with `include=logs`), `cancelAutomationRun`, `listTriggerInstances`, `listSends`. + summary: Update a trigger + description: "Updates one or more editable fields (`title`, `description`, `key`, `payloadSchema`). `key: null` clears the key; a key another trigger in the brand holds is `409 TRIGGER_KEY_TAKEN`. Whether a trigger fires is governed solely by `automation.published` on each wired automation — there is no per-trigger off-switch. Integration-provisioned triggers are immutable here (`422 TRIGGER_IMMUTABLE`). While a published automation uses the trigger, a `payloadSchema` change that would stop it from running (removing or retyping a field it reads, or declaring the shape of an open object or list so one of its merge tags no longer resolves) is refused with `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`. Returns the bare updated row." parameters: - - schema: - type: string - minLength: 1 - maxLength: 64 - required: false - name: automationId - in: query - schema: type: string minLength: 1 maxLength: 256 - required: false + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + example: user_signed_up + required: true + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). name: triggerEventId - in: query - - schema: - type: string - minLength: 4 - maxLength: 64 - required: false - name: triggerInstanceId - in: query - - schema: - type: string - maxLength: 254 - pattern: ^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$ - description: "Only runs for this recipient, matched case-insensitively against each run's stored `recipientEmail` column. A run written before that column existed carries the address only inside its trigger payload: the row still REPORTS `recipientEmail` (the projection reads the payload as a fallback), but the filter cannot reach it until `migration_mutations:backfillRecipientEmail` has run on the deployment." - required: false - description: "Only runs for this recipient, matched case-insensitively against each run's stored `recipientEmail` column. A run written before that column existed carries the address only inside its trigger payload: the row still REPORTS `recipientEmail` (the projection reads the payload as a fallback), but the filter cannot reach it until `migration_mutations:backfillRecipientEmail` has run on the deployment." - name: recipientEmail - in: query - - schema: - type: string - enum: - - queued - - running - - completed - - failed - - canceled - required: false - name: status - in: query - - schema: - type: string - enum: - - live - - test - required: false - name: mode - in: query - - schema: - type: string - format: date-time - required: false - name: from - in: query - - schema: - type: string - format: date-time - required: false - name: to - in: query - - schema: - anyOf: - - type: number - - type: string - type: integer - minimum: 1 - maximum: 100 - required: false - name: limit - in: query - description: Page size (1-100). Defaults to 100. - example: 50 - - schema: - type: string - minLength: 1 - maxLength: 8192 - required: false - name: cursor - in: query + in: path - name: X-Brand-Id in: header required: false @@ -39303,9 +43267,17 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TriggersPatchRequest" + example: + title: User Signed Up (v2) responses: "200": - description: A page of runs. + description: Updated. headers: x-request-id: schema: @@ -39338,28 +43310,30 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationRunsListResponse" + $ref: "#/components/schemas/TriggerRow" example: - data: - - automationRunId: jh7acthhe99pq4448xh83gn54x8eennb - automationId: M3nRk8LqW2xT5vBp9YcZd - automationVersionId: Kx9Pb2Nt6Wq4Ym8Lz1Rv3 - definitionHash: 9c1f3a6e2b7d4c8f0a5e1b9d3c7f2a6e4b8d0c1f5a9e3b7d2c6f0a4e8b1d5c9f - triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD - mode: live - status: completed - recipientEmail: jane@example.com - startedAt: '2026-04-08T12:34:56.789Z' - completedAt: '2026-04-08T12:35:10.120Z' - pagination: - limit: 100 - cursor: null - hasMore: false + triggerEventId: user_signed_up + title: User Signed Up + description: Fires when a user completes signup. + provider: brew_api + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + - key: firstName + type: string + required: false + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + + `TRIGGER_KEY_RESERVED`: That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit. headers: x-request-id: schema: @@ -39391,6 +43365,15 @@ paths: message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." suggestion: Fix the field reported in `param` and retry. docs: https://docs.brew.new/api-reference/api/errors + triggerKeyReserved: + summary: TRIGGER_KEY_RESERVED + value: + error: + code: TRIGGER_KEY_RESERVED + type: invalid_request + message: "That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit." + suggestion: Pick a key outside `brew.*`, `kit` and `by-key`. Under `auth.*` only the starter-kit auth keys are allowed. + docs: https://docs.brew.new/api-reference/api/errors "401": description: |- `API_KEY_REVOKED`: The API key was revoked. @@ -39497,7 +43480,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -39520,6 +43506,75 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + triggerEventNotFound: + summary: TRIGGER_EVENT_NOT_FOUND + value: + error: + code: TRIGGER_EVENT_NOT_FOUND + type: not_found + message: No trigger with that id exists in the brand. + suggestion: List triggers with GET /v1/automations/triggers. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads. + + `TRIGGER_KEY_TAKEN`: Another trigger in the brand already uses that key. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + contractLockedByPublishedAutomations: + summary: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS + value: + error: + code: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS + type: conflict + message: "A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads." + suggestion: Unpublish or detach the published automations that consume this trigger, or make a backward-compatible change (adding optional fields and loosening enforcement stay allowed). + docs: https://docs.brew.new/api-reference/api/errors + triggerKeyTaken: + summary: TRIGGER_KEY_TAKEN + value: + error: + code: TRIGGER_KEY_TAKEN + type: conflict + message: Another trigger in the brand already uses that key. + suggestion: "Choose another key, or clear it on the trigger that holds it with PATCH /v1/automations/triggers/{triggerEventId} and `key: null`." + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: "`TRIGGER_IMMUTABLE`: Integration-provisioned and Brew-event triggers cannot be changed through the API." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + triggerImmutable: + summary: TRIGGER_IMMUTABLE + value: + error: + code: TRIGGER_IMMUTABLE + type: invalid_request + message: Integration-provisioned and Brew-event triggers cannot be changed through the API. + suggestion: Only brew_api and custom triggers can be patched. Toggle integration per-event settings on the Integrations page instead. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -39534,7 +43589,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -39575,7 +43630,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -39607,42 +43662,24 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.read - /v1/automations/runs/{automationRunId}: - get: - operationId: getAutomationRun + x-brew-rate-policy: triggers.write + delete: + operationId: deleteTrigger tags: - Automations - summary: Get an automation run - description: |- - Reads one run as the bare row: the automation and version it executed (`automationVersionId` is a valid publish pin, `definitionHash` the graph fingerprint), the fire behind it (`triggerInstanceId`), `mode`, `status` and timestamps. - - **Use when** debugging one contact’s journey or confirming a test run finished. - - **Input** `automationRunId` in the path; `include=logs` inlines the newest 100 execution log rows in `orderIndex` order and sets `logsTruncated` when older rows exist. Each node status change is its own row: a node that runs has a `running` row (no `completedAt`), then its terminal `completed` or `failed` row, or `skipped` when the run is canceled while it runs; a node a branch rules out has only a `skipped` row. A node’s outcome is its last row. - - **Returns** `200` with the row; `logs` is present only when requested. - - **Errors** `404 AUTOMATION_RUN_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. - - **See also** `listAutomationRuns`, `cancelAutomationRun`, `listSends` (`?automationRunId=`), `getEventsAnalytics` (`?automationRunId=`). + summary: Delete a trigger + description: "Deletes a custom trigger. Refused with `409 TRIGGER_HAS_DEPENDENT_AUTOMATIONS` while automations still reference it (`details.referencingAutomations` lists each blocker). Idempotent — an already-gone trigger resolves with `deleted: false`." parameters: - schema: type: string minLength: 1 - maxLength: 64 + maxLength: 256 + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + example: user_signed_up required: true - name: automationRunId + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + name: triggerEventId in: path - - schema: - type: string - description: "Expansions: `logs` inlines the newest 100 execution log rows, one per node status change (a node's outcome is its last row by `orderIndex`), and reports `logsTruncated`." - x-brew-include-tokens: - - logs - required: false - description: "Expansions: `logs` inlines the newest 100 execution log rows, one per node status change (a node's outcome is its last row by `orderIndex`), and reports `logsTruncated`." - name: include - in: query - name: X-Brand-Id in: header required: false @@ -39654,7 +43691,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The run row. + description: Deleted (or already gone). headers: x-request-id: schema: @@ -39687,28 +43724,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationRun" + $ref: "#/components/schemas/TriggersDeleteResponse" example: - automationRunId: jh7acthhe99pq4448xh83gn54x8eennb - automationId: M3nRk8LqW2xT5vBp9YcZd - automationVersionId: Kx9Pb2Nt6Wq4Ym8Lz1Rv3 - definitionHash: 9c1f3a6e2b7d4c8f0a5e1b9d3c7f2a6e4b8d0c1f5a9e3b7d2c6f0a4e8b1d5c9f - triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD - mode: live - status: completed - recipientEmail: jane@example.com - startedAt: '2026-04-08T12:34:56.789Z' - completedAt: '2026-04-08T12:35:10.120Z' - logs: - - automationRunId: jh7acthhe99pq4448xh83gn54x8eennb - nodeId: node_send_welcome - nodeName: Send welcome - nodeType: sendEmail - status: completed - orderIndex: 0 - startedAt: '2026-04-08T12:34:57.000Z' - completedAt: '2026-04-08T12:35:10.000Z' - logsTruncated: false + triggerEventId: user_signed_up + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -39852,9 +43871,9 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUTOMATION_RUN_NOT_FOUND`: No automation run with that id exists in the brand. - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -39868,15 +43887,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationRunNotFound: - summary: AUTOMATION_RUN_NOT_FOUND - value: - error: - code: AUTOMATION_RUN_NOT_FOUND - type: not_found - message: No automation run with that id exists in the brand. - suggestion: List runs with GET /v1/automations/runs. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -39886,6 +43896,44 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + triggerEventNotFound: + summary: TRIGGER_EVENT_NOT_FOUND + value: + error: + code: TRIGGER_EVENT_NOT_FOUND + type: not_found + message: No trigger with that id exists in the brand. + suggestion: List triggers with GET /v1/automations/triggers. + docs: https://docs.brew.new/api-reference/api/errors + "409": + description: "`TRIGGER_HAS_DEPENDENT_AUTOMATIONS`: Automations still reference the trigger; `details.referencingAutomations[]` names them." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + triggerHasDependentAutomations: + summary: TRIGGER_HAS_DEPENDENT_AUTOMATIONS + value: + error: + code: TRIGGER_HAS_DEPENDENT_AUTOMATIONS + type: conflict + message: Automations still reference the trigger; `details.referencingAutomations[]` names them. + suggestion: Delete or detach the automations listed in `details.referencingAutomations` first, then retry DELETE. + docs: https://docs.brew.new/api-reference/api/errors + details: + referencingAutomations: + - automationId: V1StGXR8_Z5jdHi6B-myT + name: Welcome series + published: true "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -39900,7 +43948,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -39941,7 +43989,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -39973,32 +44021,35 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.read - /v1/automations/runs/{automationRunId}/cancel: - post: - operationId: cancelAutomationRun + x-brew-rate-policy: triggers.write + /v1/automations/triggers/{triggerEventId}/readiness: + get: + operationId: checkTriggerReady tags: - Automations - summary: Cancel an automation run + summary: Check a trigger is ready to fire description: |- - Stops ONE run (an event execution or a test run). Marks it `canceled` (first-terminal-wins: a late completion from the dying workflow can never overwrite it), wakes a run parked on a wait node so it observes the cancel now, and terminates the durable workflow run so a step stuck retrying is freed too. + Reports whether a fire would start anything right now, and everything a caller needs to wire one. Runs the same credential, brand scope and permission gates a real fire hits, without firing. - **Use when** a contact must not receive the remaining steps of a journey that already started. + **Use when** wiring an external service, before sending a live event. - **Input** `automationRunId` in the path; an optional `reason` for the audit trail. Nothing further is sent; emails already delivered are NOT recalled and a canceled run cannot be resumed. + **Returns** `200 { ready, blockers[], payloadSchema, endpoint, publishedAutomations, counts, delivery? }`. A `NO_PUBLISHED_AUTOMATION` blocker with `ready: false` means the trigger resolves and the credential passes, but nothing published listens for it yet, so a fire would start no runs. That stays a 200 because a probe has to report why a fire would not work. - **Returns** `200` with the run’s new `status` and the `previousStatus` it held. + `delivery` appears for an integration trigger whose source sends only the events set up at its end (a Shopify shop sends a topic only to its own webhook): whether Brew has received that event, and what to set up when it has not. It never changes `ready`. - **Errors** `409 RUN_NOT_CANCELLABLE` when the run already finished (`completed`, `failed` or `canceled`); `404 AUTOMATION_RUN_NOT_FOUND` for an unknown or cross-brand id. + **Errors** `404 TRIGGER_EVENT_NOT_FOUND` for an unknown or cross-brand id; `409 TRIGGER_NOT_FIREABLE` for a Brew event trigger, which Brew starts itself. - **See also** `getAutomationRun`, `cancelAudienceRun` (manual-audience launches), `updateAutomation` (`paused` / `published`). + **See also** `fireTrigger`, `getTriggerContract`, `listTriggers`. parameters: - schema: type: string minLength: 1 - maxLength: 64 + maxLength: 256 + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + example: user_signed_up required: true - name: automationRunId + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + name: triggerEventId in: path - name: X-Brand-Id in: header @@ -40009,17 +44060,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: false - content: - application/json: - schema: - $ref: "#/components/schemas/AutomationRunCancelRequest" - example: - reason: Wrong segment — stopping before the second email. responses: "200": - description: The run's new status plus the status it held before the cancel. + description: "The readiness report: whether a fire would start runs, and everything needed to wire the caller." headers: x-request-id: schema: @@ -40052,11 +44095,29 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AutomationRunCancelResponse" + $ref: "#/components/schemas/TriggerReadiness" example: - automationRunId: jh7acthhe99pq4448xh83gn54x8eennb - status: canceled - previousStatus: running + triggerEventId: user_signed_up + ready: true + blockers: [] + title: User Signed Up + provider: brew_api + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + - key: firstName + type: string + required: false + endpoint: /v1/automations/triggers/user_signed_up/fire + publishedAutomations: + - automationId: M3nRk8LqW2xT5vBp9YcZd + name: Welcome flow + counts: + automations: 1 + skipped: 0 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -40200,9 +44261,9 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUTOMATION_RUN_NOT_FOUND`: No automation run with that id exists in the brand. - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -40216,15 +44277,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - automationRunNotFound: - summary: AUTOMATION_RUN_NOT_FOUND - value: - error: - code: AUTOMATION_RUN_NOT_FOUND - type: not_found - message: No automation run with that id exists in the brand. - suggestion: List runs with GET /v1/automations/runs. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -40234,13 +44286,17 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + triggerEventNotFound: + summary: TRIGGER_EVENT_NOT_FOUND + value: + error: + code: TRIGGER_EVENT_NOT_FOUND + type: not_found + message: No trigger with that id exists in the brand. + suggestion: List triggers with GET /v1/automations/triggers. + docs: https://docs.brew.new/api-reference/api/errors "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - - `RUN_NOT_CANCELLABLE`: The run already reached a terminal state. + description: "`TRIGGER_NOT_FIREABLE`: Brew starts this trigger itself when its event happens, so it cannot be fired over the API." headers: x-request-id: schema: @@ -40254,32 +44310,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - runNotCancellable: - summary: RUN_NOT_CANCELLABLE + triggerNotFireable: + summary: TRIGGER_NOT_FIREABLE value: error: - code: RUN_NOT_CANCELLABLE + code: TRIGGER_NOT_FIREABLE type: conflict - message: The run already reached a terminal state. - suggestion: The run has already finished and can no longer be canceled. + message: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. + suggestion: Try the automation with its Test action (POST /v1/automations/{automationId}/test) instead. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -40295,7 +44333,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -40336,7 +44374,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -40366,37 +44404,49 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.write - /v1/automations/triggers/brew-events: + x-brew-rate-policy: automation.runs.read + /v1/automations/triggers/{triggerEventId}/fire: post: - operationId: ensureBrewEventTrigger + operationId: fireTrigger tags: - Automations - summary: Get or create an unsubscribe trigger + summary: Fire a trigger description: |- - Returns the brand’s trigger for one Brew event scope, creating it the first time. Brew events start automations from things that happen inside Brew. The first is `contact.unsubscribed`: a contact unsubscribed with an email’s unsubscribe link or their mail app’s one-click button (never a bounce, complaint, import or API edit). + Fires the trigger: validates the payload against the stored contract, upserts the contact it describes, then starts one run per published automation attached to the trigger. - **Use when** an automation should start when a contact unsubscribes, from one marketing domain’s list or from every email the brand sends. + **Use when** an event in your product should start every published automation attached to this trigger. - **Input** `{ event: "contact.unsubscribed", scope: "domain", domainId }` for one marketing domain’s list (ids from `listDomains`), or `{ event: "contact.unsubscribed", scope: "all" }` for every email. An unsubscribe from all clicked in one of a domain’s emails also starts that domain’s trigger, while the contact was still on its list. + **Input** `{ payload }`, validated against the trigger’s `payloadSchema`. Unknown fields are accepted and reported in `warnings[]`. The contact derived from the payload is upserted before the fan-out with the declared top-level scalar fields the fire sent; object and array fields stay template data, and a contract `fallbackValue` is never written onto the contact. A value the contact field’s type refuses is left out and reported as a `FIELD_TYPE_MISMATCH` warning (`field`, `expectedType`, `actualType`); the rest of the contact is written and the runs still start. - **Returns** `201` with the new trigger row, or `200` with the existing one (`created: false`). Brew owns its title and payload, so the row is immutable and never fires over the API. The contact has just opted out, so an automation on this trigger sends only a message they need (such as confirming the unsubscribe) from a transactional domain, or mail from another marketing domain they are still on; publish refuses a flow none of whose emails could reach them. + **Returns** `202 { triggerInstanceId, triggerEventId, status, automationRunIds[], notStarted[], publishedAutomations[], counts, warnings[], receivedAt }`. Follow a run with `getAutomationRun`, the fire itself with `getTriggerInstance`. `notStarted[]` names each automation whose run failed to start and why, so a `triggered` fire with no runs explains itself. `counts.skipped` is the recipients a suppression already covered, so `automations: 2, skipped: 2` means nothing was delivered. - **Errors** `400 INVALID_REQUEST` when `domainId` is missing for `scope: "domain"` or sent for `scope: "all"`; `404 DOMAIN_NOT_FOUND` for an unknown or cross-brand domain; `422 DOMAIN_PURPOSE_NOT_ALLOWED` for a transactional domain, which owns no list; `409 TRIGGER_LIMIT_REACHED` when the brand already holds the maximum number of triggers. + **Idempotency** send a stable `Idempotency-Key` header on every retry. A repeat answers `200` with `status: "replayed"` and the ORIGINAL run ids; nothing fires twice. A fire key is matched against the stored trigger receipt, so it replays for as long as the receipt is kept (90 days), not only the 24 hours of the shared response cache. Use one key per business action and never reuse it for a different fire. - **See also** `createAutomation`, `fireTrigger`, `listDomains`. + **Errors** `400 INVALID_PAYLOAD` when the payload does not satisfy the schema, or when `email` (or `user.email`) is not one plain address such as `jane@example.com`: a display name (`Jane `), a list or a line break is refused with `details.errors[].code: "invalid_email"`, and no contact is written; `404 TRIGGER_EVENT_NOT_FOUND` for an unknown or cross-brand id; `422 NO_PUBLISHED_AUTOMATION` when nothing published listens for the trigger; `422 DOMAIN_REQUIRED` when every matched automation sends only from the Brew sandbox (no verified domain yet), right away, and to recipients who are not members of your organization (`details: { recipient, allowed, domainsUrl }`; in a mixed fan-out those automations are listed in `notStarted[]` with `code: "DOMAIN_REQUIRED"` and the rest start); `429 SANDBOX_DAILY_LIMIT` with `Retry-After` when the organization spent today’s sandbox sends (`details: { limit, resetsAt }`); `409 IDEMPOTENCY_CONFLICT` when a key is reused with a different body; `409 TRIGGER_NOT_FIREABLE` for a Brew event trigger, which Brew starts itself (try its automation with `testAutomation`). + + **See also** `checkTriggerReady`, `getTriggerInstance`, `listAutomationRuns`. parameters: - name: Idempotency-Key in: header required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + description: Optional idempotency key for safe retries of the same trigger fire and request body. The shared response cache defaults to 24 hours; the trigger receipt replays the original run ids for as long as it is stored (90 days). Use one key per business action and never reuse it for a different fire. schema: type: string minLength: 1 maxLength: 100 example: api-request-2026-04-08-001 + - schema: + type: string + minLength: 1 + maxLength: 256 + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + example: user_signed_up + required: true + description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + name: triggerEventId + in: path - name: X-Brand-Id in: header required: false @@ -40411,14 +44461,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrewEventTriggersPostRequest" + $ref: "#/components/schemas/TriggerFireRequest" example: - event: contact.unsubscribed - scope: domain - domainId: jd7f0c2a8w1x9m4n6p3q5r8s2t + payload: + email: jane@example.com + firstName: Jane responses: "200": - description: The trigger already existed for this scope. + description: "Idempotent replay: this `Idempotency-Key` already fired. The ids are the original run’s." headers: x-request-id: schema: @@ -40451,60 +44501,23 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrewEventTriggerSaved" + $ref: "#/components/schemas/TriggerFireAccepted" example: - triggerEventId: k57abc123unsub - title: Unsubscribed from news.acme.com - description: Starts when a contact unsubscribes from emails sent from news.acme.com, from an email's unsubscribe link or the mail app's unsubscribe button. Unsubscribing from all emails in one of those emails starts it too. - provider: brew_event - providerEventKey: contact.unsubscribed - brewEvent: - event: contact.unsubscribed - scope: domain - domainHost: news.acme.com - payloadSchema: - fields: - - key: email - required: true - type: string - - children: - - key: scope - required: true - type: string - - key: domain - required: false - type: string - - key: method - required: true - type: string - - key: reasons - required: true - type: array - itemType: string - - key: reasonText - required: false - type: string - - key: emailId - required: false - type: string - - key: emailTitle - required: false - type: string - - key: sendId - required: false - type: string - - key: at - required: true - type: string - key: unsubscribe - required: true - type: object - type: object - createdAt: '2026-10-13T12:00:00.000Z' - updatedAt: '2026-10-13T12:00:00.000Z' - created: false - "201": - description: Created. + triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD + triggerEventId: user_signed_up + status: replayed + automationRunIds: + - jh7acthhe99pq4448xh83gn54x8eennb + publishedAutomations: + - automationId: M3nRk8LqW2xT5vBp9YcZd + name: Welcome flow + counts: + automations: 1 + skipped: 0 + warnings: [] + receivedAt: '2026-04-08T12:34:56.789Z' + "202": + description: "Accepted: one run started per matched published automation." headers: x-request-id: schema: @@ -40537,62 +44550,27 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrewEventTriggerSaved" + $ref: "#/components/schemas/TriggerFireAccepted" example: - triggerEventId: k57abc123unsub - title: Unsubscribed from news.acme.com - description: Starts when a contact unsubscribes from emails sent from news.acme.com, from an email's unsubscribe link or the mail app's unsubscribe button. Unsubscribing from all emails in one of those emails starts it too. - provider: brew_event - providerEventKey: contact.unsubscribed - brewEvent: - event: contact.unsubscribed - scope: domain - domainHost: news.acme.com - payloadSchema: - fields: - - key: email - required: true - type: string - - children: - - key: scope - required: true - type: string - - key: domain - required: false - type: string - - key: method - required: true - type: string - - key: reasons - required: true - type: array - itemType: string - - key: reasonText - required: false - type: string - - key: emailId - required: false - type: string - - key: emailTitle - required: false - type: string - - key: sendId - required: false - type: string - - key: at - required: true - type: string - key: unsubscribe - required: true - type: object - type: object - createdAt: '2026-10-13T12:00:00.000Z' - updatedAt: '2026-10-13T12:00:00.000Z' - created: true + triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD + triggerEventId: user_signed_up + status: triggered + automationRunIds: + - jh7acthhe99pq4448xh83gn54x8eennb + publishedAutomations: + - automationId: M3nRk8LqW2xT5vBp9YcZd + name: Welcome flow + counts: + automations: 1 + skipped: 0 + warnings: [] + receivedAt: '2026-04-08T12:34:56.789Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + `INVALID_PAYLOAD`: The test payload does not match the trigger’s payload schema. + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: @@ -40616,6 +44594,15 @@ paths: message: An organization-scoped credential called a brand-scoped operation without naming the brand. suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. docs: https://docs.brew.new/api-reference/api/authentication + invalidPayload: + summary: INVALID_PAYLOAD + value: + error: + code: INVALID_PAYLOAD + type: invalid_request + message: The test payload does not match the trigger’s payload schema. + suggestion: Send fields and types matching the trigger’s payloadSchema (see GET /v1/automations/triggers/{triggerEventId}). + docs: https://docs.brew.new/api-reference/api/errors invalidRequest: summary: INVALID_REQUEST value: @@ -40734,7 +44721,7 @@ paths: description: |- `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -40757,14 +44744,14 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - domainNotFound: - summary: DOMAIN_NOT_FOUND + triggerEventNotFound: + summary: TRIGGER_EVENT_NOT_FOUND value: error: - code: DOMAIN_NOT_FOUND + code: TRIGGER_EVENT_NOT_FOUND type: not_found - message: No domain with that id exists in the brand. - suggestion: Use GET /v1/domains to choose a verified domain. + message: No trigger with that id exists in the brand. + suggestion: List triggers with GET /v1/automations/triggers. docs: https://docs.brew.new/api-reference/api/errors "409": description: |- @@ -40772,7 +44759,7 @@ paths: `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - `TRIGGER_LIMIT_REACHED`: The brand already holds the maximum number of trigger events. + `TRIGGER_NOT_FIREABLE`: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. headers: x-request-id: schema: @@ -40804,17 +44791,20 @@ paths: message: A request with this Idempotency-Key is still executing. suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency - triggerLimitReached: - summary: TRIGGER_LIMIT_REACHED + triggerNotFireable: + summary: TRIGGER_NOT_FIREABLE value: error: - code: TRIGGER_LIMIT_REACHED + code: TRIGGER_NOT_FIREABLE type: conflict - message: The brand already holds the maximum number of trigger events. - suggestion: Delete an unused trigger with `DELETE /v1/automations/triggers/{triggerEventId}`, then retry. Each brand holds at most 100. + message: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. + suggestion: Try the automation with its Test action (POST /v1/automations/{automationId}/test) instead. docs: https://docs.brew.new/api-reference/api/errors "422": - description: "`DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional)." + description: |- + `DOMAIN_REQUIRED`: The matched automations send from the Brew sandbox until a sending domain is verified, and the sandbox only emails members of your organization; `details` carries `recipient`, `allowed` and `domainsUrl`. + + `NO_PUBLISHED_AUTOMATION`: The trigger fired, but no published automation listens for it, so nothing would run. headers: x-request-id: schema: @@ -40828,17 +44818,29 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - domainPurposeNotAllowed: - summary: DOMAIN_PURPOSE_NOT_ALLOWED + domainRequired: + summary: DOMAIN_REQUIRED value: error: - code: DOMAIN_PURPOSE_NOT_ALLOWED + code: DOMAIN_REQUIRED type: invalid_request - message: The sending domain is admitted for a different purpose than this send (marketing vs transactional). - suggestion: Use a marketing domain for campaigns and audience sends. For transactional flows, wire the design into a published automation with a trigger and fire POST /v1/automations/triggers/{triggerEventId}/fire. + message: The matched automations send from the Brew sandbox until a sending domain is verified, and the sandbox only emails members of your organization; `details` carries `recipient`, `allowed` and `domainsUrl`. + suggestion: Verify a sending domain at /domains to email anyone, or fire to an address of a member of your organization while you test. + docs: https://docs.brew.new/api-reference/api/errors + noPublishedAutomation: + summary: NO_PUBLISHED_AUTOMATION + value: + error: + code: NO_PUBLISHED_AUTOMATION + type: invalid_request + message: The trigger fired, but no published automation listens for it, so nothing would run. + suggestion: Publish an automation wired to this trigger, then fire again. GET /v1/automations/triggers/{triggerEventId}/readiness reports this before you send a live event. docs: https://docs.brew.new/api-reference/api/errors "429": - description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + description: |- + `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + + `SANDBOX_DAILY_LIMIT`: Your organization used all of today’s Brew sandbox sends (`details.limit`, default 50 a UTC day); `details.resetsAt` is when the count resets, and `Retry-After` the seconds until then. headers: x-request-id: schema: @@ -40896,6 +44898,16 @@ paths: remaining: 0 reset: 1712592360 consumed: false + sandboxDailyLimit: + summary: SANDBOX_DAILY_LIMIT + value: + error: + code: SANDBOX_DAILY_LIMIT + type: rate_limit + message: Your organization used all of today’s Brew sandbox sends (`details.limit`, default 50 a UTC day); `details.resetsAt` is when the count resets, and `Retry-After` the seconds until then. + suggestion: Wait until `details.resetsAt`, or verify a sending domain at /domains to send without the sandbox limit. + docs: https://docs.brew.new/api-reference/api/errors + retryAfter: 42 "500": description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." headers: @@ -40920,31 +44932,83 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: fail_closed x-brew-credited: false - x-brew-rate-policy: triggers.write - /v1/automations/triggers: + x-brew-rate-policy: automation.runs.write + /v1/automations/triggers/by-key/{triggerKey}/fire: post: - operationId: createTrigger + operationId: fireTriggerByKey tags: - Automations - summary: Create a trigger + summary: Fire a trigger by key description: |- - Creates a custom trigger event definition — the contract automations subscribe to. Body carries `{ title, description?, payloadSchema }`; the server mints `triggerEventId` and hardcodes `provider: "brew_api"`. Integration triggers (clerk, stripe, shopify, …) are provisioned by the corresponding integration only. + Fires the trigger whose developer `key` matches, exactly as `fireTrigger` does by id: validates the payload, upserts the contact, then starts one run per published automation attached to the trigger. Use it when the same code fires the same trigger in several brands or environments, where each trigger has its own minted id but shares the key. - `payloadSchema.fields` MUST declare `{ key: "email", type: "string", required: true }` so downstream automations can resolve a recipient. Returns `201` with the bare trigger row. + **Use when** your code names triggers by key (`order.paid`, `auth.password-reset`) instead of storing `triggerEventId`s. + + **Input** `{ payload }`, validated against the trigger’s `payloadSchema`, as for `fireTrigger`. + + **Returns** the `fireTrigger` response plus `triggerKey`: `202 { triggerInstanceId, triggerEventId, triggerKey, status, automationRunIds[], notStarted[], publishedAutomations[], counts, warnings[], receivedAt }`. + + **Idempotency** send a stable `Idempotency-Key` header on every retry. The key is matched against the stored trigger receipt of the RESOLVED trigger, so a by-key and a by-id fire of one trigger with the same key replay each other, for as long as the receipt is kept (90 days). + + **Errors** `404 TRIGGER_KEY_NOT_FOUND` when no trigger in the brand has the key (a sibling brand’s key never resolves); otherwise the `fireTrigger` errors: `400 INVALID_PAYLOAD`, `422 NO_PUBLISHED_AUTOMATION`, `422 DOMAIN_REQUIRED` and `429 SANDBOX_DAILY_LIMIT` (a sandbox automation before a domain verifies), `409 IDEMPOTENCY_CONFLICT`, and `409 TRIGGER_NOT_FIREABLE` for a Brew event trigger, which Brew starts itself. + + **See also** `fireTrigger`, `listTriggers`, `updateTrigger` (to set a key). parameters: - name: Idempotency-Key in: header required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + description: Optional idempotency key for safe retries of the same trigger fire and request body. The shared response cache defaults to 24 hours; the trigger receipt replays the original run ids for as long as it is stored (90 days). Use one key per business action and never reuse it for a different fire. schema: type: string minLength: 1 maxLength: 100 example: api-request-2026-04-08-001 + - schema: + type: string + minLength: 1 + maxLength: 64 + pattern: ^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$ + description: The developer key set on the trigger (`key` on the trigger row). Resolved inside the brand the credential acts for. + example: order.paid + required: true + description: The developer key set on the trigger (`key` on the trigger row). Resolved inside the brand the credential acts for. + name: triggerKey + in: path - name: X-Brand-Id in: header required: false @@ -40959,22 +45023,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggersPostRequest" + $ref: "#/components/schemas/TriggerFireRequest" example: - title: User Signed Up - description: Fires when a user completes signup. - payloadSchema: - type: object - fields: - - key: email - type: string - required: true - - key: firstName - type: string - required: false + payload: + email: jane@example.com + orderId: ord_1042 responses: - "201": - description: Created. The bare trigger row. + "200": + description: "Idempotent replay: this `Idempotency-Key` already fired this trigger. The ids are the original run’s." headers: x-request-id: schema: @@ -41007,30 +45063,79 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerRow" + $ref: "#/components/schemas/TriggerFireAccepted" example: - triggerEventId: user_signed_up - title: User Signed Up - description: Fires when a user completes signup. - provider: brew_api - payloadSchema: - type: object - fields: - - key: email - type: string - required: true - - key: firstName - type: string - required: false - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' + triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD + triggerEventId: Hq2LmP4xR7vB1nC5zKdWa + triggerKey: order.paid + status: replayed + automationRunIds: + - jh7acthhe99pq4448xh83gn54x8eennb + publishedAutomations: + - automationId: M3nRk8LqW2xT5vBp9YcZd + name: Receipt flow + counts: + automations: 1 + skipped: 0 + warnings: [] + receivedAt: '2026-04-08T12:34:56.789Z' + "202": + description: "Accepted: one run started per matched published automation." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/TriggerFireAccepted" + example: + triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD + triggerEventId: Hq2LmP4xR7vB1nC5zKdWa + triggerKey: order.paid + status: triggered + automationRunIds: + - jh7acthhe99pq4448xh83gn54x8eennb + publishedAutomations: + - automationId: M3nRk8LqW2xT5vBp9YcZd + name: Receipt flow + counts: + automations: 1 + skipped: 0 + warnings: [] + receivedAt: '2026-04-08T12:34:56.789Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. - `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + `INVALID_PAYLOAD`: The test payload does not match the trigger’s payload schema. - `PAYLOAD_SCHEMA_EMAIL_REQUIRED`: A trigger payload schema must declare a required string `email` field. + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: schema: @@ -41053,6 +45158,15 @@ paths: message: An organization-scoped credential called a brand-scoped operation without naming the brand. suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. docs: https://docs.brew.new/api-reference/api/authentication + invalidPayload: + summary: INVALID_PAYLOAD + value: + error: + code: INVALID_PAYLOAD + type: invalid_request + message: The test payload does not match the trigger’s payload schema. + suggestion: Send fields and types matching the trigger’s payloadSchema (see GET /v1/automations/triggers/{triggerEventId}). + docs: https://docs.brew.new/api-reference/api/errors invalidRequest: summary: INVALID_REQUEST value: @@ -41062,15 +45176,6 @@ paths: message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." suggestion: Fix the field reported in `param` and retry. docs: https://docs.brew.new/api-reference/api/errors - payloadSchemaEmailRequired: - summary: PAYLOAD_SCHEMA_EMAIL_REQUIRED - value: - error: - code: PAYLOAD_SCHEMA_EMAIL_REQUIRED - type: invalid_request - message: A trigger payload schema must declare a required string `email` field. - suggestion: 'Add { key: "email", type: "string", required: true } to payloadSchema.fields before retrying.' - docs: https://docs.brew.new/api-reference/api/errors "401": description: |- `API_KEY_REVOKED`: The API key was revoked. @@ -41177,7 +45282,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `TRIGGER_KEY_NOT_FOUND`: No trigger in the brand has that key. headers: x-request-id: schema: @@ -41200,15 +45308,22 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + triggerKeyNotFound: + summary: TRIGGER_KEY_NOT_FOUND + value: + error: + code: TRIGGER_KEY_NOT_FOUND + type: not_found + message: No trigger in the brand has that key. + suggestion: List triggers with GET /v1/automations/triggers and check each row’s `key`, or set one with PATCH /v1/automations/triggers/{triggerEventId}. + docs: https://docs.brew.new/api-reference/api/errors "409": description: |- `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - `TRIGGER_ALREADY_EXISTS`: A trigger with that id already exists in the brand. - - `TRIGGER_LIMIT_REACHED`: The brand already holds the maximum number of trigger events. + `TRIGGER_NOT_FIREABLE`: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. headers: x-request-id: schema: @@ -41240,26 +45355,20 @@ paths: message: A request with this Idempotency-Key is still executing. suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency - triggerAlreadyExists: - summary: TRIGGER_ALREADY_EXISTS - value: - error: - code: TRIGGER_ALREADY_EXISTS - type: conflict - message: A trigger with that id already exists in the brand. - suggestion: Reuse the existing trigger, or choose a different triggerEventId. - docs: https://docs.brew.new/api-reference/api/errors - triggerLimitReached: - summary: TRIGGER_LIMIT_REACHED + triggerNotFireable: + summary: TRIGGER_NOT_FIREABLE value: error: - code: TRIGGER_LIMIT_REACHED + code: TRIGGER_NOT_FIREABLE type: conflict - message: The brand already holds the maximum number of trigger events. - suggestion: Delete an unused trigger with `DELETE /v1/automations/triggers/{triggerEventId}`, then retry. Each brand holds at most 100. + message: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. + suggestion: Try the automation with its Test action (POST /v1/automations/{automationId}/test) instead. docs: https://docs.brew.new/api-reference/api/errors "422": - description: "`PAYLOAD_SCHEMA_EMAIL_REQUIRED`: A trigger payload schema must declare a required string `email` field." + description: |- + `DOMAIN_REQUIRED`: The matched automations send from the Brew sandbox until a sending domain is verified, and the sandbox only emails members of your organization; `details` carries `recipient`, `allowed` and `domainsUrl`. + + `NO_PUBLISHED_AUTOMATION`: The trigger fired, but no published automation listens for it, so nothing would run. headers: x-request-id: schema: @@ -41273,17 +45382,29 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - payloadSchemaEmailRequired: - summary: PAYLOAD_SCHEMA_EMAIL_REQUIRED + domainRequired: + summary: DOMAIN_REQUIRED value: error: - code: PAYLOAD_SCHEMA_EMAIL_REQUIRED + code: DOMAIN_REQUIRED type: invalid_request - message: A trigger payload schema must declare a required string `email` field. - suggestion: 'Add { key: "email", type: "string", required: true } to payloadSchema.fields before retrying.' + message: The matched automations send from the Brew sandbox until a sending domain is verified, and the sandbox only emails members of your organization; `details` carries `recipient`, `allowed` and `domainsUrl`. + suggestion: Verify a sending domain at /domains to email anyone, or fire to an address of a member of your organization while you test. + docs: https://docs.brew.new/api-reference/api/errors + noPublishedAutomation: + summary: NO_PUBLISHED_AUTOMATION + value: + error: + code: NO_PUBLISHED_AUTOMATION + type: invalid_request + message: The trigger fired, but no published automation listens for it, so nothing would run. + suggestion: Publish an automation wired to this trigger, then fire again. GET /v1/automations/triggers/{triggerEventId}/readiness reports this before you send a live event. docs: https://docs.brew.new/api-reference/api/errors "429": - description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." + description: |- + `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + + `SANDBOX_DAILY_LIMIT`: Your organization used all of today’s Brew sandbox sends (`details.limit`, default 50 a UTC day); `details.resetsAt` is when the count resets, and `Retry-After` the seconds until then. headers: x-request-id: schema: @@ -41296,7 +45417,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -41337,10 +45458,20 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 60 remaining: 0 reset: 1712592360 consumed: false + sandboxDailyLimit: + summary: SANDBOX_DAILY_LIMIT + value: + error: + code: SANDBOX_DAILY_LIMIT + type: rate_limit + message: Your organization used all of today’s Brew sandbox sends (`details.limit`, default 50 a UTC day); `details.resetsAt` is when the count resets, and `Retry-After` the seconds until then. + suggestion: Wait until `details.resetsAt`, or verify a sending domain at /domains to send without the sandbox limit. + docs: https://docs.brew.new/api-reference/api/errors + retryAfter: 42 "500": description: "`INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it." headers: @@ -41365,46 +45496,77 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: fail_closed x-brew-credited: false + x-brew-rate-policy: automation.runs.write + /v1/automations/triggers/{triggerEventId}/contract: get: - operationId: listTriggers + operationId: getTriggerContract tags: - Automations - summary: List triggers + summary: Get a trigger payload contract description: |- - Lists every trigger the brand has under `{ data, pagination }`: `provider: "brew_api"` for API-created customs, `provider: "clerk" | "stripe" | …` for integration-provisioned triggers, and `provider: "brew_event"` for the unsubscribe triggers Brew starts itself. Each row carries its `payloadSchema`. - - **Use when** finding the `triggerEventId` to fire, to wire an automation to, or to read the typed contract of. - - **Input** `limit` and `cursor`. - - **Returns** `200` with a page of trigger rows. + The payload contract for the trigger: the STORED contract when one has been declared (`source: "stored"`, with `contractHash`, `version`, `enforcement`), otherwise the derived one (`source: "derived_from_schema"`, built from the trigger’s flat `payloadSchema`). - **Errors** `400 INVALID_REQUEST` for an unknown query key. + Contract fields form a tree: scalars (`string` | `int` | `float` | `boolean` | `date` | `enum`), `object` nodes with `children`, and `array` nodes with `children` (element object shape) or `itemType` (scalar elements). Derived contracts may carry `type: "unknown"` where no type evidence exists. - **See also** `getTrigger` (with `include=skill`), `createTrigger`, `fireTrigger`, `listTriggerInstances`. + Pass `?format=` for generated artifacts instead of the JSON contract. parameters: - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit + - name: format in: query - example: 50 + required: false + description: Rendering. `json` (default) returns the contract object; `ts` | `zod` | `jsonschema` | `skill` return `{format, content}` — a generated TypeScript type, Zod schema, JSON Schema document, or SKILL.md wiring brief. + schema: + type: string + enum: + - json + - ts + - zod + - jsonschema + - skill - schema: type: string minLength: 1 - maxLength: 512 - required: false - name: cursor - in: query + maxLength: 256 + description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). + example: user_signed_up + required: true + description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). + name: triggerEventId + in: path - name: X-Brand-Id in: header required: false @@ -41416,7 +45578,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of trigger rows. + description: The contract (`format=json`, default) or `{format, content}` for text formats. headers: x-request-id: schema: @@ -41449,28 +45611,34 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggersListResponse" + $ref: "#/components/schemas/PayloadContractGetResponse" example: - data: - - triggerEventId: user_signed_up - title: User Signed Up - description: Fires when a user completes signup. - provider: brew_api - payloadSchema: - type: object - fields: - - key: email - type: string - required: true - - key: firstName - type: string - required: false - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' - pagination: - limit: 100 - cursor: null - hasMore: false + subjectKind: trigger + subjectId: user_signed_up + source: stored + format: json + typeName: UserSignedUpPayload + contractHash: 22545d11e1d174ba0717ed37c9c4b460c96bed51ae31ea0af18266ebba30f76a + version: 2 + enforcement: 'off' + fields: + - key: email + type: string + required: true + - key: plan + type: enum + required: false + enumValues: + - free + - pro + fallbackValue: free + - key: order + type: object + required: false + children: + - key: total + type: float + required: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -41613,7 +45781,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: schema: @@ -41636,6 +45807,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + triggerEventNotFound: + summary: TRIGGER_EVENT_NOT_FOUND + value: + error: + code: TRIGGER_EVENT_NOT_FOUND + type: not_found + message: No trigger with that id exists in the brand. + suggestion: List triggers with GET /v1/automations/triggers. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -41724,44 +45904,34 @@ paths: x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: triggers.read - /v1/automations/triggers/{triggerEventId}: - get: - operationId: getTrigger + put: + operationId: setTriggerContract tags: - Automations - summary: Get a trigger + summary: Declare a trigger payload contract description: |- - Reads one trigger as the bare row: `triggerEventId`, `title`, `provider`, `payloadSchema` and timestamps. + Declares the stored payload contract for the trigger, changes how it is enforced, or both, in one call. `fields` and `enforcement` are both optional, so arming enforcement is `{ "enforcement": "strict" }`. While a PUBLISHED automation consumes the trigger the change must stay backward compatible (the ratchet below); loosening is always allowed. - **Use when** inspecting the payload shape before firing, or generating an integration brief for the fire endpoint. + **Use when** you want generated types, a dry-run validator (`validateTriggerPayload`) and drift detection for the JSON your service fires, or when you flip `enforcement`. Draft the tree from a real payload with `inferPayloadContract`. Trigger contracts MUST keep a top-level `{ key: "email", type: "string", required: true }` field so automations can resolve a recipient. - **Input** `triggerEventId` in the path (URL-encode composite integration ids); `include=skill` adds `skill`, a SKILL.md-shaped wiring brief built from the stored contract when one exists. + **Input** `fields` is a tree: scalars (`string`, `int`, `float`, `boolean`, `date`, `enum`), `object` nodes with `children`, `array` nodes with `children` (object elements), `itemType` (scalar elements) or neither. The tree is validated BEFORE any write: unknown-typed nodes are refused, keys are unique per level, an `object` declares at least one child, and no two top-level fields that a fire copies onto the contact fold to one contact field (`plan` and `Plan`, `team_size` and `Team Size`; rename one or set `syncToContact: false` on one). `enforcement`: `off` (advisory, the default for a new contract), `prune` (drop undeclared keys before the run), `strict` (reject a payload carrying them). A `fallbackValue` satisfies `required` in EVERY mode: a producer that omits the key never fails a fire, every recipient gets the fallback. The version bumps only when the behavioral surface changes (keys, types, required, fallbacks; not descriptions or examples). Declaring a contract never changes fire behavior by itself. Loosening is the escape hatch when `strict` rejects a drifted producer in production. - **Returns** `200` with the row; `skill` is present only when requested. + **Returns** `200` with the same body a follow-up `getTriggerContract` returns plus `warnings[]` (always present, often empty): `ENFORCEMENT_LOOSENED_WHILE_PUBLISHED` when enforcement went down while published automations consume the trigger (tightening back is refused until they are unpublished or detached), and one `REQUIRED_FIELD_SATISFIED_BY_FALLBACK` per required field that declares a fallback (`field` is its dotted path). Ratchet while a published automation consumes the trigger: tighten `off→prune`, `off→strict`, `prune→strict` = `409`; loosen = `200` + warning; remove, retype or enum-narrow a field, or add a required field WITHOUT a fallback = `409`; add an optional field, widen a type, add an enum value, or add a required field WITH a fallback = `200` (+ warning per fallback). With no published consumer every change is `200`. - **Errors** `404 TRIGGER_EVENT_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. + **Errors** `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS` for a change the ratchet refuses (`message` names the incompatibility; unpublish or detach the consumers, or make a compatible change); `404 TRIGGER_EVENT_NOT_FOUND`; `422 TRIGGER_IMMUTABLE` for a Brew event trigger, whose contract Brew owns; `400 INVALID_REQUEST` (`param: "fields"`) when the tree fails structural validation, or when a body with no `fields` targets a trigger that has no stored contract yet. - **See also** `listTriggers`, `getTriggerContract`, `fireTrigger`, `checkTriggerReady`. + **See also** `getTriggerContract`, `validateTriggerPayload`, `inferPayloadContract`, `updateAutomation` (publishing a consumer starts the ratchet). parameters: - schema: type: string minLength: 1 maxLength: 256 - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). example: user_signed_up required: true - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). name: triggerEventId in: path - - schema: - type: string - description: "Expansions: `skill` adds a SKILL.md-shaped wiring brief for the fire endpoint." - x-brew-include-tokens: - - skill - required: false - description: "Expansions: `skill` adds a SKILL.md-shaped wiring brief for the fire endpoint." - name: include - in: query - name: X-Brand-Id in: header required: false @@ -41771,9 +45941,35 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PayloadContractPutRequest" + example: + fields: + - key: email + type: string + required: true + - key: plan + type: enum + required: false + enumValues: + - free + - pro + fallbackValue: free + description: Billing plan at signup time. + - key: order + type: object + required: false + children: + - key: total + type: float + required: true responses: "200": - description: The trigger row. + description: Stored. The contract as a follow-up GET would return it, plus `warnings[]` about the write. headers: x-request-id: schema: @@ -41806,23 +46002,37 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerRow" + $ref: "#/components/schemas/PayloadContractPutResponse" example: - triggerEventId: user_signed_up - title: User Signed Up - description: Fires when a user completes signup. - provider: brew_api - payloadSchema: - type: object - fields: - - key: email - type: string - required: true - - key: firstName - type: string - required: false - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' + subjectKind: trigger + subjectId: user_signed_up + source: stored + format: json + typeName: UserSignedUpPayload + contractHash: 22545d11e1d174ba0717ed37c9c4b460c96bed51ae31ea0af18266ebba30f76a + version: 3 + enforcement: prune + fields: + - key: email + type: string + required: true + - key: plan + type: enum + required: false + enumValues: + - free + - pro + fallbackValue: free + - key: order + type: object + required: false + children: + - key: total + type: float + required: true + warnings: + - code: ENFORCEMENT_LOOSENED_WHILE_PUBLISHED + message: Enforcement loosened from strict to prune while 1 published automation(s) consume this trigger. Loosening is allowed as the operator escape hatch, but tightening back to strict will be refused with 409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS until they are unpublished or detached. "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -42000,6 +46210,54 @@ paths: message: No trigger with that id exists in the brand. suggestion: List triggers with GET /v1/automations/triggers. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: "`CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + contractLockedByPublishedAutomations: + summary: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS + value: + error: + code: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS + type: conflict + message: "A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads." + suggestion: Unpublish or detach the published automations that consume this trigger, or make a backward-compatible change (adding optional fields and loosening enforcement stay allowed). + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: "`TRIGGER_IMMUTABLE`: Integration-provisioned and Brew-event triggers cannot be changed through the API." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + triggerImmutable: + summary: TRIGGER_IMMUTABLE + value: + error: + code: TRIGGER_IMMUTABLE + type: invalid_request + message: Integration-provisioned and Brew-event triggers cannot be changed through the API. + suggestion: Only brew_api and custom triggers can be patched. Toggle integration per-event settings on the Integrations page instead. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -42014,7 +46272,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -42055,7 +46313,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -42087,22 +46345,26 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: triggers.read - patch: - operationId: updateTrigger + x-brew-rate-policy: triggers.write + /v1/automations/triggers/{triggerEventId}/contract/validate: + post: + operationId: validateTriggerPayload tags: - Automations - summary: Update a trigger - description: Updates one or more editable fields (`title`, `description`, `payloadSchema`). Whether a trigger fires is governed solely by `automation.published` on each wired automation — there is no per-trigger off-switch. Integration-provisioned triggers are immutable here (`422 TRIGGER_IMMUTABLE`). While a published automation uses the trigger, a `payloadSchema` change that would stop it from running (removing or retyping a field it reads, or declaring the shape of an open object or list so one of its merge tags no longer resolves) is refused with `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`. Returns the bare updated row. + summary: Validate a trigger payload + description: |- + Dry-runs a payload against the trigger's contract — the SAME validator the live path runs, against the stored contract when one exists (derived otherwise; `source` says which). Nothing fires, sends, or writes. + + An invalid payload is still a `200`: the verdict is the response — read `valid`, `errors`, `warnings`, `resolvedPayload` (fallbacks applied), and `prunedKeys`. Pass `enforcement: "prune" | "strict"` to preview a different mode than the stored one. Fire for real with `POST …/fire`. parameters: - schema: type: string minLength: 1 maxLength: 256 - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). example: user_signed_up required: true - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). + description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). name: triggerEventId in: path - name: X-Brand-Id @@ -42119,12 +46381,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggersPatchRequest" + $ref: "#/components/schemas/PayloadContractValidateRequest" example: - title: User Signed Up (v2) + payload: + email: jane@example.com + coupon: SPRING responses: "200": - description: Updated. + description: The verdict — for BOTH valid and invalid payloads (`valid` discriminates). headers: x-request-id: schema: @@ -42157,23 +46421,21 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerRow" + $ref: "#/components/schemas/PayloadContractValidateResponse" example: - triggerEventId: user_signed_up - title: User Signed Up - description: Fires when a user completes signup. - provider: brew_api - payloadSchema: - type: object - fields: - - key: email - type: string - required: true - - key: firstName - type: string - required: false - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' + valid: true + errors: [] + warnings: + - code: unexpected_key + field: coupon + message: Field "coupon" is not declared in the contract + resolvedPayload: + email: jane@example.com + plan: free + prunedKeys: + - coupon + source: stored + contractHash: 22545d11e1d174ba0717ed37c9c4b460c96bed51ae31ea0af18266ebba30f76a "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -42351,54 +46613,6 @@ paths: message: No trigger with that id exists in the brand. suggestion: List triggers with GET /v1/automations/triggers. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: "`CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - contractLockedByPublishedAutomations: - summary: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS - value: - error: - code: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS - type: conflict - message: "A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads." - suggestion: Unpublish or detach the published automations that consume this trigger, or make a backward-compatible change (adding optional fields and loosening enforcement stay allowed). - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: "`TRIGGER_IMMUTABLE`: Integration-provisioned and Brew-event triggers cannot be changed through the API." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - triggerImmutable: - summary: TRIGGER_IMMUTABLE - value: - error: - code: TRIGGER_IMMUTABLE - type: invalid_request - message: Integration-provisioned and Brew-event triggers cannot be changed through the API. - suggestion: Only brew_api and custom triggers can be patched. Toggle integration per-event settings on the Integrations page instead. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -42413,7 +46627,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -42454,7 +46668,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -42486,36 +46700,32 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: triggers.write - delete: - operationId: deleteTrigger + x-brew-rate-policy: triggers.read + /v1/payload-contracts/infer: + post: + operationId: inferPayloadContract tags: - Automations - summary: Delete a trigger - description: "Deletes a custom trigger. Refused with `409 TRIGGER_HAS_DEPENDENT_AUTOMATIONS` while automations still reference it (`details.referencingAutomations` lists each blocker). Idempotent — an already-gone trigger resolves with `deleted: false`." - parameters: - - schema: - type: string - minLength: 1 - maxLength: 256 - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). - example: user_signed_up - required: true - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). - name: triggerEventId - in: path - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + summary: Infer a payload contract from an example + description: |- + Drafts a contract from a real example payload: paste the JSON your system sends and get back typed `fields` — integers and floats split automatically, full ISO timestamps become `date`, object/array shapes are walked recursively. Every inferred field is `required: true` (relax explicitly). + + Spots inference cannot type (nulls, empty arrays) degrade honestly and are listed in `issues` with the offending path. Nothing is saved: review the draft, then `PUT` it to `…/triggers/{triggerEventId}/contract`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PayloadContractInferRequest" + example: + example: + email: jane@example.com + order: + total: 9.5 + placedAt: '2026-04-08T12:00:00.000Z' responses: "200": - description: Deleted (or already gone). + description: The unsaved contract draft. headers: x-request-id: schema: @@ -42548,10 +46758,23 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggersDeleteResponse" + $ref: "#/components/schemas/PayloadContractInferResponse" example: - triggerEventId: user_signed_up - deleted: true + fields: + - key: email + type: string + required: true + - key: order + type: object + required: true + children: + - key: total + type: float + required: true + - key: placedAt + type: date + required: true + issues: [] "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -42641,8 +46864,6 @@ paths: `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. - `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. - `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). headers: x-request-id: @@ -42675,15 +46896,6 @@ paths: message: A brand-scoped credential named a brand other than the one it is bound to. suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. docs: https://docs.brew.new/api-reference/api/authentication - insufficientPermissions: - summary: INSUFFICIENT_PERMISSIONS - value: - error: - code: INSUFFICIENT_PERMISSIONS - type: authorization_error - message: The credential lacks the permission scope the operation needs. - suggestion: Use an API key or session with the required permission. - docs: https://docs.brew.new/api-reference/api/authentication insufficientRole: summary: INSUFFICIENT_ROLE value: @@ -42694,10 +46906,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -42720,44 +46929,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - triggerEventNotFound: - summary: TRIGGER_EVENT_NOT_FOUND - value: - error: - code: TRIGGER_EVENT_NOT_FOUND - type: not_found - message: No trigger with that id exists in the brand. - suggestion: List triggers with GET /v1/automations/triggers. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: "`TRIGGER_HAS_DEPENDENT_AUTOMATIONS`: Automations still reference the trigger; `details.referencingAutomations[]` names them." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - triggerHasDependentAutomations: - summary: TRIGGER_HAS_DEPENDENT_AUTOMATIONS - value: - error: - code: TRIGGER_HAS_DEPENDENT_AUTOMATIONS - type: conflict - message: Automations still reference the trigger; `details.referencingAutomations[]` names them. - suggestion: Delete or detach the automations listed in `details.referencingAutomations` first, then retry DELETE. - docs: https://docs.brew.new/api-reference/api/errors - details: - referencingAutomations: - - automationId: V1StGXR8_Z5jdHi6B-myT - name: Welcome series - published: true "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -42772,7 +46943,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -42813,7 +46984,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -42841,40 +47012,54 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: automations + parameters: + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + x-brew-scope: none x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: triggers.write - /v1/automations/triggers/{triggerEventId}/readiness: + x-brew-rate-policy: triggers.read + /v1/app-emails: get: - operationId: checkTriggerReady + operationId: listAppEmails tags: - - Automations - summary: Check a trigger is ready to fire + - App Emails + summary: List app emails description: |- - Reports whether a fire would start anything right now, and everything a caller needs to wire one. Runs the same credential, brand scope and permission gates a real fire hits, without firing. - - **Use when** wiring an external service, before sending a live event. - - **Returns** `200 { ready, blockers[], payloadSchema, endpoint, publishedAutomations, counts, delivery? }`. A `NO_PUBLISHED_AUTOMATION` blocker with `ready: false` means the trigger resolves and the credential passes, but nothing published listens for it yet, so a fire would start no runs. That stays a 200 because a probe has to report why a fire would not work. + Lists the brand’s app emails: each keyed trigger an app-email setup wired to a design and an automation, under `{ data, pagination }`. - `delivery` appears for an integration trigger whose source sends only the events set up at its end (a Shopify shop sends a topic only to its own webhook): whether Brew has received that event, and what to set up when it has not. It never changes `ready`. + **Use when** checking which emails your app can send by key and whether each one sends now. - **Errors** `404 TRIGGER_EVENT_NOT_FOUND` for an unknown or cross-brand id; `409 TRIGGER_NOT_FIREABLE` for a Brew event trigger, which Brew starts itself. + **Returns** `200` with rows carrying `triggerKey`, `emailId`, `automationId`, the starter and `upgradeAvailable`, `published`, the `sender` (`domain`, `sandbox` or `none`) and `readiness` (`ready`, `teamOnly`, `blockers`), as a fire sends now: from the published version while a newer draft waits, through the sandbox only while the domain is in its first check. - **See also** `fireTrigger`, `getTriggerContract`, `listTriggers`. + **See also** `upsertAppEmail`, `setUpAppEmailKit`, `fireTriggerByKey`. parameters: + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 - schema: type: string minLength: 1 - maxLength: 256 - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). - example: user_signed_up - required: true - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). - name: triggerEventId - in: path + maxLength: 512 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -42886,7 +47071,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: "The readiness report: whether a fire would start runs, and everything needed to wire the caller." + description: A page of app emails. headers: x-request-id: schema: @@ -42919,29 +47104,29 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerReadiness" + $ref: "#/components/schemas/AppEmailsListResponse" example: - triggerEventId: user_signed_up - ready: true - blockers: [] - title: User Signed Up - provider: brew_api - payloadSchema: - type: object - fields: - - key: email - type: string - required: true - - key: firstName - type: string - required: false - endpoint: /v1/automations/triggers/user_signed_up/fire - publishedAutomations: - - automationId: M3nRk8LqW2xT5vBp9YcZd - name: Welcome flow - counts: - automations: 1 - skipped: 0 + data: + - triggerKey: auth.password-reset + triggerEventId: Kq3vXf9LbN2mR7tY1wPzA + title: Password reset + emailId: V1StGXR8_Z5jdHi6B-myT + automationId: aZ8xQ4mN1pR6tY3vL0cBw + starterId: auth.password-reset + starterVersion: 1 + upgradeAvailable: false + published: true + sender: + mode: sandbox + readiness: + ready: true + teamOnly: true + blockers: [] + updatedAt: '2026-10-13T12:00:00.000Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -43084,10 +47269,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -43110,39 +47292,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - triggerEventNotFound: - summary: TRIGGER_EVENT_NOT_FOUND - value: - error: - code: TRIGGER_EVENT_NOT_FOUND - type: not_found - message: No trigger with that id exists in the brand. - suggestion: List triggers with GET /v1/automations/triggers. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: "`TRIGGER_NOT_FIREABLE`: Brew starts this trigger itself when its event happens, so it cannot be fired over the API." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - triggerNotFireable: - summary: TRIGGER_NOT_FIREABLE - value: - error: - code: TRIGGER_NOT_FIREABLE - type: conflict - message: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. - suggestion: Try the automation with its Test action (POST /v1/automations/{automationId}/test) instead. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -43230,46 +47379,28 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.read - /v1/automations/triggers/{triggerEventId}/fire: - post: - operationId: fireTrigger + x-brew-rate-policy: app_emails.read + /v1/app-emails/{triggerKey}: + get: + operationId: getAppEmail tags: - - Automations - summary: Fire a trigger + - App Emails + summary: Get an app email description: |- - Fires the trigger: validates the payload against the stored contract, upserts the contact it describes, then starts one run per published automation attached to the trigger. - - **Use when** an event in your product should start every published automation attached to this trigger. - - **Input** `{ payload }`, validated against the trigger’s `payloadSchema`. Unknown fields are accepted and reported in `warnings[]`. The contact derived from the payload is upserted before the fan-out with the declared top-level scalar fields the fire sent; object and array fields stay template data, and a contract `fallbackValue` is never written onto the contact. A value the contact field’s type refuses is left out and reported as a `FIELD_TYPE_MISMATCH` warning (`field`, `expectedType`, `actualType`); the rest of the contact is written and the runs still start. - - **Returns** `202 { triggerInstanceId, triggerEventId, status, automationRunIds[], notStarted[], publishedAutomations[], counts, warnings[], receivedAt }`. Follow a run with `getAutomationRun`, the fire itself with `getTriggerInstance`. `notStarted[]` names each automation whose run failed to start and why, so a `triggered` fire with no runs explains itself. `counts.skipped` is the recipients a suppression already covered, so `automations: 2, skipped: 2` means nothing was delivered. - - **Idempotency** send a stable `Idempotency-Key` header on every retry. A repeat answers `200` with `status: "replayed"` and the ORIGINAL run ids; nothing fires twice. A fire key is matched against the stored trigger receipt, so it replays for as long as the receipt is kept (90 days), not only the 24 hours of the shared response cache. Use one key per business action and never reuse it for a different fire. - - **Errors** `400 INVALID_PAYLOAD` when the payload does not satisfy the schema, or when `email` (or `user.email`) is not one plain address such as `jane@example.com`: a display name (`Jane `), a list or a line break is refused with `details.errors[].code: "invalid_email"`, and no contact is written; `404 TRIGGER_EVENT_NOT_FOUND` for an unknown or cross-brand id; `422 NO_PUBLISHED_AUTOMATION` when nothing published listens for the trigger; `409 IDEMPOTENCY_CONFLICT` when a key is reused with a different body; `409 TRIGGER_NOT_FIREABLE` for a Brew event trigger, which Brew starts itself (try its automation with `testAutomation`). + Reads one app email by its trigger key, as the list row. - **See also** `checkTriggerReady`, `getTriggerInstance`, `listAutomationRuns`. + **Errors** `404 TRIGGER_KEY_NOT_FOUND` when no app email is set up for the key in this brand. parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries of the same trigger fire and request body. The shared response cache defaults to 24 hours; the trigger receipt replays the original run ids for as long as it is stored (90 days). Use one key per business action and never reuse it for a different fire. - schema: - type: string - minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 - schema: type: string minLength: 1 - maxLength: 256 - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). - example: user_signed_up + maxLength: 64 + pattern: ^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$ + description: The trigger key your code sends the email by. Starter-kit keys (`auth.password-reset`, `lifecycle.welcome`, …) default to their own template. + example: auth.password-reset required: true - description: Trigger id returned by `POST /v1/automations/triggers`. Custom triggers use `tri_…` ids; integration triggers use composite ids (e.g. `clerk:org_…:brand_…:user.created`, URL-encode the colons). - name: triggerEventId + description: The trigger key your code sends the email by. Starter-kit keys (`auth.password-reset`, `lifecycle.welcome`, …) default to their own template. + name: triggerKey in: path - name: X-Brand-Id in: header @@ -43280,19 +47411,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TriggerFireRequest" - example: - payload: - email: jane@example.com - firstName: Jane responses: "200": - description: "Idempotent replay: this `Idempotency-Key` already fired. The ids are the original run’s." + description: The app email. headers: x-request-id: schema: @@ -43325,76 +47446,28 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerFireAccepted" + $ref: "#/components/schemas/AppEmailRow" example: - triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD - triggerEventId: user_signed_up - status: replayed - automationRunIds: - - jh7acthhe99pq4448xh83gn54x8eennb - publishedAutomations: - - automationId: M3nRk8LqW2xT5vBp9YcZd - name: Welcome flow - counts: - automations: 1 - skipped: 0 - warnings: [] - receivedAt: '2026-04-08T12:34:56.789Z' - "202": - description: "Accepted: one run started per matched published automation." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - X-RateLimit-Limit: - schema: - type: integer - description: Requests allowed in the current rolling rate limit window. - example: 100 - required: true - description: Requests allowed in the current rolling rate limit window. - X-RateLimit-Remaining: - schema: - type: integer - description: Requests remaining in the current rolling rate limit window. - example: 99 - required: true - description: Requests remaining in the current rolling rate limit window. - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - example: 1712592360 - required: true - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - content: - application/json: - schema: - $ref: "#/components/schemas/TriggerFireAccepted" - example: - triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD - triggerEventId: user_signed_up - status: triggered - automationRunIds: - - jh7acthhe99pq4448xh83gn54x8eennb - publishedAutomations: - - automationId: M3nRk8LqW2xT5vBp9YcZd - name: Welcome flow - counts: - automations: 1 - skipped: 0 - warnings: [] - receivedAt: '2026-04-08T12:34:56.789Z' + triggerKey: auth.password-reset + triggerEventId: Kq3vXf9LbN2mR7tY1wPzA + title: Password reset + emailId: V1StGXR8_Z5jdHi6B-myT + automationId: aZ8xQ4mN1pR6tY3vL0cBw + starterId: auth.password-reset + starterVersion: 1 + upgradeAvailable: false + published: true + sender: + mode: sandbox + readiness: + ready: true + teamOnly: true + blockers: [] + updatedAt: '2026-10-13T12:00:00.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. - `INVALID_PAYLOAD`: The test payload does not match the trigger’s payload schema. - `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: @@ -43418,15 +47491,6 @@ paths: message: An organization-scoped credential called a brand-scoped operation without naming the brand. suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. docs: https://docs.brew.new/api-reference/api/authentication - invalidPayload: - summary: INVALID_PAYLOAD - value: - error: - code: INVALID_PAYLOAD - type: invalid_request - message: The test payload does not match the trigger’s payload schema. - suggestion: Send fields and types matching the trigger’s payloadSchema (see GET /v1/automations/triggers/{triggerEventId}). - docs: https://docs.brew.new/api-reference/api/errors invalidRequest: summary: INVALID_REQUEST value: @@ -43505,127 +47569,47 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - accountSuspended: - summary: ACCOUNT_SUSPENDED - value: - error: - code: ACCOUNT_SUSPENDED - type: authorization_error - message: The organization behind the credential is suspended. - suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. - docs: https://docs.brew.new/api-reference/api/authentication - brandScopeMismatch: - summary: BRAND_SCOPE_MISMATCH - value: - error: - code: BRAND_SCOPE_MISMATCH - type: authorization_error - message: A brand-scoped credential named a brand other than the one it is bound to. - suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientPermissions: - summary: INSUFFICIENT_PERMISSIONS - value: - error: - code: INSUFFICIENT_PERMISSIONS - type: authorization_error - message: The credential lacks the permission scope the operation needs. - suggestion: Use an API key or session with the required permission. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientRole: - summary: INSUFFICIENT_ROLE - value: - error: - code: INSUFFICIENT_ROLE - type: authorization_error - message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." - suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. - docs: https://docs.brew.new/api-reference/api/authentication - "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - brandNotFound: - summary: BRAND_NOT_FOUND - value: - error: - code: BRAND_NOT_FOUND - type: not_found - message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - suggestion: List the brands this credential can reach with GET /v1/brands. - docs: https://docs.brew.new/api-reference/api/errors - triggerEventNotFound: - summary: TRIGGER_EVENT_NOT_FOUND - value: - error: - code: TRIGGER_EVENT_NOT_FOUND - type: not_found - message: No trigger with that id exists in the brand. - suggestion: List triggers with GET /v1/automations/triggers. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - - `TRIGGER_NOT_FIREABLE`: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT + accountSuspended: + summary: ACCOUNT_SUSPENDED value: error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS + code: ACCOUNT_SUSPENDED + type: authorization_error + message: The organization behind the credential is suspended. + suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. + docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH value: error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - triggerNotFireable: - summary: TRIGGER_NOT_FIREABLE + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS value: error: - code: TRIGGER_NOT_FIREABLE - type: conflict - message: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. - suggestion: Try the automation with its Test action (POST /v1/automations/{automationId}/test) instead. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: "`NO_PUBLISHED_AUTOMATION`: The trigger fired, but no published automation listens for it, so nothing would run." + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `TRIGGER_KEY_NOT_FOUND`: No trigger in the brand has that key. headers: x-request-id: schema: @@ -43639,14 +47623,23 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - noPublishedAutomation: - summary: NO_PUBLISHED_AUTOMATION + brandNotFound: + summary: BRAND_NOT_FOUND value: error: - code: NO_PUBLISHED_AUTOMATION - type: invalid_request - message: The trigger fired, but no published automation listens for it, so nothing would run. - suggestion: Publish an automation wired to this trigger, then fire again. GET /v1/automations/triggers/{triggerEventId}/readiness reports this before you send a live event. + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + triggerKeyNotFound: + summary: TRIGGER_KEY_NOT_FOUND + value: + error: + code: TRIGGER_KEY_NOT_FOUND + type: not_found + message: No trigger in the brand has that key. + suggestion: List triggers with GET /v1/automations/triggers and check each row’s `key`, or set one with PATCH /v1/automations/triggers/{triggerEventId}. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -43662,7 +47655,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -43703,7 +47696,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -43731,76 +47724,43 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations x-brew-brand-scope: brand - x-brew-idempotency: fail_closed + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.write - /v1/automations/triggers/{triggerEventId}/contract: - get: - operationId: getTriggerContract + x-brew-rate-policy: app_emails.read + put: + operationId: upsertAppEmail tags: - - Automations - summary: Get a trigger payload contract + - App Emails + summary: Set up an app email description: |- - The payload contract for the trigger: the STORED contract when one has been declared (`source: "stored"`, with `contractHash`, `version`, `enforcement`), otherwise the derived one (`source: "derived_from_schema"`, built from the trigger’s flat `payloadSchema`). + Sets up one email your app sends by key, in one idempotent call: creates or reuses the trigger with that key and its payload contract, builds the design from a starter-kit template (`starter`) or your own `source`, wires an automation that sends it and publishes it. - Contract fields form a tree: scalars (`string` | `int` | `float` | `boolean` | `date` | `enum`), `object` nodes with `children`, and `array` nodes with `children` (element object shape) or `itemType` (scalar elements). Derived contracts may carry `type: "unknown"` where no type evidence exists. + **Use when** your code should send `brew.send(’’, payload)` and nothing exists yet, or to bring an existing setup up to date. - Pass `?format=` for generated artifacts instead of the JSON contract. + **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger. }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A transactional key never sends from a marketing domain: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. + + **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a marketing domain picked on a transactional key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + + **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`): the setup imports its design the way `importEmail` does. + + **Returns** `201` when this call set the key up, `200` on a re-run, with the ids, `published`, what was `created`, the `sender`, the stored `payloadSchema`, a `fireExample` (path, example payload, curl and `brew.send`) and `warnings`. + + **Errors** `400 APP_EMAIL_STARTER_UNKNOWN`, `400 TRIGGER_KEY_RESERVED`, `404 DOMAIN_NOT_FOUND`, `409 APP_EMAIL_EDITED_IN_BREW`, `409 APP_EMAIL_TRIGGER_IN_USE`, `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS` (a `payloadSchema` that drops or retypes a field the live automation reads), `409 APP_EMAIL_SETUP_IN_PROGRESS` (other setups of the key kept writing first; retry later), `409 TRIGGER_KEY_TAKEN` (another setup of the key won a race), `409 TRIGGER_LIMIT_REACHED`, `413 PAYLOAD_TOO_LARGE` and `422 EMAIL_IMPORT_FAILED` for the source, `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH` (a marketing `domainId` for a transactional key), `422 AUTOMATION_GRAPH_INVALID` and `404 AUTOMATION_NOT_FOUND`, `TRIGGER_EVENT_NOT_FOUND` or `NOT_FOUND` when the trigger, automation or design changes during the setup. + + **See also** `setUpAppEmailKit`, `fireTriggerByKey`, `listAppEmails`. parameters: - - name: format - in: query - required: false - description: Rendering. `json` (default) returns the contract object; `ts` | `zod` | `jsonschema` | `skill` return `{format, content}` — a generated TypeScript type, Zod schema, JSON Schema document, or SKILL.md wiring brief. - schema: - type: string - enum: - - json - - ts - - zod - - jsonschema - - skill - schema: type: string minLength: 1 - maxLength: 256 - description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). - example: user_signed_up + maxLength: 64 + pattern: ^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$ + description: The trigger key your code sends the email by. Starter-kit keys (`auth.password-reset`, `lifecycle.welcome`, …) default to their own template. + example: auth.password-reset required: true - description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). - name: triggerEventId + description: The trigger key your code sends the email by. Starter-kit keys (`auth.password-reset`, `lifecycle.welcome`, …) default to their own template. + name: triggerKey in: path - name: X-Brand-Id in: header @@ -43811,9 +47771,40 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AppEmailPutRequest" + examples: + starter: + summary: A starter-kit key + value: {} + source: + summary: Your own design on your own key + value: + source: + format: jsx + content: Your order {{ trigger.orderId }} shipped. + subject: Your order shipped + secretCode: + summary: A one-time code, kept out of stored copies + value: + source: + format: jsx + content: Your sign-in code is {{ trigger.code }}. + subject: Your sign-in code + payloadSchema: + fields: + - key: code + type: string + required: true + pii: high + secret: true responses: "200": - description: The contract (`format=json`, default) or `{format, content}` for text formats. + description: "Re-run: the app email as it now stands." headers: x-request-id: schema: @@ -43846,39 +47837,147 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PayloadContractGetResponse" + $ref: "#/components/schemas/AppEmailSetupResult" example: - subjectKind: trigger - subjectId: user_signed_up - source: stored - format: json - typeName: UserSignedUpPayload - contractHash: 22545d11e1d174ba0717ed37c9c4b460c96bed51ae31ea0af18266ebba30f76a - version: 2 - enforcement: 'off' - fields: - - key: email - type: string - required: true - - key: plan - type: enum - required: false - enumValues: - - free - - pro - fallbackValue: free - - key: order - type: object - required: false - children: - - key: total - type: float - required: true + triggerKey: auth.password-reset + triggerEventId: Kq3vXf9LbN2mR7tY1wPzA + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: h7Nc2pLq9XvB4sT8mWk1E + automationId: aZ8xQ4mN1pR6tY3vL0cBw + published: true + created: + trigger: false + email: false + automation: false + sender: + mode: sandbox + from: sandbox@email.brew.new + fromName: Acme via Brew sandbox + sandbox: + dailyLimit: 50 + remainingToday: 50 + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + pii: high + - key: confirmationUrl + type: string + required: false + pii: high + secret: true + syncToContact: false + fireExample: + method: POST + path: /v1/automations/triggers/by-key/auth.password-reset/fire + examplePayload: + email: ada@example.com + confirmationUrl: https://app.example.com/auth/confirm?token=4f9c2a + curl: |- + curl -X POST 'https://brew.new/api/v1/automations/triggers/by-key/auth.password-reset/fire' \ + -H "Authorization: Bearer $BREW_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"payload":{"email":"ada@example.com"}}' + sdk: "await brew.send('auth.password-reset', { email: 'ada@example.com', confirmationUrl: 'https://app.example.com/auth/confirm?token=4f9c2a' })" + warnings: + - code: SANDBOX_SENDER + message: '"auth.password-reset" sends from the Brew sandbox to members of your organization only. Add and verify a transactional sending domain, then run the setup again.' + field: domainId + "201": + description: Set up for the first time. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/AppEmailSetupResult" + example: + triggerKey: auth.password-reset + triggerEventId: Kq3vXf9LbN2mR7tY1wPzA + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: h7Nc2pLq9XvB4sT8mWk1E + automationId: aZ8xQ4mN1pR6tY3vL0cBw + published: true + created: + trigger: true + email: true + automation: true + sender: + mode: sandbox + from: sandbox@email.brew.new + fromName: Acme via Brew sandbox + sandbox: + dailyLimit: 50 + remainingToday: 50 + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + pii: high + - key: confirmationUrl + type: string + required: false + pii: high + secret: true + syncToContact: false + fireExample: + method: POST + path: /v1/automations/triggers/by-key/auth.password-reset/fire + examplePayload: + email: ada@example.com + confirmationUrl: https://app.example.com/auth/confirm?token=4f9c2a + curl: |- + curl -X POST 'https://brew.new/api/v1/automations/triggers/by-key/auth.password-reset/fire' \ + -H "Authorization: Bearer $BREW_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"payload":{"email":"ada@example.com"}}' + sdk: "await brew.send('auth.password-reset', { email: 'ada@example.com', confirmationUrl: 'https://app.example.com/auth/confirm?token=4f9c2a' })" + warnings: + - code: SANDBOX_SENDER + message: '"auth.password-reset" sends from the Brew sandbox to members of your organization only. Add and verify a transactional sending domain, then run the setup again.' + field: domainId "400": description: |- + `APP_EMAIL_STARTER_UNKNOWN`: The starter names no starter-kit template, or a different one than the kit key being set up. + + `AUTOMATION_GRAPH_INVALID`: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + + `TRIGGER_KEY_RESERVED`: That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit. headers: x-request-id: schema: @@ -43892,6 +47991,29 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + appEmailStarterUnknown: + summary: APP_EMAIL_STARTER_UNKNOWN + value: + error: + code: APP_EMAIL_STARTER_UNKNOWN + type: invalid_request + message: The starter names no starter-kit template, or a different one than the kit key being set up. + suggestion: Use a starter-kit key such as `auth.password-reset`; on a kit key, the starter must be that key. + docs: https://docs.brew.new/api-reference/api/errors + automationGraphInvalid: + summary: AUTOMATION_GRAPH_INVALID + value: + error: + code: AUTOMATION_GRAPH_INVALID + type: invalid_request + message: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. + suggestion: Fix every issue reported in `details.issues` then resubmit. + docs: https://docs.brew.new/api-reference/api/errors + details: + issues: + - kind: missing_send_email_domain + nodeId: send-1 + message: sendEmail node send-1 names no domainId. brandIdRequired: summary: BRAND_ID_REQUIRED value: @@ -43910,6 +48032,15 @@ paths: message: "The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." suggestion: Fix the field reported in `param` and retry. docs: https://docs.brew.new/api-reference/api/errors + triggerKeyReserved: + summary: TRIGGER_KEY_RESERVED + value: + error: + code: TRIGGER_KEY_RESERVED + type: invalid_request + message: "That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit." + suggestion: Pick a key outside `brew.*`, `kit` and `by-key`. Under `auth.*` only the starter-kit auth keys are allowed. + docs: https://docs.brew.new/api-reference/api/errors "401": description: |- `API_KEY_REVOKED`: The API key was revoked. @@ -44017,8 +48148,14 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- + `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. + + `NOT_FOUND`: No v1 resource lives at this path. + `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. headers: x-request-id: @@ -44033,6 +48170,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + automationNotFound: + summary: AUTOMATION_NOT_FOUND + value: + error: + code: AUTOMATION_NOT_FOUND + type: not_found + message: No automation with that id exists in the brand. + suggestion: List automations with GET /v1/automations. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -44042,6 +48188,24 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + domainNotFound: + summary: DOMAIN_NOT_FOUND + value: + error: + code: DOMAIN_NOT_FOUND + type: not_found + message: No domain with that id exists in the brand. + suggestion: Use GET /v1/domains to choose a verified domain. + docs: https://docs.brew.new/api-reference/api/errors + notFound: + summary: NOT_FOUND + value: + error: + code: NOT_FOUND + type: not_found + message: No v1 resource lives at this path. + suggestion: Check the request path and HTTP method against the API reference. + docs: https://docs.brew.new/api-reference/api/api-introduction triggerEventNotFound: summary: TRIGGER_EVENT_NOT_FOUND value: @@ -44051,6 +48215,161 @@ paths: message: No trigger with that id exists in the brand. suggestion: List triggers with GET /v1/automations/triggers. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `APP_EMAIL_EDITED_IN_BREW`: The app email’s design was edited in Brew since it was set up, so the setup did not replace it. + + `APP_EMAIL_SETUP_IN_PROGRESS`: Other setups of this app email’s key were writing at the same time, so this one stopped before finishing. + + `APP_EMAIL_TRIGGER_IN_USE`: The trigger with this key already starts a published automation that no app-email setup made, so setting the key up would send a second email on every fire. + + `CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads. + + `TRIGGER_KEY_TAKEN`: Another trigger in the brand already uses that key. + + `TRIGGER_LIMIT_REACHED`: The brand already holds the maximum number of trigger events. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + appEmailEditedInBrew: + summary: APP_EMAIL_EDITED_IN_BREW + value: + error: + code: APP_EMAIL_EDITED_IN_BREW + type: conflict + message: The app email’s design was edited in Brew since it was set up, so the setup did not replace it. + suggestion: "Keep the edited design by sending the same starter or source again, or pass `overwrite: true` to replace it." + docs: https://docs.brew.new/api-reference/api/errors + details: + triggerKey: auth.password-reset + emailId: V1StGXR8_Z5jdHi6B-myT + linkedEmailVersionId: h7Nc2pLq9XvB4sT8mWk1E + currentEmailVersionId: p2Rk8LmQ4xZ7vN1cT5bWy + appEmailSetupInProgress: + summary: APP_EMAIL_SETUP_IN_PROGRESS + value: + error: + code: APP_EMAIL_SETUP_IN_PROGRESS + type: conflict + message: Other setups of this app email’s key were writing at the same time, so this one stopped before finishing. + suggestion: Run the setup again once the others finish; it picks up what they left. + docs: https://docs.brew.new/api-reference/api/errors + appEmailTriggerInUse: + summary: APP_EMAIL_TRIGGER_IN_USE + value: + error: + code: APP_EMAIL_TRIGGER_IN_USE + type: conflict + message: The trigger with this key already starts a published automation that no app-email setup made, so setting the key up would send a second email on every fire. + suggestion: Unpublish or delete the automations in `details.automationIds`, then run the setup again, or keep sending from them. + docs: https://docs.brew.new/api-reference/api/errors + details: + triggerKey: order.shipped + triggerEventId: xK4p9Qm2Lr7Tz1Vb8NcWd + automationIds: + - b7Hq2Zp9Lm4Xr1Tc6VwKe + contractLockedByPublishedAutomations: + summary: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS + value: + error: + code: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS + type: conflict + message: "A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads." + suggestion: Unpublish or detach the published automations that consume this trigger, or make a backward-compatible change (adding optional fields and loosening enforcement stay allowed). + docs: https://docs.brew.new/api-reference/api/errors + triggerKeyTaken: + summary: TRIGGER_KEY_TAKEN + value: + error: + code: TRIGGER_KEY_TAKEN + type: conflict + message: Another trigger in the brand already uses that key. + suggestion: "Choose another key, or clear it on the trigger that holds it with PATCH /v1/automations/triggers/{triggerEventId} and `key: null`." + docs: https://docs.brew.new/api-reference/api/errors + triggerLimitReached: + summary: TRIGGER_LIMIT_REACHED + value: + error: + code: TRIGGER_LIMIT_REACHED + type: conflict + message: The brand already holds the maximum number of trigger events. + suggestion: Delete an unused trigger with `DELETE /v1/automations/triggers/{triggerEventId}`, then retry. Each brand holds at most 100. + docs: https://docs.brew.new/api-reference/api/errors + "413": + description: "`PAYLOAD_TOO_LARGE`: The request body exceeds the route cap." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + payloadTooLarge: + summary: PAYLOAD_TOO_LARGE + value: + error: + code: PAYLOAD_TOO_LARGE + type: invalid_request + message: The request body exceeds the route cap. + suggestion: Reduce the payload size and retry. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: |- + `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: The `domainId` names a marketing domain for a transactional app email (auth, trial ending, payment failed), which would send it with an unsubscribe link and never to anyone who unsubscribed. + + `EMAIL_IMPORT_FAILED`: The supplied markup could not be imported as an email. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + appEmailDomainPurposeMismatch: + summary: APP_EMAIL_DOMAIN_PURPOSE_MISMATCH + value: + error: + code: APP_EMAIL_DOMAIN_PURPOSE_MISMATCH + type: invalid_request + message: The `domainId` names a marketing domain for a transactional app email (auth, trial ending, payment failed), which would send it with an unsubscribe link and never to anyone who unsubscribed. + suggestion: "Pass a transactional domain, or leave `domainId` out: the setup then picks a verified transactional domain, or the sandbox until one verifies." + docs: https://docs.brew.new/api-reference/api/errors + details: + triggerKey: auth.password-reset + domainId: k57c2nq8xw1v3m9d4pyr6tb0hs + messageClass: transactional + sendingPurpose: marketing + emailImportFailed: + summary: EMAIL_IMPORT_FAILED + value: + error: + code: EMAIL_IMPORT_FAILED + type: invalid_request + message: The supplied markup could not be imported as an email. + suggestion: Check that `content` is valid for the declared `format` and retry. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -44065,7 +48384,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 20 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -44106,7 +48425,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 20 remaining: 0 reset: 1712592360 consumed: false @@ -44135,38 +48454,39 @@ paths: suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations + x-brew-scopes: + - automations + - emails x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: triggers.read - put: - operationId: setTriggerContract + x-brew-rate-policy: app_emails.write + /v1/app-emails/kit: + post: + operationId: setUpAppEmailKit tags: - - Automations - summary: Declare a trigger payload contract + - App Emails + summary: Set up the app email kit description: |- - Declares the stored payload contract for the trigger, changes how it is enforced, or both, in one call. `fields` and `enforcement` are both optional, so arming enforcement is `{ "enforcement": "strict" }`. While a PUBLISHED automation consumes the trigger the change must stay backward compatible (the ratchet below); loosening is always allowed. + Sets up the starter kit: auth emails (verify email, magic link, one-time code, password reset, invite, email change, reauthentication, security notice) and lifecycle emails (welcome, trial ending, payment failed, win-back). Each key is set up exactly as `upsertAppEmail` does with its own starter, a few at a time. - **Use when** you want generated types, a dry-run validator (`validateTriggerPayload`) and drift detection for the JSON your service fires, or when you flip `enforcement`. Draft the tree from a real payload with `inferPayloadContract`. Trigger contracts MUST keep a top-level `{ key: "email", type: "string", required: true }` field so automations can resolve a recipient. - - **Input** `fields` is a tree: scalars (`string`, `int`, `float`, `boolean`, `date`, `enum`), `object` nodes with `children`, `array` nodes with `children` (object elements), `itemType` (scalar elements) or neither. The tree is validated BEFORE any write: unknown-typed nodes are refused, keys are unique per level, an `object` declares at least one child, and no two top-level fields that a fire copies onto the contact fold to one contact field (`plan` and `Plan`, `team_size` and `Team Size`; rename one or set `syncToContact: false` on one). `enforcement`: `off` (advisory, the default for a new contract), `prune` (drop undeclared keys before the run), `strict` (reject a payload carrying them). A `fallbackValue` satisfies `required` in EVERY mode: a producer that omits the key never fails a fire, every recipient gets the fallback. The version bumps only when the behavioral surface changes (keys, types, required, fallbacks; not descriptions or examples). Declaring a contract never changes fire behavior by itself. Loosening is the escape hatch when `strict` rejects a drifted producer in production. + **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a marketing `domainId` sets up the marketing keys and answers each transactional key `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. - **Returns** `200` with the same body a follow-up `getTriggerContract` returns plus `warnings[]` (always present, often empty): `ENFORCEMENT_LOOSENED_WHILE_PUBLISHED` when enforcement went down while published automations consume the trigger (tightening back is refused until they are unpublished or detached), and one `REQUIRED_FIELD_SATISFIED_BY_FALLBACK` per required field that declares a fallback (`field` is its dotted path). Ratchet while a published automation consumes the trigger: tighten `off→prune`, `off→strict`, `prune→strict` = `409`; loosen = `200` + warning; remove, retype or enum-narrow a field, or add a required field WITHOUT a fallback = `409`; add an optional field, widen a type, add an enum value, or add a required field WITH a fallback = `200` (+ warning per fallback). With no published consumer every change is `200`. + **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. - **Errors** `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS` for a change the ratchet refuses (`message` names the incompatibility; unpublish or detach the consumers, or make a compatible change); `404 TRIGGER_EVENT_NOT_FOUND`; `422 TRIGGER_IMMUTABLE` for a Brew event trigger, whose contract Brew owns; `400 INVALID_REQUEST` (`param: "fields"`) when the tree fails structural validation, or when a body with no `fields` targets a trigger that has no stored contract yet. + **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`), as for `upsertAppEmail`. - **See also** `getTriggerContract`, `validateTriggerPayload`, `inferPayloadContract`, `updateAutomation` (publishing a consumer starts the ratchet). + **See also** `upsertAppEmail`, `listAppEmails`. parameters: - - schema: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: type: string minLength: 1 - maxLength: 256 - description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). - example: user_signed_up - required: true - description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). - name: triggerEventId - in: path + maxLength: 100 + example: api-request-2026-04-08-001 - name: X-Brand-Id in: header required: false @@ -44181,30 +48501,15 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PayloadContractPutRequest" + $ref: "#/components/schemas/AppEmailsKitRequest" example: - fields: - - key: email - type: string - required: true - - key: plan - type: enum - required: false - enumValues: - - free - - pro - fallbackValue: free - description: Billing plan at signup time. - - key: order - type: object - required: false - children: - - key: total - type: float - required: true + sets: + - auth + - lifecycle + stack: better-auth responses: "200": - description: Stored. The contract as a follow-up GET would return it, plus `warnings[]` about the write. + description: One result per key, and the stack’s snippets. headers: x-request-id: schema: @@ -44237,37 +48542,67 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PayloadContractPutResponse" + $ref: "#/components/schemas/AppEmailsKitResponse" example: - subjectKind: trigger - subjectId: user_signed_up - source: stored - format: json - typeName: UserSignedUpPayload - contractHash: 22545d11e1d174ba0717ed37c9c4b460c96bed51ae31ea0af18266ebba30f76a - version: 3 - enforcement: prune - fields: - - key: email - type: string - required: true - - key: plan - type: enum - required: false - enumValues: - - free - - pro - fallbackValue: free - - key: order - type: object - required: false - children: - - key: total - type: float - required: true - warnings: - - code: ENFORCEMENT_LOOSENED_WHILE_PUBLISHED - message: Enforcement loosened from strict to prune while 1 published automation(s) consume this trigger. Loosening is allowed as the operator escape hatch, but tightening back to strict will be refused with 409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS until they are unpublished or detached. + results: + - ok: true + triggerKey: auth.password-reset + triggerEventId: Kq3vXf9LbN2mR7tY1wPzA + emailId: V1StGXR8_Z5jdHi6B-myT + emailVersionId: h7Nc2pLq9XvB4sT8mWk1E + automationId: aZ8xQ4mN1pR6tY3vL0cBw + published: true + created: + trigger: true + email: true + automation: true + sender: + mode: sandbox + from: sandbox@email.brew.new + fromName: Acme via Brew sandbox + sandbox: + dailyLimit: 50 + remainingToday: 50 + payloadSchema: + type: object + fields: + - key: email + type: string + required: true + pii: high + - key: confirmationUrl + type: string + required: false + pii: high + secret: true + syncToContact: false + fireExample: + method: POST + path: /v1/automations/triggers/by-key/auth.password-reset/fire + examplePayload: + email: ada@example.com + confirmationUrl: https://app.example.com/auth/confirm?token=4f9c2a + curl: |- + curl -X POST 'https://brew.new/api/v1/automations/triggers/by-key/auth.password-reset/fire' \ + -H "Authorization: Bearer $BREW_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"payload":{"email":"ada@example.com"}}' + sdk: "await brew.send('auth.password-reset', { email: 'ada@example.com', confirmationUrl: 'https://app.example.com/auth/confirm?token=4f9c2a' })" + warnings: + - code: SANDBOX_SENDER + message: '"auth.password-reset" sends from the Brew sandbox to members of your organization only. Add and verify a transactional sending domain, then run the setup again.' + field: domainId + stack: + provider: better-auth + authDelivery: code + note: Better Auth hands each hook the link or code and Brew sends the email. + keys: + - auth.verify-email + - auth.password-reset + snippets: + - title: Email and password + file: lib/auth.ts + code: "waitUntil(brew.send('auth.password-reset', { email: user.email, confirmationUrl: url }))" "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -44410,10 +48745,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -44436,17 +48768,11 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - triggerEventNotFound: - summary: TRIGGER_EVENT_NOT_FOUND - value: - error: - code: TRIGGER_EVENT_NOT_FOUND - type: not_found - message: No trigger with that id exists in the brand. - suggestion: List triggers with GET /v1/automations/triggers. - docs: https://docs.brew.new/api-reference/api/errors "409": - description: "`CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads." + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -44460,39 +48786,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - contractLockedByPublishedAutomations: - summary: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT value: error: - code: CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS + code: IDEMPOTENCY_CONFLICT type: conflict - message: "A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads." - suggestion: Unpublish or detach the published automations that consume this trigger, or make a backward-compatible change (adding optional fields and loosening enforcement stay allowed). - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: "`TRIGGER_IMMUTABLE`: Integration-provisioned and Brew-event triggers cannot be changed through the API." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - triggerImmutable: - summary: TRIGGER_IMMUTABLE + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS value: error: - code: TRIGGER_IMMUTABLE - type: invalid_request - message: Integration-provisioned and Brew-event triggers cannot be changed through the API. - suggestion: Only brew_api and custom triggers can be patched. Toggle integration per-event settings on the Integrations page instead. - docs: https://docs.brew.new/api-reference/api/errors + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -44507,7 +48818,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 20 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -44548,7 +48859,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 20 remaining: 0 reset: 1712592360 consumed: false @@ -44577,31 +48888,57 @@ paths: suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: automations + x-brew-scopes: + - automations + - emails x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: triggers.write - /v1/automations/triggers/{triggerEventId}/contract/validate: - post: - operationId: validateTriggerPayload + x-brew-rate-policy: app_emails.write + /v1/automations/trigger-instances: + get: + operationId: listTriggerInstances tags: - Automations - summary: Validate a trigger payload + summary: List trigger instances description: |- - Dry-runs a payload against the trigger's contract — the SAME validator the live path runs, against the stored contract when one exists (derived otherwise; `source` says which). Nothing fires, sends, or writes. + Lists fired trigger-event instances, newest first, under `{ data, pagination }`: the audit log of every inbound fire, from the API (`source: "api"`), an integration webhook (`source: "integration"`), or Brew itself (`source: "brew_event"`, a contact unsubscribing). Each row links the fire to the automations it matched and the runs it started (`automationRunIds`). - An invalid payload is still a `200`: the verdict is the response — read `valid`, `errors`, `warnings`, `resolvedPayload` (fallbacks applied), and `prunedKeys`. Pass `enforcement: "prune" | "strict"` to preview a different mode than the stored one. Fire for real with `POST …/fire`. + **Use when** confirming that fires arrived, or following a fire to its runs and sends. + + **Input** `triggerEventId` to narrow to one trigger; `limit` and `cursor` (an opaque native cursor). + + **Returns** `200` with a page of `TriggerInstance` rows. + + **Errors** `400 INVALID_REQUEST` for an unknown query key or a malformed cursor. + + **See also** `getTriggerInstance`, `fireTrigger`, `listAutomationRuns` (`?triggerInstanceId=`), `listSends` (`?triggerInstanceId=`). parameters: - schema: type: string minLength: 1 maxLength: 256 - description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). - example: user_signed_up - required: true - description: Trigger id returned by `POST /v1/automations/triggers` (integration triggers use composite ids — URL-encode the colons). + required: false name: triggerEventId - in: path + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 8192 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -44611,19 +48948,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PayloadContractValidateRequest" - example: - payload: - email: jane@example.com - coupon: SPRING responses: "200": - description: The verdict — for BOTH valid and invalid payloads (`valid` discriminates). + description: A page of fired trigger-event instances. headers: x-request-id: schema: @@ -44656,21 +48983,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PayloadContractValidateResponse" + $ref: "#/components/schemas/TriggerInstancesListResponse" example: - valid: true - errors: [] - warnings: - - code: unexpected_key - field: coupon - message: Field "coupon" is not declared in the contract - resolvedPayload: - email: jane@example.com - plan: free - prunedKeys: - - coupon - source: stored - contractHash: 22545d11e1d174ba0717ed37c9c4b460c96bed51ae31ea0af18266ebba30f76a + data: + - triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD + source: api + provider: brew_api + triggerEventId: user_signed_up + state: processed + matchedAutomationIds: + - M3nRk8LqW2xT5vBp9YcZd + automationRunIds: + - jh7acthhe99pq4448xh83gn54x8eennb + attempts: 1 + receivedAt: '2026-04-08T12:34:56.789Z' + processedAt: '2026-04-08T12:34:57.100Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -44813,10 +49144,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -44839,15 +49167,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - triggerEventNotFound: - summary: TRIGGER_EVENT_NOT_FOUND - value: - error: - code: TRIGGER_EVENT_NOT_FOUND - type: not_found - message: No trigger with that id exists in the brand. - suggestion: List triggers with GET /v1/automations/triggers. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -44935,32 +49254,45 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: triggers.read - /v1/payload-contracts/infer: - post: - operationId: inferPayloadContract + x-brew-rate-policy: automation.runs.read + /v1/automations/trigger-instances/{triggerInstanceId}: + get: + operationId: getTriggerInstance tags: - Automations - summary: Infer a payload contract from an example + summary: Get a trigger instance description: |- - Drafts a contract from a real example payload: paste the JSON your system sends and get back typed `fields` — integers and floats split automatically, full ISO timestamps become `date`, object/array shapes are walked recursively. Every inferred field is `required: true` (relax explicitly). + Reads one fired trigger-event instance as the bare row: its `state`, the automations it matched, the `automationRunIds` it started and the `rejectionReason` when it was refused. - Spots inference cannot type (nulls, empty arrays) degrade honestly and are listed in `issues` with the offending path. Nothing is saved: review the draft, then `PUT` it to `…/triggers/{triggerEventId}/contract`. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PayloadContractInferRequest" - example: - example: - email: jane@example.com - order: - total: 9.5 - placedAt: '2026-04-08T12:00:00.000Z' + **Use when** following the `triggerInstanceId` a fire returned to the runs it started. + + **Input** `triggerInstanceId` in the path. + + **Returns** `200` with the row. + + **Errors** `404 TRIGGER_INSTANCE_NOT_FOUND` for an unknown or cross-brand id. + + **See also** `listTriggerInstances`, `getAutomationRun`, `listSends` (`?triggerInstanceId=`). + parameters: + - schema: + type: string + minLength: 1 + maxLength: 64 + required: true + name: triggerInstanceId + in: path + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The unsaved contract draft. + description: The fired trigger-event instance. headers: x-request-id: schema: @@ -44993,23 +49325,20 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PayloadContractInferResponse" + $ref: "#/components/schemas/TriggerInstance" example: - fields: - - key: email - type: string - required: true - - key: order - type: object - required: true - children: - - key: total - type: float - required: true - - key: placedAt - type: date - required: true - issues: [] + triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD + source: api + provider: brew_api + triggerEventId: user_signed_up + state: processed + matchedAutomationIds: + - M3nRk8LqW2xT5vBp9YcZd + automationRunIds: + - jh7acthhe99pq4448xh83gn54x8eennb + attempts: 1 + receivedAt: '2026-04-08T12:34:56.789Z' + processedAt: '2026-04-08T12:34:57.100Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -45099,6 +49428,8 @@ paths: `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). headers: x-request-id: @@ -45131,6 +49462,15 @@ paths: message: A brand-scoped credential named a brand other than the one it is bound to. suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication insufficientRole: summary: INSUFFICIENT_ROLE value: @@ -45141,7 +49481,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `TRIGGER_INSTANCE_NOT_FOUND`: No fired trigger instance with that id exists in the brand. headers: x-request-id: schema: @@ -45164,6 +49507,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + triggerInstanceNotFound: + summary: TRIGGER_INSTANCE_NOT_FOUND + value: + error: + code: TRIGGER_INSTANCE_NOT_FOUND + type: not_found + message: No fired trigger instance with that id exists in the brand. + suggestion: List recent fires with GET /v1/automations/trigger-instances. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -45247,65 +49599,41 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - parameters: - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - x-brew-scope: none + x-brew-scope: automations x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: triggers.read - /v1/automations/trigger-instances: - get: - operationId: listTriggerInstances + x-brew-rate-policy: automation.runs.read + /v1/contacts: + post: + operationId: upsertContacts tags: - - Automations - summary: List trigger instances + - Contacts + summary: Create or update contacts description: |- - Lists fired trigger-event instances, newest first, under `{ data, pagination }`: the audit log of every inbound fire, from the API (`source: "api"`), an integration webhook (`source: "integration"`), or Brew itself (`source: "brew_event"`, a contact unsubscribing). Each row links the fire to the automations it matched and the runs it started (`automationRunIds`). + Upserts a single contact OR a batch (`{ contacts: [...] }`, up to 1000 rows). Unknown custom fields auto-create field definitions on the brand, typed from the batch (native booleans/numbers, ISO-date strings → `date`, anything else `string`). `fieldsCreated` names only the definitions this request created: when two requests create the same new field at once, the first definition wins and the values in the other request are coerced or refused against it, exactly as if it had been sent second. Create the field first with `POST /v1/fields` to pin its type. - **Use when** confirming that fires arrived, or following a fire to its runs and sends. + Custom-field names are folded to one key: words split at spaces, `_`, `-` and a lower-to-upper letter change, then joined camelCase (`Plan`, `PLAN` and `plan` are the field `plan`; `team_size` and `Team Size` are `teamSize`). A key written under its folded name adds a `FIELD_NAME_NORMALIZED` warning (`original`, `normalized`). Two keys that fold to one field (`plan` and `Plan`) would lose one value, so they are a `422 FIELD_NAME_COLLISION` on a single upsert (nothing written) and a per-row `errors[]` entry (`code: FIELD_NAME_COLLISION`) in a batch. - **Input** `triggerEventId` to narrow to one trigger; `limit` and `cursor` (an opaque native cursor). + Custom-field values are coerced to the definition's type (dates → epoch ms, `"1,234"` → 1234, `yes`/`no` → booleans). A value that cannot be coerced (`"$49"` in a number field) is a `409 FIELD_TYPE_MISMATCH` on a single upsert and a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) in a batch — the other rows still land. A value that was converted adds one `warnings[]` entry per field (`code: FIELD_TYPE_COERCED`, `field`, `from`, `to`); a date written to a `date` field is not reported. A `customFields` key is never stored under a core field name. One whose loss would change the contact (`subscribed`, `First Name`, `consent`, a suppression flag or domain opt-out, or an `email` naming another address) is a `422 CORE_FIELD_IMMUTABLE` on a single upsert (nothing written) and a per-row `errors[]` entry (`code: CORE_FIELD_IMMUTABLE`, `field`) in a batch; send `firstName`, `lastName`, `subscribed` and `consent` at the top level. A key naming a field Brew manages (`createdAt`, `validationStatus`) is dropped with a `warnings[]` entry (`code: CORE_FIELD_IGNORED`, `field`) and the contact is still written, and an `email` key repeating the contact address is ignored. - **Returns** `200` with a page of `TriggerInstance` rows. + Dates: a JSON number is epoch milliseconds. A string may be ISO (`2026-04-03`, with or without a time and offset), a Unix timestamp in seconds (10 digits) or milliseconds (13), a month name (`3 Apr 2026`, `April 3, 2026`, `03-Apr-26`), year-first (`2026/04/03`), compact (`20260403`), month and year read as the 1st (`2026-04`), dotted day-first (`03.04.2026`), or a slash or dash date. A slash or dash date with a day over 12 reads that way (`13/04/2026` is 13 April). One that reads either way (`03/04/2026`) follows the day/month order the batch's other dates in that field prove, else month/day, with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Send `YYYY-MM-DD` to be unambiguous. - **Errors** `400 INVALID_REQUEST` for an unknown query key or a malformed cursor. + Optional `consent: { source: "api" | "form" | "import", capturedAt?, policyVersion?, evidence? }` records marketing consent provenance on the contact (per row, or once at batch level as the default). It never changes `subscribed`. An inline marketing send later needs the contact to be subscribed, and warns when no record exists. - **See also** `getTriggerInstance`, `fireTrigger`, `listAutomationRuns` (`?triggerInstanceId=`), `listSends` (`?triggerInstanceId=`). + `subscribed: false` unsubscribes the contact, new or existing. `subscribed: true` only applies to a NEW contact: Brew never re-subscribes a contact who opted out, so a single upsert asking for it is a `422 RESUBSCRIBE_NOT_ALLOWED` (nothing written), and a batch row keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). + + Single: `201` with `{ contact, created, fieldsCreated, warnings }`. Batch: `200` with `{ summary, fieldsCreated, errors, warnings }` — or `207` when some rows failed (per-row errors in `errors[]`). parameters: - - schema: - type: string - minLength: 1 - maxLength: 256 - required: false - name: triggerEventId - in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 + - name: Idempotency-Key + in: header required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - - schema: + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: type: string minLength: 1 - maxLength: 8192 - required: false - name: cursor - in: query + maxLength: 100 + example: api-request-2026-04-08-001 - name: X-Brand-Id in: header required: false @@ -45315,9 +49643,39 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsPostRequest" + examples: + single: + summary: Upsert a single contact + value: + email: jane@example.com + firstName: Jane + customFields: + plan: enterprise + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + evidence: newsletter checkbox on /signup + batch: + summary: Upsert a batch + value: + contacts: + - email: jane@example.com + firstName: Jane + - email: john@example.com + firstName: John + consent: + source: import + policyVersion: 2026-03 responses: "200": - description: A page of fired trigger-event instances. + description: Batch upsert applied (every row succeeded). headers: x-request-id: schema: @@ -45350,25 +49708,103 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerInstancesListResponse" + $ref: "#/components/schemas/ContactsPostBatchResponse" example: - data: - - triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD - source: api - provider: brew_api - triggerEventId: user_signed_up - state: processed - matchedAutomationIds: - - M3nRk8LqW2xT5vBp9YcZd - automationRunIds: - - jh7acthhe99pq4448xh83gn54x8eennb - attempts: 1 - receivedAt: '2026-04-08T12:34:56.789Z' - processedAt: '2026-04-08T12:34:57.100Z' - pagination: - limit: 100 - cursor: null - hasMore: false + summary: + inserted: 2 + updated: 0 + failed: 0 + fieldsCreated: [] + errors: [] + warnings: [] + "201": + description: Single upsert applied. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsPostSingleResponse" + example: + contact: + email: jane@example.com + firstName: Jane + lastName: Doe + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: enterprise + revenue: 4200 + created: true + fieldsCreated: + - plan + warnings: [] + "207": + description: Batch partially applied — inspect `errors[]` for each failed row. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsPostBatchResponse" + example: + summary: + inserted: 1 + updated: 0 + failed: 2 + fieldsCreated: [] + errors: + - email: not-an-email + code: INVALID_EMAIL + message: not-an-email is not a valid email address. + - email: john@example.com + code: FIELD_TYPE_MISMATCH + field: customFields.plan + message: Field 'customFields.plan' is type 'number' but received string '$49'. + warnings: [] "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -45452,6 +49888,34 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + insufficientCredits: + summary: INSUFFICIENT_CREDITS + value: + error: + code: INSUFFICIENT_CREDITS + type: payment_required + message: The credit balance is below what this operation requires; `details.cost` is the amount needed. + suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. + docs: https://docs.brew.new/api-reference/api/credits + details: + cost: 5 + remaining: 0 + planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -45534,6 +49998,133 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field. + + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + fieldTypeMismatch: + summary: FIELD_TYPE_MISMATCH + value: + error: + code: FIELD_TYPE_MISMATCH + type: conflict + message: A value does not match the declared type of its custom field. + suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. + docs: https://docs.brew.new/api-reference/api/errors + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + "422": + description: |- + `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. + + `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + + `FIELD_NAME_COLLISION`: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. + + `INVALID_EMAIL`: A contact email is not a deliverable address shape. + + `MISSING_EMAIL`: A contact row has no `email`, the primary key. + + `RESUBSCRIBE_NOT_ALLOWED`: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + batchTooLarge: + summary: BATCH_TOO_LARGE + value: + error: + code: BATCH_TOO_LARGE + type: invalid_request + message: A batch write exceeds the per-request row cap. + suggestion: Split the request into smaller batches and retry. + docs: https://docs.brew.new/api-reference/api/errors + coreFieldImmutable: + summary: CORE_FIELD_IMMUTABLE + value: + error: + code: CORE_FIELD_IMMUTABLE + type: invalid_request + message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + suggestion: Use a custom field name instead. + docs: https://docs.brew.new/api-reference/api/errors + fieldNameCollision: + summary: FIELD_NAME_COLLISION + value: + error: + code: FIELD_NAME_COLLISION + type: invalid_request + message: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. + suggestion: Send one of the two keys, or rename one so it names a different field. + docs: https://docs.brew.new/api-reference/api/errors + invalidEmail: + summary: INVALID_EMAIL + value: + error: + code: INVALID_EMAIL + type: invalid_request + message: A contact email is not a deliverable address shape. + suggestion: Fix the address named in `param` and retry. + docs: https://docs.brew.new/api-reference/api/errors + missingEmail: + summary: MISSING_EMAIL + value: + error: + code: MISSING_EMAIL + type: invalid_request + message: A contact row has no `email`, the primary key. + suggestion: Provide an email address for every contact in the request body. + docs: https://docs.brew.new/api-reference/api/errors + resubscribeNotAllowed: + summary: RESUBSCRIBE_NOT_ALLOWED + value: + error: + code: RESUBSCRIBE_NOT_ALLOWED + type: invalid_request + message: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. + suggestion: Drop `subscribed` from the write; a contact who opted out re-subscribes through their own action, or from their contact page in the Brew app once they ask. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -45548,7 +50139,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -45589,7 +50180,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -45617,37 +50208,110 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: automations + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: none - x-brew-credited: false - x-brew-rate-policy: automation.runs.read - /v1/automations/trigger-instances/{triggerInstanceId}: + x-brew-idempotency: replay + x-brew-credited: true get: - operationId: getTriggerInstance + operationId: listContacts tags: - - Automations - summary: Get a trigger instance + - Contacts + summary: List contacts description: |- - Reads one fired trigger-event instance as the bare row: its `state`, the automations it matched, the `automationRunIds` it started and the `rejectionReason` when it was refused. + Lists the brand’s contacts under `{ data, pagination }`, newest first by default. - **Use when** following the `triggerInstanceId` a fire returned to the runs it started. + **Use when** paging through contacts, running a free-text search, or reading one saved audience’s members. Typed filter clauses and counts are `searchContacts`. - **Input** `triggerInstanceId` in the path. + **Input** `search` (a whole email address matches only that contact, with its `+` encoded as `%2B`; other text matches any of its words in email, first and last name), `audienceId` (only that saved audience’s members), `sort` (any core column or custom field, default `createdAt`) and `order`, `limit` and `cursor`. - **Returns** `200` with the row. + **Returns** `200` with a page of `Contact` rows. - **Errors** `404 TRIGGER_INSTANCE_NOT_FOUND` for an unknown or cross-brand id. + **Errors** `400 INVALID_REQUEST` for an unknown `audienceId`, an unsupported `sort` field or an invalid cursor. - **See also** `listTriggerInstances`, `getAutomationRun`, `listSends` (`?triggerInstanceId=`). + **See also** `getContact`, `searchContacts`, `upsertContacts`, `listContactFields`. parameters: + - schema: + type: string + minLength: 1 + description: A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. + required: false + description: A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. + name: search + in: query - schema: type: string minLength: 1 maxLength: 64 - required: true - name: triggerInstanceId - in: path + description: Only members of this saved audience. + required: false + description: Only members of this saved audience. + name: audienceId + in: query + - schema: + type: string + minLength: 1 + default: createdAt + required: false + name: sort + in: query + - schema: + type: string + enum: + - asc + - desc + default: desc + required: false + name: order + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 8192 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -45659,7 +50323,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The fired trigger-event instance. + description: A page of contacts. headers: x-request-id: schema: @@ -45692,20 +50356,48 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/TriggerInstance" + $ref: "#/components/schemas/ContactsListResponse" example: - triggerInstanceId: tin_4fZ2kQ9pLm7xR1tV8wYcD - source: api - provider: brew_api - triggerEventId: user_signed_up - state: processed - matchedAutomationIds: - - M3nRk8LqW2xT5vBp9YcZd - automationRunIds: - - jh7acthhe99pq4448xh83gn54x8eennb - attempts: 1 - receivedAt: '2026-04-08T12:34:56.789Z' - processedAt: '2026-04-08T12:34:57.100Z' + data: + - email: jane@example.com + firstName: Jane + lastName: Doe + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: enterprise + revenue: 4200 + - email: john@example.com + firstName: John + lastName: Smith + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: starter + pagination: + limit: 50 + cursor: eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0= + hasMore: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -45848,10 +50540,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `TRIGGER_INSTANCE_NOT_FOUND`: No fired trigger instance with that id exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -45874,15 +50563,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - triggerInstanceNotFound: - summary: TRIGGER_INSTANCE_NOT_FOUND - value: - error: - code: TRIGGER_INSTANCE_NOT_FOUND - type: not_found - message: No fired trigger instance with that id exists in the brand. - suggestion: List recent fires with GET /v1/automations/trigger-instances. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -45966,41 +50646,29 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: automations + x-brew-scope: contacts x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: automation.runs.read - /v1/contacts: - post: - operationId: upsertContacts + x-brew-rate-policy: contacts.read + /v1/contacts/{email}: + patch: + operationId: updateContact tags: - Contacts - summary: Create or update contacts - description: |- - Upserts a single contact OR a batch (`{ contacts: [...] }`, up to 1000 rows). Unknown custom fields auto-create field definitions on the brand, typed from the batch (native booleans/numbers, ISO-date strings → `date`, anything else `string`). `fieldsCreated` names only the definitions this request created: when two requests create the same new field at once, the first definition wins and the values in the other request are coerced or refused against it, exactly as if it had been sent second. Create the field first with `POST /v1/fields` to pin its type. - - Custom-field names are folded to one key: words split at spaces, `_`, `-` and a lower-to-upper letter change, then joined camelCase (`Plan`, `PLAN` and `plan` are the field `plan`; `team_size` and `Team Size` are `teamSize`). A key written under its folded name adds a `FIELD_NAME_NORMALIZED` warning (`original`, `normalized`). Two keys that fold to one field (`plan` and `Plan`) would lose one value, so they are a `422 FIELD_NAME_COLLISION` on a single upsert (nothing written) and a per-row `errors[]` entry (`code: FIELD_NAME_COLLISION`) in a batch. - - Custom-field values are coerced to the definition's type (dates → epoch ms, `"1,234"` → 1234, `yes`/`no` → booleans). A value that cannot be coerced (`"$49"` in a number field) is a `409 FIELD_TYPE_MISMATCH` on a single upsert and a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) in a batch — the other rows still land. A value that was converted adds one `warnings[]` entry per field (`code: FIELD_TYPE_COERCED`, `field`, `from`, `to`); a date written to a `date` field is not reported. A `customFields` key is never stored under a core field name. One whose loss would change the contact (`subscribed`, `First Name`, `consent`, a suppression flag or domain opt-out, or an `email` naming another address) is a `422 CORE_FIELD_IMMUTABLE` on a single upsert (nothing written) and a per-row `errors[]` entry (`code: CORE_FIELD_IMMUTABLE`, `field`) in a batch; send `firstName`, `lastName`, `subscribed` and `consent` at the top level. A key naming a field Brew manages (`createdAt`, `validationStatus`) is dropped with a `warnings[]` entry (`code: CORE_FIELD_IGNORED`, `field`) and the contact is still written, and an `email` key repeating the contact address is ignored. - - Dates: a JSON number is epoch milliseconds. A string may be ISO (`2026-04-03`, with or without a time and offset), a Unix timestamp in seconds (10 digits) or milliseconds (13), a month name (`3 Apr 2026`, `April 3, 2026`, `03-Apr-26`), year-first (`2026/04/03`), compact (`20260403`), month and year read as the 1st (`2026-04`), dotted day-first (`03.04.2026`), or a slash or dash date. A slash or dash date with a day over 12 reads that way (`13/04/2026` is 13 April). One that reads either way (`03/04/2026`) follows the day/month order the batch's other dates in that field prove, else month/day, with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Send `YYYY-MM-DD` to be unambiguous. - - Optional `consent: { source: "api" | "form" | "import", capturedAt?, policyVersion?, evidence? }` records marketing consent provenance on the contact (per row, or once at batch level as the default). It never changes `subscribed`. An inline marketing send later needs the contact to be subscribed, and warns when no record exists. - - `subscribed: false` unsubscribes the contact, new or existing. `subscribed: true` only applies to a NEW contact: Brew never re-subscribes a contact who opted out, so a single upsert asking for it is a `422 RESUBSCRIBE_NOT_ALLOWED` (nothing written), and a batch row keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). - - Single: `201` with `{ contact, created, fieldsCreated, warnings }`. Batch: `200` with `{ summary, fieldsCreated, errors, warnings }` — or `207` when some rows failed (per-row errors in `errors[]`). + summary: Update a contact + description: "Patches fields on the contact (`{ fields: { : } }` — core columns or custom fields) and/or records marketing consent provenance (`{ consent: { source, capturedAt?, policyVersion?, evidence? } }`, never re-subscribing an opt-out). Send one or both. Returns `{ contact, updated, warnings }`: `updated` lists the fields whose stored value changed (`consent` included), and is empty when every value sent matched what was stored (nothing is written then). `warnings` carries the same `DATE_ORDER_ASSUMED` and `FIELD_TYPE_COERCED` caveats a single upsert reports: an ambiguous date such as `03/04/2026` reads month/day, so send `YYYY-MM-DD`. A JSON number past 9007199254740991 (2^53 - 1) arrives already rounded, so a `number` or `string` field refuses it with `409 FIELD_TYPE_MISMATCH`; send large ids as JSON strings to a `string` field. `fields.subscribed: false` unsubscribes; `true` on a contact who opted out is a `422 RESUBSCRIBE_NOT_ALLOWED` (they re-subscribe through their own action, or from their contact page in the Brew app). Two `fields` keys that name one field (`plan` and `Plan`, `customFields.plan` and `plan`) are a `422 FIELD_NAME_COLLISION`, nothing written." parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: + - schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + maxLength: 320 + description: The contact’s email address (URL-encoded). Email is the contact primary key. + example: jane%40example.com + required: true + description: The contact’s email address (URL-encoded). Email is the contact primary key. + name: email + in: path - name: X-Brand-Id in: header required: false @@ -46015,77 +50683,24 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsPostRequest" + $ref: "#/components/schemas/ContactsPatchRequest" examples: - single: - summary: Upsert a single contact + fields: + summary: Change fields value: - email: jane@example.com - firstName: Jane - customFields: - plan: enterprise - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - evidence: newsletter checkbox on /signup - batch: - summary: Upsert a batch + fields: + firstName: Janet + plan: scale + consent: + summary: Record consent value: - contacts: - - email: jane@example.com - firstName: Jane - - email: john@example.com - firstName: John consent: - source: import + source: api policyVersion: 2026-03 + evidence: imported from the CRM opt-in list responses: "200": - description: Batch upsert applied (every row succeeded). - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - X-RateLimit-Limit: - schema: - type: integer - description: Requests allowed in the current rolling rate limit window. - example: 100 - required: true - description: Requests allowed in the current rolling rate limit window. - X-RateLimit-Remaining: - schema: - type: integer - description: Requests remaining in the current rolling rate limit window. - example: 99 - required: true - description: Requests remaining in the current rolling rate limit window. - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - example: 1712592360 - required: true - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - content: - application/json: - schema: - $ref: "#/components/schemas/ContactsPostBatchResponse" - example: - summary: - inserted: 2 - updated: 0 - failed: 0 - fieldsCreated: [] - errors: [] - warnings: [] - "201": - description: Single upsert applied. + description: Patched. headers: x-request-id: schema: @@ -46118,11 +50733,11 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsPostSingleResponse" + $ref: "#/components/schemas/ContactsPatchResponse" example: contact: email: jane@example.com - firstName: Jane + firstName: Janet lastName: Doe subscribed: true validationStatus: valid @@ -46139,38 +50754,10 @@ paths: customFields: plan: enterprise revenue: 4200 - created: true - fieldsCreated: + updated: + - firstName - plan - warnings: [] - "207": - description: Batch partially applied — inspect `errors[]` for each failed row. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ContactsPostBatchResponse" - example: - summary: - inserted: 1 - updated: 0 - failed: 2 - fieldsCreated: [] - errors: - - email: not-an-email - code: INVALID_EMAIL - message: not-an-email is not a valid email address. - - email: john@example.com - code: FIELD_TYPE_MISMATCH - field: customFields.plan - message: Field 'customFields.plan' is type 'number' but received string '$49'. + - consent warnings: [] "400": description: |- @@ -46255,34 +50842,6 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication - "402": - description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - insufficientCredits: - summary: INSUFFICIENT_CREDITS - value: - error: - code: INSUFFICIENT_CREDITS - type: payment_required - message: The credit balance is below what this operation requires; `details.cost` is the amount needed. - suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. - docs: https://docs.brew.new/api-reference/api/credits - details: - cost: 5 - remaining: 0 - planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -46342,7 +50901,12 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. + + `FIELD_NOT_FOUND`: No custom field definition with that name exists in the brand. headers: x-request-id: schema: @@ -46365,13 +50929,26 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + contactNotFound: + summary: CONTACT_NOT_FOUND + value: + error: + code: CONTACT_NOT_FOUND + type: not_found + message: No contact with that email exists in the brand. + suggestion: Create the contact first with POST /v1/contacts. + docs: https://docs.brew.new/api-reference/api/errors + fieldNotFound: + summary: FIELD_NOT_FOUND + value: + error: + code: FIELD_NOT_FOUND + type: not_found + message: No custom field definition with that name exists in the brand. + suggestion: Create the field first with POST /v1/fields or use a field that already exists. + docs: https://docs.brew.new/api-reference/api/errors "409": - description: |- - `FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field. - - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + description: "`FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field." headers: x-request-id: schema: @@ -46394,24 +50971,6 @@ paths: message: A value does not match the declared type of its custom field. suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. docs: https://docs.brew.new/api-reference/api/errors - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency "422": description: |- `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. @@ -46420,10 +50979,6 @@ paths: `FIELD_NAME_COLLISION`: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. - `INVALID_EMAIL`: A contact email is not a deliverable address shape. - - `MISSING_EMAIL`: A contact row has no `email`, the primary key. - `RESUBSCRIBE_NOT_ALLOWED`: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. headers: x-request-id: @@ -46465,24 +51020,6 @@ paths: message: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. suggestion: Send one of the two keys, or rename one so it names a different field. docs: https://docs.brew.new/api-reference/api/errors - invalidEmail: - summary: INVALID_EMAIL - value: - error: - code: INVALID_EMAIL - type: invalid_request - message: A contact email is not a deliverable address shape. - suggestion: Fix the address named in `param` and retry. - docs: https://docs.brew.new/api-reference/api/errors - missingEmail: - summary: MISSING_EMAIL - value: - error: - code: MISSING_EMAIL - type: invalid_request - message: A contact row has no `email`, the primary key. - suggestion: Provide an email address for every contact in the request body. - docs: https://docs.brew.new/api-reference/api/errors resubscribeNotAllowed: summary: RESUBSCRIBE_NOT_ALLOWED value: @@ -46506,7 +51043,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -46547,7 +51084,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -46575,110 +51112,28 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: replay - x-brew-credited: true - get: - operationId: listContacts + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: contacts.write_single + delete: + operationId: deleteContact tags: - Contacts - summary: List contacts - description: |- - Lists the brand’s contacts under `{ data, pagination }`, newest first by default. - - **Use when** paging through contacts, running a free-text search, or reading one saved audience’s members. Typed filter clauses and counts are `searchContacts`. - - **Input** `search` (a whole email address matches only that contact, with its `+` encoded as `%2B`; other text matches any of its words in email, first and last name), `audienceId` (only that saved audience’s members), `sort` (any core column or custom field, default `createdAt`) and `order`, `limit` and `cursor`. - - **Returns** `200` with a page of `Contact` rows. - - **Errors** `400 INVALID_REQUEST` for an unknown `audienceId`, an unsupported `sort` field or an invalid cursor. - - **See also** `getContact`, `searchContacts`, `upsertContacts`, `listContactFields`. + summary: Delete a contact + description: "Deletes the contact. Idempotent — an unknown email resolves with `deleted: false`. For bulk deletion use `POST /v1/contacts/batch-delete`." parameters: - schema: type: string minLength: 1 - description: A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. - required: false - description: A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. - name: search - in: query - - schema: - type: string - minLength: 1 - maxLength: 64 - description: Only members of this saved audience. - required: false - description: Only members of this saved audience. - name: audienceId - in: query - - schema: - type: string - minLength: 1 - default: createdAt - required: false - name: sort - in: query - - schema: - type: string - enum: - - asc - - desc - default: desc - required: false - name: order - in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - - schema: - type: string - minLength: 1 - maxLength: 8192 - required: false - name: cursor - in: query + maxLength: 320 + description: The contact’s email address (URL-encoded). Email is the contact primary key. + example: jane%40example.com + required: true + description: The contact’s email address (URL-encoded). Email is the contact primary key. + name: email + in: path - name: X-Brand-Id in: header required: false @@ -46690,7 +51145,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of contacts. + description: Deleted (or already gone). headers: x-request-id: schema: @@ -46723,48 +51178,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsListResponse" + $ref: "#/components/schemas/ContactDeleteResponse" example: - data: - - email: jane@example.com - firstName: Jane - lastName: Doe - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: enterprise - revenue: 4200 - - email: john@example.com - firstName: John - lastName: Smith - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: starter - pagination: - limit: 50 - cursor: eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0= - hasMore: true + email: jane@example.com + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -47017,14 +51434,24 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: contacts.read - /v1/contacts/{email}: - patch: - operationId: updateContact + x-brew-rate-policy: contacts.write_single + get: + operationId: getContact tags: - Contacts - summary: Update a contact - description: "Patches fields on the contact (`{ fields: { : } }` — core columns or custom fields) and/or records marketing consent provenance (`{ consent: { source, capturedAt?, policyVersion?, evidence? } }`, never re-subscribing an opt-out). Send one or both. Returns `{ contact, updated, warnings }`: `updated` lists the fields whose stored value changed (`consent` included), and is empty when every value sent matched what was stored (nothing is written then). `warnings` carries the same `DATE_ORDER_ASSUMED` and `FIELD_TYPE_COERCED` caveats a single upsert reports: an ambiguous date such as `03/04/2026` reads month/day, so send `YYYY-MM-DD`. A JSON number past 9007199254740991 (2^53 - 1) arrives already rounded, so a `number` or `string` field refuses it with `409 FIELD_TYPE_MISMATCH`; send large ids as JSON strings to a `string` field. `fields.subscribed: false` unsubscribes; `true` on a contact who opted out is a `422 RESUBSCRIBE_NOT_ALLOWED` (they re-subscribe through their own action, or from their contact page in the Brew app). Two `fields` keys that name one field (`plan` and `Plan`, `customFields.plan` and `plan`) are a `422 FIELD_NAME_COLLISION`, nothing written." + summary: Get a contact + description: |- + Reads one contact as the bare `Contact` row: core columns, `subscribed`, `consent`, validation state and `customFields`. + + **Use when** checking whether an address exists, reading its consent record, or inspecting its custom fields before a send. + + **Input** `email` in the path (URL-encoded; matched case-insensitively). `include=openProfile` attaches `openProfile`: the smart-send open-time profile, 48 UTC half-hour open counts (`histogram`), `totalOpens`, `lastOpenedAt`, and once there is enough history `bestOpenMinuteUtc`, `bestSendMinuteUtc` and `confidence`; `null` when the contact has no opens folded yet. It is not bot detection: machine opens cannot be told apart in it (per-event classification is on `getEventsAnalytics`). It needs the `emails` scope as well as `contacts`. + + **Returns** `200` with the row. + + **Errors** `404 CONTACT_NOT_FOUND` when no contact carries that address; `403 INSUFFICIENT_PERMISSIONS` when `include=openProfile` is asked for without the `emails` scope; `400 INVALID_REQUEST` for an unknown `include` token. + + **See also** `listContacts`, `updateContact`, `deleteContact`, `upsertContacts`. parameters: - schema: type: string @@ -47036,6 +51463,15 @@ paths: description: The contact’s email address (URL-encoded). Email is the contact primary key. name: email in: path + - schema: + type: string + description: "Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well)." + x-brew-include-tokens: + - openProfile + required: false + description: "Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well)." + name: include + in: query - name: X-Brand-Id in: header required: false @@ -47045,29 +51481,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ContactsPatchRequest" - examples: - fields: - summary: Change fields - value: - fields: - firstName: Janet - plan: scale - consent: - summary: Record consent - value: - consent: - source: api - policyVersion: 2026-03 - evidence: imported from the CRM opt-in list responses: "200": - description: Patched. + description: The contact row, with `openProfile` when `include=openProfile`. headers: x-request-id: schema: @@ -47100,32 +51516,26 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsPatchResponse" + $ref: "#/components/schemas/ContactWithOpenProfile" example: - contact: - email: jane@example.com - firstName: Janet - lastName: Doe - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: enterprise - revenue: 4200 - updated: - - firstName - - plan - - consent - warnings: [] + email: jane@example.com + firstName: Jane + lastName: Doe + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: enterprise + revenue: 4200 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -47272,8 +51682,6 @@ paths: `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. - - `FIELD_NOT_FOUND`: No custom field definition with that name exists in the brand. headers: x-request-id: schema: @@ -47305,97 +51713,6 @@ paths: message: No contact with that email exists in the brand. suggestion: Create the contact first with POST /v1/contacts. docs: https://docs.brew.new/api-reference/api/errors - fieldNotFound: - summary: FIELD_NOT_FOUND - value: - error: - code: FIELD_NOT_FOUND - type: not_found - message: No custom field definition with that name exists in the brand. - suggestion: Create the field first with POST /v1/fields or use a field that already exists. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: "`FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - fieldTypeMismatch: - summary: FIELD_TYPE_MISMATCH - value: - error: - code: FIELD_TYPE_MISMATCH - type: conflict - message: A value does not match the declared type of its custom field. - suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: |- - `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. - - `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. - - `FIELD_NAME_COLLISION`: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. - - `RESUBSCRIBE_NOT_ALLOWED`: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - batchTooLarge: - summary: BATCH_TOO_LARGE - value: - error: - code: BATCH_TOO_LARGE - type: invalid_request - message: A batch write exceeds the per-request row cap. - suggestion: Split the request into smaller batches and retry. - docs: https://docs.brew.new/api-reference/api/errors - coreFieldImmutable: - summary: CORE_FIELD_IMMUTABLE - value: - error: - code: CORE_FIELD_IMMUTABLE - type: invalid_request - message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. - suggestion: Use a custom field name instead. - docs: https://docs.brew.new/api-reference/api/errors - fieldNameCollision: - summary: FIELD_NAME_COLLISION - value: - error: - code: FIELD_NAME_COLLISION - type: invalid_request - message: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. - suggestion: Send one of the two keys, or rename one so it names a different field. - docs: https://docs.brew.new/api-reference/api/errors - resubscribeNotAllowed: - summary: RESUBSCRIBE_NOT_ALLOWED - value: - error: - code: RESUBSCRIBE_NOT_ALLOWED - type: invalid_request - message: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. - suggestion: Drop `subscribed` from the write; a contact who opted out re-subscribes through their own action, or from their contact page in the Brew app once they ask. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -47483,36 +51800,51 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: contacts.write_single - delete: - operationId: deleteContact + x-brew-rate-policy: contacts.read + /v1/contacts/search: + post: + operationId: searchContacts tags: - Contacts - summary: Delete a contact - description: "Deletes the contact. Idempotent — an unknown email resolves with `deleted: false`. For bulk deletion use `POST /v1/contacts/batch-delete`." - parameters: - - schema: - type: string - minLength: 1 - maxLength: 320 - description: The contact’s email address (URL-encoded). Email is the contact primary key. - example: jane%40example.com - required: true - description: The contact’s email address (URL-encoded). Email is the contact primary key. - name: email - in: path - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + summary: Get contacts + description: |- + The single "Get Contacts" read. Structured search over the brand’s contacts: free-text `search`, `filters` (`{ field, operator, value }` combined with `logic: "and" | "or" | "none"`; the allowed operators depend on the field’s type — see the `operator` schema — and an unsupported pairing is a `400`, never a dropped clause), `sort` + `order`, and cursor pagination. Returns `{ data, pagination }`. + + `search` with a whole email address matches only that contact, like `filters: [{ field: "email", operator: "equals", value: "…" }]` or `GET /v1/contacts/{email}`. Any other `search` text matches the contacts whose email, first or last name contains any of its words, which can include other contacts, in `sort` order, so act on a contact found by its address. Omit every filter to list everything. + + Pass an optional `audienceId` to scope the search to a saved audience’s members — its stored filter set is evaluated as one unit with its own `logicalOperator` (an OR audience stays an OR) and then ANDed with `filters`, so the page is exactly who a send to that audience reaches (an unknown / cross-brand id, or an audience a send would refuse → `400`). + + Set `count: true` to get `{ count }` instead of a page. + + `include: ["openProfile"]` attaches each returned contact’s smart-send open-time profile (`openProfile`, as on `getContact`; `null` when they have no opens yet). Each row costs one profile read, so a page then holds at most 10 contacts (`pagination.limit` reports the size read); it needs the `emails` scope as well (`403 INSUFFICIENT_PERMISSIONS` without it) and is refused with `count: true`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsSearchRequest" + examples: + filtered: + summary: Filter + sort + value: + filters: + - field: plan + operator: equals + value: enterprise + sort: createdAt + order: desc + limit: 50 + count: + summary: Count only + value: + filters: + - field: subscribed + operator: equals + value: 'true' + count: true responses: "200": - description: Deleted (or already gone). + description: "A page of matching contacts — or `{ count }` when `count: true`." headers: x-request-id: schema: @@ -47545,10 +51877,55 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactDeleteResponse" - example: - email: jane@example.com - deleted: true + $ref: "#/components/schemas/ContactsSearchSuccessResponse" + examples: + page: + summary: Result page + value: + data: + - email: jane@example.com + firstName: Jane + lastName: Doe + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: enterprise + revenue: 4200 + - email: john@example.com + firstName: John + lastName: Smith + subscribed: true + validationStatus: valid + lastValidatedAt: '2026-04-08T12:01:00.000Z' + suppressed: false + suppressedReason: null + consent: + source: form + capturedAt: '2026-04-08T12:00:00.000Z' + policyVersion: 2026-03 + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + importId: null + customFields: + plan: starter + pagination: + limit: 50 + cursor: eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0= + hasMore: true + count: + summary: Count mode + value: + count: 1284 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -47714,6 +52091,66 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: "`FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + fieldTypeMismatch: + summary: FIELD_TYPE_MISMATCH + value: + error: + code: FIELD_TYPE_MISMATCH + type: conflict + message: A value does not match the declared type of its custom field. + suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: |- + `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. + + `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + batchTooLarge: + summary: BATCH_TOO_LARGE + value: + error: + code: BATCH_TOO_LARGE + type: invalid_request + message: A batch write exceeds the per-request row cap. + suggestion: Split the request into smaller batches and retry. + docs: https://docs.brew.new/api-reference/api/errors + coreFieldImmutable: + summary: CORE_FIELD_IMMUTABLE + value: + error: + code: CORE_FIELD_IMMUTABLE + type: invalid_request + message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + suggestion: Use a custom field name instead. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -47797,48 +52234,7 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: contacts - x-brew-brand-scope: brand - x-brew-idempotency: none - x-brew-credited: false - x-brew-rate-policy: contacts.write_single - get: - operationId: getContact - tags: - - Contacts - summary: Get a contact - description: |- - Reads one contact as the bare `Contact` row: core columns, `subscribed`, `consent`, validation state and `customFields`. - - **Use when** checking whether an address exists, reading its consent record, or inspecting its custom fields before a send. - - **Input** `email` in the path (URL-encoded; matched case-insensitively). `include=openProfile` attaches `openProfile`: the smart-send open-time profile, 48 UTC half-hour open counts (`histogram`), `totalOpens`, `lastOpenedAt`, and once there is enough history `bestOpenMinuteUtc`, `bestSendMinuteUtc` and `confidence`; `null` when the contact has no opens folded yet. It is not bot detection: machine opens cannot be told apart in it (per-event classification is on `getEventsAnalytics`). It needs the `emails` scope as well as `contacts`. - - **Returns** `200` with the row. - - **Errors** `404 CONTACT_NOT_FOUND` when no contact carries that address; `403 INSUFFICIENT_PERMISSIONS` when `include=openProfile` is asked for without the `emails` scope; `400 INVALID_REQUEST` for an unknown `include` token. - - **See also** `listContacts`, `updateContact`, `deleteContact`, `upsertContacts`. parameters: - - schema: - type: string - minLength: 1 - maxLength: 320 - description: The contact’s email address (URL-encoded). Email is the contact primary key. - example: jane%40example.com - required: true - description: The contact’s email address (URL-encoded). Email is the contact primary key. - name: email - in: path - - schema: - type: string - description: "Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well)." - x-brew-include-tokens: - - openProfile - required: false - description: "Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well)." - name: include - in: query - name: X-Brand-Id in: header required: false @@ -47848,9 +52244,32 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + x-brew-scope: contacts + x-brew-brand-scope: brand + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: contacts.read + /v1/contacts/validate: + post: + operationId: validateContacts + tags: + - Contacts + summary: Validate email deliverability + description: "Batch deliverability check (up to 100 addresses) — run BEFORE importing or sending. Each address is classified `valid` (safe), `risky` (deliverable but flagged — e.g. role account, disposable, catch-all), or `invalid` (undeliverable / do-not-send), with a machine-readable `reason` and a `didYouMean` typo correction when applicable. Partial outages retain the legacy `status: risky` with `checkOutcome: unavailable` (not a completed risky verdict) and preserve the stored verdict. `persistence` reports matched, modified and failed update-only writes; validation never grants subscription or clears suppression. Cost: 2 credits PER ADDRESS (`X-Credit-Cost` = 2 × the number of addresses), charged ONLY on success — if the check cannot complete for the whole batch, the call returns `503` (transient outages are retryable; configuration failures need support) and is NOT billed." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsValidateRequest" + example: + emails: + - ada@gmail.com + - temp@mailinator.com + - admin@stripe.com responses: "200": - description: The contact row, with `openProfile` when `include=openProfile`. + description: One classification row per input address. headers: x-request-id: schema: @@ -47883,26 +52302,33 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactWithOpenProfile" + $ref: "#/components/schemas/ContactsValidateResponse" example: - email: jane@example.com - firstName: Jane - lastName: Doe - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: enterprise - revenue: 4200 + data: + - email: ada@gmail.com + valid: true + status: valid + checkOutcome: completed + - email: temp@mailinator.com + valid: false + status: invalid + checkOutcome: completed + reason: undeliverable + - email: ada@gmial.com + valid: false + status: risky + checkOutcome: completed + reason: unknown + didYouMean: ada@gmail.com + - email: admin@stripe.com + valid: false + status: risky + checkOutcome: completed + reason: role_address + persistence: + matched: 4 + modified: 4 + failed: 0 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -47986,6 +52412,34 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + insufficientCredits: + summary: INSUFFICIENT_CREDITS + value: + error: + code: INSUFFICIENT_CREDITS + type: payment_required + message: The credit balance is below what this operation requires; `details.cost` is the amount needed. + suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. + docs: https://docs.brew.new/api-reference/api/credits + details: + cost: 5 + remaining: 0 + planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -48045,10 +52499,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -48071,15 +52522,42 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - contactNotFound: - summary: CONTACT_NOT_FOUND + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT value: error: - code: CONTACT_NOT_FOUND - type: not_found - message: No contact with that email exists in the brand. - suggestion: Create the contact first with POST /v1/contacts. - docs: https://docs.brew.new/api-reference/api/errors + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -48094,7 +52572,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -48135,7 +52613,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -48163,55 +52641,87 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + parameters: + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: none - x-brew-credited: false - x-brew-rate-policy: contacts.read - /v1/contacts/search: + x-brew-idempotency: replay + x-brew-credited: true + x-brew-rate-policy: content.transform + /v1/contacts/import-csv: post: - operationId: searchContacts + operationId: importContactsCsv tags: - Contacts - summary: Get contacts + summary: Bulk-import contacts from CSV description: |- - The single "Get Contacts" read. Structured search over the brand’s contacts: free-text `search`, `filters` (`{ field, operator, value }` combined with `logic: "and" | "or" | "none"`; the allowed operators depend on the field’s type — see the `operator` schema — and an unsupported pairing is a `400`, never a dropped clause), `sort` + `order`, and cursor pagination. Returns `{ data, pagination }`. + Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to the custom field its name folds to, the same camelCase name `POST /v1/contacts` stores (`Plan` → `plan`, `Team Size` → `teamSize`); `email` is required. A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. Two columns that land on one custom field (`plan,Plan`: names fold as on `POST /v1/contacts`) are a `422 FIELD_NAME_COLLISION` before any row is read: map one to another name or leave it out with `""`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and each contact it writes carries `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. - `search` with a whole email address matches only that contact, like `filters: [{ field: "email", operator: "equals", value: "…" }]` or `GET /v1/contacts/{email}`. Any other `search` text matches the contacts whose email, first or last name contains any of its words, which can include other contacts, in `sort` order, so act on a contact found by its address. Omit every filter to list everything. + CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. A converted cell adds a `warnings[]` entry (`code: FIELD_TYPE_COERCED`, `field`, `from`, `to`); a date written to a `date` field is not reported. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). - Pass an optional `audienceId` to scope the search to a saved audience’s members — its stored filter set is evaluated as one unit with its own `logicalOperator` (an OR audience stays an OR) and then ANDed with `filters`, so the page is exactly who a send to that audience reaches (an unknown / cross-brand id, or an audience a send would refuse → `400`). + A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. - Set `count: true` to get `{ count }` instead of a page. + A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing (with `onConflict: "skip"` an existing contact is left unchanged, and its `CONTACT_EXISTS_SKIPPED` warning says so). A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). - `include: ["openProfile"]` attaches each returned contact’s smart-send open-time profile (`openProfile`, as on `getContact`; `null` when they have no opens yet). Each row costs one profile read, so a page then holds at most 10 contacts (`pagination.limit` reports the size read); it needs the `emails` scope as well (`403 INSUFFICIENT_PERMISSIONS` without it) and is refused with `count: true`. + Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ContactsSearchRequest" - examples: - filtered: - summary: Filter + sort - value: - filters: - - field: plan - operator: equals - value: enterprise - sort: createdAt - order: desc - limit: 50 - count: - summary: Count only - value: - filters: - - field: subscribed - operator: equals - value: 'true' - count: true + $ref: "#/components/schemas/ContactsImportCsvRequest" + example: + csv: |- + email,firstName,lastName + ada@example.com,Ada,Lovelace + grace@example.com,Grace,Hopper + mapping: + email: email + firstName: firstName + lastName: lastName + fileName: customers.csv responses: "200": - description: "A page of matching contacts — or `{ count }` when `count: true`." + description: Import summary (all rows valid). headers: x-request-id: schema: @@ -48244,55 +52754,76 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsSearchSuccessResponse" - examples: - page: - summary: Result page - value: - data: - - email: jane@example.com - firstName: Jane - lastName: Doe - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: enterprise - revenue: 4200 - - email: john@example.com - firstName: John - lastName: Smith - subscribed: true - validationStatus: valid - lastValidatedAt: '2026-04-08T12:01:00.000Z' - suppressed: false - suppressedReason: null - consent: - source: form - capturedAt: '2026-04-08T12:00:00.000Z' - policyVersion: 2026-03 - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - importId: null - customFields: - plan: starter - pagination: - limit: 50 - cursor: eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0= - hasMore: true - count: - summary: Count mode - value: - count: 1284 + $ref: "#/components/schemas/ContactsImportResponse" + example: + summary: + inserted: 2 + updated: 0 + failed: 0 + skipped: 0 + total: 2 + fieldsCreated: [] + errors: [] + warnings: [] + importId: imp_01JABCDEF0123456789XYZ + "207": + description: Partial success — some rows failed validation. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsImportResponse" + example: + summary: + inserted: 1 + updated: 0 + failed: 1 + skipped: 2 + total: 4 + fieldsCreated: [] + errors: + - email: john@example.com + row: 2 + code: FIELD_TYPE_MISMATCH + field: customFields.plan + message: Field 'customFields.plan' is type 'number' but received string '$49'. + warnings: + - code: CSV_ROW_SKIPPED + row: 3 + message: Row 3 has no email address, so it was skipped. + - code: CSV_ROW_SKIPPED + row: 4 + email: not-an-email + message: Row 4 holds 'not-an-email', which is not a valid email address, so it was skipped. + importId: imp_01JABCDEF0123456789XYZ "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -48376,6 +52907,34 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + insufficientCredits: + summary: INSUFFICIENT_CREDITS + value: + error: + code: INSUFFICIENT_CREDITS + type: payment_required + message: The credit balance is below what this operation requires; `details.cost` is the amount needed. + suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. + docs: https://docs.brew.new/api-reference/api/credits + details: + cost: 5 + remaining: 0 + planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -48459,7 +53018,10 @@ paths: suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors "409": - description: "`FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field." + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -48473,20 +53035,26 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - fieldTypeMismatch: - summary: FIELD_TYPE_MISMATCH + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT value: error: - code: FIELD_TYPE_MISMATCH + code: IDEMPOTENCY_CONFLICT type: conflict - message: A value does not match the declared type of its custom field. - suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. - docs: https://docs.brew.new/api-reference/api/errors + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "422": - description: |- - `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. - - `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + description: "`FIELD_NAME_COLLISION`: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost." headers: x-request-id: schema: @@ -48500,23 +53068,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - batchTooLarge: - summary: BATCH_TOO_LARGE - value: - error: - code: BATCH_TOO_LARGE - type: invalid_request - message: A batch write exceeds the per-request row cap. - suggestion: Split the request into smaller batches and retry. - docs: https://docs.brew.new/api-reference/api/errors - coreFieldImmutable: - summary: CORE_FIELD_IMMUTABLE + fieldNameCollision: + summary: FIELD_NAME_COLLISION value: error: - code: CORE_FIELD_IMMUTABLE + code: FIELD_NAME_COLLISION type: invalid_request - message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. - suggestion: Use a custom field name instead. + message: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. + suggestion: Send one of the two keys, or rename one so it names a different field. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -48532,7 +53091,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -48573,7 +53132,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -48601,6 +53160,37 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors parameters: - name: X-Brand-Id in: header @@ -48613,30 +53203,41 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: none - x-brew-credited: false - x-brew-rate-policy: contacts.read - /v1/contacts/validate: + x-brew-idempotency: replay + x-brew-credited: true + x-brew-rate-policy: contacts.write_batch + /v1/contacts/import: post: - operationId: validateContacts + operationId: importContacts tags: - Contacts - summary: Validate email deliverability - description: "Batch deliverability check (up to 100 addresses) — run BEFORE importing or sending. Each address is classified `valid` (safe), `risky` (deliverable but flagged — e.g. role account, disposable, catch-all), or `invalid` (undeliverable / do-not-send), with a machine-readable `reason` and a `didYouMean` typo correction when applicable. Partial outages retain the legacy `status: risky` with `checkOutcome: unavailable` (not a completed risky verdict) and preserve the stored verdict. `persistence` reports matched, modified and failed update-only writes; validation never grants subscription or clears suppression. Cost: 2 credits PER ADDRESS (`X-Credit-Cost` = 2 × the number of addresses), charged ONLY on success — if the check cannot complete for the whole batch, the call returns `503` (transient outages are retryable; configuration failures need support) and is NOT billed." + summary: Bulk-import contacts from JSON + description: |- + Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. + + The batch is listed in Import History under `fileName` (optional), and each contact it writes carries the returned `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. + + Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ContactsValidateRequest" + $ref: "#/components/schemas/ContactsImportRequest" example: - emails: - - ada@gmail.com - - temp@mailinator.com - - admin@stripe.com + contacts: + - email: ada@example.com + firstName: Ada + - email: grace@example.com + firstName: Grace + customFields: + plan: pro + consent: + source: import + fileName: crm-export.json responses: "200": - description: One classification row per input address. + description: Import summary (all rows valid). headers: x-request-id: schema: @@ -48669,33 +53270,69 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsValidateResponse" + $ref: "#/components/schemas/ContactsImportResponse" example: - data: - - email: ada@gmail.com - valid: true - status: valid - checkOutcome: completed - - email: temp@mailinator.com - valid: false - status: invalid - checkOutcome: completed - reason: undeliverable - - email: ada@gmial.com - valid: false - status: risky - checkOutcome: completed - reason: unknown - didYouMean: ada@gmail.com - - email: admin@stripe.com - valid: false - status: risky - checkOutcome: completed - reason: role_address - persistence: - matched: 4 - modified: 4 + summary: + inserted: 2 + updated: 0 failed: 0 + skipped: 0 + total: 2 + fieldsCreated: + - plan + errors: [] + warnings: [] + importId: imp_01JABCDEF0123456789XYZ + "207": + description: Partial success — some rows failed validation. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/ContactsImportResponse" + example: + summary: + inserted: 1 + updated: 0 + failed: 1 + skipped: 0 + total: 2 + fieldsCreated: [] + errors: + - email: not-an-email + row: 2 + code: INVALID_EMAIL + message: "'not-an-email' is not a valid email address." + warnings: [] + importId: imp_01JABCDEF0123456789XYZ "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -48939,7 +53576,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -48980,7 +53617,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -49053,88 +53690,46 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: true - x-brew-rate-policy: content.transform - /v1/contacts/import-csv: + x-brew-rate-policy: contacts.write_batch + /v1/contacts/batch-delete: post: - operationId: importContactsCsv + operationId: batchDeleteContacts tags: - Contacts - summary: Bulk-import contacts from CSV - description: |- - Parse a raw CSV string and upsert the rows as contacts (≤ 1000). By default each column header maps to the custom field its name folds to, the same camelCase name `POST /v1/contacts` stores (`Plan` → `plan`, `Team Size` → `teamSize`); `email` is required. A header that spells a core field, ignoring case, spaces, `_` and `-`, or whose field key equals it (`First.Name`), maps to it: `Email`, `First Name`, `Last Name` and `Subscribed` fill `email`, `firstName`, `lastName` and `subscribed`. Pass `mapping` to remap single columns; the columns it leaves out keep their default mapping, `""` leaves a column out, and a key that names no column is a `400`. Two columns that land on one custom field (`plan,Plan`: names fold as on `POST /v1/contacts`) are a `422 FIELD_NAME_COLLISION` before any row is read: map one to another name or leave it out with `""`. A core field that `mapping` fills is filled from that column alone: another column whose default would fill it is left out with a `CSV_COLUMN_IGNORED` warning. Rows with a missing/invalid email are SKIPPED (counted in `summary.skipped`), not errored; disposable-domain rows are imported with `validationStatus: "risky"` (deliverable but flagged). Returns the same `{ summary, fieldsCreated, errors, warnings }` as batch-create plus `importId` — `207` when some rows fail. The batch is listed in Import History under `fileName` (optional), and each contact it writes carries `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). For rows you already hold as JSON, use `POST /v1/contacts/import`. - - CSV cells are text: a column mapped onto an EXISTING field is coerced to that field's type (dates → epoch ms, `1,234` → 1234, `yes`/`no` → booleans), and a cell that cannot be coerced (`$49` in a number column) fails ITS row with a per-row `errors[]` entry (`code: FIELD_TYPE_MISMATCH`, `field`) while the other rows still land. A converted cell adds a `warnings[]` entry (`code: FIELD_TYPE_COERCED`, `field`, `from`, `to`); a date written to a `date` field is not reported. An undeclared column is created as a `string` field unless every value is an ISO date (→ `date`); predeclare `number` / `bool` columns with `POST /v1/fields`. A column whose field Brew manages (`Created At`, `Validation Status`) is not imported and adds a `warnings[]` entry (`code: CSV_COLUMN_IGNORED`, `field` names the column). - - A date column is read in ONE day/month order: the order its own dates prove (a day over 12 anywhere in the column, `13/04/2026`), else `dateOrder` (`day_first` reads `03/04/2026` as 3 April, `month_first` as 4 March), else month/day with a `warnings[]` entry (`code: DATE_ORDER_ASSUMED`, `field`). Dotted dates (`03.04.2026`) are day-first. ISO (`2026-04-03`), Unix seconds or milliseconds, month names (`3 Apr 2026`), year-first (`2026/04/03`) and compact (`20260403`) dates need no order. Convert to `YYYY-MM-DD` to be unambiguous. - - A `subscribed` column reads true/false and ESP status words (`unsubscribed`, `cleaned`, `blocklisted`, `transactional`, `active`, …). An opt-out unsubscribes the contact, new or existing (with `onConflict: "skip"` an existing contact is left unchanged, and its `CONTACT_EXISTS_SKIPPED` warning says so). A word that is not a subscription status (`maybe`) fails its row (`code: FIELD_TYPE_MISMATCH`, `field: subscribed`) instead of importing the contact subscribed. A subscribed value only applies to new contacts: a row that asks to re-subscribe a contact who opted out keeps its other fields and adds a `warnings[]` entry (`code: RESUBSCRIBE_SKIPPED`, `email`). - - Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. + summary: Batch-delete contacts + description: "Deletes up to 1000 contacts by email. Returns the deleted count plus `notFound[]` for emails that had no contact. Addresses are lookup keys, not re-validated: any address a contact was stored under matches, and one that matches nothing lands in `notFound[]` instead of failing the batch." + parameters: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ContactsImportCsvRequest" + $ref: "#/components/schemas/ContactsBatchDeleteRequest" example: - csv: |- - email,firstName,lastName - ada@example.com,Ada,Lovelace - grace@example.com,Grace,Hopper - mapping: - email: email - firstName: firstName - lastName: lastName - fileName: customers.csv + emails: + - jane@example.com + - gone@example.com responses: "200": - description: Import summary (all rows valid). - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - X-RateLimit-Limit: - schema: - type: integer - description: Requests allowed in the current rolling rate limit window. - example: 100 - required: true - description: Requests allowed in the current rolling rate limit window. - X-RateLimit-Remaining: - schema: - type: integer - description: Requests remaining in the current rolling rate limit window. - example: 99 - required: true - description: Requests remaining in the current rolling rate limit window. - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - example: 1712592360 - required: true - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - content: - application/json: - schema: - $ref: "#/components/schemas/ContactsImportResponse" - example: - summary: - inserted: 2 - updated: 0 - failed: 0 - skipped: 0 - total: 2 - fieldsCreated: [] - errors: [] - warnings: [] - importId: imp_01JABCDEF0123456789XYZ - "207": - description: Partial success — some rows failed validation. + description: Batch delete applied. headers: x-request-id: schema: @@ -49167,30 +53762,11 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsImportResponse" + $ref: "#/components/schemas/ContactsBatchDeleteResponse" example: - summary: - inserted: 1 - updated: 0 - failed: 1 - skipped: 2 - total: 4 - fieldsCreated: [] - errors: - - email: john@example.com - row: 2 - code: FIELD_TYPE_MISMATCH - field: customFields.plan - message: Field 'customFields.plan' is type 'number' but received string '$49'. - warnings: - - code: CSV_ROW_SKIPPED - row: 3 - message: Row 3 has no email address, so it was skipped. - - code: CSV_ROW_SKIPPED - row: 4 - email: not-an-email - message: Row 4 holds 'not-an-email', which is not a valid email address, so it was skipped. - importId: imp_01JABCDEF0123456789XYZ + deletedCount: 1 + notFound: + - gone@example.com "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -49274,34 +53850,6 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication - "402": - description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - insufficientCredits: - summary: INSUFFICIENT_CREDITS - value: - error: - code: INSUFFICIENT_CREDITS - type: payment_required - message: The credit balance is below what this operation requires; `details.cost` is the amount needed. - suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. - docs: https://docs.brew.new/api-reference/api/credits - details: - cost: 5 - remaining: 0 - planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -49421,7 +53969,7 @@ paths: suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency "422": - description: "`FIELD_NAME_COLLISION`: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost." + description: "`BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap." headers: x-request-id: schema: @@ -49435,14 +53983,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - fieldNameCollision: - summary: FIELD_NAME_COLLISION + batchTooLarge: + summary: BATCH_TOO_LARGE value: error: - code: FIELD_NAME_COLLISION + code: BATCH_TOO_LARGE type: invalid_request - message: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. - suggestion: Send one of the two keys, or rename one so it names a different field. + message: A batch write exceeds the per-request row cap. + suggestion: Split the request into smaller batches and retry. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -49527,38 +54075,54 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: contacts + x-brew-brand-scope: brand + x-brew-idempotency: replay + x-brew-credited: false + x-brew-rate-policy: contacts.write_batch + /v1/fields: + get: + operationId: listContactFields + tags: + - Contact Fields + summary: List contact fields + description: "Lists core contact columns and custom field definitions under `{ data, pagination }`. Use the field names in audience filters, contact search, and `{{ field | fallback }}` merge tags. Add `?include=coverage` to decorate each row with fill stats — the % of contacts holding a non-empty value plus the most common values (sampled; `approximate: true` on large populations) — and optionally `audienceId` to scope those stats to one saved audience. Check coverage before personalizing on a field so most recipients see a real value, not the fallback." parameters: + - name: include + in: query + required: false + description: "Opt-in expansion: `coverage` adds per-field fill stats (percent of contacts with a non-empty value + top values)." + schema: + type: string + enum: + - coverage + x-brew-include-tokens: + - coverage + - name: audienceId + in: query + required: false + description: Scope `include=coverage` stats to one saved audience (`404 AUDIENCE_NOT_FOUND` on an unknown / cross-brand id). Ignored without the include. + schema: + type: string + minLength: 1 + maxLength: 64 + - name: limit + in: query + required: false + description: Page size (1–100). Defaults to 100. + schema: + type: integer + minimum: 1 + maximum: 100 + example: 50 + - name: cursor + in: query + required: false + description: Opaque pagination cursor echoed from the previous page’s `pagination.cursor`. Omit for the first page. + schema: + type: string + minLength: 1 + maxLength: 512 - name: X-Brand-Id in: header required: false @@ -49568,90 +54132,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - x-brew-scope: contacts - x-brew-brand-scope: brand - x-brew-idempotency: replay - x-brew-credited: true - x-brew-rate-policy: contacts.write_batch - /v1/contacts/import: - post: - operationId: importContacts - tags: - - Contacts - summary: Bulk-import contacts from JSON - description: |- - Create or update up to 1000 contacts from JSON rows and record them as one Import History batch: the JSON twin of `POST /v1/contacts/import-csv`. Rows take the batch-create shape (`email`, `firstName`, `lastName`, `subscribed`, `customFields`, `consent`) and follow its rules: a missing or invalid email, or an address repeated in the batch, fails its row (`errors[]`, `207`) while the others land. New contacts are subscribed unless the row says `subscribed: false`; re-subscribing a contact who opted out keeps its other fields and adds a `RESUBSCRIBE_SKIPPED` warning. - - The batch is listed in Import History under `fileName` (optional), and each contact it writes carries the returned `importId` until a later import touches it (filter on it to list them; deleting the import in the app removes the contacts it created). `POST /v1/contacts` with `contacts[]` writes the same rows without an Import History entry, for recurring syncs. - - Two opt-in modes. `dryRun: true` returns the response this import would return (`summary`, `errors`, row `warnings`, `fieldsCreated` as fields it would create) with `dryRun: true` and no `importId`, and writes nothing: no contacts, field definitions or Import History entry; `validate` is not run and nothing is charged. Insert vs update is decided by the same address match the write uses, so a real run right after returns the same counts. `onConflict: "skip"` only creates missing contacts: each existing contact is left unchanged (an opt-out row included), counted in `summary.skipped` with a `CONTACT_EXISTS_SKIPPED` warning naming its `row`. Either way `inserted + updated + failed + skipped = total`. A dry run cannot foresee failures that only happen while writing (a concurrent change, a database refusal), so the real run can still fail where the preview succeeded. With `validate: true`, inline checks are still metered per submitted row, rows `skip` left alone included; a request that leaves no address to check is not charged. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ContactsImportRequest" - example: - contacts: - - email: ada@example.com - firstName: Ada - - email: grace@example.com - firstName: Grace - customFields: - plan: pro - consent: - source: import - fileName: crm-export.json responses: "200": - description: Import summary (all rows valid). - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - X-RateLimit-Limit: - schema: - type: integer - description: Requests allowed in the current rolling rate limit window. - example: 100 - required: true - description: Requests allowed in the current rolling rate limit window. - X-RateLimit-Remaining: - schema: - type: integer - description: Requests remaining in the current rolling rate limit window. - example: 99 - required: true - description: Requests remaining in the current rolling rate limit window. - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - example: 1712592360 - required: true - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - content: - application/json: - schema: - $ref: "#/components/schemas/ContactsImportResponse" - example: - summary: - inserted: 2 - updated: 0 - failed: 0 - skipped: 0 - total: 2 - fieldsCreated: - - plan - errors: [] - warnings: [] - importId: imp_01JABCDEF0123456789XYZ - "207": - description: Partial success — some rows failed validation. + description: A page of field definitions. headers: x-request-id: schema: @@ -49684,22 +54167,38 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsImportResponse" + $ref: "#/components/schemas/FieldsGetResponse" example: - summary: - inserted: 1 - updated: 0 - failed: 1 - skipped: 0 - total: 2 - fieldsCreated: [] - errors: - - email: not-an-email - row: 2 - code: INVALID_EMAIL - message: "'not-an-email' is not a valid email address." - warnings: [] - importId: imp_01JABCDEF0123456789XYZ + data: + - fieldName: email + fieldType: string + label: Email + isCore: true + isFilterable: true + isSortable: true + isSearchable: true + - fieldName: plan + fieldType: string + label: Plan + isCore: false + isFilterable: true + isSortable: true + isSearchable: false + coverage: + percent: 62 + approximate: true + topValues: + - value: scale + count: 610 + percent: 51 + - value: starter + count: 420 + percent: 35 + dominantValue: scale + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -49783,34 +54282,6 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication - "402": - description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - insufficientCredits: - summary: INSUFFICIENT_CREDITS - value: - error: - code: INSUFFICIENT_CREDITS - type: payment_required - message: The credit balance is below what this operation requires; `details.cost` is the amount needed. - suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. - docs: https://docs.brew.new/api-reference/api/credits - details: - cost: 5 - remaining: 0 - planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -49870,7 +54341,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -49884,6 +54358,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + audienceNotFound: + summary: AUDIENCE_NOT_FOUND + value: + error: + code: AUDIENCE_NOT_FOUND + type: not_found + message: No audience with that id exists in the brand. + suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -49893,42 +54376,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -49943,7 +54390,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -49984,7 +54431,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -50012,59 +54459,17 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors - parameters: - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: replay - x-brew-credited: true - x-brew-rate-policy: contacts.write_batch - /v1/contacts/batch-delete: + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: fields.read post: - operationId: batchDeleteContacts + operationId: createContactField tags: - - Contacts - summary: Batch-delete contacts - description: "Deletes up to 1000 contacts by email. Returns the deleted count plus `notFound[]` for emails that had no contact. Addresses are lookup keys, not re-validated: any address a contact was stored under matches, and one that matches nothing lands in `notFound[]` instead of failing the batch." + - Contact Fields + summary: Create a contact field + description: 'Creates a custom field definition (`{ fieldName, fieldType: "string" | "number" | "date" | "bool" }`). A `number` field holds values up to 9007199254740991 (2^53 - 1) in magnitude, the largest integer it stores exactly; a contact write with a larger JSON number is refused (in a `string` field too, since it arrives already rounded), so send 64-bit ids and counters as JSON strings to a `string` field. Returns `201` with the bare definition. (Contact upserts also auto-create unknown fields — explicit creation is for pinning the type up front.)' parameters: - name: Idempotency-Key in: header @@ -50089,14 +54494,13 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsBatchDeleteRequest" + $ref: "#/components/schemas/FieldsPostRequest" example: - emails: - - jane@example.com - - gone@example.com + fieldName: plan + fieldType: string responses: - "200": - description: Batch delete applied. + "201": + description: Created. headers: x-request-id: schema: @@ -50129,11 +54533,15 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactsBatchDeleteResponse" + $ref: "#/components/schemas/ContactFieldDefinition" example: - deletedCount: 1 - notFound: - - gone@example.com + fieldName: plan + fieldType: string + label: Plan + isCore: false + isFilterable: true + isSortable: true + isSearchable: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -50336,7 +54744,7 @@ paths: suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency "422": - description: "`BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap." + description: "`CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field." headers: x-request-id: schema: @@ -50350,14 +54758,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - batchTooLarge: - summary: BATCH_TOO_LARGE + coreFieldImmutable: + summary: CORE_FIELD_IMMUTABLE value: error: - code: BATCH_TOO_LARGE + code: CORE_FIELD_IMMUTABLE type: invalid_request - message: A batch write exceeds the per-request row cap. - suggestion: Split the request into smaller batches and retry. + message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + suggestion: Use a custom field name instead. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -50373,7 +54781,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -50414,7 +54822,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -50446,50 +54854,35 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: contacts.write_batch - /v1/fields: + x-brew-rate-policy: fields.write + /v1/fields/{fieldName}: get: - operationId: listContactFields + operationId: getContactField tags: - Contact Fields - summary: List contact fields - description: "Lists core contact columns and custom field definitions under `{ data, pagination }`. Use the field names in audience filters, contact search, and `{{ field | fallback }}` merge tags. Add `?include=coverage` to decorate each row with fill stats — the % of contacts holding a non-empty value plus the most common values (sampled; `approximate: true` on large populations) — and optionally `audienceId` to scope those stats to one saved audience. Check coverage before personalizing on a field so most recipients see a real value, not the fallback." + summary: Get a contact field + description: |- + Reads one contact field definition (a core column or a custom field) as the bare row: `fieldName`, `fieldType` and its filter/sort/search flags. + + **Use when** confirming a merge-tag key or a filter field exists before using it. + + **Input** `fieldName` in the path. + + **Returns** `200` with the row. + + **Errors** `404 FIELD_NOT_FOUND` when no definition carries that name. + + **See also** `listContactFields`, `createContactField`, `deleteContactField`. parameters: - - name: include - in: query - required: false - description: "Opt-in expansion: `coverage` adds per-field fill stats (percent of contacts with a non-empty value + top values)." - schema: - type: string - enum: - - coverage - x-brew-include-tokens: - - coverage - - name: audienceId - in: query - required: false - description: Scope `include=coverage` stats to one saved audience (`404 AUDIENCE_NOT_FOUND` on an unknown / cross-brand id). Ignored without the include. - schema: - type: string - minLength: 1 - maxLength: 64 - - name: limit - in: query - required: false - description: Page size (1–100). Defaults to 100. - schema: - type: integer - minimum: 1 - maximum: 100 - example: 50 - - name: cursor - in: query - required: false - description: Opaque pagination cursor echoed from the previous page’s `pagination.cursor`. Omit for the first page. - schema: + - schema: type: string minLength: 1 - maxLength: 512 + description: The custom field name (URL-encoded when needed). + example: plan + required: true + description: The custom field name (URL-encoded when needed). + name: fieldName + in: path - name: X-Brand-Id in: header required: false @@ -50501,7 +54894,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of field definitions. + description: The field definition. headers: x-request-id: schema: @@ -50534,38 +54927,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/FieldsGetResponse" + $ref: "#/components/schemas/ContactFieldDefinition" example: - data: - - fieldName: email - fieldType: string - label: Email - isCore: true - isFilterable: true - isSortable: true - isSearchable: true - - fieldName: plan - fieldType: string - label: Plan - isCore: false - isFilterable: true - isSortable: true - isSearchable: false - coverage: - percent: 62 - approximate: true - topValues: - - value: scale - count: 610 - percent: 51 - - value: starter - count: 420 - percent: 35 - dominantValue: scale - pagination: - limit: 100 - cursor: null - hasMore: false + fieldName: plan + fieldType: string "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -50709,9 +55074,9 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `FIELD_NOT_FOUND`: No custom field definition with that name exists in the brand. headers: x-request-id: schema: @@ -50725,15 +55090,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceNotFound: - summary: AUDIENCE_NOT_FOUND - value: - error: - code: AUDIENCE_NOT_FOUND - type: not_found - message: No audience with that id exists in the brand. - suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -50743,6 +55099,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + fieldNotFound: + summary: FIELD_NOT_FOUND + value: + error: + code: FIELD_NOT_FOUND + type: not_found + message: No custom field definition with that name exists in the brand. + suggestion: Create the field first with POST /v1/fields or use a field that already exists. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -50831,22 +55196,33 @@ paths: x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: fields.read - post: - operationId: createContactField + delete: + operationId: deleteContactField tags: - Contact Fields - summary: Create a contact field - description: 'Creates a custom field definition (`{ fieldName, fieldType: "string" | "number" | "date" | "bool" }`). A `number` field holds values up to 9007199254740991 (2^53 - 1) in magnitude, the largest integer it stores exactly; a contact write with a larger JSON number is refused (in a `string` field too, since it arrives already rounded), so send 64-bit ids and counters as JSON strings to a `string` field. Returns `201` with the bare definition. (Contact upserts also auto-create unknown fields — explicit creation is for pinning the type up front.)' + summary: Delete a contact field + description: |- + Deletes a custom field and clears its value from every contact. The values cannot be recovered, and a field created later with the same name starts empty. + + **Use when** a field and its values should go. To change a field's type, delete it, create it again with the new type and re-import the values. Check saved audiences first: afterwards a filter on the field sees no value, so `is_empty` and `not_exists` match every contact. + + **Input** `fieldName` in the path. + + **Returns** `200` with `{ fieldName, deleted }`. A name with no custom field resolves `deleted: false` and clears nothing, so repeating the call is safe. The three legacy consent fields (`marketingConsentSource`, `marketingConsentCapturedAt`, `marketingConsentPolicyVersion`) lose only the definition: their values stay, because they still serve as consent evidence. + + **Errors** `422 CORE_FIELD_IMMUTABLE` for a core column. `409 AUDIENCE_BUILD_ACTIVE` for the hidden field of an event-derived audience that is still building; retry once the build finishes. + + **See also** `listContactFields`, `getContactField`, `createContactField`. parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: + - schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + description: The custom field name (URL-encoded when needed). + example: plan + required: true + description: The custom field name (URL-encoded when needed). + name: fieldName + in: path - name: X-Brand-Id in: header required: false @@ -50856,18 +55232,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/FieldsPostRequest" - example: - fieldName: plan - fieldType: string responses: - "201": - description: Created. + "200": + description: Deleted (or already gone). headers: x-request-id: schema: @@ -50900,15 +55267,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactFieldDefinition" + $ref: "#/components/schemas/FieldsDeleteResponse" example: fieldName: plan - fieldType: string - label: Plan - isCore: false - isFilterable: true - isSortable: true - isSearchable: false + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -51075,10 +55437,7 @@ paths: suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + description: "`AUDIENCE_BUILD_ACTIVE`: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet." headers: x-request-id: schema: @@ -51092,24 +55451,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS + audienceBuildActive: + summary: AUDIENCE_BUILD_ACTIVE value: error: - code: IDEMPOTENCY_IN_PROGRESS + code: AUDIENCE_BUILD_ACTIVE type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency + message: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. + suggestion: Wait for the build to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. + docs: https://docs.brew.new/api-reference/api/errors "422": description: "`CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field." headers: @@ -51219,37 +55569,55 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: fields.write - /v1/fields/{fieldName}: + /v1/audiences: get: - operationId: getContactField + operationId: listAudiences tags: - - Contact Fields - summary: Get a contact field + - Audiences + summary: List audiences description: |- - Reads one contact field definition (a core column or a custom field) as the bare row: `fieldName`, `fieldType` and its filter/sort/search flags. + Lists the brand’s saved audiences under `{ data, pagination }`. Each row carries its contact `filters`, a member `count` and ISO timestamps; an audience is the recipient target for `createSend`. Stale counts are refreshed on a best-effort basis. If a recount fails or reaches its time budget, the saved count is returned. Use `GET /v1/audiences/{audienceId}?include=count` for an on-demand count. - **Use when** confirming a merge-tag key or a filter field exists before using it. + **Use when** choosing the `audienceId` for a campaign or an automation launch. - **Input** `fieldName` in the path. + **Input** `limit` and `cursor`. - **Returns** `200` with the row. + **Returns** `200` with a page of `Audience` rows. - **Errors** `404 FIELD_NOT_FOUND` when no definition carries that name. + **Errors** `400 INVALID_REQUEST` for an unknown query key. - **See also** `listContactFields`, `createContactField`, `deleteContactField`. + **See also** `getAudience` (live `count`, `build`), `createAudience`, `searchContacts`. parameters: - schema: type: string minLength: 1 - description: The custom field name (URL-encoded when needed). - example: plan - required: true - description: The custom field name (URL-encoded when needed). - name: fieldName - in: path + maxLength: 200 + description: Only audiences whose name contains this text (case-insensitive). + required: false + description: Only audiences whose name contains this text (case-insensitive). + name: search + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 512 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -51261,7 +55629,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The field definition. + description: A page of audiences. headers: x-request-id: schema: @@ -51294,10 +55662,24 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContactFieldDefinition" + $ref: "#/components/schemas/AudiencesListResponse" example: - fieldName: plan - fieldType: string + data: + - audienceId: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f + audienceName: Nordic Founders + filters: + filters: + - field: country + operator: equals + value: 'NO' + logicalOperator: and + count: 1284 + createdAt: '2026-04-08T12:34:56.789Z' + updatedAt: '2026-04-08T12:34:56.789Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -51440,10 +55822,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `FIELD_NOT_FOUND`: No custom field definition with that name exists in the brand. + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -51466,15 +55845,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - fieldNotFound: - summary: FIELD_NOT_FOUND - value: - error: - code: FIELD_NOT_FOUND - type: not_found - message: No custom field definition with that name exists in the brand. - suggestion: Create the field first with POST /v1/fields or use a field that already exists. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -51558,38 +55928,27 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: contacts + x-brew-scope: audiences x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: fields.read - delete: - operationId: deleteContactField + x-brew-rate-policy: audiences.read + post: + operationId: createAudience tags: - - Contact Fields - summary: Delete a contact field - description: |- - Deletes a custom field and clears its value from every contact. The values cannot be recovered, and a field created later with the same name starts empty. - - **Use when** a field and its values should go. To change a field's type, delete it, create it again with the new type and re-import the values. Check saved audiences first: afterwards a filter on the field sees no value, so `is_empty` and `not_exists` match every contact. - - **Input** `fieldName` in the path. - - **Returns** `200` with `{ fieldName, deleted }`. A name with no custom field resolves `deleted: false` and clears nothing, so repeating the call is safe. The three legacy consent fields (`marketingConsentSource`, `marketingConsentCapturedAt`, `marketingConsentPolicyVersion`) lose only the definition: their values stay, because they still serve as consent evidence. - - **Errors** `422 CORE_FIELD_IMMUTABLE` for a core column. `409 AUDIENCE_BUILD_ACTIVE` for the hidden field of an event-derived audience that is still building; retry once the build finishes. - - **See also** `listContactFields`, `getContactField`, `createContactField`. + - Audiences + summary: Create an audience + description: 'Creates a saved audience from a name + filter set (`{ filters: [{ field, operator, value? }], logicalOperator: "and" | "or" }`). Returns `201` with the bare audience row. An `email in [...]` clause listing more than 100 addresses is converted automatically: a fresh date custom field is stamped on the listed EXISTING contacts and the stored filter becomes `customFields. is_not_empty` (a snapshot — addresses with no contact record are skipped), reported back as `emailListMaterializations`; 10,000 addresses max per clause.' parameters: - - schema: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: type: string minLength: 1 - description: The custom field name (URL-encoded when needed). - example: plan - required: true - description: The custom field name (URL-encoded when needed). - name: fieldName - in: path + maxLength: 100 + example: api-request-2026-04-08-001 - name: X-Brand-Id in: header required: false @@ -51599,9 +55958,23 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AudiencesPostRequest" + example: + name: Nordic Founders + filters: + filters: + - field: country + operator: equals + value: 'NO' + logicalOperator: and responses: - "200": - description: Deleted (or already gone). + "201": + description: Created. headers: x-request-id: schema: @@ -51634,10 +56007,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/FieldsDeleteResponse" + $ref: "#/components/schemas/AudienceWriteResponse" example: - fieldName: plan - deleted: true + audienceId: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f + audienceName: Nordic Founders + filters: + filters: + - field: country + operator: equals + value: 'NO' + logicalOperator: and + count: 1284 + createdAt: '2026-04-08T12:34:56.789Z' + updatedAt: '2026-04-08T12:34:56.789Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -51804,7 +56186,12 @@ paths: suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors "409": - description: "`AUDIENCE_BUILD_ACTIVE`: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet." + description: |- + `FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field. + + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -51818,15 +56205,33 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceBuildActive: - summary: AUDIENCE_BUILD_ACTIVE + fieldTypeMismatch: + summary: FIELD_TYPE_MISMATCH value: error: - code: AUDIENCE_BUILD_ACTIVE + code: FIELD_TYPE_MISMATCH type: conflict - message: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. - suggestion: Wait for the build to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. + message: A value does not match the declared type of its custom field. + suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. docs: https://docs.brew.new/api-reference/api/errors + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "422": description: "`CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field." headers: @@ -51865,7 +56270,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -51906,7 +56311,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -51934,56 +56339,49 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: contacts + x-brew-scope: audiences x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: fields.write - /v1/audiences: + x-brew-rate-policy: audiences.write + /v1/audiences/{audienceId}: get: - operationId: listAudiences + operationId: getAudience tags: - Audiences - summary: List audiences + summary: Get an audience description: |- - Lists the brand’s saved audiences under `{ data, pagination }`. Each row carries its contact `filters`, a member `count` and ISO timestamps; an audience is the recipient target for `createSend`. Stale counts are refreshed on a best-effort basis. If a recount fails or reaches its time budget, the saved count is returned. Use `GET /v1/audiences/{audienceId}?include=count` for an on-demand count. + Reads one saved audience as the bare `Audience` row. - **Use when** choosing the `audienceId` for a campaign or an automation launch. + **Use when** sizing a send, checking an audience’s filters, or polling an event-cohort build started by `createAudienceFromEvents`. - **Input** `limit` and `cursor`. + **Input** `audienceId` in the path; `include=count` replaces the cached `count` with a freshly computed member total and `include=build` attaches the latest event-cohort build status (comma-separate both). - **Returns** `200` with a page of `Audience` rows. + **Returns** `200` with the row; `build` is present only when requested and a build exists. - **Errors** `400 INVALID_REQUEST` for an unknown query key. + **Errors** `404 AUDIENCE_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. - **See also** `getAudience` (live `count`, `build`), `createAudience`, `searchContacts`. + **See also** `listAudiences`, `updateAudience`, `duplicateAudience`, `createSend`. parameters: - schema: type: string minLength: 1 - maxLength: 200 - description: Only audiences whose name contains this text (case-insensitive). - required: false - description: Only audiences whose name contains this text (case-insensitive). - name: search - in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 + maxLength: 64 + description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. + example: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f + required: true + description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. + name: audienceId + in: path - schema: type: string - minLength: 1 - maxLength: 512 + description: "Comma-separated expansions: `count` recomputes the live member total; `build` attaches the latest event-cohort build status." + x-brew-include-tokens: + - count + - build required: false - name: cursor + description: "Comma-separated expansions: `count` recomputes the live member total; `build` attaches the latest event-cohort build status." + name: include in: query - name: X-Brand-Id in: header @@ -51996,7 +56394,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of audiences. + description: The audience row. headers: x-request-id: schema: @@ -52029,24 +56427,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudiencesListResponse" + $ref: "#/components/schemas/Audience" example: - data: - - audienceId: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f - audienceName: Nordic Founders - filters: - filters: - - field: country - operator: equals - value: 'NO' - logicalOperator: and - count: 1284 - createdAt: '2026-04-08T12:34:56.789Z' - updatedAt: '2026-04-08T12:34:56.789Z' - pagination: - limit: 100 - cursor: null - hasMore: false + audienceId: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f + audienceName: Nordic Founders + filters: + filters: + - field: country + operator: equals + value: 'NO' + logicalOperator: and + count: 1284 + createdAt: '2026-04-08T12:34:56.789Z' + updatedAt: '2026-04-08T12:34:56.789Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -52189,7 +56582,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -52203,6 +56599,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + audienceNotFound: + summary: AUDIENCE_NOT_FOUND + value: + error: + code: AUDIENCE_NOT_FOUND + type: not_found + message: No audience with that id exists in the brand. + suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -52300,22 +56705,23 @@ paths: x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: audiences.read - post: - operationId: createAudience + patch: + operationId: updateAudience tags: - Audiences - summary: Create an audience - description: 'Creates a saved audience from a name + filter set (`{ filters: [{ field, operator, value? }], logicalOperator: "and" | "or" }`). Returns `201` with the bare audience row. An `email in [...]` clause listing more than 100 addresses is converted automatically: a fresh date custom field is stamped on the listed EXISTING contacts and the stored filter becomes `customFields. is_not_empty` (a snapshot — addresses with no contact record are skipped), reported back as `emailListMaterializations`; 10,000 addresses max per clause.' + summary: Update an audience + description: "Updates `name` and/or `filters` (at least one required). Pass the latest row’s `updatedAt` as `expectedUpdatedAt` to reject stale read-modify-write edits. Returns the bare updated row. Replacement filters follow the same email-list rule as create: an `email in [...]` clause over 100 addresses is converted into a stamped custom-field snapshot (a fresh field per update), reported as `emailListMaterializations`." parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: + - schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + maxLength: 64 + description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. + example: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f + required: true + description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. + name: audienceId + in: path - name: X-Brand-Id in: header required: false @@ -52330,18 +56736,12 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudiencesPostRequest" + $ref: "#/components/schemas/AudiencesPatchRequest" example: - name: Nordic Founders - filters: - filters: - - field: country - operator: equals - value: 'NO' - logicalOperator: and + name: Nordic Founders (active) responses: - "201": - description: Created. + "200": + description: Updated. headers: x-request-id: schema: @@ -52529,7 +56929,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -52543,6 +56946,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + audienceNotFound: + summary: AUDIENCE_NOT_FOUND + value: + error: + code: AUDIENCE_NOT_FOUND + type: not_found + message: No audience with that id exists in the brand. + suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -52554,11 +56966,13 @@ paths: docs: https://docs.brew.new/api-reference/api/errors "409": description: |- - `FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field. + `AUDIENCE_BUILD_ACTIVE`: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + `AUDIENCE_EDIT_CONFLICT`: The audience changed since it was read; the update was not applied. - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + `AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE`: The audience’s filters cannot add or remove these contacts exactly (for example, AND-combined conditions, or an OR branch that would still match). + + `FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field. headers: x-request-id: schema: @@ -52572,33 +56986,42 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - fieldTypeMismatch: - summary: FIELD_TYPE_MISMATCH + audienceBuildActive: + summary: AUDIENCE_BUILD_ACTIVE value: error: - code: FIELD_TYPE_MISMATCH + code: AUDIENCE_BUILD_ACTIVE type: conflict - message: A value does not match the declared type of its custom field. - suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. + message: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. + suggestion: Wait for the build to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. docs: https://docs.brew.new/api-reference/api/errors - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT + audienceEditConflict: + summary: AUDIENCE_EDIT_CONFLICT value: error: - code: IDEMPOTENCY_CONFLICT + code: AUDIENCE_EDIT_CONFLICT type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS + message: The audience changed since it was read; the update was not applied. + suggestion: Reload the latest audience filters and retry. + docs: https://docs.brew.new/api-reference/api/errors + audienceMembershipNotExpressible: + summary: AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE value: error: - code: IDEMPOTENCY_IN_PROGRESS + code: AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency + message: The audience’s filters cannot add or remove these contacts exactly (for example, AND-combined conditions, or an OR branch that would still match). + suggestion: Rewrite the audience with `filters` instead, or create a separate audience for these contacts. + docs: https://docs.brew.new/api-reference/api/errors + fieldTypeMismatch: + summary: FIELD_TYPE_MISMATCH + value: + error: + code: FIELD_TYPE_MISMATCH + type: conflict + message: A value does not match the declared type of its custom field. + suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. + docs: https://docs.brew.new/api-reference/api/errors "422": description: "`CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field." headers: @@ -52708,27 +57131,15 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: audiences x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: audiences.write - /v1/audiences/{audienceId}: - get: - operationId: getAudience + delete: + operationId: deleteAudience tags: - Audiences - summary: Get an audience - description: |- - Reads one saved audience as the bare `Audience` row. - - **Use when** sizing a send, checking an audience’s filters, or polling an event-cohort build started by `createAudienceFromEvents`. - - **Input** `audienceId` in the path; `include=count` replaces the cached `count` with a freshly computed member total and `include=build` attaches the latest event-cohort build status (comma-separate both). - - **Returns** `200` with the row; `build` is present only when requested and a build exists. - - **Errors** `404 AUDIENCE_NOT_FOUND` for an unknown or cross-brand id; `400 INVALID_REQUEST` for an unknown `include` token. - - **See also** `listAudiences`, `updateAudience`, `duplicateAudience`, `createSend`. + summary: Delete an audience + description: "Deletes the audience. Idempotent — an unknown id resolves with `deleted: false`." parameters: - schema: type: string @@ -52740,16 +57151,6 @@ paths: description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. name: audienceId in: path - - schema: - type: string - description: "Comma-separated expansions: `count` recomputes the live member total; `build` attaches the latest event-cohort build status." - x-brew-include-tokens: - - count - - build - required: false - description: "Comma-separated expansions: `count` recomputes the live member total; `build` attaches the latest event-cohort build status." - name: include - in: query - name: X-Brand-Id in: header required: false @@ -52761,7 +57162,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The audience row. + description: Deleted (or already gone). headers: x-request-id: schema: @@ -52794,19 +57195,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Audience" + $ref: "#/components/schemas/AudiencesDeleteResponse" example: audienceId: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f - audienceName: Nordic Founders - filters: - filters: - - field: country - operator: equals - value: 'NO' - logicalOperator: and - count: 1284 - createdAt: '2026-04-08T12:34:56.789Z' - updatedAt: '2026-04-08T12:34:56.789Z' + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -52949,10 +57341,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. - - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -52966,15 +57355,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceNotFound: - summary: AUDIENCE_NOT_FOUND - value: - error: - code: AUDIENCE_NOT_FOUND - type: not_found - message: No audience with that id exists in the brand. - suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -52984,6 +57364,30 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: "`AUDIENCE_BUILD_ACTIVE`: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + audienceBuildActive: + summary: AUDIENCE_BUILD_ACTIVE + value: + error: + code: AUDIENCE_BUILD_ACTIVE + type: conflict + message: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. + suggestion: Wait for the build to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -52998,7 +57402,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -53039,7 +57443,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -53071,24 +57475,24 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: audiences.read - patch: - operationId: updateAudience + x-brew-rate-policy: audiences.write + /v1/audiences/from-events: + post: + operationId: createAudienceFromEvents tags: - Audiences - summary: Update an audience - description: "Updates `name` and/or `filters` (at least one required). Pass the latest row’s `updatedAt` as `expectedUpdatedAt` to reject stale read-modify-write edits. Returns the bare updated row. Replacement filters follow the same email-list rule as create: an `email in [...]` clause over 100 addresses is converted into a stamped custom-field snapshot (a fresh field per update), reported as `emailListMaterializations`." + summary: Create an audience from analytics events + description: "Creates a frozen audience snapshot from the same membership filters as `/events`: 1–10 canonical event types, an absolute window up to 90 days (`from` may be omitted when `sendId` is set — the window then derives from that send), optional send/email ids, multi-select automation/audience ids, recipient include/exclude rules, and the machine-click policy. `cohort.exclude` builds the COMPLEMENT instead: every member of a base audience (default: the send's audience; `\"all\"` = the whole brand) who did NOT match — the base's clauses are copied at build time AND the cohort field must be empty — which is how to re-send after a cancelled send (`eventTypes: [\"sent\"]`, the `sendId`, `exclude: {}`). Every call generates a unique date field; each matching existing contact stores its latest matching-event timestamp. Returns `201` immediately with `materializationStatus: pending` plus build progress. The audience is unavailable for campaign, smart-send, and automation delivery until the status becomes `ready`. Failed/partial/stale builds remain non-sendable and partial values are cleaned. Poll `GET /v1/audiences/{audienceId}?include=build`." parameters: - - schema: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: type: string minLength: 1 - maxLength: 64 - description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. - example: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f - required: true - description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. - name: audienceId - in: path + maxLength: 100 + example: api-request-2026-04-08-001 - name: X-Brand-Id in: header required: false @@ -53103,12 +57507,16 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudiencesPatchRequest" + $ref: "#/components/schemas/AudiencesFromEventsRequest" example: - name: Nordic Founders (active) + cohort: + eventTypes: + - opened + from: '2026-07-04T00:00:00.000Z' + to: '2026-08-03T00:00:00.000Z' responses: - "200": - description: Updated. + "201": + description: Created. The audience exists immediately; `build` tracks the background stamping run. headers: x-request-id: schema: @@ -53141,19 +57549,35 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudienceWriteResponse" + $ref: "#/components/schemas/AudiencesFromEventsResponse" example: audienceId: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f - audienceName: Nordic Founders + audienceName: Openers · Jul 4 – Aug 3, 2026 filters: filters: - - field: country - operator: equals - value: 'NO' + - field: customFields.openedJul4Aug3 + operator: is_not_empty + type: date logicalOperator: and - count: 1284 + count: 0 createdAt: '2026-04-08T12:34:56.789Z' updatedAt: '2026-04-08T12:34:56.789Z' + materializationStatus: pending + build: + jobId: kh7c2m4q8n9w2p1x7c3b5v6n9h7s2d4f + status: pending + cohort: + eventTypes: + - opened + from: '2026-07-04T00:00:00.000Z' + to: '2026-08-03T00:00:00.000Z' + field: + key: openedJul4Aug3 + label: Opened Jul 4 – Aug 3 + type: date + counts: + eventsScanned: 0 + enqueuedAt: '2026-08-03T12:00:00.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -53296,10 +57720,7 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. - - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -53313,15 +57734,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceNotFound: - summary: AUDIENCE_NOT_FOUND - value: - error: - code: AUDIENCE_NOT_FOUND - type: not_found - message: No audience with that id exists in the brand. - suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -53333,13 +57745,11 @@ paths: docs: https://docs.brew.new/api-reference/api/errors "409": description: |- - `AUDIENCE_BUILD_ACTIVE`: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. - - `AUDIENCE_EDIT_CONFLICT`: The audience changed since it was read; the update was not applied. + `AUDIENCE_BUILD_ALREADY_ACTIVE`: Too many audience builds are already in progress for the workspace. - `AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE`: The audience’s filters cannot add or remove these contacts exactly (for example, AND-combined conditions, or an OR branch that would still match). + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - `FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field. + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -53353,66 +57763,33 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceBuildActive: - summary: AUDIENCE_BUILD_ACTIVE - value: - error: - code: AUDIENCE_BUILD_ACTIVE - type: conflict - message: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. - suggestion: Wait for the build to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. - docs: https://docs.brew.new/api-reference/api/errors - audienceEditConflict: - summary: AUDIENCE_EDIT_CONFLICT + audienceBuildAlreadyActive: + summary: AUDIENCE_BUILD_ALREADY_ACTIVE value: error: - code: AUDIENCE_EDIT_CONFLICT + code: AUDIENCE_BUILD_ALREADY_ACTIVE type: conflict - message: The audience changed since it was read; the update was not applied. - suggestion: Reload the latest audience filters and retry. + message: Too many audience builds are already in progress for the workspace. + suggestion: Wait for the in-flight builds to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. docs: https://docs.brew.new/api-reference/api/errors - audienceMembershipNotExpressible: - summary: AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT value: error: - code: AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE + code: IDEMPOTENCY_CONFLICT type: conflict - message: The audience’s filters cannot add or remove these contacts exactly (for example, AND-combined conditions, or an OR branch that would still match). - suggestion: Rewrite the audience with `filters` instead, or create a separate audience for these contacts. - docs: https://docs.brew.new/api-reference/api/errors - fieldTypeMismatch: - summary: FIELD_TYPE_MISMATCH + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS value: error: - code: FIELD_TYPE_MISMATCH + code: IDEMPOTENCY_IN_PROGRESS type: conflict - message: A value does not match the declared type of its custom field. - suggestion: Send a value that matches the field's type. To change the type, delete the field (this clears its value on every contact) and create it again. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: "`CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - coreFieldImmutable: - summary: CORE_FIELD_IMMUTABLE - value: - error: - code: CORE_FIELD_IMMUTABLE - type: invalid_request - message: The field is a core contact column and cannot be created, changed, or deleted as a custom field. - suggestion: Use a custom field name instead. - docs: https://docs.brew.new/api-reference/api/errors + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -53427,7 +57804,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -53468,7 +57845,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -53496,18 +57873,28 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: audiences + x-brew-scope: contacts x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: audiences.write - delete: - operationId: deleteAudience + x-brew-rate-policy: contacts.write_batch + /v1/audiences/{audienceId}/duplicate: + post: + operationId: duplicateAudience tags: - Audiences - summary: Delete an audience - description: "Deletes the audience. Idempotent — an unknown id resolves with `deleted: false`." + summary: Duplicate an audience + description: Creates a deterministic copy of the saved audience’s live filters with a server-generated “(copy)” name. Contacts are not copied; both audiences resolve against the same contact records. parameters: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 - schema: type: string minLength: 1 @@ -53528,8 +57915,8 @@ paths: maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: - "200": - description: Deleted (or already gone). + "201": + description: Copied. headers: x-request-id: schema: @@ -53562,10 +57949,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudiencesDeleteResponse" + $ref: "#/components/schemas/Audience" example: - audienceId: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f - deleted: true + audienceId: mh8b9x5r9n1k3p2y8c4d6w7q1j9t3f5g + audienceName: Nordic Founders (copy) + filters: + filters: + - field: country + operator: equals + value: 'NO' + logicalOperator: and + count: 1284 + createdAt: '2026-04-08T12:34:56.789Z' + updatedAt: '2026-04-08T12:34:56.789Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -53708,7 +58104,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. + + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). headers: x-request-id: schema: @@ -53722,6 +58121,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + audienceNotFound: + summary: AUDIENCE_NOT_FOUND + value: + error: + code: AUDIENCE_NOT_FOUND + type: not_found + message: No audience with that id exists in the brand. + suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. + docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -53732,7 +58140,14 @@ paths: suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors "409": - description: "`AUDIENCE_BUILD_ACTIVE`: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet." + description: |- + `AUDIENCE_BUILD_ACTIVE`: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. + + `AUDIENCE_EDIT_CONFLICT`: The audience changed since it was read; the update was not applied. + + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -53755,6 +58170,33 @@ paths: message: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. suggestion: Wait for the build to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. docs: https://docs.brew.new/api-reference/api/errors + audienceEditConflict: + summary: AUDIENCE_EDIT_CONFLICT + value: + error: + code: AUDIENCE_EDIT_CONFLICT + type: conflict + message: The audience changed since it was read; the update was not applied. + suggestion: Reload the latest audience filters and retry. + docs: https://docs.brew.new/api-reference/api/errors + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -53840,26 +58282,66 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: audiences x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false x-brew-rate-policy: audiences.write - /v1/audiences/from-events: - post: - operationId: createAudienceFromEvents + /v1/domains: + get: + operationId: listDomains tags: - - Audiences - summary: Create an audience from analytics events - description: "Creates a frozen audience snapshot from the same membership filters as `/events`: 1–10 canonical event types, an absolute window up to 90 days (`from` may be omitted when `sendId` is set — the window then derives from that send), optional send/email ids, multi-select automation/audience ids, recipient include/exclude rules, and the machine-click policy. `cohort.exclude` builds the COMPLEMENT instead: every member of a base audience (default: the send's audience; `\"all\"` = the whole brand) who did NOT match — the base's clauses are copied at build time AND the cohort field must be empty — which is how to re-send after a cancelled send (`eventTypes: [\"sent\"]`, the `sendId`, `exclude: {}`). Every call generates a unique date field; each matching existing contact stores its latest matching-event timestamp. Returns `201` immediately with `materializationStatus: pending` plus build progress. The audience is unavailable for campaign, smart-send, and automation delivery until the status becomes `ready`. Failed/partial/stale builds remain non-sendable and partial values are cleaned. Poll `GET /v1/audiences/{audienceId}?include=build`." + - Domains + summary: List domains + description: |- + Lists the brand’s sending domains under `{ data, pagination }`: ALL lifecycle states by default, so a caller mid-verification can read `pending` rows and their DNS `records`. + + **Use when** picking the `domainId` for `createSend` or a sendEmail node, or reading DNS records to publish. + + **Input** `sendableOnly=true` keeps only verified, send-ready domains (the set valid for sends and automation `sendEmail` nodes); `sendingPurpose` keeps one purpose (campaigns and audience sends require `marketing`; automation sendEmail nodes accept both); `limit` and `cursor`. + + **Returns** `200` with a page of `Domain` rows. + + **Errors** `400 INVALID_REQUEST` for an unknown query key or purpose. + + **See also** `getDomain`, `addDomain`, `verifyDomain`, `getDomainHealth`. parameters: - - name: Idempotency-Key - in: header + - schema: + type: string + enum: + - 'true' + - 'false' + description: When `true`, only verified send-ready domains. required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: + description: When `true`, only verified send-ready domains. + name: sendableOnly + in: query + - schema: + type: string + enum: + - marketing + - transactional + description: Filter listed domains by purpose. Use marketing for campaigns and automations. + required: false + description: Filter listed domains by purpose. Use marketing for campaigns and automations. + name: sendingPurpose + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + maxLength: 512 + required: false + name: cursor + in: query - name: X-Brand-Id in: header required: false @@ -53869,21 +58351,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AudiencesFromEventsRequest" - example: - cohort: - eventTypes: - - opened - from: '2026-07-04T00:00:00.000Z' - to: '2026-08-03T00:00:00.000Z' responses: - "201": - description: Created. The audience exists immediately; `build` tracks the background stamping run. + "200": + description: A page of domains. headers: x-request-id: schema: @@ -53916,35 +58386,30 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/AudiencesFromEventsResponse" + $ref: "#/components/schemas/DomainsListResponse" example: - audienceId: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f - audienceName: Openers · Jul 4 – Aug 3, 2026 - filters: - filters: - - field: customFields.openedJul4Aug3 - operator: is_not_empty - type: date - logicalOperator: and - count: 0 - createdAt: '2026-04-08T12:34:56.789Z' - updatedAt: '2026-04-08T12:34:56.789Z' - materializationStatus: pending - build: - jobId: kh7c2m4q8n9w2p1x7c3b5v6n9h7s2d4f - status: pending - cohort: - eventTypes: - - opened - from: '2026-07-04T00:00:00.000Z' - to: '2026-08-03T00:00:00.000Z' - field: - key: openedJul4Aug3 - label: Opened Jul 4 – Aug 3 - type: date - counts: - eventsScanned: 0 - enqueuedAt: '2026-08-03T12:00:00.000Z' + data: + - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + domainUrl: https://send.example.com + name: send.example.com + region: us-east-1 + status: pending + sendingEnabled: true + sendable: false + sendingPurpose: marketing + records: + - record: DKIM + name: resend._domainkey + type: TXT + ttl: Auto + status: pending + value: p=MIGfMA0... + createdAt: '2026-04-08T12:34:56.789Z' + updatedAt: '2026-04-08T12:34:56.789Z' + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -54110,53 +58575,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `AUDIENCE_BUILD_ALREADY_ACTIVE`: Too many audience builds are already in progress for the workspace. - - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - audienceBuildAlreadyActive: - summary: AUDIENCE_BUILD_ALREADY_ACTIVE - value: - error: - code: AUDIENCE_BUILD_ALREADY_ACTIVE - type: conflict - message: Too many audience builds are already in progress for the workspace. - suggestion: Wait for the in-flight builds to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. - docs: https://docs.brew.new/api-reference/api/errors - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -54171,7 +58589,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 10 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -54212,7 +58630,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 10 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -54240,18 +58658,17 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: contacts + x-brew-scope: domains x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: contacts.write_batch - /v1/audiences/{audienceId}/duplicate: + x-brew-rate-policy: domains.read post: - operationId: duplicateAudience + operationId: addDomain tags: - - Audiences - summary: Duplicate an audience - description: Creates a deterministic copy of the saved audience’s live filters with a server-generated “(copy)” name. Contacts are not copied; both audiences resolve against the same contact records. + - Domains + summary: Add a domain + description: 'Registers a sending domain with the provider. Optional `sendingPurpose` is `marketing` (default; campaigns, audience sends, automations) or `transactional` (event-triggered automation mail: no unsubscribe link required, merely-unsubscribed contacts still receive it, and open and click tracking are off). Returns `201` with the bare row in `status: "pending"` plus the DNS `records` to publish. When the domain already publishes a different DKIM key where Brew asks for its record, the domain is still added and the row carries a `DKIM_SELECTOR_IN_USE` warning whose `suggestedNames` are subdomains to add instead. Once the records are live, call `POST /v1/domains/{domainId}/verify` until `sendable: true`.' parameters: - name: Idempotency-Key in: header @@ -54262,16 +58679,6 @@ paths: minLength: 1 maxLength: 100 example: api-request-2026-04-08-001 - - schema: - type: string - minLength: 1 - maxLength: 64 - description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. - example: jn7a8w4q8m9k2p1x7c3b5v6n9h7s2d4f - required: true - description: Audience id (opaque Convex document id) returned by `POST /v1/audiences` and listed by `GET /v1/audiences`. - name: audienceId - in: path - name: X-Brand-Id in: header required: false @@ -54281,9 +58688,17 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DomainsPostRequest" + example: + name: send.example.com responses: "201": - description: Copied. + description: Added. Publish the returned DNS records, then verify. `warnings` appears only with a caveat about the domain just added. headers: x-request-id: schema: @@ -54316,17 +58731,23 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Audience" + $ref: "#/components/schemas/DomainsPostResponse" example: - audienceId: mh8b9x5r9n1k3p2y8c4d6w7q1j9t3f5g - audienceName: Nordic Founders (copy) - filters: - filters: - - field: country - operator: equals - value: 'NO' - logicalOperator: and - count: 1284 + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + domainUrl: https://send.example.com + name: send.example.com + region: us-east-1 + status: pending + sendingEnabled: true + sendable: false + sendingPurpose: marketing + records: + - record: DKIM + name: resend._domainkey + type: TXT + ttl: Auto + status: pending + value: p=MIGfMA0... createdAt: '2026-04-08T12:34:56.789Z' updatedAt: '2026-04-08T12:34:56.789Z' "400": @@ -54472,9 +58893,9 @@ paths: docs: https://docs.brew.new/api-reference/api/authentication "404": description: |- - `AUDIENCE_NOT_FOUND`: No audience with that id exists in the brand. - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. headers: x-request-id: schema: @@ -54488,15 +58909,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceNotFound: - summary: AUDIENCE_NOT_FOUND - value: - error: - code: AUDIENCE_NOT_FOUND - type: not_found - message: No audience with that id exists in the brand. - suggestion: Use GET /v1/audiences to choose a saved audience, or send to explicit addresses. - docs: https://docs.brew.new/api-reference/api/errors brandNotFound: summary: BRAND_NOT_FOUND value: @@ -54506,11 +58918,24 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + domainNotFound: + summary: DOMAIN_NOT_FOUND + value: + error: + code: DOMAIN_NOT_FOUND + type: not_found + message: No domain with that id exists in the brand. + suggestion: Use GET /v1/domains to choose a verified domain. + docs: https://docs.brew.new/api-reference/api/errors "409": description: |- - `AUDIENCE_BUILD_ACTIVE`: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. + `DOMAIN_ALREADY_EXISTS`: The brand already has this domain. - `AUDIENCE_EDIT_CONFLICT`: The audience changed since it was read; the update was not applied. + `DOMAIN_CLAIMED_ELSEWHERE`: The domain is registered in another Brew workspace. + + `DOMAIN_OTHER_BRAND`: The domain is attached to a different brand in this workspace. + + `DOMAIN_VERIFIED_ELSEWHERE`: Another workspace already verified this domain. `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. @@ -54528,23 +58953,41 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - audienceBuildActive: - summary: AUDIENCE_BUILD_ACTIVE + domainAlreadyExists: + summary: DOMAIN_ALREADY_EXISTS value: error: - code: AUDIENCE_BUILD_ACTIVE + code: DOMAIN_ALREADY_EXISTS type: conflict - message: The audience is being built, so neither it nor the hidden field its build stamps can be changed, copied, or deleted yet. - suggestion: Wait for the build to finish (poll GET /v1/audiences/{audienceId}?include=build), then retry. + message: The brand already has this domain. + suggestion: Fetch the existing domain with GET /v1/domains. docs: https://docs.brew.new/api-reference/api/errors - audienceEditConflict: - summary: AUDIENCE_EDIT_CONFLICT + domainClaimedElsewhere: + summary: DOMAIN_CLAIMED_ELSEWHERE value: error: - code: AUDIENCE_EDIT_CONFLICT + code: DOMAIN_CLAIMED_ELSEWHERE type: conflict - message: The audience changed since it was read; the update was not applied. - suggestion: Reload the latest audience filters and retry. + message: The domain is registered in another Brew workspace. + suggestion: Remove it from the other workspace first, or contact support to move it. + docs: https://docs.brew.new/api-reference/api/errors + domainOtherBrand: + summary: DOMAIN_OTHER_BRAND + value: + error: + code: DOMAIN_OTHER_BRAND + type: conflict + message: The domain is attached to a different brand in this workspace. + suggestion: Use that brand’s credential, or remove the domain from it first. + docs: https://docs.brew.new/api-reference/api/errors + domainVerifiedElsewhere: + summary: DOMAIN_VERIFIED_ELSEWHERE + value: + error: + code: DOMAIN_VERIFIED_ELSEWHERE + type: conflict + message: Another workspace already verified this domain. + suggestion: Only unverified domains can be reclaimed. Remove it from the other workspace first. docs: https://docs.brew.new/api-reference/api/errors idempotencyConflict: summary: IDEMPOTENCY_CONFLICT @@ -54564,6 +59007,42 @@ paths: message: A request with this Idempotency-Key is still executing. suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency + "422": + description: |- + `DOMAIN_PROVIDER_ERROR`: The sending provider rejected the domain. + + `DOMAIN_VERIFICATION_FAILED`: The DNS records are not published yet, so verification did not pass. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + domainProviderError: + summary: DOMAIN_PROVIDER_ERROR + value: + error: + code: DOMAIN_PROVIDER_ERROR + type: invalid_request + message: The sending provider rejected the domain. + suggestion: Check the domain name is a valid, registrable domain and retry. + docs: https://docs.brew.new/api-reference/api/errors + domainVerificationFailed: + summary: DOMAIN_VERIFICATION_FAILED + value: + error: + code: DOMAIN_VERIFICATION_FAILED + type: invalid_request + message: The DNS records are not published yet, so verification did not pass. + suggestion: Confirm the DNS records from GET /v1/domains are published, then retry verify. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -54578,7 +59057,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 20 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -54619,7 +59098,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 20 remaining: 0 reset: 1712592360 consumed: false @@ -54647,68 +59126,40 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: audiences + x-brew-scope: domains x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: audiences.write - /v1/domains: + x-brew-rate-policy: domains.write + /v1/domains/{domainId}: get: - operationId: listDomains + operationId: getDomain tags: - Domains - summary: List domains + summary: Get a domain description: |- - Lists the brand’s sending domains under `{ data, pagination }`: ALL lifecycle states by default, so a caller mid-verification can read `pending` rows and their DNS `records`. + Reads one sending domain as the bare `Domain` row: verification `status`, DNS `records`, `sendable`, `sendingPurpose` and the `openTracking`/`clickTracking` flags. Sender defaults are write-only here: the row does not echo them. - **Use when** picking the `domainId` for `createSend` or a sendEmail node, or reading DNS records to publish. + **Use when** polling verification after `verifyDomain`, or confirming a domain before a send. - **Input** `sendableOnly=true` keeps only verified, send-ready domains (the set valid for sends and automation `sendEmail` nodes); `sendingPurpose` keeps one purpose (campaigns and audience sends require `marketing`; automation sendEmail nodes accept both); `limit` and `cursor`. + **Input** `domainId` in the path. - **Returns** `200` with a page of `Domain` rows. + **Returns** `200` with the row. - **Errors** `400 INVALID_REQUEST` for an unknown query key or purpose. + **Errors** `404 DOMAIN_NOT_FOUND` for an unknown or cross-brand id. - **See also** `getDomain`, `addDomain`, `verifyDomain`, `getDomainHealth`. + **See also** `listDomains`, `verifyDomain`, `getDomainHealth`, `updateDomain`. parameters: - - schema: - type: string - enum: - - 'true' - - 'false' - description: When `true`, only verified send-ready domains. - required: false - description: When `true`, only verified send-ready domains. - name: sendableOnly - in: query - - schema: - type: string - enum: - - marketing - - transactional - description: Filter listed domains by purpose. Use marketing for campaigns and automations. - required: false - description: Filter listed domains by purpose. Use marketing for campaigns and automations. - name: sendingPurpose - in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - schema: type: string minLength: 1 - maxLength: 512 - required: false - name: cursor - in: query + maxLength: 64 + description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. + example: kx7bkh53hasmfeh5kd7sqgykt187g8ww + required: true + description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. + name: domainId + in: path - name: X-Brand-Id in: header required: false @@ -54720,7 +59171,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of domains. + description: The domain row. headers: x-request-id: schema: @@ -54753,30 +59204,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainsListResponse" + $ref: "#/components/schemas/Domain" example: - data: - - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - domainUrl: https://send.example.com - name: send.example.com - region: us-east-1 + domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww + domainUrl: https://send.example.com + name: send.example.com + region: us-east-1 + status: pending + sendingEnabled: true + sendable: false + sendingPurpose: marketing + records: + - record: DKIM + name: resend._domainkey + type: TXT + ttl: Auto status: pending - sendingEnabled: true - sendable: false - sendingPurpose: marketing - records: - - record: DKIM - name: resend._domainkey - type: TXT - ttl: Auto - status: pending - value: p=MIGfMA0... - createdAt: '2026-04-08T12:34:56.789Z' - updatedAt: '2026-04-08T12:34:56.789Z' - pagination: - limit: 100 - cursor: null - hasMore: false + value: p=MIGfMA0... + createdAt: '2026-04-08T12:34:56.789Z' + updatedAt: '2026-04-08T12:34:56.789Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -54919,7 +59365,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. headers: x-request-id: schema: @@ -54942,6 +59391,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + domainNotFound: + summary: DOMAIN_NOT_FOUND + value: + error: + code: DOMAIN_NOT_FOUND + type: not_found + message: No domain with that id exists in the brand. + suggestion: Use GET /v1/domains to choose a verified domain. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -55030,22 +59488,23 @@ paths: x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: domains.read - post: - operationId: addDomain + patch: + operationId: updateDomain tags: - Domains - summary: Add a domain - description: 'Registers a sending domain with the provider. Optional `sendingPurpose` is `marketing` (default; campaigns, audience sends, automations) or `transactional` (event-triggered automation mail — no unsubscribe link required, merely-unsubscribed contacts still receive it). Returns `201` with the bare row in `status: "pending"` plus the DNS `records` to publish. Once the records are live, call `POST /v1/domains/{domainId}/verify` until `sendable: true`.' + summary: Update domain settings + description: "Updates sender defaults (`defaultSenderName`, `defaultFromEmail`, `defaultReplyToEmail`) and/or `sendingPurpose` — at least one required. `defaultFromEmail` is the sender MAILBOX (the text before `@`) — the host is always this domain, so a full address is accepted and reduced to its local part; `defaultReplyToEmail`, by contrast, is a whole address. Live gates fail closed if a campaign still points at a transactional domain. A transactional domain has open and click tracking switched off whenever it is still on; a change back to `marketing` switches tracking on again. Verification is its own action: `POST /v1/domains/{domainId}/verify`. Returns the bare updated row." parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: + - schema: type: string minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 + maxLength: 64 + description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. + example: kx7bkh53hasmfeh5kd7sqgykt187g8ww + required: true + description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. + name: domainId + in: path - name: X-Brand-Id in: header required: false @@ -55060,12 +59519,13 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainsPostRequest" + $ref: "#/components/schemas/DomainsPatchRequest" example: - name: send.example.com + defaultSenderName: Brew + defaultFromEmail: hello responses: - "201": - description: Added — publish the returned DNS records, then verify. + "200": + description: Updated. headers: x-request-id: schema: @@ -55303,10 +59763,6 @@ paths: `DOMAIN_OTHER_BRAND`: The domain is attached to a different brand in this workspace. `DOMAIN_VERIFIED_ELSEWHERE`: Another workspace already verified this domain. - - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -55356,24 +59812,6 @@ paths: message: Another workspace already verified this domain. suggestion: Only unverified domains can be reclaimed. Remove it from the other workspace first. docs: https://docs.brew.new/api-reference/api/errors - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency "422": description: |- `DOMAIN_PROVIDER_ERROR`: The sending provider rejected the domain. @@ -55495,27 +59933,15 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: domains x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: domains.write - /v1/domains/{domainId}: - get: - operationId: getDomain + delete: + operationId: deleteDomain tags: - Domains - summary: Get a domain - description: |- - Reads one sending domain as the bare `Domain` row: verification `status`, DNS `records`, `sendable`, `sendingPurpose` and the `openTracking`/`clickTracking` flags. Sender defaults are write-only here: the row does not echo them. - - **Use when** polling verification after `verifyDomain`, or confirming a domain before a send. - - **Input** `domainId` in the path. - - **Returns** `200` with the row. - - **Errors** `404 DOMAIN_NOT_FOUND` for an unknown or cross-brand id. - - **See also** `listDomains`, `verifyDomain`, `getDomainHealth`, `updateDomain`. + summary: Delete a domain + description: "Removes the domain from the provider and deletes the local row. Idempotent — an unknown id resolves with `deleted: false`." parameters: - schema: type: string @@ -55538,7 +59964,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The domain row. + description: Deleted (or already gone). headers: x-request-id: schema: @@ -55571,25 +59997,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Domain" + $ref: "#/components/schemas/DomainsDeleteResponse" example: domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - domainUrl: https://send.example.com - name: send.example.com - region: us-east-1 - status: pending - sendingEnabled: true - sendable: false - sendingPurpose: marketing - records: - - record: DKIM - name: resend._domainkey - type: TXT - ttl: Auto - status: pending - value: p=MIGfMA0... - createdAt: '2026-04-08T12:34:56.789Z' - updatedAt: '2026-04-08T12:34:56.789Z' + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -55767,6 +60178,100 @@ paths: message: No domain with that id exists in the brand. suggestion: Use GET /v1/domains to choose a verified domain. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `DOMAIN_ALREADY_EXISTS`: The brand already has this domain. + + `DOMAIN_CLAIMED_ELSEWHERE`: The domain is registered in another Brew workspace. + + `DOMAIN_OTHER_BRAND`: The domain is attached to a different brand in this workspace. + + `DOMAIN_VERIFIED_ELSEWHERE`: Another workspace already verified this domain. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + domainAlreadyExists: + summary: DOMAIN_ALREADY_EXISTS + value: + error: + code: DOMAIN_ALREADY_EXISTS + type: conflict + message: The brand already has this domain. + suggestion: Fetch the existing domain with GET /v1/domains. + docs: https://docs.brew.new/api-reference/api/errors + domainClaimedElsewhere: + summary: DOMAIN_CLAIMED_ELSEWHERE + value: + error: + code: DOMAIN_CLAIMED_ELSEWHERE + type: conflict + message: The domain is registered in another Brew workspace. + suggestion: Remove it from the other workspace first, or contact support to move it. + docs: https://docs.brew.new/api-reference/api/errors + domainOtherBrand: + summary: DOMAIN_OTHER_BRAND + value: + error: + code: DOMAIN_OTHER_BRAND + type: conflict + message: The domain is attached to a different brand in this workspace. + suggestion: Use that brand’s credential, or remove the domain from it first. + docs: https://docs.brew.new/api-reference/api/errors + domainVerifiedElsewhere: + summary: DOMAIN_VERIFIED_ELSEWHERE + value: + error: + code: DOMAIN_VERIFIED_ELSEWHERE + type: conflict + message: Another workspace already verified this domain. + suggestion: Only unverified domains can be reclaimed. Remove it from the other workspace first. + docs: https://docs.brew.new/api-reference/api/errors + "422": + description: |- + `DOMAIN_PROVIDER_ERROR`: The sending provider rejected the domain. + + `DOMAIN_VERIFICATION_FAILED`: The DNS records are not published yet, so verification did not pass. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + domainProviderError: + summary: DOMAIN_PROVIDER_ERROR + value: + error: + code: DOMAIN_PROVIDER_ERROR + type: invalid_request + message: The sending provider rejected the domain. + suggestion: Check the domain name is a valid, registrable domain and retry. + docs: https://docs.brew.new/api-reference/api/errors + domainVerificationFailed: + summary: DOMAIN_VERIFICATION_FAILED + value: + error: + code: DOMAIN_VERIFICATION_FAILED + type: invalid_request + message: The DNS records are not published yet, so verification did not pass. + suggestion: Confirm the DNS records from GET /v1/domains are published, then retry verify. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -55781,7 +60286,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 20 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -55822,7 +60327,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 20 remaining: 0 reset: 1712592360 consumed: false @@ -55854,14 +60359,24 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: domains.read - patch: - operationId: updateDomain + x-brew-rate-policy: domains.write + /v1/domains/{domainId}/verify: + post: + operationId: verifyDomain tags: - Domains - summary: Update domain settings - description: "Updates sender defaults (`defaultSenderName`, `defaultFromEmail`, `defaultReplyToEmail`) and/or `sendingPurpose` — at least one required. `defaultFromEmail` is the sender MAILBOX (the text before `@`) — the host is always this domain, so a full address is accepted and reduced to its local part; `defaultReplyToEmail`, by contrast, is a whole address. Live gates fail closed if a campaign still points at a transactional domain. Verification is its own action: `POST /v1/domains/{domainId}/verify`. Returns the bare updated row." + summary: Verify a domain + description: "Re-checks the DNS records with the provider and refreshes the row. Empty body. Safe to poll — keep calling until `sendable: true` (DNS propagation can take minutes to hours). Returns the bare refreshed row." parameters: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 - schema: type: string minLength: 1 @@ -55881,18 +60396,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/DomainsPatchRequest" - example: - defaultSenderName: Brew - defaultFromEmail: hello responses: "200": - description: Updated. + description: The refreshed domain row. headers: x-request-id: schema: @@ -55931,9 +60437,9 @@ paths: domainUrl: https://send.example.com name: send.example.com region: us-east-1 - status: pending + status: verified sendingEnabled: true - sendable: false + sendable: true sendingPurpose: marketing records: - record: DKIM @@ -55944,6 +60450,7 @@ paths: value: p=MIGfMA0... createdAt: '2026-04-08T12:34:56.789Z' updatedAt: '2026-04-08T12:34:56.789Z' + verifiedAt: '2026-04-08T13:00:00.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -56130,6 +60637,10 @@ paths: `DOMAIN_OTHER_BRAND`: The domain is attached to a different brand in this workspace. `DOMAIN_VERIFIED_ELSEWHERE`: Another workspace already verified this domain. + + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -56179,6 +60690,24 @@ paths: message: Another workspace already verified this domain. suggestion: Only unverified domains can be reclaimed. Remove it from the other workspace first. docs: https://docs.brew.new/api-reference/api/errors + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "422": description: |- `DOMAIN_PROVIDER_ERROR`: The sending provider rejected the domain. @@ -56300,16 +60829,51 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: domains x-brew-brand-scope: brand - x-brew-idempotency: none + x-brew-idempotency: replay x-brew-credited: false x-brew-rate-policy: domains.write - delete: - operationId: deleteDomain + /v1/domains/{domainId}/unsubscribes: + get: + operationId: listDomainUnsubscribes tags: - Domains - summary: Delete a domain - description: "Removes the domain from the provider and deletes the local row. Idempotent — an unknown id resolves with `deleted: false`." + summary: List a domain's unsubscribes + description: "Every MARKETING sending domain is its own unsubscribe list, keyed by the domain's host inside the brand's contacts. Returns one page (`{ data, pagination }`, newest first). Each row carries `scope`: `domain` (on this host's list), `all` (the brand-wide opt-out — `subscribed: false` on the contact, which blocks every marketing send) or `both`. `?scope=` narrows; `?q=` searches by address. Transactional domains own no list → `422 DOMAIN_PURPOSE_NOT_ALLOWED`." parameters: + - name: q + in: query + required: false + description: An address (anything with `@`) matches that address — exactly once complete, by prefix while partial. Other text is a word search over the address and the contact name. + schema: + type: string + maxLength: 320 + - name: scope + in: query + required: false + description: "Which opt-outs to include: `domain` = this host’s list only, `all` = the brand-wide opt-out only, `any` (default) = either." + schema: + type: string + enum: + - any + - domain + - all + - name: limit + in: query + required: false + description: Page size (1–100). Defaults to 100. + schema: + type: integer + minimum: 1 + maximum: 100 + example: 50 + - name: cursor + in: query + required: false + description: Opaque pagination cursor echoed from the previous page’s `pagination.cursor`. Omit for the first page. + schema: + type: string + minLength: 1 + maxLength: 512 - schema: type: string minLength: 1 @@ -56331,7 +60895,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: Deleted (or already gone). + description: A page of the domain’s unsubscribe list. headers: x-request-id: schema: @@ -56364,10 +60928,20 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainsDeleteResponse" + $ref: "#/components/schemas/DomainUnsubscribesListResponse" example: domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - deleted: true + domainHost: send.example.com + data: + - email: jane@example.com + scope: domain + unsubscribedAt: '2026-09-17T09:12:00.000Z' + source: link + sendId: kh7c1w2m3n4p5q6r7s8t9u0v1w2x3y4z + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -56545,69 +61119,8 @@ paths: message: No domain with that id exists in the brand. suggestion: Use GET /v1/domains to choose a verified domain. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `DOMAIN_ALREADY_EXISTS`: The brand already has this domain. - - `DOMAIN_CLAIMED_ELSEWHERE`: The domain is registered in another Brew workspace. - - `DOMAIN_OTHER_BRAND`: The domain is attached to a different brand in this workspace. - - `DOMAIN_VERIFIED_ELSEWHERE`: Another workspace already verified this domain. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - domainAlreadyExists: - summary: DOMAIN_ALREADY_EXISTS - value: - error: - code: DOMAIN_ALREADY_EXISTS - type: conflict - message: The brand already has this domain. - suggestion: Fetch the existing domain with GET /v1/domains. - docs: https://docs.brew.new/api-reference/api/errors - domainClaimedElsewhere: - summary: DOMAIN_CLAIMED_ELSEWHERE - value: - error: - code: DOMAIN_CLAIMED_ELSEWHERE - type: conflict - message: The domain is registered in another Brew workspace. - suggestion: Remove it from the other workspace first, or contact support to move it. - docs: https://docs.brew.new/api-reference/api/errors - domainOtherBrand: - summary: DOMAIN_OTHER_BRAND - value: - error: - code: DOMAIN_OTHER_BRAND - type: conflict - message: The domain is attached to a different brand in this workspace. - suggestion: Use that brand’s credential, or remove the domain from it first. - docs: https://docs.brew.new/api-reference/api/errors - domainVerifiedElsewhere: - summary: DOMAIN_VERIFIED_ELSEWHERE - value: - error: - code: DOMAIN_VERIFIED_ELSEWHERE - type: conflict - message: Another workspace already verified this domain. - suggestion: Only unverified domains can be reclaimed. Remove it from the other workspace first. - docs: https://docs.brew.new/api-reference/api/errors "422": - description: |- - `DOMAIN_PROVIDER_ERROR`: The sending provider rejected the domain. - - `DOMAIN_VERIFICATION_FAILED`: The DNS records are not published yet, so verification did not pass. + description: "`DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional)." headers: x-request-id: schema: @@ -56621,23 +61134,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - domainProviderError: - summary: DOMAIN_PROVIDER_ERROR - value: - error: - code: DOMAIN_PROVIDER_ERROR - type: invalid_request - message: The sending provider rejected the domain. - suggestion: Check the domain name is a valid, registrable domain and retry. - docs: https://docs.brew.new/api-reference/api/errors - domainVerificationFailed: - summary: DOMAIN_VERIFICATION_FAILED + domainPurposeNotAllowed: + summary: DOMAIN_PURPOSE_NOT_ALLOWED value: error: - code: DOMAIN_VERIFICATION_FAILED + code: DOMAIN_PURPOSE_NOT_ALLOWED type: invalid_request - message: The DNS records are not published yet, so verification did not pass. - suggestion: Confirm the DNS records from GET /v1/domains are published, then retry verify. + message: The sending domain is admitted for a different purpose than this send (marketing vs transactional). + suggestion: Use a marketing domain for campaigns and audience sends. For transactional flows, wire the design into a published automation with a trigger and fire POST /v1/automations/triggers/{triggerEventId}/fire. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -56653,7 +61157,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -56694,7 +61198,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -56726,14 +61230,13 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: domains.write - /v1/domains/{domainId}/verify: + x-brew-rate-policy: domains.read post: - operationId: verifyDomain + operationId: addDomainUnsubscribes tags: - Domains - summary: Verify a domain - description: "Re-checks the DNS records with the provider and refreshes the row. Empty body. Safe to poll — keep calling until `sendable: true` (DNS propagation can take minutes to hours). Returns the bare refreshed row." + summary: Add addresses to a domain's unsubscribe list + description: "Suppresses up to 1,000 addresses from THIS marketing domain's mail. An existing contact is suppressed for this host only — sends from OTHER marketing domains still reach it; set `subscribed: false` on the contact for a brand-wide opt-out. An address with no contact is created already globally unsubscribed, so every marketing domain skips it (`summary.created`). Addresses already on the list count under `alreadyUnsubscribed`, never errored (idempotent); malformed ones are reported in `invalid` and skipped." parameters: - name: Idempotency-Key in: header @@ -56763,9 +61266,19 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DomainUnsubscribesAddRequest" + example: + emails: + - jane@example.com + - sam@example.com responses: "200": - description: The refreshed domain row. + description: Per-address outcome summary. headers: x-request-id: schema: @@ -56798,26 +61311,17 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Domain" + $ref: "#/components/schemas/DomainUnsubscribesAddResponse" example: domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - domainUrl: https://send.example.com - name: send.example.com - region: us-east-1 - status: verified - sendingEnabled: true - sendable: true - sendingPurpose: marketing - records: - - record: DKIM - name: resend._domainkey - type: TXT - ttl: Auto - status: pending - value: p=MIGfMA0... - createdAt: '2026-04-08T12:34:56.789Z' - updatedAt: '2026-04-08T12:34:56.789Z' - verifiedAt: '2026-04-08T13:00:00.000Z' + domainHost: send.example.com + summary: + received: 2 + added: 1 + alreadyUnsubscribed: 0 + created: 1 + invalid: 0 + invalid: [] "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -56997,14 +61501,6 @@ paths: docs: https://docs.brew.new/api-reference/api/errors "409": description: |- - `DOMAIN_ALREADY_EXISTS`: The brand already has this domain. - - `DOMAIN_CLAIMED_ELSEWHERE`: The domain is registered in another Brew workspace. - - `DOMAIN_OTHER_BRAND`: The domain is attached to a different brand in this workspace. - - `DOMAIN_VERIFIED_ELSEWHERE`: Another workspace already verified this domain. - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. @@ -57021,42 +61517,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - domainAlreadyExists: - summary: DOMAIN_ALREADY_EXISTS - value: - error: - code: DOMAIN_ALREADY_EXISTS - type: conflict - message: The brand already has this domain. - suggestion: Fetch the existing domain with GET /v1/domains. - docs: https://docs.brew.new/api-reference/api/errors - domainClaimedElsewhere: - summary: DOMAIN_CLAIMED_ELSEWHERE - value: - error: - code: DOMAIN_CLAIMED_ELSEWHERE - type: conflict - message: The domain is registered in another Brew workspace. - suggestion: Remove it from the other workspace first, or contact support to move it. - docs: https://docs.brew.new/api-reference/api/errors - domainOtherBrand: - summary: DOMAIN_OTHER_BRAND - value: - error: - code: DOMAIN_OTHER_BRAND - type: conflict - message: The domain is attached to a different brand in this workspace. - suggestion: Use that brand’s credential, or remove the domain from it first. - docs: https://docs.brew.new/api-reference/api/errors - domainVerifiedElsewhere: - summary: DOMAIN_VERIFIED_ELSEWHERE - value: - error: - code: DOMAIN_VERIFIED_ELSEWHERE - type: conflict - message: Another workspace already verified this domain. - suggestion: Only unverified domains can be reclaimed. Remove it from the other workspace first. - docs: https://docs.brew.new/api-reference/api/errors idempotencyConflict: summary: IDEMPOTENCY_CONFLICT value: @@ -57076,10 +61536,7 @@ paths: suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency "422": - description: |- - `DOMAIN_PROVIDER_ERROR`: The sending provider rejected the domain. - - `DOMAIN_VERIFICATION_FAILED`: The DNS records are not published yet, so verification did not pass. + description: "`DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional)." headers: x-request-id: schema: @@ -57093,23 +61550,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - domainProviderError: - summary: DOMAIN_PROVIDER_ERROR - value: - error: - code: DOMAIN_PROVIDER_ERROR - type: invalid_request - message: The sending provider rejected the domain. - suggestion: Check the domain name is a valid, registrable domain and retry. - docs: https://docs.brew.new/api-reference/api/errors - domainVerificationFailed: - summary: DOMAIN_VERIFICATION_FAILED + domainPurposeNotAllowed: + summary: DOMAIN_PURPOSE_NOT_ALLOWED value: error: - code: DOMAIN_VERIFICATION_FAILED + code: DOMAIN_PURPOSE_NOT_ALLOWED type: invalid_request - message: The DNS records are not published yet, so verification did not pass. - suggestion: Confirm the DNS records from GET /v1/domains are published, then retry verify. + message: The sending domain is admitted for a different purpose than this send (marketing vs transactional). + suggestion: Use a marketing domain for campaigns and audience sends. For transactional flows, wire the design into a published automation with a trigger and fire POST /v1/automations/triggers/{triggerEventId}/fire. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -57199,48 +61647,14 @@ paths: x-brew-idempotency: replay x-brew-credited: false x-brew-rate-policy: domains.write - /v1/domains/{domainId}/unsubscribes: - get: - operationId: listDomainUnsubscribes + /v1/domains/{domainId}/unsubscribes/{email}: + delete: + operationId: removeDomainUnsubscribe tags: - Domains - summary: List a domain's unsubscribes - description: "Every MARKETING sending domain is its own unsubscribe list, keyed by the domain's host inside the brand's contacts. Returns one page (`{ data, pagination }`, newest first). Each row carries `scope`: `domain` (on this host's list), `all` (the brand-wide opt-out — `subscribed: false` on the contact, which blocks every marketing send) or `both`. `?scope=` narrows; `?q=` searches by address. Transactional domains own no list → `422 DOMAIN_PURPOSE_NOT_ALLOWED`." + summary: Remove an address from a domain's unsubscribe list + description: "Takes one address off THIS domain's list (resubscribes it to this host only). Idempotent — `removed: false` when the address was not on the list. Never clears the brand-wide opt-out: `globallyUnsubscribed: true` means every marketing send still skips the contact until `PATCH /v1/contacts/{email}` sets `subscribed: true`. URL-encode the address." parameters: - - name: q - in: query - required: false - description: An address (anything with `@`) matches that address — exactly once complete, by prefix while partial. Other text is a word search over the address and the contact name. - schema: - type: string - maxLength: 320 - - name: scope - in: query - required: false - description: "Which opt-outs to include: `domain` = this host’s list only, `all` = the brand-wide opt-out only, `any` (default) = either." - schema: - type: string - enum: - - any - - domain - - all - - name: limit - in: query - required: false - description: Page size (1–100). Defaults to 100. - schema: - type: integer - minimum: 1 - maximum: 100 - example: 50 - - name: cursor - in: query - required: false - description: Opaque pagination cursor echoed from the previous page’s `pagination.cursor`. Omit for the first page. - schema: - type: string - minLength: 1 - maxLength: 512 - schema: type: string minLength: 1 @@ -57251,6 +61665,16 @@ paths: description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. name: domainId in: path + - schema: + type: string + minLength: 3 + maxLength: 320 + description: The address to take off the list (URL-encoded). + example: jane%40example.com + required: true + description: The address to take off the list (URL-encoded). + name: email + in: path - name: X-Brand-Id in: header required: false @@ -57262,7 +61686,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of the domain’s unsubscribe list. + description: Removed (or was not listed). headers: x-request-id: schema: @@ -57295,20 +61719,13 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainUnsubscribesListResponse" + $ref: "#/components/schemas/DomainUnsubscribeRemoveResponse" example: domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww domainHost: send.example.com - data: - - email: jane@example.com - scope: domain - unsubscribedAt: '2026-09-17T09:12:00.000Z' - source: link - sendId: kh7c1w2m3n4p5q6r7s8t9u0v1w2x3y4z - pagination: - limit: 100 - cursor: null - hasMore: false + email: jane@example.com + removed: true + globallyUnsubscribed: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -57524,7 +61941,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 20 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -57565,7 +61982,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 20 remaining: 0 reset: 1712592360 consumed: false @@ -57597,13 +62014,14 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: domains.read + x-brew-rate-policy: domains.write + /v1/domains/{domainId}/unsubscribes/import: post: - operationId: addDomainUnsubscribes + operationId: importDomainUnsubscribes tags: - Domains - summary: Add addresses to a domain's unsubscribe list - description: "Suppresses up to 1,000 addresses from THIS marketing domain's mail. An existing contact is suppressed for this host only — sends from OTHER marketing domains still reach it; set `subscribed: false` on the contact for a brand-wide opt-out. An address with no contact is created already globally unsubscribed, so every marketing domain skips it (`summary.created`). Addresses already on the list count under `alreadyUnsubscribed`, never errored (idempotent); malformed ones are reported in `invalid` and skipped." + summary: Import a CSV into a domain's unsubscribe list + description: "Migrates an unsubscribe list (another ESP's export) into THIS marketing domain's list. `csv` is the file text: an `email` header — or any column of addresses, or one headerless column; pass `column` to name the header when detection fails (`400` otherwise). Synchronous and bounded: 10,000 rows per call (`truncated: true` when the file carried more — split it across calls) and 2,000,000 characters (a longer `csv` is rejected as `400 INVALID_REQUEST`). Idempotent per address. An address with no contact row is created already globally unsubscribed, exactly as `POST /v1/domains/{domainId}/unsubscribes` does, so every marketing domain skips it. The contact CSV importer's `subscribed` column is the BRAND-WIDE flag — use this endpoint for per-domain opt-outs." parameters: - name: Idempotency-Key in: header @@ -57638,14 +62056,15 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainUnsubscribesAddRequest" + $ref: "#/components/schemas/DomainUnsubscribesImportRequest" example: - emails: - - jane@example.com - - sam@example.com + csv: | + email + jane@example.com + sam@example.com responses: "200": - description: Per-address outcome summary. + description: Import summary. headers: x-request-id: schema: @@ -57678,17 +62097,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainUnsubscribesAddResponse" + $ref: "#/components/schemas/DomainUnsubscribesImportResponse" example: domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww domainHost: send.example.com summary: - received: 2 + rows: 2 added: 1 alreadyUnsubscribed: 0 created: 1 - invalid: 0 - invalid: [] + skipped: 0 + skippedSample: [] + truncated: false + column: email "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -57902,6 +62323,30 @@ paths: message: A request with this Idempotency-Key is still executing. suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency + "413": + description: "`PAYLOAD_TOO_LARGE`: The request body exceeds the route cap." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + payloadTooLarge: + summary: PAYLOAD_TOO_LARGE + value: + error: + code: PAYLOAD_TOO_LARGE + type: invalid_request + message: The request body exceeds the route cap. + suggestion: Reduce the payload size and retry. + docs: https://docs.brew.new/api-reference/api/errors "422": description: "`DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional)." headers: @@ -58014,14 +62459,24 @@ paths: x-brew-idempotency: replay x-brew-credited: false x-brew-rate-policy: domains.write - /v1/domains/{domainId}/unsubscribes/{email}: - delete: - operationId: removeDomainUnsubscribe + /v1/domains/{domainId}/unsubscribes/export: + get: + operationId: exportDomainUnsubscribes tags: - Domains - summary: Remove an address from a domain's unsubscribe list - description: "Takes one address off THIS domain's list (resubscribes it to this host only). Idempotent — `removed: false` when the address was not on the list. Never clears the brand-wide opt-out: `globallyUnsubscribed: true` means every marketing send still skips the contact until `PATCH /v1/contacts/{email}` sets `subscribed: true`. URL-encode the address." + summary: Export a domain's unsubscribe list + description: "The whole list as CSV text (`Email,Scope,Unsubscribed At,Source,Send ID`) inside the JSON envelope — the v1 transport is JSON-only. Capped at 50,000 rows or ~4 MB of CSV, whichever comes first, so the response stays under the platform body limit (`truncated: true` beyond; narrow with `?scope=`)." parameters: + - name: scope + in: query + required: false + description: "Which opt-outs to include: `domain` = this host’s list only, `all` = the brand-wide opt-out only, `any` (default) = either." + schema: + type: string + enum: + - any + - domain + - all - schema: type: string minLength: 1 @@ -58032,16 +62487,6 @@ paths: description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. name: domainId in: path - - schema: - type: string - minLength: 3 - maxLength: 320 - description: The address to take off the list (URL-encoded). - example: jane%40example.com - required: true - description: The address to take off the list (URL-encoded). - name: email - in: path - name: X-Brand-Id in: header required: false @@ -58053,7 +62498,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: Removed (or was not listed). + description: CSV text plus counts. headers: x-request-id: schema: @@ -58086,13 +62531,15 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainUnsubscribeRemoveResponse" + $ref: "#/components/schemas/DomainUnsubscribesExportResponse" example: domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww domainHost: send.example.com - email: jane@example.com - removed: true - globallyUnsubscribed: false + csv: "Email,Scope,Unsubscribed At,Source,Send ID\r + + jane@example.com,domain,2026-09-17T09:12:00.000Z,link,kh7c1w2m3n4p5q6r7s8t9u0v1w2x3y4z\r\n" + rowCount: 1 + truncated: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -58308,7 +62755,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -58349,7 +62796,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -58381,24 +62828,15 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: domains.write - /v1/domains/{domainId}/unsubscribes/import: - post: - operationId: importDomainUnsubscribes + x-brew-rate-policy: domains.read + /v1/domains/{domainId}/health: + get: + operationId: getDomainHealth tags: - Domains - summary: Import a CSV into a domain's unsubscribe list - description: "Migrates an unsubscribe list (another ESP's export) into THIS marketing domain's list. `csv` is the file text: an `email` header — or any column of addresses, or one headerless column; pass `column` to name the header when detection fails (`400` otherwise). Synchronous and bounded: 10,000 rows per call (`truncated: true` when the file carried more — split it across calls) and 2,000,000 characters (a longer `csv` is rejected as `400 INVALID_REQUEST`). Idempotent per address. An address with no contact row is created already globally unsubscribed, exactly as `POST /v1/domains/{domainId}/unsubscribes` does, so every marketing domain skips it. The contact CSV importer's `subscribed` column is the BRAND-WIDE flag — use this endpoint for per-domain opt-outs." + summary: Get domain health + description: "The domain's deliverability health in one FREE read: a `verdict` (`healthy` / `at_risk` / `critical`) with actionable `signals`, DNS/auth state incl. DMARC, active percentage-based gradual sends, sampled bounce/complaint activity from campaign and automation sends, workspace reputation, and inbox-placement history. No bounce or complaint threshold is attached to a gradual-send configuration. `include=scoreHistory` adds `scoreHistory`: up to 50 saved score snapshots, newest first (score, grade, confidence, the event that saved it, and each pillar's score and weight); only snapshots saved under this brand count, searched among the domain's newest 500, so a domain that moved between brands can show fewer. `include=scoreRuns` adds `scoreRuns`: the last 5 automated domain score runs (the 5-variant seed check), each with its status in the shared run vocabulary, the score it ended on, the credits it cost and every variant's placement test." parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: - type: string - minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 - schema: type: string minLength: 1 @@ -58409,6 +62847,16 @@ paths: description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. name: domainId in: path + - schema: + type: string + description: "Expansions: `scoreHistory` attaches up to 50 saved score snapshots, newest first; `scoreRuns` the last 5 automated domain score runs." + x-brew-include-tokens: + - scoreHistory + - scoreRuns + required: false + description: "Expansions: `scoreHistory` attaches up to 50 saved score snapshots, newest first; `scoreRuns` the last 5 automated domain score runs." + name: include + in: query - name: X-Brand-Id in: header required: false @@ -58418,20 +62866,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/DomainUnsubscribesImportRequest" - example: - csv: | - email - jane@example.com - sam@example.com responses: "200": - description: Import summary. + description: The aggregate health report. headers: x-request-id: schema: @@ -58464,19 +62901,93 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainUnsubscribesImportResponse" + $ref: "#/components/schemas/DomainHealth" example: domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - domainHost: send.example.com - summary: - rows: 2 - added: 1 - alreadyUnsubscribed: 0 - created: 1 - skipped: 0 - skippedSample: [] - truncated: false - column: email + name: send.example.com + status: verified + sendable: true + verdict: at_risk + readiness: ready_with_warnings + score: + placementMeasured: true + value: 72 + grade: fair + confidence: medium + components: + placement: + score: 68 + weight: 0.35 + basis: seed_test + authentication: + score: 100 + weight: 0.2 + basis: dns + reputation: + score: 74 + weight: 0.2 + basis: org_history + content: + score: 62 + weight: 0.15 + basis: recent_sends + posture: + score: 55 + weight: 0.1 + basis: warmup_stage + trend: + direction: up + previousScore: 64 + previousComputedAt: '2026-06-21T09:00:00.000Z' + authentication: + spf: verified + dkim: verified + dmarc: missing + tracking: + linksStatus: live + openTracking: true + clickTracking: true + warmup: + - sendId: W7nT1vPb9Ycr4Kq8ZxL2m + currentTranche: 3 + sentSoFar: 175 + rampEndsAt: '2026-07-15T00:00:00.000Z' + dailyVolume: [] + domainActivity: + sampled: true + sampleSendCount: 12 + sentCount: 2400 + bouncedCount: 31 + complainedCount: 1 + bounceRate: 0.0129 + complaintRate: 0.0004 + orgReputation: + scope: org + totalSent: 18250 + bounceRate: 0.011 + complaintRate: 0.0003 + recentPlacementTests: + - testId: ibp_2f1c9d8a + emailId: V1StGXR8_Z5jdHi6B-myT + status: completed + overall: + total: 41 + inbox: 33 + spam: 8 + missing: 0 + pending: 0 + authentication: + spf: pass + dkim: pass + dmarc: pass + spamFilterFlagged: false + createdAt: '2026-07-13T17:00:00.000Z' + signals: + - id: dmarc_missing + severity: warning + summary: No DMARC record is configured for this domain. + suggestion: 'Add a TXT record: _dmarc.send.example.com TXT "v=DMARC1; p=reject; rua=mailto:dmarc@send.example.com" — Gmail/Yahoo bulk-sender rules require DMARC. This policy blocks spoofing. Then re-verify the domain to pick it up.' + checkedAt: '2026-07-13T18:00:00.000Z' "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -58654,90 +63165,6 @@ paths: message: No domain with that id exists in the brand. suggestion: Use GET /v1/domains to choose a verified domain. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - "413": - description: "`PAYLOAD_TOO_LARGE`: The request body exceeds the route cap." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - payloadTooLarge: - summary: PAYLOAD_TOO_LARGE - value: - error: - code: PAYLOAD_TOO_LARGE - type: invalid_request - message: The request body exceeds the route cap. - suggestion: Reduce the payload size and retry. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: "`DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional)." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - domainPurposeNotAllowed: - summary: DOMAIN_PURPOSE_NOT_ALLOWED - value: - error: - code: DOMAIN_PURPOSE_NOT_ALLOWED - type: invalid_request - message: The sending domain is admitted for a different purpose than this send (marketing vs transactional). - suggestion: Use a marketing domain for campaigns and audience sends. For transactional flows, wire the design into a published automation with a trigger and fire POST /v1/automations/triggers/{triggerEventId}/fire. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -58752,7 +63179,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -58793,7 +63220,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -58823,49 +63250,128 @@ paths: docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: domains x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: domains.write - /v1/domains/{domainId}/unsubscribes/export: + x-brew-rate-policy: domains.read + /v1/templates: get: - operationId: exportDomainUnsubscribes + operationId: listTemplates tags: - - Domains - summary: Export a domain's unsubscribe list - description: "The whole list as CSV text (`Email,Scope,Unsubscribed At,Source,Send ID`) inside the JSON envelope — the v1 transport is JSON-only. Capped at 50,000 rows or ~4 MB of CSV, whichever comes first, so the response stays under the platform body limit (`truncated: true` beyond; narrow with `?scope=`)." + - Templates + summary: List templates + description: "Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters and a group filter (a browse group: every category in it); query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand, category and group (refreshed every 15 minutes), instead of rows; add groupBy=brand, groupBy=category or groupBy=group for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query)." parameters: - - name: scope + - schema: + type: string + minLength: 1 + description: Only this brand's templates, by domain (e.g. `brew.new`); case-insensitive. + required: false + description: Only this brand's templates, by domain (e.g. `brew.new`); case-insensitive. + name: brand in: query + - schema: + type: string + minLength: 1 + description: "Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general." required: false - description: "Which opt-outs to include: `domain` = this host’s list only, `all` = the brand-wide opt-out only, `any` (default) = either." - schema: + description: "Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general." + name: category + in: query + - schema: type: string enum: - - any - - domain - - all + - welcome-onboarding + - newsletter + - content + - events + - product-news + - sales + - retention + - company + - transactional + - internal + - other + description: "A browse group: every category in it (e.g. `events` = invitations, reminders and recaps)." + required: false + description: "A browse group: every category in it (e.g. `events` = invitations, reminders and recaps)." + name: group + in: query - schema: type: string minLength: 1 - maxLength: 64 - description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. - example: kx7bkh53hasmfeh5kd7sqgykt187g8ww - required: true - description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. - name: domainId - in: path - - name: X-Brand-Id - in: header + maxLength: 500 + description: Rank by relevance to this text instead of newest first (top 200; `brand`/`category` still narrow). required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: + description: Rank by relevance to this text instead of newest first (top 200; `brand`/`category` still narrow). + name: semantic + in: query + - schema: type: string minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + maxLength: 250 + description: Case-insensitive title substring or exact public template identifier. Applied within semantic results when semantic is supplied. + required: false + description: Case-insensitive title substring or exact public template identifier. Applied within semantic results when semantic is supplied. + name: query + in: query + - schema: + type: string + enum: + - full + - summary + description: Full rows include HTML. Summary rows include selection metadata and links only. + required: false + description: Full rows include HTML. Summary rows include selection metadata and links only. + name: representation + in: query + - schema: + anyOf: + - type: boolean + - type: string + enum: + - 'true' + - 'false' + description: Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. + required: false + description: Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. + name: count + in: query + - schema: + type: string + enum: + - brand + - category + - group + description: "With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`." + x-brew-group-by-tokens: + - brand + - category + - group + required: false + description: "With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`." + name: groupBy + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: "Page size, 1–100 (default 50: each row carries its full HTML)." + example: 50 + required: false + description: "Page size, 1–100 (default 50: each row carries its full HTML)." + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 8192 + required: false + name: cursor + in: query responses: "200": - description: CSV text plus counts. + description: A page of templates matching the filters, or their counts when count=true. headers: x-request-id: schema: @@ -58898,20 +63404,147 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainUnsubscribesExportResponse" + anyOf: + - $ref: "#/components/schemas/TemplatesListResponse" + - type: object + properties: + data: + type: array + items: + type: object + properties: + emailId: + type: string + minLength: 1 + title: + type: string + minLength: 1 + category: + type: string + minLength: 1 + brand: + type: string + minLength: 1 + previewImage: + type: string + format: uri + updatedAt: + type: string + minLength: 1 + format: date-time + referenceEmailId: + type: string + minLength: 1 + viewUrl: + type: string + format: uri + required: + - emailId + - title + - previewImage + - updatedAt + - referenceEmailId + - viewUrl + additionalProperties: false + pagination: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + cursor: + type: + - string + - "null" + hasMore: + type: boolean + required: + - limit + - cursor + - hasMore + additionalProperties: false + required: + - data + - pagination + additionalProperties: false + - type: object + properties: + count: + type: integer + minimum: 0 + description: Templates matching `brand`/`category`. + groupBy: + type: string + enum: + - brand + - category + - group + groups: + type: array + items: + type: object + properties: + value: + type: string + minLength: 1 + description: The brand domain, category or group; pass it back as the filter of the same name (`brand`, `category` or `group`). + name: + type: string + minLength: 1 + description: The brand's curated display name, when it has one. + count: + type: integer + exclusiveMinimum: 0 + required: + - value + - count + additionalProperties: false + description: "With `groupBy`: up to 100 groups, largest first." + groupCount: + type: integer + minimum: 0 + description: Distinct brands or categories across every page. + ungroupedCount: + type: integer + minimum: 0 + description: Matching templates with no brand (or no category), so in no group. + pagination: + type: object + properties: + limit: + type: integer + minimum: 1 + maximum: 100 + cursor: + type: + - string + - "null" + hasMore: + type: boolean + required: + - limit + - cursor + - hasMore + additionalProperties: false + required: + - count + additionalProperties: false example: - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - domainHost: send.example.com - csv: "Email,Scope,Unsubscribed At,Source,Send ID\r - - jane@example.com,domain,2026-09-17T09:12:00.000Z,link,kh7c1w2m3n4p5q6r7s8t9u0v1w2x3y4z\r\n" - rowCount: 1 - truncated: false + data: + - emailId: seed-vercel-newsletter + title: Vercel Newsletter + category: newsletter + brand: vercel.com + html: … + previewImage: https://storage.example.com/templates/seed.png + updatedAt: '2026-04-08T00:00:00.000Z' + pagination: + limit: 50 + cursor: eyJ… + hasMore: true "400": - description: |- - `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. - - `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." headers: x-request-id: schema: @@ -58925,15 +63558,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandIdRequired: - summary: BRAND_ID_REQUIRED - value: - error: - code: BRAND_ID_REQUIRED - type: invalid_request - message: An organization-scoped credential called a brand-scoped operation without naming the brand. - suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. - docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -58991,14 +63615,7 @@ paths: suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication "403": - description: |- - `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. - - `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. - - `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. - - `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + description: "`ACCOUNT_SUSPENDED`: The organization behind the credential is suspended." headers: x-request-id: schema: @@ -59021,93 +63638,6 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication - brandScopeMismatch: - summary: BRAND_SCOPE_MISMATCH - value: - error: - code: BRAND_SCOPE_MISMATCH - type: authorization_error - message: A brand-scoped credential named a brand other than the one it is bound to. - suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientPermissions: - summary: INSUFFICIENT_PERMISSIONS - value: - error: - code: INSUFFICIENT_PERMISSIONS - type: authorization_error - message: The credential lacks the permission scope the operation needs. - suggestion: Use an API key or session with the required permission. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientRole: - summary: INSUFFICIENT_ROLE - value: - error: - code: INSUFFICIENT_ROLE - type: authorization_error - message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." - suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. - docs: https://docs.brew.new/api-reference/api/authentication - "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - brandNotFound: - summary: BRAND_NOT_FOUND - value: - error: - code: BRAND_NOT_FOUND - type: not_found - message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - suggestion: List the brands this credential can reach with GET /v1/brands. - docs: https://docs.brew.new/api-reference/api/errors - domainNotFound: - summary: DOMAIN_NOT_FOUND - value: - error: - code: DOMAIN_NOT_FOUND - type: not_found - message: No domain with that id exists in the brand. - suggestion: Use GET /v1/domains to choose a verified domain. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: "`DOMAIN_PURPOSE_NOT_ALLOWED`: The sending domain is admitted for a different purpose than this send (marketing vs transactional)." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - domainPurposeNotAllowed: - summary: DOMAIN_PURPOSE_NOT_ALLOWED - value: - error: - code: DOMAIN_PURPOSE_NOT_ALLOWED - type: invalid_request - message: The sending domain is admitted for a different purpose than this send (marketing vs transactional). - suggestion: Use a marketing domain for campaigns and audience sends. For transactional flows, wire the design into a published automation with a trigger and fire POST /v1/automations/triggers/{triggerEventId}/fire. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -59191,51 +63721,40 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: domains - x-brew-brand-scope: brand + x-brew-scope: none + x-brew-brand-scope: org x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: domains.read - /v1/domains/{domainId}/health: + x-brew-rate-policy: templates.read + /v1/templates/{templateId}: get: - operationId: getDomainHealth + operationId: getTemplate tags: - - Domains - summary: Get domain health - description: "The domain's deliverability health in one FREE read: a `verdict` (`healthy` / `at_risk` / `critical`) with actionable `signals`, DNS/auth state incl. DMARC, active percentage-based gradual sends, sampled bounce/complaint activity from campaign and automation sends, workspace reputation, and inbox-placement history. No bounce or complaint threshold is attached to a gradual-send configuration. `include=scoreHistory` adds `scoreHistory`: up to 50 saved score snapshots, newest first (score, grade, confidence, the event that saved it, and each pillar's score and weight); only snapshots saved under this brand count, searched among the domain's newest 500, so a domain that moved between brands can show fewer. `include=scoreRuns` adds `scoreRuns`: the last 5 automated domain score runs (the 5-variant seed check), each with its status in the shared run vocabulary, the score it ended on, the credits it cost and every variant's placement test." + - Templates + summary: Get a public template + description: Returns metadata, preview and canonical links, and referenceEmailId for one public template. Add include=html to inspect its rendered content. Large HTML is returned as a downloadable content.url instead of truncated JSON. parameters: - schema: type: string minLength: 1 - maxLength: 64 - description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. - example: kx7bkh53hasmfeh5kd7sqgykt187g8ww + maxLength: 256 required: true - description: Domain id (opaque Convex document id) returned by `POST /v1/domains` and listed by `GET /v1/domains`. - name: domainId + name: templateId in: path - schema: type: string - description: "Expansions: `scoreHistory` attaches up to 50 saved score snapshots, newest first; `scoreRuns` the last 5 automated domain score runs." + enum: + - html + description: "Expansions: `html` adds the rendered HTML; a large page arrives as a `content.url` download link instead." x-brew-include-tokens: - - scoreHistory - - scoreRuns + - html required: false - description: "Expansions: `scoreHistory` attaches up to 50 saved score snapshots, newest first; `scoreRuns` the last 5 automated domain score runs." + description: "Expansions: `html` adds the rendered HTML; a large page arrives as a `content.url` download link instead." name: include in: query - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The aggregate health report. + description: Template details and optional rendered content. headers: x-request-id: schema: @@ -59268,98 +63787,67 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DomainHealth" - example: - domainId: kx7bkh53hasmfeh5kd7sqgykt187g8ww - name: send.example.com - status: verified - sendable: true - verdict: at_risk - readiness: ready_with_warnings - score: - placementMeasured: true - value: 72 - grade: fair - confidence: medium - components: - placement: - score: 68 - weight: 0.35 - basis: seed_test - authentication: - score: 100 - weight: 0.2 - basis: dns - reputation: - score: 74 - weight: 0.2 - basis: org_history - content: - score: 62 - weight: 0.15 - basis: recent_sends - posture: - score: 55 - weight: 0.1 - basis: warmup_stage - trend: - direction: up - previousScore: 64 - previousComputedAt: '2026-06-21T09:00:00.000Z' - authentication: - spf: verified - dkim: verified - dmarc: missing - tracking: - linksStatus: live - openTracking: true - clickTracking: true - warmup: - - sendId: W7nT1vPb9Ycr4Kq8ZxL2m - currentTranche: 3 - sentSoFar: 175 - rampEndsAt: '2026-07-15T00:00:00.000Z' - dailyVolume: [] - domainActivity: - sampled: true - sampleSendCount: 12 - sentCount: 2400 - bouncedCount: 31 - complainedCount: 1 - bounceRate: 0.0129 - complaintRate: 0.0004 - orgReputation: - scope: org - totalSent: 18250 - bounceRate: 0.011 - complaintRate: 0.0003 - recentPlacementTests: - - testId: ibp_2f1c9d8a - emailId: V1StGXR8_Z5jdHi6B-myT - status: completed - overall: - total: 41 - inbox: 33 - spam: 8 - missing: 0 - pending: 0 - authentication: - spf: pass - dkim: pass - dmarc: pass - spamFilterFlagged: false - createdAt: '2026-07-13T17:00:00.000Z' - signals: - - id: dmarc_missing - severity: warning - summary: No DMARC record is configured for this domain. - suggestion: 'Add a TXT record: _dmarc.send.example.com TXT "v=DMARC1; p=reject; rua=mailto:dmarc@send.example.com" — Gmail/Yahoo bulk-sender rules require DMARC. This policy blocks spoofing. Then re-verify the domain to pick it up.' - checkedAt: '2026-07-13T18:00:00.000Z' + type: object + properties: + emailId: + type: string + minLength: 1 + title: + type: string + minLength: 1 + category: + type: string + minLength: 1 + brand: + type: string + minLength: 1 + previewImage: + type: string + format: uri + updatedAt: + type: string + minLength: 1 + format: date-time + referenceEmailId: + type: string + minLength: 1 + viewUrl: + type: string + format: uri + templateId: + type: string + minLength: 1 + html: + type: string + content: + type: object + properties: + url: + type: string + format: uri + mimeType: + type: string + enum: + - text/html + bytes: + type: integer + minimum: 0 + required: + - url + - mimeType + - bytes + additionalProperties: false + required: + - emailId + - title + - previewImage + - updatedAt + - referenceEmailId + - viewUrl + - templateId + additionalProperties: false "400": - description: |- - `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. - - `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." headers: x-request-id: schema: @@ -59373,15 +63861,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandIdRequired: - summary: BRAND_ID_REQUIRED - value: - error: - code: BRAND_ID_REQUIRED - type: invalid_request - message: An organization-scoped credential called a brand-scoped operation without naming the brand. - suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. - docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -59439,14 +63918,7 @@ paths: suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication "403": - description: |- - `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. - - `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. - - `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. - - `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + description: "`ACCOUNT_SUSPENDED`: The organization behind the credential is suspended." headers: x-request-id: schema: @@ -59469,38 +63941,8 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication - brandScopeMismatch: - summary: BRAND_SCOPE_MISMATCH - value: - error: - code: BRAND_SCOPE_MISMATCH - type: authorization_error - message: A brand-scoped credential named a brand other than the one it is bound to. - suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientPermissions: - summary: INSUFFICIENT_PERMISSIONS - value: - error: - code: INSUFFICIENT_PERMISSIONS - type: authorization_error - message: The credential lacks the permission scope the operation needs. - suggestion: Use an API key or session with the required permission. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientRole: - summary: INSUFFICIENT_ROLE - value: - error: - code: INSUFFICIENT_ROLE - type: authorization_error - message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." - suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. - docs: https://docs.brew.new/api-reference/api/authentication "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. + description: "`TEMPLATE_NOT_FOUND`: No public template matches that id." headers: x-request-id: schema: @@ -59514,23 +63956,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandNotFound: - summary: BRAND_NOT_FOUND - value: - error: - code: BRAND_NOT_FOUND - type: not_found - message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - suggestion: List the brands this credential can reach with GET /v1/brands. - docs: https://docs.brew.new/api-reference/api/errors - domainNotFound: - summary: DOMAIN_NOT_FOUND + templateNotFound: + summary: TEMPLATE_NOT_FOUND value: error: - code: DOMAIN_NOT_FOUND + code: TEMPLATE_NOT_FOUND type: not_found - message: No domain with that id exists in the brand. - suggestion: Use GET /v1/domains to choose a verified domain. + message: No public template matches that id. + suggestion: List templates with GET /v1/templates and use a returned emailId as the templateId. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -59615,130 +64048,121 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: domains - x-brew-brand-scope: brand + x-brew-scope: none + x-brew-brand-scope: org x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: domains.read - /v1/templates: + x-brew-rate-policy: templates.read + /v1/flows: get: - operationId: listTemplates + operationId: listFlows tags: - Templates - summary: List templates - description: "Lists public templates under { data, pagination }. The default full representation includes HTML. Set representation=summary for metadata, previewImage, viewUrl, and referenceEmailId without HTML. Supports exact brand and category filters and a group filter (a browse group: every category in it); query matches title text or an exact template identifier. semantic ranks up to 200 matches using vector search; query additionally filters those matches when both are supplied. Browse defaults to 50 full rows or 20 summary rows. Brand and category match case-insensitively. Continue with pagination.cursor, including after an empty filtered page with hasMore=true. Templates are organization-wide references; pass referenceEmailId to POST /v1/emails to remix one. Set count=true for { count }, the number of templates matching brand, category and group (refreshed every 15 minutes), instead of rows; add groupBy=brand, groupBy=category or groupBy=group for per-value counts, largest first, 100 groups a page with the same pagination.cursor (count does not combine with semantic or query)." + summary: List flows + description: |- + Lists public email flows, one brand’s real onboarding or newsletter sequence with the day each email landed, as cards under `{ data, pagination, total }`. Organization-wide: the same bounded corpus the gallery shows (a few hundred flows), paged with `limit`/`cursor`. + + **Use when** studying how real brands sequence email to plan or remix an automation, or finding the `slug` to read one flow in full. + + **Input** `brand` (exact domain), `category`, `type` (`signup` | `newsletter`), `sort` (`newest` | `emails` | `span` | `remixes`), or `semantic` for relevance-ranked search (then `sort` is ignored). + + **Returns** `200` with flow cards and `total`, the number of flows the query matches across every page (filters narrow it; `semantic` only orders it), so one `limit=1` call counts them. `isTotalExact` is false when the read was cut at 500 flows (the newest, or the 500 nearest a `semantic` query; filters apply after it), and `total` is then a floor. A card never carries `anchor` or `steps`; every card carries the `slug` that `getFlow` takes. A flow holds at most 12 steps: capture keeps the first 12 and cuts the tail, so `emailCount: 12` means twelve **or more** were sent. + + **Errors** `400 INVALID_REQUEST` for an unknown query key or value; `503 SERVICE_UNAVAILABLE` when `semantic` search cannot run (no index, or its kill switch); retry without `semantic`. + + **See also** `getFlow`, `listTemplates`. parameters: - schema: type: string minLength: 1 - description: Only this brand's templates, by domain (e.g. `brew.new`); case-insensitive. + description: Exact brand domain filter for LIST, e.g. `brew.new`. required: false - description: Only this brand's templates, by domain (e.g. `brew.new`); case-insensitive. + description: Exact brand domain filter for LIST, e.g. `brew.new`. name: brand in: query - schema: type: string - minLength: 1 - description: "Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general." + enum: + - verification + - transactional + - direct_sales + - welcome + - promotion + - newsletter + - education + - abandoned_cart + - winback + - other + description: Filter LIST by the flow’s dominant step category (`welcome`, `newsletter`, `promotion`, `education`, …). required: false - description: "Email category, matched case-insensitively (unknown → empty page): welcome, onboarding, newsletter, content, event-invitation, event-reminder, event-recap, product-launch, product-update, product-pitch, promotional, cart-abandonment, re-engagement, subscription, feedback-request, referral, company-news, fundraising, order-confirmation, shipping-update, receipt, password-reset, verification, security-alert, account-update, notification, support, internal, business, general." + description: Filter LIST by the flow’s dominant step category (`welcome`, `newsletter`, `promotion`, `education`, …). name: category in: query - schema: type: string enum: - - welcome-onboarding - newsletter - - content - - events - - product-news - - sales - - retention - - company - - transactional - - internal - - other - description: "A browse group: every category in it (e.g. `events` = invitations, reminders and recaps)." + - signup + description: "Filter LIST by how the sequence starts: `signup` (after creating an account) or `newsletter` (after subscribing)." required: false - description: "A browse group: every category in it (e.g. `events` = invitations, reminders and recaps)." - name: group + description: "Filter LIST by how the sequence starts: `signup` (after creating an account) or `newsletter` (after subscribing)." + name: type in: query - schema: type: string - minLength: 1 - maxLength: 500 - description: Rank by relevance to this text instead of newest first (top 200; `brand`/`category` still narrow). + enum: + - onboarding + - trial + - events + - product_updates + - promotions + description: Filter LIST by send-stream kind. required: false - description: Rank by relevance to this text instead of newest first (top 200; `brand`/`category` still narrow). - name: semantic + description: Filter LIST by send-stream kind. + name: kind in: query - schema: type: string minLength: 1 - maxLength: 250 - description: Case-insensitive title substring or exact public template identifier. Applied within semantic results when semantic is supplied. - required: false - description: Case-insensitive title substring or exact public template identifier. Applied within semantic results when semantic is supplied. - name: query - in: query - - schema: - type: string - enum: - - full - - summary - description: Full rows include HTML. Summary rows include selection metadata and links only. - required: false - description: Full rows include HTML. Summary rows include selection metadata and links only. - name: representation - in: query - - schema: - anyOf: - - type: boolean - - type: string - enum: - - 'true' - - 'false' - description: Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. + maxLength: 500 + description: Semantic search over what the sequences are about — “developer onboarding drip”, “trial expiry win-back”. Results come back in relevance order and `sort` is ignored. required: false - description: Return `{ count }`, the number of templates matching `brand`/`category`/`group`, instead of rows. Not with `semantic` or `query`. - name: count + description: Semantic search over what the sequences are about — “developer onboarding drip”, “trial expiry win-back”. Results come back in relevance order and `sort` is ignored. + name: semantic in: query - schema: type: string enum: - - brand - - category - - group - description: "With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`." - x-brew-group-by-tokens: - - brand - - category - - group + - newest + - emails + - span + - remixes + description: "LIST order: `newest` (default), `emails` (longest sequences first), `span` (sequences that run the longest first), `remixes` (most remixed first)." required: false - description: "With `count: true`: also count per brand, category or group, largest first, 100 groups a page; continue with `cursor`." - name: groupBy + description: "LIST order: `newest` (default), `emails` (longest sequences first), `span` (sequences that run the longest first), `remixes` (most remixed first)." + name: sort in: query - schema: type: integer minimum: 1 maximum: 100 - description: "Page size, 1–100 (default 50: each row carries its full HTML)." + description: Page size (1-100). Defaults to 100. example: 50 required: false - description: "Page size, 1–100 (default 50: each row carries its full HTML)." + description: Page size (1-100). Defaults to 100. name: limit in: query example: 50 - schema: type: string minLength: 1 - maxLength: 8192 + maxLength: 512 required: false name: cursor in: query responses: "200": - description: A page of templates matching the filters, or their counts when count=true. + description: A page of flow cards. headers: x-request-id: schema: @@ -59771,145 +64195,32 @@ paths: content: application/json: schema: - anyOf: - - $ref: "#/components/schemas/TemplatesListResponse" - - type: object - properties: - data: - type: array - items: - type: object - properties: - emailId: - type: string - minLength: 1 - title: - type: string - minLength: 1 - category: - type: string - minLength: 1 - brand: - type: string - minLength: 1 - previewImage: - type: string - format: uri - updatedAt: - type: string - minLength: 1 - format: date-time - referenceEmailId: - type: string - minLength: 1 - viewUrl: - type: string - format: uri - required: - - emailId - - title - - previewImage - - updatedAt - - referenceEmailId - - viewUrl - additionalProperties: false - pagination: - type: object - properties: - limit: - type: integer - minimum: 1 - maximum: 100 - cursor: - type: - - string - - "null" - hasMore: - type: boolean - required: - - limit - - cursor - - hasMore - additionalProperties: false - required: - - data - - pagination - additionalProperties: false - - type: object - properties: - count: - type: integer - minimum: 0 - description: Templates matching `brand`/`category`. - groupBy: - type: string - enum: - - brand - - category - - group - groups: - type: array - items: - type: object - properties: - value: - type: string - minLength: 1 - description: The brand domain, category or group; pass it back as the filter of the same name (`brand`, `category` or `group`). - name: - type: string - minLength: 1 - description: The brand's curated display name, when it has one. - count: - type: integer - exclusiveMinimum: 0 - required: - - value - - count - additionalProperties: false - description: "With `groupBy`: up to 100 groups, largest first." - groupCount: - type: integer - minimum: 0 - description: Distinct brands or categories across every page. - ungroupedCount: - type: integer - minimum: 0 - description: Matching templates with no brand (or no category), so in no group. - pagination: - type: object - properties: - limit: - type: integer - minimum: 1 - maximum: 100 - cursor: - type: - - string - - "null" - hasMore: - type: boolean - required: - - limit - - cursor - - hasMore - additionalProperties: false - required: - - count - additionalProperties: false + $ref: "#/components/schemas/FlowsListResponse" example: data: - - emailId: seed-vercel-newsletter - title: Vercel Newsletter - category: newsletter - brand: vercel.com - html: … - previewImage: https://storage.example.com/templates/seed.png - updatedAt: '2026-04-08T00:00:00.000Z' + - slug: brew.new + brand: + name: Brew + logo: https://cdn.brew.new/brand/fetched-logo/brew.new/logo.png + title: Brew onboarding flow + type: signup + category: welcome + categoryLabel: Welcome + emailCount: 6 + spanDays: 14 + remixCount: 12 + previewImages: + - https://cdn.brew.new/email-preview-brew-1.png + - https://cdn.brew.new/email-preview-brew-2.png + - https://cdn.brew.new/email-preview-brew-3.png + publishedAt: '2026-09-01T12:00:00.000Z' + updatedAt: '2026-09-01T12:00:00.000Z' pagination: - limit: 50 - cursor: eyJ… - hasMore: true + limit: 100 + cursor: null + hasMore: false + total: 1 + isTotalExact: true "400": description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." headers: @@ -60088,40 +64399,91 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + "503": + description: "`SERVICE_UNAVAILABLE`: Semantic search over flows cannot run right now (no search index, or its kill switch), so a `semantic` query is refused rather than answered with an empty page." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: Semantic search over flows cannot run right now (no search index, or its kill switch), so a `semantic` query is refused rather than answered with an empty page. + suggestion: "Retry without `semantic`: `brand`, `category`, `type` and `sort` still narrow and order the list." + docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: none x-brew-brand-scope: org x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: templates.read - /v1/templates/{templateId}: + /v1/flows/{slug}: get: - operationId: getTemplate + operationId: getFlow tags: - Templates - summary: Get a public template - description: Returns metadata, preview and canonical links, and referenceEmailId for one public template. Add include=html to inspect its rendered content. Large HTML is returned as a downloadable content.url instead of truncated JSON. + summary: Get a flow + description: |- + One public flow as the bare row, with `anchor` (what day 0 means) and every step’s `subject`, `previewText`, `dayOffset`, `delayDays`, `category`, `previewImage` and `emailId`. Organization-wide. + + **Use when** reading a sequence in full before remixing it, or fetching each step’s rendered body. + + **Input** `slug` in the path: the brand domain from a `listFlows` card (case-insensitive). `include=html` adds each step’s rendered HTML, best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow. + + **Returns** `200` with the flow. A step’s `emailId` is a template reference: pass it as `referenceEmailId` on `POST /v1/emails`; for the body itself use `include=html` here (`GET /v1/templates` lists the gallery and takes no id). + + **Errors** `404 FLOW_NOT_FOUND` when the flow is unknown, private, or a step’s template was taken down (the anonymous read fails closed as a whole). + + **See also** `listFlows`, `generateEmail`. parameters: - schema: type: string minLength: 1 - maxLength: 256 + maxLength: 253 + description: The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. + example: brew.new required: true - name: templateId + description: The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. + name: slug in: path - schema: type: string - enum: - - html - description: "Expansions: `html` adds the rendered HTML; a large page arrives as a `content.url` download link instead." + pattern: ^[a-z][a-z-]*[a-z](?:-[0-9]{1,3})?$ + description: Stream segment for a bare-domain `slug`. + required: false + description: Stream segment for a bare-domain `slug`. + name: kind + in: query + - schema: + type: string + description: "Detail-only expansions, comma-separated. `html` adds each step’s rendered HTML. Rendered email is bulky — over MCP only the first step or two fit one response; the rest arrive without `html` and the summary says how many were kept. A step’s `emailId` is already a valid `referenceEmailId`, so reuse needs no body; for the bodies themselves call `GET /v1/flows/{slug}?include=html` over REST, which has no cap. Best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow." x-brew-include-tokens: - html required: false - description: "Expansions: `html` adds the rendered HTML; a large page arrives as a `content.url` download link instead." + description: "Detail-only expansions, comma-separated. `html` adds each step’s rendered HTML. Rendered email is bulky — over MCP only the first step or two fit one response; the rest arrive without `html` and the summary says how many were kept. A step’s `emailId` is already a valid `referenceEmailId`, so reuse needs no body; for the bodies themselves call `GET /v1/flows/{slug}?include=html` over REST, which has no cap. Best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow." name: include in: query responses: "200": - description: Template details and optional rendered content. + description: The flow, with `anchor` and every step. headers: x-request-id: schema: @@ -60154,65 +64516,44 @@ paths: content: application/json: schema: - type: object - properties: - emailId: - type: string - minLength: 1 - title: - type: string - minLength: 1 - category: - type: string - minLength: 1 - brand: - type: string - minLength: 1 - previewImage: - type: string - format: uri - updatedAt: - type: string - minLength: 1 - format: date-time - referenceEmailId: - type: string - minLength: 1 - viewUrl: - type: string - format: uri - templateId: - type: string - minLength: 1 - html: - type: string - content: - type: object - properties: - url: - type: string - format: uri - mimeType: - type: string - enum: - - text/html - bytes: - type: integer - minimum: 0 - required: - - url - - mimeType - - bytes - additionalProperties: false - required: - - emailId - - title - - previewImage - - updatedAt - - referenceEmailId - - viewUrl - - templateId - additionalProperties: false + $ref: "#/components/schemas/Flow" + example: + slug: brew.new + brand: + name: Brew + logo: https://cdn.brew.new/brand/fetched-logo/brew.new/logo.png + title: Brew onboarding flow + type: signup + category: welcome + categoryLabel: Welcome + emailCount: 6 + spanDays: 14 + remixCount: 12 + previewImages: + - https://cdn.brew.new/email-preview-brew-1.png + - https://cdn.brew.new/email-preview-brew-2.png + - https://cdn.brew.new/email-preview-brew-3.png + publishedAt: '2026-09-01T12:00:00.000Z' + updatedAt: '2026-09-01T12:00:00.000Z' + anchor: signedUpAt + steps: + - order: 1 + dayOffset: 0 + delayDays: 0 + subject: Welcome to Brew + previewText: Here’s how to set up your first brand. + category: welcome + categoryLabel: Welcome + emailId: pt1_k97nvhqe6xgyj67g58ajwj1tj58egexx + previewImage: https://cdn.brew.new/email-preview-brew-1.png + - order: 2 + dayOffset: 2.1 + delayDays: 2.1 + subject: Three templates to try today + category: education + categoryLabel: Education + emailId: pt1_k97s409kdrzbeajqffq6011wfn8egmdq + previewImage: https://cdn.brew.new/email-preview-brew-2.png "400": description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." headers: @@ -60309,7 +64650,7 @@ paths: suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`TEMPLATE_NOT_FOUND`: No public template matches that id." + description: "`FLOW_NOT_FOUND`: No public flow matches that slug: it is unknown, private, or one of its steps is no longer public." headers: x-request-id: schema: @@ -60323,14 +64664,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - templateNotFound: - summary: TEMPLATE_NOT_FOUND + flowNotFound: + summary: FLOW_NOT_FOUND value: error: - code: TEMPLATE_NOT_FOUND + code: FLOW_NOT_FOUND type: not_found - message: No public template matches that id. - suggestion: List templates with GET /v1/templates and use a returned emailId as the templateId. + message: "No public flow matches that slug: it is unknown, private, or one of its steps is no longer public." + suggestion: List flows with GET /v1/flows and use a returned slug (the brand domain, for example notion.com). docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -60420,116 +64761,43 @@ paths: x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: templates.read - /v1/flows: + /v1/brand: get: - operationId: listFlows + operationId: getBrand tags: - - Templates - summary: List flows + - Brand + summary: Get the brand bound to this API key description: |- - Lists public email flows, one brand’s real onboarding or newsletter sequence with the day each email landed, as cards under `{ data, pagination, total }`. Organization-wide: the same bounded corpus the gallery shows (a few hundred flows), paged with `limit`/`cursor`. - - **Use when** studying how real brands sequence email to plan or remix an automation, or finding the `slug` to read one flow in full. - - **Input** `brand` (exact domain), `category`, `type` (`signup` | `newsletter`), `sort` (`newest` | `emails` | `span` | `remixes`), or `semantic` for relevance-ranked search (then `sort` is ignored). - - **Returns** `200` with flow cards and `total`, the number of flows the query matches across every page (filters narrow it; `semantic` only orders it), so one `limit=1` call counts them. `isTotalExact` is false when the read was cut at 500 flows (the newest, or the 500 nearest a `semantic` query; filters apply after it), and `total` is then a floor. A card never carries `anchor` or `steps`; every card carries the `slug` that `getFlow` takes. A flow holds at most 12 steps: capture keeps the first 12 and cuts the tail, so `emailCount: 12` means twelve **or more** were sent. + Returns the bare brand row for the single brand bound to the API key. Check `ready` (`status === "completed"`) before calling `POST /v1/emails`, which returns `422 BRAND_NOT_READY` until brand extraction finishes. - **Errors** `400 INVALID_REQUEST` for an unknown query key or value; `503 SERVICE_UNAVAILABLE` when `semantic` search cannot run (no index, or its kill switch); retry without `semantic`. + `identity` is always returned: the brand name and tagline saved in Brew (`identity.brandName`, `identity.tagline`), plus its description, contact details and links. `domain` is the website the brand was extracted from, not its name. - **See also** `getFlow`, `listTemplates`. + Embed the design context in the same call with `?include=emailDesign,imageStyle,logos` (comma-separated). `emailDesign` / `imageStyle` are the markdown design system the email agent follows; `logos` is the CDN logo set. `include=identity` is still accepted and changes nothing. The paginated image library is a separate read (`GET /v1/brand/images`). parameters: - schema: type: string - minLength: 1 - description: Exact brand domain filter for LIST, e.g. `brew.new`. - required: false - description: Exact brand domain filter for LIST, e.g. `brew.new`. - name: brand - in: query - - schema: - type: string - enum: - - verification - - transactional - - direct_sales - - welcome - - promotion - - newsletter - - education - - abandoned_cart - - winback - - other - description: Filter LIST by the flow’s dominant step category (`welcome`, `newsletter`, `promotion`, `education`, …). - required: false - description: Filter LIST by the flow’s dominant step category (`welcome`, `newsletter`, `promotion`, `education`, …). - name: category - in: query - - schema: - type: string - enum: - - newsletter - - signup - description: "Filter LIST by how the sequence starts: `signup` (after creating an account) or `newsletter` (after subscribing)." - required: false - description: "Filter LIST by how the sequence starts: `signup` (after creating an account) or `newsletter` (after subscribing)." - name: type - in: query - - schema: - type: string - enum: - - onboarding - - trial - - events - - product_updates - - promotions - description: Filter LIST by send-stream kind. - required: false - description: Filter LIST by send-stream kind. - name: kind - in: query - - schema: - type: string - minLength: 1 - maxLength: 500 - description: Semantic search over what the sequences are about — “developer onboarding drip”, “trial expiry win-back”. Results come back in relevance order and `sort` is ignored. - required: false - description: Semantic search over what the sequences are about — “developer onboarding drip”, “trial expiry win-back”. Results come back in relevance order and `sort` is ignored. - name: semantic - in: query - - schema: - type: string - enum: - - newest - - emails - - span - - remixes - description: "LIST order: `newest` (default), `emails` (longest sequences first), `span` (sequences that run the longest first), `remixes` (most remixed first)." + description: "Comma-separated sub-resources to embed: `emailDesign`, `imageStyle`, `logos`. `identity` (saved name, tagline, description, links) is always returned; naming it is accepted and changes nothing." + x-brew-include-tokens: + - identity + - emailDesign + - imageStyle + - logos required: false - description: "LIST order: `newest` (default), `emails` (longest sequences first), `span` (sequences that run the longest first), `remixes` (most remixed first)." - name: sort + description: "Comma-separated sub-resources to embed: `emailDesign`, `imageStyle`, `logos`. `identity` (saved name, tagline, description, links) is always returned; naming it is accepted and changes nothing." + name: include in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 + - name: X-Brand-Id + in: header required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - - schema: + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: type: string minLength: 1 - maxLength: 512 - required: false - name: cursor - in: query + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of flow cards. + description: The brand bound to this API key with its saved `identity`, plus any sub-resources named in `?include=`. headers: x-request-id: schema: @@ -60562,34 +64830,22 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/FlowsListResponse" + $ref: "#/components/schemas/BrandGetResponse" example: - data: - - slug: brew.new - brand: - name: Brew - logo: https://cdn.brew.new/brand/fetched-logo/brew.new/logo.png - title: Brew onboarding flow - type: signup - category: welcome - categoryLabel: Welcome - emailCount: 6 - spanDays: 14 - remixCount: 12 - previewImages: - - https://cdn.brew.new/email-preview-brew-1.png - - https://cdn.brew.new/email-preview-brew-2.png - - https://cdn.brew.new/email-preview-brew-3.png - publishedAt: '2026-09-01T12:00:00.000Z' - updatedAt: '2026-09-01T12:00:00.000Z' - pagination: - limit: 100 - cursor: null - hasMore: false - total: 1 - isTotalExact: true + brandId: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + domain: vercel.com + status: completed + ready: true + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + identity: + brandName: Vercel + tagline: Develop. Preview. Ship. "400": - description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: schema: @@ -60603,6 +64859,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -60660,7 +64925,12 @@ paths: suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication "403": - description: "`ACCOUNT_SUSPENDED`: The organization behind the credential is suspended." + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). headers: x-request-id: schema: @@ -60683,6 +64953,48 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -60766,91 +65078,39 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: Semantic search over flows cannot run right now (no search index, or its kill switch), so a `semantic` query is refused rather than answered with an empty page." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: Semantic search over flows cannot run right now (no search index, or its kill switch), so a `semantic` query is refused rather than answered with an empty page. - suggestion: "Retry without `semantic`: `brand`, `category`, `type` and `sort` still narrow and order the list." - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: none - x-brew-brand-scope: org + x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: templates.read - /v1/flows/{slug}: - get: - operationId: getFlow + x-brew-rate-policy: brand.read + patch: + operationId: updateBrand tags: - - Templates - summary: Get a flow - description: |- - One public flow as the bare row, with `anchor` (what day 0 means) and every step’s `subject`, `previewText`, `dayOffset`, `delayDays`, `category`, `previewImage` and `emailId`. Organization-wide. - - **Use when** reading a sequence in full before remixing it, or fetching each step’s rendered body. - - **Input** `slug` in the path: the brand domain from a `listFlows` card (case-insensitive). `include=html` adds each step’s rendered HTML, best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow. - - **Returns** `200` with the flow. A step’s `emailId` is a template reference: pass it as `referenceEmailId` on `POST /v1/emails`; for the body itself use `include=html` here (`GET /v1/templates` lists the gallery and takes no id). - - **Errors** `404 FLOW_NOT_FOUND` when the flow is unknown, private, or a step’s template was taken down (the anonymous read fails closed as a whole). + - Brand + summary: Update brand identity + design system + description: "Partial update of the writable design artifacts in one call. `identity` shallow-merges the provided fields; `emailDesign` / `imageStyle` replace the markdown design-system documents the email agent follows. Provide at least one. Returns the brand in the `GET /v1/brand` shape: `identity` always, plus the markdown documents the call wrote." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BrandPatchRequest" + examples: + identity: + summary: Merge identity fields + value: + identity: + tagline: Complete user management for modern apps. + design: + summary: Replace the email-design house style + value: + emailDesign: |- + # email-design.md - **See also** `listFlows`, `generateEmail`. - parameters: - - schema: - type: string - minLength: 1 - maxLength: 253 - description: The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. - example: brew.new - required: true - description: The flow’s brand domain. A bare domain returns the brand’s primary flow; one send stream’s flow is `?kind=` on it (or the path `/v1/flows/{domain}/{kind}`), as each `GET /v1/flows` row’s `slug` (`/`) names it. Case-insensitive. - name: slug - in: path - - schema: - type: string - pattern: ^[a-z][a-z-]*[a-z](?:-[0-9]{1,3})?$ - description: Stream segment for a bare-domain `slug`. - required: false - description: Stream segment for a bare-domain `slug`. - name: kind - in: query - - schema: - type: string - description: "Detail-only expansions, comma-separated. `html` adds each step’s rendered HTML. Rendered email is bulky — over MCP only the first step or two fit one response; the rest arrive without `html` and the summary says how many were kept. A step’s `emailId` is already a valid `referenceEmailId`, so reuse needs no body; for the bodies themselves call `GET /v1/flows/{slug}?include=html` over REST, which has no cap. Best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow." - x-brew-include-tokens: - - html - required: false - description: "Detail-only expansions, comma-separated. `html` adds each step’s rendered HTML. Rendered email is bulky — over MCP only the first step or two fit one response; the rest arrive without `html` and the summary says how many were kept. A step’s `emailId` is already a valid `referenceEmailId`, so reuse needs no body; for the bodies themselves call `GET /v1/flows/{slug}?include=html` over REST, which has no cap. Best-effort per step: a step whose body is no longer servable comes back without `html` rather than failing the flow." - name: include - in: query + Updated house style… responses: "200": - description: The flow, with `anchor` and every step. + description: The updated brand with its `identity`, plus the markdown documents the call wrote. headers: x-request-id: schema: @@ -60883,46 +65143,20 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Flow" + $ref: "#/components/schemas/BrandGetResponse" example: - slug: brew.new - brand: - name: Brew - logo: https://cdn.brew.new/brand/fetched-logo/brew.new/logo.png - title: Brew onboarding flow - type: signup - category: welcome - categoryLabel: Welcome - emailCount: 6 - spanDays: 14 - remixCount: 12 - previewImages: - - https://cdn.brew.new/email-preview-brew-1.png - - https://cdn.brew.new/email-preview-brew-2.png - - https://cdn.brew.new/email-preview-brew-3.png - publishedAt: '2026-09-01T12:00:00.000Z' - updatedAt: '2026-09-01T12:00:00.000Z' - anchor: signedUpAt - steps: - - order: 1 - dayOffset: 0 - delayDays: 0 - subject: Welcome to Brew - previewText: Here’s how to set up your first brand. - category: welcome - categoryLabel: Welcome - emailId: pt1_k97nvhqe6xgyj67g58ajwj1tj58egexx - previewImage: https://cdn.brew.new/email-preview-brew-1.png - - order: 2 - dayOffset: 2.1 - delayDays: 2.1 - subject: Three templates to try today - category: education - categoryLabel: Education - emailId: pt1_k97s409kdrzbeajqffq6011wfn8egmdq - previewImage: https://cdn.brew.new/email-preview-brew-2.png + brandId: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + domain: vercel.com + status: completed + ready: true + identity: + brandName: Vercel + tagline: Complete user management for modern apps. "400": - description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: schema: @@ -60936,6 +65170,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -60993,7 +65236,14 @@ paths: suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication "403": - description: "`ACCOUNT_SUSPENDED`: The organization behind the credential is suspended." + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). headers: x-request-id: schema: @@ -61016,8 +65266,35 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`FLOW_NOT_FOUND`: No public flow matches that slug: it is unknown, private, or one of its steps is no longer public." + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: x-request-id: schema: @@ -61031,14 +65308,14 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - flowNotFound: - summary: FLOW_NOT_FOUND + brandNotFound: + summary: BRAND_NOT_FOUND value: error: - code: FLOW_NOT_FOUND + code: BRAND_NOT_FOUND type: not_found - message: "No public flow matches that slug: it is unknown, private, or one of its steps is no longer public." - suggestion: List flows with GET /v1/flows and use a returned slug (the brand domain, for example notion.com). + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." @@ -61054,7 +65331,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -61095,7 +65372,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -61123,35 +65400,76 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: none - x-brew-brand-scope: org + parameters: + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + x-brew-scope: emails + x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: templates.read - /v1/brand: + x-brew-rate-policy: brand.write + /v1/brand/images: get: - operationId: getBrand + operationId: getBrandImages tags: - Brand - summary: Get the brand bound to this API key - description: |- - Returns the bare brand row for the single brand bound to the API key. Check `ready` (`status === "completed"`) before calling `POST /v1/emails`, which returns `422 BRAND_NOT_READY` until brand extraction finishes. - - `identity` is always returned: the brand name and tagline saved in Brew (`identity.brandName`, `identity.tagline`), plus its description, contact details and links. `domain` is the website the brand was extracted from, not its name. - - Embed the design context in the same call with `?include=emailDesign,imageStyle,logos` (comma-separated). `emailDesign` / `imageStyle` are the markdown design system the email agent follows; `logos` is the CDN logo set. `include=identity` is still accepted and changes nothing. The paginated image library is a separate read (`GET /v1/brand/images`). + summary: Get brand images + description: "The brand’s asset library, as the Assets page shows it: logos, brand images (from the site or uploaded, including the social preview and site screenshot) and images made with Brew. Omit `?q=` to BROWSE it, newest first or `?sort=oldest`. Pass `?q=` for SEMANTIC search over brand and generated images (the query is embedded + vector-searched), in relevance order. Both are free. `?kind=logo|brand|generated` narrows either mode; logos are not searchable. Each row carries `assetId` (the id the app opens at `/assets?image=`), `kind`, `url` and what is known: description, dimensions, category, page, when it was added, and a logo’s variant. (`GET /v1/brand` returns the brand `identity`, and the smaller `emailDesign` / `imageStyle` / `logos` sub-resources embed with `?include=…`; images stay separate because they paginate.)" parameters: - schema: type: string - description: "Comma-separated sub-resources to embed: `emailDesign`, `imageStyle`, `logos`. `identity` (saved name, tagline, description, links) is always returned; naming it is accepted and changes nothing." - x-brew-include-tokens: - - identity - - emailDesign - - imageStyle - - logos + minLength: 1 + maxLength: 500 + description: Semantic search over what the images show. Results come back in relevance order and `sort` is ignored. Logos are not searchable. required: false - description: "Comma-separated sub-resources to embed: `emailDesign`, `imageStyle`, `logos`. `identity` (saved name, tagline, description, links) is always returned; naming it is accepted and changes nothing." - name: include + description: Semantic search over what the images show. Results come back in relevance order and `sort` is ignored. Logos are not searchable. + name: q + in: query + - schema: + type: string + enum: + - logo + - brand + - generated + description: "`logo`, `brand` (images from the site or uploaded, including the social preview and site screenshot) or `generated` (made with Brew). Omit for every kind." + required: false + description: "`logo`, `brand` (images from the site or uploaded, including the social preview and site screenshot) or `generated` (made with Brew). Omit for every kind." + name: kind + in: query + - schema: + type: string + enum: + - newest + - oldest + description: "Browse order: `newest` (default) or `oldest`." + required: false + description: "Browse order: `newest` (default) or `oldest`." + name: sort + in: query + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 512 + required: false + name: cursor in: query - name: X-Brand-Id in: header @@ -61164,7 +65482,7 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The brand bound to this API key with its saved `identity`, plus any sub-resources named in `?include=`. + description: A page of brand assets (semantic results, in relevance order, when `?q=`). headers: x-request-id: schema: @@ -61197,17 +65515,28 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrandGetResponse" + $ref: "#/components/schemas/BrandImagesResponse" example: - brandId: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - domain: vercel.com - status: completed - ready: true - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - identity: - brandName: Vercel - tagline: Develop. Preview. Ship. + data: + - assetId: 5bc912f9 + kind: generated + url: https://cdn.brew.new/cnt/abc.png + description: Clerk user-profile component + width: 1056 + height: 1002 + addedAt: '2026-09-24T18:02:11.000Z' + - assetId: 1d4e7a20 + kind: logo + url: https://cdn.brew.new/cnt/logo.svg + logo: + type: logo + theme: dark + background: transparent + format: svg + pagination: + limit: 20 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -61297,6 +65626,8 @@ paths: `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). headers: x-request-id: @@ -61329,6 +65660,15 @@ paths: message: A brand-scoped credential named a brand other than the one it is bound to. suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication insufficientRole: summary: INSUFFICIENT_ROLE value: @@ -61445,39 +65785,51 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: none + x-brew-scope: emails x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: brand.read - patch: - operationId: updateBrand + /v1/brand/images/{assetId}: + delete: + operationId: deleteBrandImage tags: - Brand - summary: Update brand identity + design system - description: "Partial update of the writable design artifacts in one call. `identity` shallow-merges the provided fields; `emailDesign` / `imageStyle` replace the markdown design-system documents the email agent follows. Provide at least one. Returns the brand in the `GET /v1/brand` shape: `identity` always, plus the markdown documents the call wrote." - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/BrandPatchRequest" - examples: - identity: - summary: Merge identity fields - value: - identity: - tagline: Complete user management for modern apps. - design: - summary: Replace the email-design house style - value: - emailDesign: |- - # email-design.md + summary: Delete a brand image + description: |- + Removes one image from the brand library and from image search, as Delete image on the Assets page does. Its file stays hosted at its URL, so emails already using it keep rendering. - Updated house style… + **Use when** an image should no longer be offered for new designs. Confirm with the user first. Free; no credits are charged. + + **Input** `assetId` in the path, as `getBrandImages` returns it. One image per call. + + **Returns** `200` with `{ assetId, deleted }`. An `assetId` not in the library resolves `deleted: false` and changes nothing, so repeating the call is safe. + + **Errors** `400 INVALID_REQUEST` for a logo (manage logos on the Assets page) or a malformed `assetId`. + + **See also** `getBrandImages`, `addImage`. + parameters: + - schema: + type: string + pattern: ^[0-9a-f]{8}$ + description: "The image to delete: the `assetId` that `GET /v1/brand/images` returns." + example: 5bc912f9 + required: true + description: "The image to delete: the `assetId` that `GET /v1/brand/images` returns." + name: assetId + in: path + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: The updated brand with its `identity`, plus the markdown documents the call wrote. + description: Deleted (or not in the library). headers: x-request-id: schema: @@ -61510,15 +65862,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrandGetResponse" + $ref: "#/components/schemas/BrandImageDeleteResponse" example: - brandId: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - domain: vercel.com - status: completed - ready: true - identity: - brandName: Vercel - tagline: Complete user management for modern apps. + assetId: 5bc912f9 + deleted: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -61767,58 +66114,31 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - parameters: - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg x-brew-scope: emails x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: brand.write - /v1/brand/images: + /v1/brands: get: - operationId: getBrandImages + operationId: listBrands tags: - - Brand - summary: Get brand images - description: "The brand’s asset library, as the Assets page shows it: logos, brand images (from the site or uploaded, including the social preview and site screenshot) and images made with Brew. Omit `?q=` to BROWSE it, newest first or `?sort=oldest`. Pass `?q=` for SEMANTIC search over brand and generated images (the query is embedded + vector-searched), in relevance order. Both are free. `?kind=logo|brand|generated` narrows either mode; logos are not searchable. Each row carries `assetId` (the id the app opens at `/assets?image=`), `kind`, `url` and what is known: description, dimensions, category, page, when it was added, and a logo’s variant. (`GET /v1/brand` returns the brand `identity`, and the smaller `emailDesign` / `imageStyle` / `logos` sub-resources embed with `?include=…`; images stay separate because they paginate.)" + - Brands + summary: List the brands this credential can reach + description: |- + Organization-scoped credentials see every brand in the organization; a brand-scoped credential sees exactly the one brand it is bound to. + + This is how an organization-scoped credential discovers the ids it passes in `X-Brand-Id` on every other endpoint. parameters: - - schema: - type: string - minLength: 1 - maxLength: 500 - description: Semantic search over what the images show. Results come back in relevance order and `sort` is ignored. Logos are not searchable. - required: false - description: Semantic search over what the images show. Results come back in relevance order and `sort` is ignored. Logos are not searchable. - name: q - in: query - schema: type: string enum: - - logo - - brand - - generated - description: "`logo`, `brand` (images from the site or uploaded, including the social preview and site screenshot) or `generated` (made with Brew). Omit for every kind." - required: false - description: "`logo`, `brand` (images from the site or uploaded, including the social preview and site screenshot) or `generated` (made with Brew). Omit for every kind." - name: kind - in: query - - schema: - type: string - enum: - - newest - - oldest - description: "Browse order: `newest` (default) or `oldest`." + - extracting + - completed + - failed + - deleting required: false - description: "Browse order: `newest` (default) or `oldest`." - name: sort + name: status in: query - schema: type: integer @@ -61838,18 +66158,9 @@ paths: required: false name: cursor in: query - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: "200": - description: A page of brand assets (semantic results, in relevance order, when `?q=`). + description: The brands this credential can reach. headers: x-request-id: schema: @@ -61882,33 +66193,29 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrandImagesResponse" + $ref: "#/components/schemas/BrandsListResponse" example: data: - - assetId: 5bc912f9 - kind: generated - url: https://cdn.brew.new/cnt/abc.png - description: Clerk user-profile component - width: 1056 - height: 1002 - addedAt: '2026-09-24T18:02:11.000Z' - - assetId: 1d4e7a20 - kind: logo - url: https://cdn.brew.new/cnt/logo.svg - logo: - type: logo - theme: dark - background: transparent - format: svg + - brandId: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + domain: vercel.com + status: completed + ready: true + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:05:00.000Z' + - brandId: kx9d1p2qbrxy4nkm55ftz3lmvn21abcd + domain: acme.com + status: extracting + ready: false + progress: 0 + phase: analyzing + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' pagination: - limit: 20 + limit: 100 cursor: null hasMore: false "400": - description: |- - `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. - - `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." headers: x-request-id: schema: @@ -61922,15 +66229,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandIdRequired: - summary: BRAND_ID_REQUIRED - value: - error: - code: BRAND_ID_REQUIRED - type: invalid_request - message: An organization-scoped credential called a brand-scoped operation without naming the brand. - suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. - docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -61988,14 +66286,7 @@ paths: suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication "403": - description: |- - `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. - - `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. - - `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. - - `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + description: "`ACCOUNT_SUSPENDED`: The organization behind the credential is suspended." headers: x-request-id: schema: @@ -62018,57 +66309,6 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication - brandScopeMismatch: - summary: BRAND_SCOPE_MISMATCH - value: - error: - code: BRAND_SCOPE_MISMATCH - type: authorization_error - message: A brand-scoped credential named a brand other than the one it is bound to. - suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientPermissions: - summary: INSUFFICIENT_PERMISSIONS - value: - error: - code: INSUFFICIENT_PERMISSIONS - type: authorization_error - message: The credential lacks the permission scope the operation needs. - suggestion: Use an API key or session with the required permission. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientRole: - summary: INSUFFICIENT_ROLE - value: - error: - code: INSUFFICIENT_ROLE - type: authorization_error - message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." - suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. - docs: https://docs.brew.new/api-reference/api/authentication - "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - brandNotFound: - summary: BRAND_NOT_FOUND - value: - error: - code: BRAND_NOT_FOUND - type: not_found - message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - suggestion: List the brands this credential can reach with GET /v1/brands. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -62152,51 +66392,44 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails - x-brew-brand-scope: brand + x-brew-scope: none + x-brew-brand-scope: org x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: brand.read - /v1/brand/images/{assetId}: - delete: - operationId: deleteBrandImage + x-brew-rate-policy: brands.read + post: + operationId: createBrand tags: - - Brand - summary: Delete a brand image + - Brands + summary: Create a brand and start its extraction description: |- - Removes one image from the brand library and from image search, as Delete image on the Assets page does. Its file stays hosted at its URL, so emails already using it keep rendering. - - **Use when** an image should no longer be offered for new designs. Confirm with the user first. Free; no credits are charged. - - **Input** `assetId` in the path, as `getBrandImages` returns it. One image per call. - - **Returns** `200` with `{ assetId, deleted }`. An `assetId` not in the library resolves `deleted: false` and changes nothing, so repeating the call is safe. + Starts an asynchronous brand extraction that crawls the site and builds the design system. Returns `201` immediately with `status: "extracting"` — poll `GET /v1/brands/{brandId}` until `ready` is `true` (typically 1–3 minutes) before calling `POST /v1/emails`, which returns `422 BRAND_NOT_READY` until then. - **Errors** `400 INVALID_REQUEST` for a logo (manage logos on the Assets page) or a malformed `assetId`. + Requires an ORGANIZATION-scoped credential (`403 ORG_SCOPE_REQUIRED` otherwise) and the `brands` scope, which is NOT implied by `emails`. - **See also** `getBrandImages`, `addImage`. + Extraction requires a non-empty credit balance but is not itself charged to your credits. parameters: - - schema: - type: string - pattern: ^[0-9a-f]{8}$ - description: "The image to delete: the `assetId` that `GET /v1/brand/images` returns." - example: 5bc912f9 - required: true - description: "The image to delete: the `assetId` that `GET /v1/brand/images` returns." - name: assetId - in: path - - name: X-Brand-Id + - name: Idempotency-Key in: header required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. schema: type: string minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + maxLength: 100 + example: api-request-2026-04-08-001 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BrandsCreateRequest" + example: + url: acme.com + instructions: Use the product pages for tone. Primary brand color is the deep navy in the header. responses: - "200": - description: Deleted (or not in the library). + "201": + description: The brand was created and extraction started. Poll `extraction.statusUrl`. headers: x-request-id: schema: @@ -62229,15 +66462,21 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrandImageDeleteResponse" + $ref: "#/components/schemas/BrandsCreateResponse" example: - assetId: 5bc912f9 - deleted: true + brandId: kx9d1p2qbrxy4nkm55ftz3lmvn21abcd + domain: acme.com + status: extracting + ready: false + progress: 0 + phase: analyzing + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' + extraction: + chatId: 6f1c2b9e-6c2a-4a2e-9f5f-2b7d9a3e4c11 + statusUrl: /v1/brands/kx9d1p2qbrxy4nkm55ftz3lmvn21abcd "400": - description: |- - `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. - - `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." headers: x-request-id: schema: @@ -62251,15 +66490,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandIdRequired: - summary: BRAND_ID_REQUIRED - value: - error: - code: BRAND_ID_REQUIRED - type: invalid_request - message: An organization-scoped credential called a brand-scoped operation without naming the brand. - suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. - docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -62316,15 +66546,55 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: |- + `BRAND_LIMIT_REACHED`: The plan allows no more brands. + + `INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandLimitReached: + summary: BRAND_LIMIT_REACHED + value: + error: + code: BRAND_LIMIT_REACHED + type: payment_required + message: The plan allows no more brands. + suggestion: Delete an unused brand, or upgrade the plan to add more brands. + docs: https://docs.brew.new/api-reference/api/credits + insufficientCredits: + summary: INSUFFICIENT_CREDITS + value: + error: + code: INSUFFICIENT_CREDITS + type: payment_required + message: The credit balance is below what this operation requires; `details.cost` is the amount needed. + suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. + docs: https://docs.brew.new/api-reference/api/credits + details: + cost: 5 + remaining: 0 + planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. - `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. - `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + + `ORG_SCOPE_REQUIRED`: The operation acts on the organization, so a brand-scoped credential cannot call it. headers: x-request-id: schema: @@ -62347,15 +66617,6 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication - brandScopeMismatch: - summary: BRAND_SCOPE_MISMATCH - value: - error: - code: BRAND_SCOPE_MISMATCH - type: authorization_error - message: A brand-scoped credential named a brand other than the one it is bound to. - suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. - docs: https://docs.brew.new/api-reference/api/authentication insufficientPermissions: summary: INSUFFICIENT_PERMISSIONS value: @@ -62374,8 +66635,22 @@ paths: message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication - "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + orgScopeRequired: + summary: ORG_SCOPE_REQUIRED + value: + error: + code: ORG_SCOPE_REQUIRED + type: authorization_error + message: The operation acts on the organization, so a brand-scoped credential cannot call it. + suggestion: Create an organization-scoped API key in Settings, API, or reconnect MCP at the organization level. + docs: https://docs.brew.new/api-reference/api/authentication + "409": + description: |- + `BRAND_DOMAIN_CONFLICT`: The organization already has a brand for that domain. + + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. headers: x-request-id: schema: @@ -62389,15 +66664,33 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandNotFound: - summary: BRAND_NOT_FOUND + brandDomainConflict: + summary: BRAND_DOMAIN_CONFLICT value: error: - code: BRAND_NOT_FOUND - type: not_found - message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - suggestion: List the brands this credential can reach with GET /v1/brands. + code: BRAND_DOMAIN_CONFLICT + type: conflict + message: The organization already has a brand for that domain. + suggestion: Find the existing brand with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -62412,7 +66705,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 5 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -62453,7 +66746,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 5 remaining: 0 reset: 1712592360 consumed: false @@ -62481,53 +66774,66 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails - x-brew-brand-scope: brand - x-brew-idempotency: none + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: brands + x-brew-brand-scope: org + x-brew-idempotency: replay x-brew-credited: false - x-brew-rate-policy: brand.write - /v1/brands: + x-brew-rate-policy: brands.write + /v1/brands/{brandId}: get: - operationId: listBrands + operationId: getBrandById tags: - Brands - summary: List the brands this credential can reach + summary: Get one brand’s lifecycle state description: |- - Organization-scoped credentials see every brand in the organization; a brand-scoped credential sees exactly the one brand it is bound to. + The polling endpoint for `POST /v1/brands`. Reports `status`, `progress` and `phase` while extracting, and `error` when extraction failed. Terminal states are `completed` and `failed`. - This is how an organization-scoped credential discovers the ids it passes in `X-Brand-Id` on every other endpoint. + For the brand’s DESIGN CONTEXT (identity, markdown design system, logos) use `GET /v1/brand` instead. parameters: - - schema: - type: string - enum: - - extracting - - completed - - failed - - deleting - required: false - name: status - in: query - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - schema: type: string minLength: 1 - maxLength: 512 - required: false - name: cursor - in: query + maxLength: 64 + description: Brand id (opaque Convex document id) returned by `POST /v1/brands` and listed by `GET /v1/brands`. + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + required: true + description: Brand id (opaque Convex document id) returned by `POST /v1/brands` and listed by `GET /v1/brands`. + name: brandId + in: path responses: "200": - description: The brands this credential can reach. + description: The brand. headers: x-request-id: schema: @@ -62560,27 +66866,16 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrandsListResponse" + $ref: "#/components/schemas/BrandGetByIdResponse" example: - data: - - brandId: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - domain: vercel.com - status: completed - ready: true - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:05:00.000Z' - - brandId: kx9d1p2qbrxy4nkm55ftz3lmvn21abcd - domain: acme.com - status: extracting - ready: false - progress: 0 - phase: analyzing - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' - pagination: - limit: 100 - cursor: null - hasMore: false + brandId: kx9d1p2qbrxy4nkm55ftz3lmvn21abcd + domain: acme.com + status: extracting + ready: false + progress: 0 + phase: analyzing + createdAt: '2026-04-08T12:00:00.000Z' + updatedAt: '2026-04-08T12:00:00.000Z' "400": description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." headers: @@ -62676,6 +66971,30 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -62764,17 +67083,13 @@ paths: x-brew-idempotency: none x-brew-credited: false x-brew-rate-policy: brands.read + /v1/content/generate-image: post: - operationId: createBrand + operationId: generateImage tags: - - Brands - summary: Create a brand and start its extraction - description: |- - Starts an asynchronous brand extraction that crawls the site and builds the design system. Returns `201` immediately with `status: "extracting"` — poll `GET /v1/brands/{brandId}` until `ready` is `true` (typically 1–3 minutes) before calling `POST /v1/emails`, which returns `422 BRAND_NOT_READY` until then. - - Requires an ORGANIZATION-scoped credential (`403 ORG_SCOPE_REQUIRED` otherwise) and the `brands` scope, which is NOT implied by `emails`. - - Extraction requires a non-empty credit balance but is not itself charged to your credits. + - Content + summary: Generate an image + description: "Generates an image via the Brew AI image pipeline. `text-to-image` (default) creates from a prompt; `image-editing` edits `image1` (required) guided by the prompt. Returns a CDN-hosted URL, and saves the image to the brand’s generated images (`GET /v1/brand/images?kind=generated`). Usage-metered: charges the actual image gateway cost (no fixed price)." parameters: - name: Idempotency-Key in: header @@ -62785,18 +67100,25 @@ paths: minLength: 1 maxLength: 100 example: api-request-2026-04-08-001 + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg requestBody: required: true + description: Prompt + optional mode / aspect ratio / source images. content: application/json: schema: - $ref: "#/components/schemas/BrandsCreateRequest" - example: - url: acme.com - instructions: Use the product pages for tone. Primary brand color is the deep navy in the header. + $ref: "#/components/schemas/ContentGenerateImageRequest" responses: - "201": - description: The brand was created and extraction started. Poll `extraction.statusUrl`. + "200": + description: The operation result. headers: x-request-id: schema: @@ -62829,21 +67151,12 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrandsCreateResponse" - example: - brandId: kx9d1p2qbrxy4nkm55ftz3lmvn21abcd - domain: acme.com - status: extracting - ready: false - progress: 0 - phase: analyzing - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' - extraction: - chatId: 6f1c2b9e-6c2a-4a2e-9f5f-2b7d9a3e4c11 - statusUrl: /v1/brands/kx9d1p2qbrxy4nkm55ftz3lmvn21abcd + $ref: "#/components/schemas/ContentImageResponse" "400": - description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: schema: @@ -62857,6 +67170,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -62914,10 +67236,7 @@ paths: suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication "402": - description: |- - `BRAND_LIMIT_REACHED`: The plan allows no more brands. - - `INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed. + description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." headers: x-request-id: schema: @@ -62931,15 +67250,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandLimitReached: - summary: BRAND_LIMIT_REACHED - value: - error: - code: BRAND_LIMIT_REACHED - type: payment_required - message: The plan allows no more brands. - suggestion: Delete an unused brand, or upgrade the plan to add more brands. - docs: https://docs.brew.new/api-reference/api/credits insufficientCredits: summary: INSUFFICIENT_CREDITS value: @@ -62957,11 +67267,11 @@ paths: description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). - - `ORG_SCOPE_REQUIRED`: The operation acts on the organization, so a brand-scoped credential cannot call it. headers: x-request-id: schema: @@ -62984,6 +67294,15 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication insufficientPermissions: summary: INSUFFICIENT_PERMISSIONS value: @@ -63002,19 +67321,32 @@ paths: message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication - orgScopeRequired: - summary: ORG_SCOPE_REQUIRED + "404": + description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND value: error: - code: ORG_SCOPE_REQUIRED - type: authorization_error - message: The operation acts on the organization, so a brand-scoped credential cannot call it. - suggestion: Create an organization-scoped API key in Settings, API, or reconnect MCP at the organization level. - docs: https://docs.brew.new/api-reference/api/authentication + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors "409": description: |- - `BRAND_DOMAIN_CONFLICT`: The organization already has a brand for that domain. - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. @@ -63031,15 +67363,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandDomainConflict: - summary: BRAND_DOMAIN_CONFLICT - value: - error: - code: BRAND_DOMAIN_CONFLICT - type: conflict - message: The organization already has a brand for that domain. - suggestion: Find the existing brand with GET /v1/brands. - docs: https://docs.brew.new/api-reference/api/errors idempotencyConflict: summary: IDEMPOTENCY_CONFLICT value: @@ -63058,6 +67381,30 @@ paths: message: A request with this Idempotency-Key is still executing. suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency + "422": + description: "`CONTENT_OPERATION_FAILED`: The media operation could not be completed for the given input." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + contentOperationFailed: + summary: CONTENT_OPERATION_FAILED + value: + error: + code: CONTENT_OPERATION_FAILED + type: invalid_request + message: The media operation could not be completed for the given input. + suggestion: Check the input described in the message and retry. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -63072,7 +67419,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 5 + example: 20 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -63113,7 +67460,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 5 + limit: 20 remaining: 0 reset: 1712592360 consumed: false @@ -63172,35 +67519,47 @@ paths: message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. suggestion: Retry the request after a short delay. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: brands - x-brew-brand-scope: org + x-brew-scope: emails + x-brew-brand-scope: brand x-brew-idempotency: replay - x-brew-credited: false - x-brew-rate-policy: brands.write - /v1/brands/{brandId}: - get: - operationId: getBrandById + x-brew-credited: true + x-brew-rate-policy: content.generate + /v1/content/gif: + post: + operationId: createGif tags: - - Brands - summary: Get one brand’s lifecycle state - description: |- - The polling endpoint for `POST /v1/brands`. Reports `status`, `progress` and `phase` while extracting, and `error` when extraction failed. Terminal states are `completed` and `failed`. - - For the brand’s DESIGN CONTEXT (identity, markdown design system, logos) use `GET /v1/brand` instead. + - Content + summary: Generate a GIF + description: "Produces a looping animated GIF (5-second source clip), routed by the `from` discriminator: `prompt` generates an on-brand still then animates it, `image` animates a source image (both via the AI gif workflow), and `video` converts a source MP4. For `prompt` and `image` sources, aspect ratio is one of `16:9`, `9:16`, `1:1` (default `16:9`), and the GIF has that shape: a source image of another shape is centre-cropped when that keeps at least 75% of it, otherwise padded in its own edge colour, never stretched. `imageUrl` takes any public PNG, JPEG, WebP, GIF (first frame), AVIF or TIFF up to 50 MB and 268 megapixels, of any size or shape; SVG, HEIC and BMP return `422 CONTENT_OPERATION_FAILED`. For `video`, aspectRatio is not accepted; use width and fps for conversion. Returns CDN-hosted `gifUrl` (plus `videoUrl`, `width`/`height` and motion metadata for the AI sources). Credit-metered (fixed cost: prompt 20, image 10, video 10)." parameters: - - schema: + - name: Idempotency-Key + in: header + required: false + description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + schema: + type: string + minLength: 1 + maxLength: 100 + example: api-request-2026-04-08-001 + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: type: string minLength: 1 maxLength: 64 - description: Brand id (opaque Convex document id) returned by `POST /v1/brands` and listed by `GET /v1/brands`. - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - required: true - description: Brand id (opaque Convex document id) returned by `POST /v1/brands` and listed by `GET /v1/brands`. - name: brandId - in: path + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + description: 'A `from` discriminator: `{ from:"prompt", prompt, … }`, `{ from:"image", imageUrl, … }`, or `{ from:"video", videoUrl, … }`.' + content: + application/json: + schema: + $ref: "#/components/schemas/ContentGifRequest" responses: "200": - description: The brand. + description: The operation result. headers: x-request-id: schema: @@ -63233,18 +67592,12 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/BrandGetByIdResponse" - example: - brandId: kx9d1p2qbrxy4nkm55ftz3lmvn21abcd - domain: acme.com - status: extracting - ready: false - progress: 0 - phase: analyzing - createdAt: '2026-04-08T12:00:00.000Z' - updatedAt: '2026-04-08T12:00:00.000Z' + $ref: "#/components/schemas/ContentGifResponse" "400": - description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: schema: @@ -63258,6 +67611,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -63314,8 +67676,43 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication + "402": + description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + insufficientCredits: + summary: INSUFFICIENT_CREDITS + value: + error: + code: INSUFFICIENT_CREDITS + type: payment_required + message: The credit balance is below what this operation requires; `details.cost` is the amount needed. + suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. + docs: https://docs.brew.new/api-reference/api/credits + details: + cost: 5 + remaining: 0 + planKey: free "403": - description: "`ACCOUNT_SUSPENDED`: The organization behind the credential is suspended." + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). headers: x-request-id: schema: @@ -63338,6 +67735,33 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication "404": description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." headers: @@ -63362,6 +67786,66 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + "409": + description: |- + `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + + `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + idempotencyConflict: + summary: IDEMPOTENCY_CONFLICT + value: + error: + code: IDEMPOTENCY_CONFLICT + type: conflict + message: The same Idempotency-Key was reused with a different request body. + suggestion: Reuse the original payload or send a new idempotency key. + docs: https://docs.brew.new/api-reference/api/idempotency + idempotencyInProgress: + summary: IDEMPOTENCY_IN_PROGRESS + value: + error: + code: IDEMPOTENCY_IN_PROGRESS + type: conflict + message: A request with this Idempotency-Key is still executing. + suggestion: Wait for the original request to finish, then retry the same key to replay its response. + docs: https://docs.brew.new/api-reference/api/idempotency + "422": + description: "`CONTENT_OPERATION_FAILED`: The media operation could not be completed for the given input." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + contentOperationFailed: + summary: CONTENT_OPERATION_FAILED + value: + error: + code: CONTENT_OPERATION_FAILED + type: invalid_request + message: The media operation could not be completed for the given input. + suggestion: Check the input described in the message and retry. + docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -63376,7 +67860,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 20 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -63417,7 +67901,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 20 remaining: 0 reset: 1712592360 consumed: false @@ -63445,18 +67929,49 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: none - x-brew-brand-scope: org - x-brew-idempotency: none - x-brew-credited: false - x-brew-rate-policy: brands.read - /v1/content/generate-image: + "503": + description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + Retry-After: + schema: + type: integer + description: Seconds to wait before retrying the request. + example: 42 + required: true + description: Seconds to wait before retrying the request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + serviceUnavailable: + summary: SERVICE_UNAVAILABLE + value: + error: + code: SERVICE_UNAVAILABLE + type: service_unavailable + message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. + suggestion: Retry the request after a short delay. + docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: replay + x-brew-credited: true + x-brew-rate-policy: content.generate + /v1/content/transform: post: - operationId: generateImage + operationId: transformImage tags: - Content - summary: Generate an image - description: "Generates an image via the Brew AI image pipeline. `text-to-image` (default) creates from a prompt; `image-editing` edits `image1` (required) guided by the prompt. Returns a CDN-hosted URL, and saves the image to the brand’s generated images (`GET /v1/brand/images?kind=generated`). Usage-metered: charges the actual image gateway cost (no fixed price)." + summary: Transform an image + description: "Deterministic image transform routed by the `operation` discriminator: `optimize` rehosts a palette PNG (resize ≤1200px), `resize` produces exact dimensions via the vision-guided fal pipeline (Sharp cover-crop fallback), `remove_background` returns a transparent PNG cutout (Bria RMBG 2.0). Returns the CDN-hosted `url` + dimensions. Credit-metered (fixed cost: optimize 1, resize 2, remove_background 1)." parameters: - name: Idempotency-Key in: header @@ -63478,11 +67993,11 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg requestBody: required: true - description: Prompt + optional mode / aspect ratio / source images. + description: 'An `operation` discriminator: `{ operation:"optimize", imageUrl }`, `{ operation:"resize", imageUrl, width, height, … }`, or `{ operation:"remove_background", imageUrl }`.' content: application/json: schema: - $ref: "#/components/schemas/ContentGenerateImageRequest" + $ref: "#/components/schemas/ContentTransformRequest" responses: "200": description: The operation result. @@ -63518,7 +68033,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContentImageResponse" + $ref: "#/components/schemas/ContentTransformResponse" "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -63786,7 +68301,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -63827,7 +68342,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -63890,14 +68405,14 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: true - x-brew-rate-policy: content.generate - /v1/content/gif: + x-brew-rate-policy: content.transform + /v1/content/html-to-png: post: - operationId: createGif + operationId: htmlToPng tags: - Content - summary: Generate a GIF - description: "Produces a looping animated GIF (5-second source clip), routed by the `from` discriminator: `prompt` generates an on-brand still then animates it, `image` animates a source image (both via the AI gif workflow), and `video` converts a source MP4. For `prompt` and `image` sources, aspect ratio is one of `16:9`, `9:16`, `1:1` (default `16:9`), and the GIF has that shape: a source image of another shape is centre-cropped when that keeps at least 75% of it, otherwise padded in its own edge colour, never stretched. `imageUrl` takes any public PNG, JPEG, WebP, GIF (first frame), AVIF or TIFF up to 50 MB and 268 megapixels, of any size or shape; SVG, HEIC and BMP return `422 CONTENT_OPERATION_FAILED`. For `video`, aspectRatio is not accepted; use width and fps for conversion. Returns CDN-hosted `gifUrl` (plus `videoUrl`, `width`/`height` and motion metadata for the AI sources). Credit-metered (fixed cost: prompt 20, image 10, video 10)." + summary: Render HTML to a PNG + description: Renders an HTML document to a PNG screenshot hosted on the CDN. Credit-metered (fixed cost). parameters: - name: Idempotency-Key in: header @@ -63919,11 +68434,11 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg requestBody: required: true - description: 'A `from` discriminator: `{ from:"prompt", prompt, … }`, `{ from:"image", imageUrl, … }`, or `{ from:"video", videoUrl, … }`.' + description: Full `html` + optional viewport width / max height. content: application/json: schema: - $ref: "#/components/schemas/ContentGifRequest" + $ref: "#/components/schemas/ContentHtmlToPngRequest" responses: "200": description: The operation result. @@ -63959,7 +68474,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContentGifResponse" + $ref: "#/components/schemas/ContentPngResponse" "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -64227,7 +68742,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 20 + example: 60 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -64268,7 +68783,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 20 + limit: 60 remaining: 0 reset: 1712592360 consumed: false @@ -64331,14 +68846,25 @@ paths: x-brew-brand-scope: brand x-brew-idempotency: replay x-brew-credited: true - x-brew-rate-policy: content.generate - /v1/content/transform: + x-brew-rate-policy: content.transform + /v1/content/add-image: post: - operationId: transformImage + operationId: addImage tags: - Content - summary: Transform an image - description: "Deterministic image transform routed by the `operation` discriminator: `optimize` rehosts a palette PNG (resize ≤1200px), `resize` produces exact dimensions via the vision-guided fal pipeline (Sharp cover-crop fallback), `remove_background` returns a transparent PNG cutout (Bria RMBG 2.0). Returns the CDN-hosted `url` + dimensions. Credit-metered (fixed cost: optimize 1, resize 2, remove_background 1)." + summary: Add image + description: |- + Adds an image to the brand image library: fetches or reads it, converts it, stores it, and vector-indexes it so the email agent can find it. Free; no credits are charged. + + **Use when** a public image URL, a batch of them, or a local file should be reusable in designs and searches. + + **Input** exactly one of `imageUrl` (one public URL, synchronous), `imageUrls` (1 to 100 public URLs, a durable background import) or `uploadId` (a local file whose bytes were sent to the `uploadUrl` from `createImageUpload`, synchronous). + + **Returns** `200` with `{ url, width, height, aspectRatio, assetId }` for `imageUrl` and `uploadId`; `202` with `{ accepted, skipped, runId }` for `imageUrls`. An image whose size could not be measured answers `width` and `height` `0` and `aspectRatio` `unknown`. Repeating an `uploadId` call returns the same answer for 24 hours, even if the image was deleted since. + + **Errors** `422 CONTENT_OPERATION_FAILED` when the image cannot be fetched, decoded or saved (for an upload: bytes that are not PNG, JPEG, GIF, WebP, AVIF, TIFF or SVG). For `uploadId`: `404 UPLOAD_NOT_FOUND` (unknown, expired or another brand), `409 UPLOAD_NOT_RECEIVED` (the bytes were never sent), `409 UPLOAD_IN_PROGRESS` (another call is converting it; retry shortly) and `413 PAYLOAD_TOO_LARGE` (over 20 MB, or an SVG over 2 MB). + + **See also** `createImageUpload`, `getBrandImages`, `deleteBrandImage`. parameters: - name: Idempotency-Key in: header @@ -64360,14 +68886,14 @@ paths: example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg requestBody: required: true - description: 'An `operation` discriminator: `{ operation:"optimize", imageUrl }`, `{ operation:"resize", imageUrl, width, height, … }`, or `{ operation:"remove_background", imageUrl }`.' + description: Pass one public `imageUrl` for a synchronous import, `imageUrls` with 1 to 100 public URLs for a durable batch import, or the `uploadId` of a local file sent through `POST /v1/content/image-uploads`. content: application/json: schema: - $ref: "#/components/schemas/ContentTransformRequest" + $ref: "#/components/schemas/ContentAddImageRequest" responses: "200": - description: The operation result. + description: The image was optimized, saved to the brand library, and rehosted; `url` is a durable cdn.brew.new URL. headers: x-request-id: schema: @@ -64400,7 +68926,52 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContentTransformResponse" + $ref: "#/components/schemas/ContentAddImageResponse" + example: + url: https://cdn.brew.new/cnt/1d943539cbe0cc60.png + width: 1200 + height: 675 + aspectRatio: wide + assetId: 5f750e5f + "202": + description: The batch was accepted for durable import; images appear in the brand library progressively. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + X-RateLimit-Limit: + schema: + type: integer + description: Requests allowed in the current rolling rate limit window. + example: 100 + required: true + description: Requests allowed in the current rolling rate limit window. + X-RateLimit-Remaining: + schema: + type: integer + description: Requests remaining in the current rolling rate limit window. + example: 99 + required: true + description: Requests remaining in the current rolling rate limit window. + X-RateLimit-Reset: + schema: + type: integer + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + example: 1712592360 + required: true + description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. + content: + application/json: + schema: + $ref: "#/components/schemas/ContentAddImageBatchResponse" + example: + accepted: 3 + skipped: 1 + runId: brand-image-import-01JZ9X8Y7W6V5T4S3R2Q1P0N9M "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -64484,34 +69055,6 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication - "402": - description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - insufficientCredits: - summary: INSUFFICIENT_CREDITS - value: - error: - code: INSUFFICIENT_CREDITS - type: payment_required - message: The credit balance is below what this operation requires; `details.cost` is the amount needed. - suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. - docs: https://docs.brew.new/api-reference/api/credits - details: - cost: 5 - remaining: 0 - planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -64571,7 +69114,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `UPLOAD_NOT_FOUND`: The image upload is unknown, expired, or belongs to another brand. headers: x-request-id: schema: @@ -64594,11 +69140,24 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + uploadNotFound: + summary: UPLOAD_NOT_FOUND + value: + error: + code: UPLOAD_NOT_FOUND + type: not_found + message: The image upload is unknown, expired, or belongs to another brand. + suggestion: Open a new upload with POST /v1/content/image-uploads, send the file, then add it within 15 minutes. + docs: https://docs.brew.new/api-reference/api/errors "409": description: |- `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + + `UPLOAD_IN_PROGRESS`: Another request is still adding this uploaded image. + + `UPLOAD_NOT_RECEIVED`: The image upload has not received its file yet. headers: x-request-id: schema: @@ -64630,6 +69189,48 @@ paths: message: A request with this Idempotency-Key is still executing. suggestion: Wait for the original request to finish, then retry the same key to replay its response. docs: https://docs.brew.new/api-reference/api/idempotency + uploadInProgress: + summary: UPLOAD_IN_PROGRESS + value: + error: + code: UPLOAD_IN_PROGRESS + type: conflict + message: Another request is still adding this uploaded image. + suggestion: Wait a few seconds, then call POST /v1/content/add-image with the same uploadId to receive its result. + docs: https://docs.brew.new/api-reference/api/errors + uploadNotReceived: + summary: UPLOAD_NOT_RECEIVED + value: + error: + code: UPLOAD_NOT_RECEIVED + type: conflict + message: The image upload has not received its file yet. + suggestion: POST the raw file bytes to the uploadUrl, then call POST /v1/content/add-image with the same uploadId. + docs: https://docs.brew.new/api-reference/api/errors + "413": + description: "`PAYLOAD_TOO_LARGE`: The request body exceeds the route cap." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + payloadTooLarge: + summary: PAYLOAD_TOO_LARGE + value: + error: + code: PAYLOAD_TOO_LARGE + type: invalid_request + message: The request body exceeds the route cap. + suggestion: Reduce the payload size and retry. + docs: https://docs.brew.new/api-reference/api/errors "422": description: "`CONTENT_OPERATION_FAILED`: The media operation could not be completed for the given input." headers: @@ -64737,78 +69338,39 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: emails x-brew-brand-scope: brand x-brew-idempotency: replay - x-brew-credited: true + x-brew-credited: false x-brew-rate-policy: content.transform - /v1/content/html-to-png: + /v1/content/image-uploads: post: - operationId: htmlToPng + operationId: createImageUpload tags: - Content - summary: Render HTML to a PNG - description: Renders an HTML document to a PNG screenshot hosted on the CDN. Credit-metered (fixed cost). - parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: - type: string - minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 - - name: X-Brand-Id - in: header - required: false - description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - schema: - type: string - minLength: 1 - maxLength: 64 - example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + summary: Create an image upload + description: |- + Opens a single-use upload for one local image file. POST the file's raw bytes to the returned `uploadUrl`, then call `addImage` with the `uploadId` to put it in the brand library. The bytes go straight to storage, so files up to 20 MB fit even though a JSON body is capped far lower. Free; no credits are charged. + + **Use when** the image is a file on your machine rather than a public URL (a public URL goes straight to `addImage` as `imageUrl`). + + **Input** `fileName`, `contentType` (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/tiff` or `image/svg+xml`) and `size` in bytes (at most 20,000,000; 2,097,152 for SVG). + + **Returns** `201` with `{ uploadId, uploadUrl, expiresAt, maxBytes }`. Within 15 minutes, send the bytes: `curl -X POST --data-binary @logo.png ""` answers `200` with `{ uploadId, status: "uploaded", size, expiresAt }`; call `addImage` before that `expiresAt` (15 minutes from when the bytes land) (`404 UPLOAD_NOT_FOUND` for an unknown or expired URL, `413 PAYLOAD_TOO_LARGE` over `maxBytes`). The URL carries its own credential; keep it private. Sending again never replaces the first file. + + **Errors** `400 INVALID_REQUEST` for an unsupported `contentType` or a `size` over the cap; `429 RATE_LIMITED` also when the brand already has 20 uploads open (its message says so, and `Retry-After` is when the oldest expires). + + **See also** `addImage`, `getBrandImages`. requestBody: required: true - description: Full `html` + optional viewport width / max height. + description: "The file to upload: its name, type and size in bytes." content: application/json: schema: - $ref: "#/components/schemas/ContentHtmlToPngRequest" + $ref: "#/components/schemas/ContentImageUploadCreateRequest" responses: - "200": - description: The operation result. + "201": + description: The upload is open. POST the raw file bytes to `uploadUrl`, then call `POST /v1/content/add-image` with `uploadId`. headers: x-request-id: schema: @@ -64841,7 +69403,12 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContentPngResponse" + $ref: "#/components/schemas/ContentImageUploadCreateResponse" + example: + uploadId: imgup_V1StGXR8_Z5jdHi6B-myT + uploadUrl: https://example-deployment-123.convex.site/uploads/brand-image?uploadId=imgup_V1StGXR8_Z5jdHi6B-myT&token=9b1c4f6e2a7d8e3f0a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f + expiresAt: '2026-10-01T18:15:00.000Z' + maxBytes: 20000000 "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -64925,34 +69492,6 @@ paths: message: The API key is malformed or unknown. suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication - "402": - description: "`INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - insufficientCredits: - summary: INSUFFICIENT_CREDITS - value: - error: - code: INSUFFICIENT_CREDITS - type: payment_required - message: The credit balance is below what this operation requires; `details.cost` is the amount needed. - suggestion: Upgrade your plan or wait for the next billing period to reset. Check your balance up front with GET /v1/usage. - docs: https://docs.brew.new/api-reference/api/credits - details: - cost: 5 - remaining: 0 - planKey: free "403": description: |- `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. @@ -65035,66 +69574,6 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - "422": - description: "`CONTENT_OPERATION_FAILED`: The media operation could not be completed for the given input." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - contentOperationFailed: - summary: CONTENT_OPERATION_FAILED - value: - error: - code: CONTENT_OPERATION_FAILED - type: invalid_request - message: The media operation could not be completed for the given input. - suggestion: Check the input described in the message and retry. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -65178,70 +69657,7 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - "503": - description: "`SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - Retry-After: - schema: - type: integer - description: Seconds to wait before retrying the request. - example: 42 - required: true - description: Seconds to wait before retrying the request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - serviceUnavailable: - summary: SERVICE_UNAVAILABLE - value: - error: - code: SERVICE_UNAVAILABLE - type: service_unavailable - message: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. - suggestion: Retry the request after a short delay. - docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails - x-brew-brand-scope: brand - x-brew-idempotency: replay - x-brew-credited: true - x-brew-rate-policy: content.transform - /v1/content/add-image: - post: - operationId: addImage - tags: - - Content - summary: Add image - description: |- - Adds an image to the brand image library: fetches or reads it, converts it, stores it, and vector-indexes it so the email agent can find it. Free; no credits are charged. - - **Use when** a public image URL, a batch of them, or a local file should be reusable in designs and searches. - - **Input** exactly one of `imageUrl` (one public URL, synchronous), `imageUrls` (1 to 100 public URLs, a durable background import) or `uploadId` (a local file whose bytes were sent to the `uploadUrl` from `createImageUpload`, synchronous). - - **Returns** `200` with `{ url, width, height, aspectRatio, assetId }` for `imageUrl` and `uploadId`; `202` with `{ accepted, skipped, runId }` for `imageUrls`. An image whose size could not be measured answers `width` and `height` `0` and `aspectRatio` `unknown`. Repeating an `uploadId` call returns the same answer for 24 hours, even if the image was deleted since. - - **Errors** `422 CONTENT_OPERATION_FAILED` when the image cannot be fetched, decoded or saved (for an upload: bytes that are not PNG, JPEG, GIF, WebP, AVIF, TIFF or SVG). For `uploadId`: `404 UPLOAD_NOT_FOUND` (unknown, expired or another brand), `409 UPLOAD_NOT_RECEIVED` (the bytes were never sent), `409 UPLOAD_IN_PROGRESS` (another call is converting it; retry shortly) and `413 PAYLOAD_TOO_LARGE` (over 20 MB, or an SVG over 2 MB). - - **See also** `createImageUpload`, `getBrandImages`, `deleteBrandImage`. parameters: - - name: Idempotency-Key - in: header - required: false - description: Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. - schema: - type: string - minLength: 1 - maxLength: 100 - example: api-request-2026-04-08-001 - name: X-Brand-Id in: header required: false @@ -65251,57 +69667,21 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - requestBody: - required: true - description: Pass one public `imageUrl` for a synchronous import, `imageUrls` with 1 to 100 public URLs for a durable batch import, or the `uploadId` of a local file sent through `POST /v1/content/image-uploads`. - content: - application/json: - schema: - $ref: "#/components/schemas/ContentAddImageRequest" + x-brew-scope: emails + x-brew-brand-scope: brand + x-brew-idempotency: disabled + x-brew-credited: false + x-brew-rate-policy: content.transform + /v1/usage: + get: + operationId: getUsage + tags: + - Usage + summary: Get usage + description: "Returns the org usage snapshot: current plan, AI credit balance (limit/used/remaining — `null` = unlimited), monthly email-send quota (`remaining` = `limit` − `used` − `reserved`, where `reserved` is held by sends still delivering), and the billing-period window. Check it before a credit-metered call. Billing belongs to the ORGANIZATION, so this needs an organization-scoped credential: a key bound to one brand gets `403 ORG_SCOPE_REQUIRED` no matter which scopes it holds." responses: "200": - description: The image was optimized, saved to the brand library, and rehosted; `url` is a durable cdn.brew.new URL. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - X-RateLimit-Limit: - schema: - type: integer - description: Requests allowed in the current rolling rate limit window. - example: 100 - required: true - description: Requests allowed in the current rolling rate limit window. - X-RateLimit-Remaining: - schema: - type: integer - description: Requests remaining in the current rolling rate limit window. - example: 99 - required: true - description: Requests remaining in the current rolling rate limit window. - X-RateLimit-Reset: - schema: - type: integer - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - example: 1712592360 - required: true - description: Unix timestamp in seconds when the oldest recorded request leaves the rolling window. - content: - application/json: - schema: - $ref: "#/components/schemas/ContentAddImageResponse" - example: - url: https://cdn.brew.new/cnt/1d943539cbe0cc60.png - width: 1200 - height: 675 - aspectRatio: wide - assetId: 5f750e5f - "202": - description: The batch was accepted for durable import; images appear in the brand library progressively. + description: Usage snapshot — plan, credits, and email-send quota. headers: x-request-id: schema: @@ -65334,16 +69714,25 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContentAddImageBatchResponse" + $ref: "#/components/schemas/UsageGetResponse" example: - accepted: 3 - skipped: 1 - runId: brand-image-import-01JZ9X8Y7W6V5T4S3R2Q1P0N9M + plan: + key: pro + name: Pro + credits: + limit: 50000 + used: 364 + remaining: 49636 + emailSends: + limit: 200000 + used: 911 + reserved: 27880 + remaining: 171209 + period: + start: '2026-05-28T18:10:35.000Z' + end: '2026-06-28T18:10:35.000Z' "400": - description: |- - `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. - - `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." headers: x-request-id: schema: @@ -65357,15 +69746,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: - brandIdRequired: - summary: BRAND_ID_REQUIRED - value: - error: - code: BRAND_ID_REQUIRED - type: invalid_request - message: An organization-scoped credential called a brand-scoped operation without naming the brand. - suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. - docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -65423,14 +69803,7 @@ paths: suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication "403": - description: |- - `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. - - `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. - - `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. - - `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + description: "`ACCOUNT_SUSPENDED`: The organization behind the credential is suspended." headers: x-request-id: schema: @@ -65453,175 +69826,6 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication - brandScopeMismatch: - summary: BRAND_SCOPE_MISMATCH - value: - error: - code: BRAND_SCOPE_MISMATCH - type: authorization_error - message: A brand-scoped credential named a brand other than the one it is bound to. - suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientPermissions: - summary: INSUFFICIENT_PERMISSIONS - value: - error: - code: INSUFFICIENT_PERMISSIONS - type: authorization_error - message: The credential lacks the permission scope the operation needs. - suggestion: Use an API key or session with the required permission. - docs: https://docs.brew.new/api-reference/api/authentication - insufficientRole: - summary: INSUFFICIENT_ROLE - value: - error: - code: INSUFFICIENT_ROLE - type: authorization_error - message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." - suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. - docs: https://docs.brew.new/api-reference/api/authentication - "404": - description: |- - `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - - `UPLOAD_NOT_FOUND`: The image upload is unknown, expired, or belongs to another brand. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - brandNotFound: - summary: BRAND_NOT_FOUND - value: - error: - code: BRAND_NOT_FOUND - type: not_found - message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - suggestion: List the brands this credential can reach with GET /v1/brands. - docs: https://docs.brew.new/api-reference/api/errors - uploadNotFound: - summary: UPLOAD_NOT_FOUND - value: - error: - code: UPLOAD_NOT_FOUND - type: not_found - message: The image upload is unknown, expired, or belongs to another brand. - suggestion: Open a new upload with POST /v1/content/image-uploads, send the file, then add it within 15 minutes. - docs: https://docs.brew.new/api-reference/api/errors - "409": - description: |- - `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - - `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. - - `UPLOAD_IN_PROGRESS`: Another request is still adding this uploaded image. - - `UPLOAD_NOT_RECEIVED`: The image upload has not received its file yet. - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - idempotencyConflict: - summary: IDEMPOTENCY_CONFLICT - value: - error: - code: IDEMPOTENCY_CONFLICT - type: conflict - message: The same Idempotency-Key was reused with a different request body. - suggestion: Reuse the original payload or send a new idempotency key. - docs: https://docs.brew.new/api-reference/api/idempotency - idempotencyInProgress: - summary: IDEMPOTENCY_IN_PROGRESS - value: - error: - code: IDEMPOTENCY_IN_PROGRESS - type: conflict - message: A request with this Idempotency-Key is still executing. - suggestion: Wait for the original request to finish, then retry the same key to replay its response. - docs: https://docs.brew.new/api-reference/api/idempotency - uploadInProgress: - summary: UPLOAD_IN_PROGRESS - value: - error: - code: UPLOAD_IN_PROGRESS - type: conflict - message: Another request is still adding this uploaded image. - suggestion: Wait a few seconds, then call POST /v1/content/add-image with the same uploadId to receive its result. - docs: https://docs.brew.new/api-reference/api/errors - uploadNotReceived: - summary: UPLOAD_NOT_RECEIVED - value: - error: - code: UPLOAD_NOT_RECEIVED - type: conflict - message: The image upload has not received its file yet. - suggestion: POST the raw file bytes to the uploadUrl, then call POST /v1/content/add-image with the same uploadId. - docs: https://docs.brew.new/api-reference/api/errors - "413": - description: "`PAYLOAD_TOO_LARGE`: The request body exceeds the route cap." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - payloadTooLarge: - summary: PAYLOAD_TOO_LARGE - value: - error: - code: PAYLOAD_TOO_LARGE - type: invalid_request - message: The request body exceeds the route cap. - suggestion: Reduce the payload size and retry. - docs: https://docs.brew.new/api-reference/api/errors - "422": - description: "`CONTENT_OPERATION_FAILED`: The media operation could not be completed for the given input." - headers: - x-request-id: - schema: - type: string - description: Unique request identifier. Share this with support when debugging a request. - example: req_8cac13fd94e6420cacdd75a1aa403a28 - required: true - description: Unique request identifier. Share this with support when debugging a request. - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorEnvelope" - examples: - contentOperationFailed: - summary: CONTENT_OPERATION_FAILED - value: - error: - code: CONTENT_OPERATION_FAILED - type: invalid_request - message: The media operation could not be completed for the given input. - suggestion: Check the input described in the message and retry. - docs: https://docs.brew.new/api-reference/api/errors "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -65636,7 +69840,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -65677,7 +69881,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -65705,39 +69909,49 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: emails - x-brew-brand-scope: brand - x-brew-idempotency: replay + x-brew-scope: none + x-brew-brand-scope: org + x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: content.transform - /v1/content/image-uploads: - post: - operationId: createImageUpload + x-brew-rate-policy: usage.read + /v1/integrations: + get: + operationId: listIntegrations tags: - - Content - summary: Create an image upload - description: |- - Opens a single-use upload for one local image file. POST the file's raw bytes to the returned `uploadUrl`, then call `addImage` with the `uploadId` to put it in the brand library. The bytes go straight to storage, so files up to 20 MB fit even though a JSON body is capped far lower. Free; no credits are charged. - - **Use when** the image is a file on your machine rather than a public URL (a public URL goes straight to `addImage` as `imageUrl`). - - **Input** `fileName`, `contentType` (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`, `image/tiff` or `image/svg+xml`) and `size` in bytes (at most 20,000,000; 2,097,152 for SVG). - - **Returns** `201` with `{ uploadId, uploadUrl, expiresAt, maxBytes }`. Within 15 minutes, send the bytes: `curl -X POST --data-binary @logo.png ""` answers `200` with `{ uploadId, status: "uploaded", size, expiresAt }`; call `addImage` before that `expiresAt` (15 minutes from when the bytes land) (`404 UPLOAD_NOT_FOUND` for an unknown or expired URL, `413 PAYLOAD_TOO_LARGE` over `maxBytes`). The URL carries its own credential; keep it private. Sending again never replaces the first file. - - **Errors** `400 INVALID_REQUEST` for an unsupported `contentType` or a `size` over the cap; `429 RATE_LIMITED` also when the brand already has 20 uploads open (its message says so, and `Retry-After` is when the oldest expires). - - **See also** `addImage`, `getBrandImages`. - requestBody: - required: true - description: "The file to upload: its name, type and size in bytes." - content: - application/json: - schema: - $ref: "#/components/schemas/ContentImageUploadCreateRequest" + - Integrations + summary: List integrations + description: Returns the product integration catalog for the brand in scope under `{ data, pagination }`, with a `connected` flag per provider. Canonical providers also expose active, ready installation counts and readiness per capability. Connecting is a human Settings hop at `/integrations/{provider}`; never paste a connect URL into model output. Brand-scoped (`X-Brand-Id` on an org credential). No permission scope. + parameters: + - schema: + type: integer + minimum: 1 + maximum: 100 + description: Page size (1-100). Defaults to 100. + example: 50 + required: false + description: Page size (1-100). Defaults to 100. + name: limit + in: query + example: 50 + - schema: + type: string + minLength: 1 + maxLength: 512 + required: false + name: cursor + in: query + - name: X-Brand-Id + in: header + required: false + description: The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + schema: + type: string + minLength: 1 + maxLength: 64 + example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg responses: - "201": - description: The upload is open. POST the raw file bytes to `uploadUrl`, then call `POST /v1/content/add-image` with `uploadId`. + "200": + description: Catalog rows for every known provider. headers: x-request-id: schema: @@ -65770,12 +69984,34 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ContentImageUploadCreateResponse" + $ref: "#/components/schemas/IntegrationsListResponse" example: - uploadId: imgup_V1StGXR8_Z5jdHi6B-myT - uploadUrl: https://example-deployment-123.convex.site/uploads/brand-image?uploadId=imgup_V1StGXR8_Z5jdHi6B-myT&token=9b1c4f6e2a7d8e3f0a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f - expiresAt: '2026-10-01T18:15:00.000Z' - maxBytes: 20000000 + data: + - provider: hubspot + name: HubSpot + category: data_out + connected: true + installationCount: 1 + capabilities: + - capabilityId: contacts_import + isReady: true + - capabilityId: crm_context + isReady: true + - capabilityId: export_template + isReady: true + - provider: klaviyo + name: Klaviyo + category: data_out + connected: true + - provider: typeform + name: Typeform + category: data_in + connected: false + comingSoon: true + pagination: + limit: 100 + cursor: null + hasMore: false "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -65918,7 +70154,10 @@ paths: suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. docs: https://docs.brew.new/api-reference/api/authentication "404": - description: "`BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization)." + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `NOT_FOUND`: No v1 resource lives at this path. headers: x-request-id: schema: @@ -65941,6 +70180,15 @@ paths: message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). suggestion: List the brands this credential can reach with GET /v1/brands. docs: https://docs.brew.new/api-reference/api/errors + notFound: + summary: NOT_FOUND + value: + error: + code: NOT_FOUND + type: not_found + message: No v1 resource lives at this path. + suggestion: Check the request path and HTTP method against the API reference. + docs: https://docs.brew.new/api-reference/api/api-introduction "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -65955,7 +70203,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 60 + example: 100 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -65996,7 +70244,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 60 + limit: 100 remaining: 0 reset: 1712592360 consumed: false @@ -66024,7 +70272,37 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors + x-brew-scope: none + x-brew-brand-scope: brand + x-brew-idempotency: none + x-brew-credited: false + x-brew-rate-policy: integrations.read + /v1/integrations/{provider}/connection: + get: + operationId: getIntegrationConnection + tags: + - Integrations + summary: Get a hosted auth hook connection + description: |- + Reads the brand’s connection for a hosted auth email hook: the provider that hands your app’s auth emails to Brew. + + **Returns** `200` with `provider`, `connectionId`, the `webhookUrl` the provider delivers to and `status` (`pending_verification` until the first signed delivery, then `active`). + + **Errors** `404 NOT_FOUND` when the provider is not connected for the brand. + + **See also** `connectIntegrationHook`, `listIntegrations`. parameters: + - schema: + type: string + enum: + - supabase_auth + - clerk + description: "`supabase_auth` (the Supabase Auth Send Email Hook) or `clerk` (Clerk webhooks; `email.created` carries the auth emails)." + example: supabase_auth + required: true + description: "`supabase_auth` (the Supabase Auth Send Email Hook) or `clerk` (Clerk webhooks; `email.created` carries the auth emails)." + name: provider + in: path - name: X-Brand-Id in: header required: false @@ -66034,21 +70312,9 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg - x-brew-scope: emails - x-brew-brand-scope: brand - x-brew-idempotency: disabled - x-brew-credited: false - x-brew-rate-policy: content.transform - /v1/usage: - get: - operationId: getUsage - tags: - - Usage - summary: Get usage - description: "Returns the org usage snapshot: current plan, AI credit balance (limit/used/remaining — `null` = unlimited), monthly email-send quota (`remaining` = `limit` − `used` − `reserved`, where `reserved` is held by sends still delivering), and the billing-period window. Check it before a credit-metered call. Billing belongs to the ORGANIZATION, so this needs an organization-scoped credential: a key bound to one brand gets `403 ORG_SCOPE_REQUIRED` no matter which scopes it holds." responses: "200": - description: Usage snapshot — plan, credits, and email-send quota. + description: The connection. headers: x-request-id: schema: @@ -66081,25 +70347,17 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/UsageGetResponse" + $ref: "#/components/schemas/IntegrationConnection" example: - plan: - key: pro - name: Pro - credits: - limit: 50000 - used: 364 - remaining: 49636 - emailSends: - limit: 200000 - used: 911 - reserved: 27880 - remaining: 171209 - period: - start: '2026-05-28T18:10:35.000Z' - end: '2026-06-28T18:10:35.000Z' + provider: supabase_auth + connectionId: iac_Q2tX9vL1mB7rN4cP8wZsA + webhookUrl: https://brew.new/api/integrations/supabase_auth/webhook/iac_Q2tX9vL1mB7rN4cP8wZsA + status: pending_verification "400": - description: "`INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender." + description: |- + `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + + `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. headers: x-request-id: schema: @@ -66113,6 +70371,15 @@ paths: schema: $ref: "#/components/schemas/ApiErrorEnvelope" examples: + brandIdRequired: + summary: BRAND_ID_REQUIRED + value: + error: + code: BRAND_ID_REQUIRED + type: invalid_request + message: An organization-scoped credential called a brand-scoped operation without naming the brand. + suggestion: List brands with GET /v1/brands, then name one with the X-Brand-Id header. There is no default brand. + docs: https://docs.brew.new/api-reference/api/authentication invalidRequest: summary: INVALID_REQUEST value: @@ -66170,7 +70437,14 @@ paths: suggestion: Check the API key format and retry with a valid active key. docs: https://docs.brew.new/api-reference/api/authentication "403": - description: "`ACCOUNT_SUSPENDED`: The organization behind the credential is suspended." + description: |- + `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + + `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + + `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + + `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). headers: x-request-id: schema: @@ -66193,6 +70467,69 @@ paths: message: The organization behind the credential is suspended. suggestion: The organization behind this credential is suspended. Contact support@brew.new; do not retry. docs: https://docs.brew.new/api-reference/api/authentication + brandScopeMismatch: + summary: BRAND_SCOPE_MISMATCH + value: + error: + code: BRAND_SCOPE_MISMATCH + type: authorization_error + message: A brand-scoped credential named a brand other than the one it is bound to. + suggestion: Omit the brand to use the one this credential is bound to, or use an organization-scoped credential to reach other brands. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientPermissions: + summary: INSUFFICIENT_PERMISSIONS + value: + error: + code: INSUFFICIENT_PERMISSIONS + type: authorization_error + message: The credential lacks the permission scope the operation needs. + suggestion: Use an API key or session with the required permission. + docs: https://docs.brew.new/api-reference/api/authentication + insufficientRole: + summary: INSUFFICIENT_ROLE + value: + error: + code: INSUFFICIENT_ROLE + type: authorization_error + message: "The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role)." + suggestion: Ask an organization admin to run this, to add you to the brand, or to make you an admin. + docs: https://docs.brew.new/api-reference/api/authentication + "404": + description: |- + `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + + `NOT_FOUND`: No v1 resource lives at this path. + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + brandNotFound: + summary: BRAND_NOT_FOUND + value: + error: + code: BRAND_NOT_FOUND + type: not_found + message: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + suggestion: List the brands this credential can reach with GET /v1/brands. + docs: https://docs.brew.new/api-reference/api/errors + notFound: + summary: NOT_FOUND + value: + error: + code: NOT_FOUND + type: not_found + message: No v1 resource lives at this path. + suggestion: Check the request path and HTTP method against the API reference. + docs: https://docs.brew.new/api-reference/api/api-introduction "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: @@ -66277,36 +70614,41 @@ paths: suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors x-brew-scope: none - x-brew-brand-scope: org + x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: usage.read - /v1/integrations: - get: - operationId: listIntegrations + x-brew-rate-policy: integrations.read + put: + operationId: connectIntegrationHook tags: - Integrations - summary: List integrations - description: Returns the product integration catalog for the brand in scope under `{ data, pagination }`, with a `connected` flag per provider. Canonical providers also expose active, ready installation counts and readiness per capability. Connecting is a human Settings hop at `/integrations/{provider}`; never paste a connect URL into model output. Brand-scoped (`X-Brand-Id` on an org credential). No permission scope. + summary: Connect a hosted auth hook + description: |- + Connects a hosted auth email hook for the brand and sets up its auth app emails, so the provider hands every auth email to Brew: the Supabase Auth Send Email Hook (`supabase_auth`) or Clerk’s `email.created` (`clerk`). + + **Input** `signingSecret`, the secret the provider signs deliveries with (Supabase: `v1,whsec_…` from Authentication > Hooks; Clerk: `whsec_…` from the endpoint). Supabase only: `settings.projectUrl`, the URL confirmation links point at (default: the one Supabase sends with each email). + + **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). Until the first signed delivery the connection is `pending_verification`. + + **Scopes** an API key needs `emails` and `automations` (`x-brew-scopes`): the call also runs the app email kit. + + **Returns** `200` with `provider`, `connectionId`, `webhookUrl`, `status` and `authEmails[]`: each auth key with `ok`, or its `error`; connecting again finishes a failed key. + + **Errors** `400 INVALID_REQUEST` for an unsupported provider, a secret in the wrong format or a project URL that is not https. + + **See also** `getIntegrationConnection`, `setUpAppEmailKit`, `listAppEmails`. parameters: - - schema: - type: integer - minimum: 1 - maximum: 100 - description: Page size (1-100). Defaults to 100. - example: 50 - required: false - description: Page size (1-100). Defaults to 100. - name: limit - in: query - example: 50 - schema: type: string - minLength: 1 - maxLength: 512 - required: false - name: cursor - in: query + enum: + - supabase_auth + - clerk + description: "`supabase_auth` (the Supabase Auth Send Email Hook) or `clerk` (Clerk webhooks; `email.created` carries the auth emails)." + example: supabase_auth + required: true + description: "`supabase_auth` (the Supabase Auth Send Email Hook) or `clerk` (Clerk webhooks; `email.created` carries the auth emails)." + name: provider + in: path - name: X-Brand-Id in: header required: false @@ -66316,9 +70658,26 @@ paths: minLength: 1 maxLength: 64 example: kx7b3s7fapqz8mjm12ekz1kxdx87yceg + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IntegrationConnectionPutRequest" + examples: + supabase: + summary: Supabase Send Email Hook + value: + signingSecret: v1,whsec_dGhpc2lzYW5leGFtcGxlc2VjcmV0Zm9yYnJldw== + settings: + projectUrl: https://abcd.supabase.co + clerk: + summary: Clerk email.created + value: + signingSecret: whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD responses: "200": - description: Catalog rows for every known provider. + description: Connected; the auth app emails are set up. headers: x-request-id: schema: @@ -66351,34 +70710,19 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/IntegrationsListResponse" + $ref: "#/components/schemas/IntegrationConnectionPutResponse" example: - data: - - provider: hubspot - name: HubSpot - category: data_out - connected: true - installationCount: 1 - capabilities: - - capabilityId: contacts_import - isReady: true - - capabilityId: crm_context - isReady: true - - capabilityId: export_template - isReady: true - - provider: klaviyo - name: Klaviyo - category: data_out - connected: true - - provider: typeform - name: Typeform - category: data_in - connected: false - comingSoon: true - pagination: - limit: 100 - cursor: null - hasMore: false + provider: supabase_auth + connectionId: iac_Q2tX9vL1mB7rN4cP8wZsA + webhookUrl: https://brew.new/api/integrations/supabase_auth/webhook/iac_Q2tX9vL1mB7rN4cP8wZsA + status: pending_verification + authEmails: + - triggerKey: auth.verify-email + ok: true + - triggerKey: auth.magic-link + ok: true + - triggerKey: auth.otp + ok: true "400": description: |- `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. @@ -66570,7 +70914,7 @@ paths: schema: type: integer description: Requests allowed in the current rolling rate limit window. - example: 100 + example: 10 required: true description: Requests allowed in the current rolling rate limit window. X-RateLimit-Remaining: @@ -66611,7 +70955,7 @@ paths: retryAfter: 42 details: scope: credential - limit: 100 + limit: 10 remaining: 0 reset: 1712592360 consumed: false @@ -66639,11 +70983,14 @@ paths: message: An unexpected failure; the x-request-id header identifies it. suggestion: Retry the request. If it keeps failing, contact support with the x-request-id header. docs: https://docs.brew.new/api-reference/api/errors - x-brew-scope: none + x-brew-scope: emails + x-brew-scopes: + - emails + - automations x-brew-brand-scope: brand x-brew-idempotency: none x-brew-credited: false - x-brew-rate-policy: integrations.read + x-brew-rate-policy: integrations.write /v1/api-keys: get: operationId: listApiKeys diff --git a/src/generated/input-keys.ts b/src/generated/input-keys.ts index a033d1fa..b0efd9d2 100644 --- a/src/generated/input-keys.ts +++ b/src/generated/input-keys.ts @@ -81,6 +81,13 @@ export const INPUT_KEYS: Readonly> = { "cursor", "limit" ], + "GET /v1/app-emails": [ + "cursor", + "limit" + ], + "GET /v1/app-emails/{triggerKey}": [ + "triggerKey" + ], "GET /v1/audiences": [ "cursor", "limit", @@ -296,6 +303,9 @@ export const INPUT_KEYS: Readonly> = { "cursor", "limit" ], + "GET /v1/integrations/{provider}/connection": [ + "provider" + ], "GET /v1/notifications": [ "cursor", "limit", @@ -361,6 +371,7 @@ export const INPUT_KEYS: Readonly> = { ], "PATCH /v1/automations/triggers/{triggerEventId}": [ "description", + "key", "payloadSchema", "title", "triggerEventId" @@ -403,6 +414,13 @@ export const INPUT_KEYS: Readonly> = { "permissions", "replacesKeyId" ], + "POST /v1/app-emails/kit": [ + "domainId", + "keys", + "overwrite", + "sets", + "stack" + ], "POST /v1/audiences": [ "filters", "name" @@ -449,6 +467,7 @@ export const INPUT_KEYS: Readonly> = { ], "POST /v1/automations/triggers": [ "description", + "key", "payloadSchema", "title" ], @@ -466,6 +485,10 @@ export const INPUT_KEYS: Readonly> = { "event", "scope" ], + "POST /v1/automations/triggers/by-key/{triggerKey}/fire": [ + "payload", + "triggerKey" + ], "POST /v1/brands": [ "excludePaths", "excludeSubdomains", @@ -659,8 +682,11 @@ export const INPUT_KEYS: Readonly> = { "format", "groupId", "groupName", + "placeholders", + "props", "subjectLine", - "title" + "title", + "variables" ], "POST /v1/fields": [ "fieldName", @@ -697,10 +723,28 @@ export const INPUT_KEYS: Readonly> = { "POST /v1/sends/{sendId}/resume": [ "sendId" ], + "PUT /v1/app-emails/{triggerKey}": [ + "domainId", + "from", + "overwrite", + "payloadSchema", + "previewText", + "publish", + "source", + "starter", + "subject", + "title", + "triggerKey" + ], "PUT /v1/automations/triggers/{triggerEventId}/contract": [ "enforcement", "fields", "name", "triggerEventId" + ], + "PUT /v1/integrations/{provider}/connection": [ + "provider", + "settings", + "signingSecret" ] } diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 50c60f2e..ee6e58d7 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -113,7 +113,9 @@ export interface paths { * * **Input** `content` (the raw source as a string) and `format`: `html` (a document or fragment), `mjml`, `jsx` (a module with a default-exported component or a bare `` element; static values, `PreviewProps` and local sub-components fold, anything executable is rejected with the offending line) or `eml` (the HTML body is imported, inline `cid:` images are rehosted, other attachments are dropped, and the Subject becomes the title). HTML, MJML and EML land as source-preserved designs with an editable projection; JSX lands as a native React Email design. Optional `baseUrl` resolves relative image paths; optional `title` and `subjectLine` (the design-default inbox subject; an EML Subject is the default); optional `groupId` (`grp_…` or `ungrouped`) or `groupName` (resolve or create), not both; omit to land Ungrouped. Limits: 5,000,000 UTF-8 bytes, 256 remote resources, 8 concurrent fetches, 10 MB per resource and 50 MB aggregate downloads. * - * **Returns** `201 { emailId, emailVersionId, html, previewImage?, assetReport, group, subjectLine? }`. Each source element with content that a design could not keep is listed once in `assetReport.warnings` as `{ code: "element_dropped", location: "markup", host: "" }`: inline `svg`, `math` and `canvas`, and `button`, `select`, `textarea` or table `caption` holding text are left out of the editable projection (the original source keeps them); `audio`, `video`, `iframe`, `object` and `embed` are removed from both, with `audio`/`video` fallback content kept. + * **Migrating from Resend** `props: "merge_tags"` (`jsx` only) keeps the root component's props as `{{ trigger. }}` merge tags filled from the trigger payload: member reads extend the path (`props.user.name` is `trigger.user.name`), default values and `??` / `||` literals become fallbacks, template literals and `href` / `src` / `alt` / `title` carry tags, and `{prop && }` becomes a `{% if %}` block. A prop used where a tag cannot go (arithmetic, a style, a `.map`) takes its `PreviewProps` or default value with a `dynamic_prop_folded` or `dynamic_control_flow_unsupported` warning; one with neither is refused. `placeholders: "resend"` rewrites Resend `{{{NAME}}}` / `{{{NAME|fallback}}}` to `{{ trigger.NAME }}` in the body and `subjectLine` (`FIRST_NAME`, `LAST_NAME`, `EMAIL` read the contact; `href="{{{RESEND_UNSUBSCRIBE_URL}}}"` becomes Brew's unsubscribe link). Resend printed those values raw and Brew escapes them (`escaping_changed`): end a tag with `| raw` only for HTML you trust. Optional `variables: [{ key, fallbackValue? }]` supplies fallbacks the source lacks (a Resend template's `variables`) and needs one of the two modes. + * + * **Returns** `201 { emailId, emailVersionId, html, previewImage?, assetReport, variables, group, subjectLine? }`. `variables` lists every merge path the design and subject read, `{ path, fallback? }`, ready to declare as the trigger payload. Each source element with content that a design could not keep is listed once in `assetReport.warnings` as `{ code: "element_dropped", location: "markup", host: "" }`: inline `svg`, `math` and `canvas`, and `button`, `select`, `textarea` or table `caption` holding text are left out of the editable projection (the original source keeps them); `audio`, `video`, `iframe`, `object` and `embed` are removed from both, with `audio`/`video` fallback content kept. * * **Errors** `422 EMAIL_IMPORT_FAILED` when the markup cannot be compiled into a design or its editable model is over the size budget (nothing is persisted); `404 EMAIL_GROUP_NOT_FOUND` for an unknown `groupId`; `413 PAYLOAD_TOO_LARGE` over the byte cap; `422 BRAND_NOT_READY` while the brand is still extracting. * @@ -807,7 +809,7 @@ export interface paths { * * **Input** Update: one or more of `name`, `description`, `nodes`, `connections`, `triggerEventId`, under the same typed condition contract as create (explicit `type`, canonical snake_case `operator`, no `value` for unary operators, type-correct values elsewhere); `dryRun: true` validates without persisting. Node ids are stored as sent, so renamed nodes keep their new ids, except the two shapes `NODE_ID_REPLACED` reports. Lifecycle: `published: true` promotes the stored latest version live (optionally pin `automationVersionId`; the graph is validated first), `published: false` unpublishes, `paused: true` or `false` freezes or resumes future send steps (`stopInFlight` also stops runs already in progress). * - * **Returns** the bare automation row, or with `dryRun: true` a `200 AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }`: `blockingIssues[]` lists per-node references the effective trigger or contact catalog cannot provide, and publish refuses every one of them, fallback or not (`fatal` says what the reference would do: `true` for filter or split conditions and triple-brace `{{{ }}}` body tokens would break the run, `false` for subject, previewText, fromName, replyTo and double-brace body tags would render empty); `valid` is false when any blocker or blocking issue is present. Updating a LIVE automation saves a draft and does not go live: the previously published version keeps serving until you republish, `published` describes the returned row, `isLive`, `liveVersion` and `liveAutomationVersionId` describe the automation, and `warnings[]` carries `DRAFT_SAVED_NOT_LIVE`. Publishing an automation whose integration trigger has not received its event on the current connection (a Shopify topic with no webhook) still succeeds, and `warnings[]` carries `TRIGGER_EVENT_NOT_RECEIVED` saying what to set up. + * **Returns** the bare automation row, or with `dryRun: true` a `200 AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }`: `blockingIssues[]` lists per-node references the effective trigger or contact catalog cannot provide, and publish refuses every one of them, fallback or not (`fatal` says what the reference would do: `true` for filter or split conditions and triple-brace `{{{ }}}` body tokens would break the run, `false` for subject, previewText, fromName, replyTo and double-brace body tags would render empty); `valid` is false when any blocker or blocking issue is present. Updating a LIVE automation saves a draft and does not go live: the previously published version keeps serving until you republish, `published` describes the returned row, `isLive`, `liveVersion` and `liveAutomationVersionId` describe the automation, and `warnings[]` carries `DRAFT_SAVED_NOT_LIVE`. Publishing an automation whose integration trigger has not received its event on the current connection (a Shopify topic with no webhook) still succeeds, and `warnings[]` carries `TRIGGER_EVENT_NOT_RECEIVED` saying what to set up. An engagement filter that watches an email sent from a transactional domain (no open or click tracking) publishes with `ENGAGEMENT_NOT_TRACKED`. * * **Errors** `404 AUTOMATION_NOT_FOUND`, `AUTOMATION_VERSION_NOT_FOUND` (pin `liveAutomationVersionId`, a `versions[]` id, or a run row's `automationVersionId`), `TRIGGER_EVENT_NOT_FOUND`; `400 AUTOMATION_GRAPH_INVALID` (`details.issues[]`); `409 AUTOMATION_VERSION_CONFLICT` (re-read, reapply, retry); `422 PUBLISH_VALIDATION_FAILED` (`details.blockers[]`; a cdn.brew.new image a step would send that does not exist is a blocker, listed in `details.missingImages[]`, and a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access), `AUTOMATION_NOT_PUBLISHED` (nothing to unpublish; with `stopInFlight: true` on a draft, also no contacts in flight to stop), `AUTOMATION_NOT_PAUSABLE` (a manual-audience automation pauses through its runs). * @@ -1151,7 +1153,9 @@ export interface paths { put?: never; /** * Create a trigger - * @description Creates a custom trigger event definition — the contract automations subscribe to. Body carries `{ title, description?, payloadSchema }`; the server mints `triggerEventId` and hardcodes `provider: "brew_api"`. Integration triggers (clerk, stripe, shopify, …) are provisioned by the corresponding integration only. + * @description Creates a custom trigger event definition — the contract automations subscribe to. Body carries `{ title, description?, key?, payloadSchema }`; the server mints `triggerEventId` and hardcodes `provider: "brew_api"`. Integration triggers (clerk, stripe, shopify, …) are provisioned by the corresponding integration only. + * + * `key` is an optional stable developer name (`order.paid`) for identifying the trigger; `POST /v1/automations/triggers/by-key/{triggerKey}/fire` fires by it. The key is unique in the brand (`409 TRIGGER_KEY_TAKEN`), and `kit`, `by-key`, `brew.*` and non-starter-kit `auth.*` keys are reserved (`400 TRIGGER_KEY_RESERVED`). * * `payloadSchema.fields` MUST declare `{ key: "email", type: "string", required: true }` so downstream automations can resolve a recipient. Returns `201` with the bare trigger row. */ @@ -1195,7 +1199,7 @@ export interface paths { head?: never; /** * Update a trigger - * @description Updates one or more editable fields (`title`, `description`, `payloadSchema`). Whether a trigger fires is governed solely by `automation.published` on each wired automation — there is no per-trigger off-switch. Integration-provisioned triggers are immutable here (`422 TRIGGER_IMMUTABLE`). While a published automation uses the trigger, a `payloadSchema` change that would stop it from running (removing or retyping a field it reads, or declaring the shape of an open object or list so one of its merge tags no longer resolves) is refused with `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`. Returns the bare updated row. + * @description Updates one or more editable fields (`title`, `description`, `key`, `payloadSchema`). `key: null` clears the key; a key another trigger in the brand holds is `409 TRIGGER_KEY_TAKEN`. Whether a trigger fires is governed solely by `automation.published` on each wired automation — there is no per-trigger off-switch. Integration-provisioned triggers are immutable here (`422 TRIGGER_IMMUTABLE`). While a published automation uses the trigger, a `payloadSchema` change that would stop it from running (removing or retyping a field it reads, or declaring the shape of an open object or list so one of its merge tags no longer resolves) is refused with `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`. Returns the bare updated row. */ patch: operations["updateTrigger"]; trace?: never; @@ -1251,7 +1255,7 @@ export interface paths { * * **Idempotency** send a stable `Idempotency-Key` header on every retry. A repeat answers `200` with `status: "replayed"` and the ORIGINAL run ids; nothing fires twice. A fire key is matched against the stored trigger receipt, so it replays for as long as the receipt is kept (90 days), not only the 24 hours of the shared response cache. Use one key per business action and never reuse it for a different fire. * - * **Errors** `400 INVALID_PAYLOAD` when the payload does not satisfy the schema, or when `email` (or `user.email`) is not one plain address such as `jane@example.com`: a display name (`Jane `), a list or a line break is refused with `details.errors[].code: "invalid_email"`, and no contact is written; `404 TRIGGER_EVENT_NOT_FOUND` for an unknown or cross-brand id; `422 NO_PUBLISHED_AUTOMATION` when nothing published listens for the trigger; `409 IDEMPOTENCY_CONFLICT` when a key is reused with a different body; `409 TRIGGER_NOT_FIREABLE` for a Brew event trigger, which Brew starts itself (try its automation with `testAutomation`). + * **Errors** `400 INVALID_PAYLOAD` when the payload does not satisfy the schema, or when `email` (or `user.email`) is not one plain address such as `jane@example.com`: a display name (`Jane `), a list or a line break is refused with `details.errors[].code: "invalid_email"`, and no contact is written; `404 TRIGGER_EVENT_NOT_FOUND` for an unknown or cross-brand id; `422 NO_PUBLISHED_AUTOMATION` when nothing published listens for the trigger; `422 DOMAIN_REQUIRED` when every matched automation sends only from the Brew sandbox (no verified domain yet), right away, and to recipients who are not members of your organization (`details: { recipient, allowed, domainsUrl }`; in a mixed fan-out those automations are listed in `notStarted[]` with `code: "DOMAIN_REQUIRED"` and the rest start); `429 SANDBOX_DAILY_LIMIT` with `Retry-After` when the organization spent today’s sandbox sends (`details: { limit, resetsAt }`); `409 IDEMPOTENCY_CONFLICT` when a key is reused with a different body; `409 TRIGGER_NOT_FIREABLE` for a Brew event trigger, which Brew starts itself (try its automation with `testAutomation`). * * **See also** `checkTriggerReady`, `getTriggerInstance`, `listAutomationRuns`. */ @@ -1262,6 +1266,38 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/automations/triggers/by-key/{triggerKey}/fire": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fire a trigger by key + * @description Fires the trigger whose developer `key` matches, exactly as `fireTrigger` does by id: validates the payload, upserts the contact, then starts one run per published automation attached to the trigger. Use it when the same code fires the same trigger in several brands or environments, where each trigger has its own minted id but shares the key. + * + * **Use when** your code names triggers by key (`order.paid`, `auth.password-reset`) instead of storing `triggerEventId`s. + * + * **Input** `{ payload }`, validated against the trigger’s `payloadSchema`, as for `fireTrigger`. + * + * **Returns** the `fireTrigger` response plus `triggerKey`: `202 { triggerInstanceId, triggerEventId, triggerKey, status, automationRunIds[], notStarted[], publishedAutomations[], counts, warnings[], receivedAt }`. + * + * **Idempotency** send a stable `Idempotency-Key` header on every retry. The key is matched against the stored trigger receipt of the RESOLVED trigger, so a by-key and a by-id fire of one trigger with the same key replay each other, for as long as the receipt is kept (90 days). + * + * **Errors** `404 TRIGGER_KEY_NOT_FOUND` when no trigger in the brand has the key (a sibling brand’s key never resolves); otherwise the `fireTrigger` errors: `400 INVALID_PAYLOAD`, `422 NO_PUBLISHED_AUTOMATION`, `422 DOMAIN_REQUIRED` and `429 SANDBOX_DAILY_LIMIT` (a sandbox automation before a domain verifies), `409 IDEMPOTENCY_CONFLICT`, and `409 TRIGGER_NOT_FIREABLE` for a Brew event trigger, which Brew starts itself. + * + * **See also** `fireTrigger`, `listTriggers`, `updateTrigger` (to set a key). + */ + post: operations["fireTriggerByKey"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/automations/triggers/{triggerEventId}/contract": { parameters: { query?: never; @@ -1344,6 +1380,100 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/app-emails": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List app emails + * @description Lists the brand’s app emails: each keyed trigger an app-email setup wired to a design and an automation, under `{ data, pagination }`. + * + * **Use when** checking which emails your app can send by key and whether each one sends now. + * + * **Returns** `200` with rows carrying `triggerKey`, `emailId`, `automationId`, the starter and `upgradeAvailable`, `published`, the `sender` (`domain`, `sandbox` or `none`) and `readiness` (`ready`, `teamOnly`, `blockers`), as a fire sends now: from the published version while a newer draft waits, through the sandbox only while the domain is in its first check. + * + * **See also** `upsertAppEmail`, `setUpAppEmailKit`, `fireTriggerByKey`. + */ + get: operations["listAppEmails"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/app-emails/{triggerKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get an app email + * @description Reads one app email by its trigger key, as the list row. + * + * **Errors** `404 TRIGGER_KEY_NOT_FOUND` when no app email is set up for the key in this brand. + */ + get: operations["getAppEmail"]; + /** + * Set up an app email + * @description Sets up one email your app sends by key, in one idempotent call: creates or reuses the trigger with that key and its payload contract, builds the design from a starter-kit template (`starter`) or your own `source`, wires an automation that sends it and publishes it. + * + * **Use when** your code should send `brew.send(’’, payload)` and nothing exists yet, or to bring an existing setup up to date. + * + * **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger. }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A transactional key never sends from a marketing domain: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. + * + * **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a marketing domain picked on a transactional key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + * + * **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`): the setup imports its design the way `importEmail` does. + * + * **Returns** `201` when this call set the key up, `200` on a re-run, with the ids, `published`, what was `created`, the `sender`, the stored `payloadSchema`, a `fireExample` (path, example payload, curl and `brew.send`) and `warnings`. + * + * **Errors** `400 APP_EMAIL_STARTER_UNKNOWN`, `400 TRIGGER_KEY_RESERVED`, `404 DOMAIN_NOT_FOUND`, `409 APP_EMAIL_EDITED_IN_BREW`, `409 APP_EMAIL_TRIGGER_IN_USE`, `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS` (a `payloadSchema` that drops or retypes a field the live automation reads), `409 APP_EMAIL_SETUP_IN_PROGRESS` (other setups of the key kept writing first; retry later), `409 TRIGGER_KEY_TAKEN` (another setup of the key won a race), `409 TRIGGER_LIMIT_REACHED`, `413 PAYLOAD_TOO_LARGE` and `422 EMAIL_IMPORT_FAILED` for the source, `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH` (a marketing `domainId` for a transactional key), `422 AUTOMATION_GRAPH_INVALID` and `404 AUTOMATION_NOT_FOUND`, `TRIGGER_EVENT_NOT_FOUND` or `NOT_FOUND` when the trigger, automation or design changes during the setup. + * + * **See also** `setUpAppEmailKit`, `fireTriggerByKey`, `listAppEmails`. + */ + put: operations["upsertAppEmail"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/app-emails/kit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set up the app email kit + * @description Sets up the starter kit: auth emails (verify email, magic link, one-time code, password reset, invite, email change, reauthentication, security notice) and lifecycle emails (welcome, trial ending, payment failed, win-back). Each key is set up exactly as `upsertAppEmail` does with its own starter, a few at a time. + * + * **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a marketing `domainId` sets up the marketing keys and answers each transactional key `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. + * + * **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. + * + * **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`), as for `upsertAppEmail`. + * + * **See also** `upsertAppEmail`, `listAppEmails`. + */ + post: operations["setUpAppEmailKit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/automations/trigger-instances": { parameters: { query?: never; @@ -1813,7 +1943,7 @@ export interface paths { put?: never; /** * Add a domain - * @description Registers a sending domain with the provider. Optional `sendingPurpose` is `marketing` (default; campaigns, audience sends, automations) or `transactional` (event-triggered automation mail — no unsubscribe link required, merely-unsubscribed contacts still receive it). Returns `201` with the bare row in `status: "pending"` plus the DNS `records` to publish. Once the records are live, call `POST /v1/domains/{domainId}/verify` until `sendable: true`. + * @description Registers a sending domain with the provider. Optional `sendingPurpose` is `marketing` (default; campaigns, audience sends, automations) or `transactional` (event-triggered automation mail: no unsubscribe link required, merely-unsubscribed contacts still receive it, and open and click tracking are off). Returns `201` with the bare row in `status: "pending"` plus the DNS `records` to publish. When the domain already publishes a different DKIM key where Brew asks for its record, the domain is still added and the row carries a `DKIM_SELECTOR_IN_USE` warning whose `suggestedNames` are subdomains to add instead. Once the records are live, call `POST /v1/domains/{domainId}/verify` until `sendable: true`. */ post: operations["addDomain"]; delete?: never; @@ -1855,7 +1985,7 @@ export interface paths { head?: never; /** * Update domain settings - * @description Updates sender defaults (`defaultSenderName`, `defaultFromEmail`, `defaultReplyToEmail`) and/or `sendingPurpose` — at least one required. `defaultFromEmail` is the sender MAILBOX (the text before `@`) — the host is always this domain, so a full address is accepted and reduced to its local part; `defaultReplyToEmail`, by contrast, is a whole address. Live gates fail closed if a campaign still points at a transactional domain. Verification is its own action: `POST /v1/domains/{domainId}/verify`. Returns the bare updated row. + * @description Updates sender defaults (`defaultSenderName`, `defaultFromEmail`, `defaultReplyToEmail`) and/or `sendingPurpose` — at least one required. `defaultFromEmail` is the sender MAILBOX (the text before `@`) — the host is always this domain, so a full address is accepted and reduced to its local part; `defaultReplyToEmail`, by contrast, is a whole address. Live gates fail closed if a campaign still points at a transactional domain. A transactional domain has open and click tracking switched off whenever it is still on; a change back to `marketing` switches tracking on again. Verification is its own action: `POST /v1/domains/{domainId}/verify`. Returns the bare updated row. */ patch: operations["updateDomain"]; trace?: never; @@ -2394,6 +2524,48 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/integrations/{provider}/connection": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a hosted auth hook connection + * @description Reads the brand’s connection for a hosted auth email hook: the provider that hands your app’s auth emails to Brew. + * + * **Returns** `200` with `provider`, `connectionId`, the `webhookUrl` the provider delivers to and `status` (`pending_verification` until the first signed delivery, then `active`). + * + * **Errors** `404 NOT_FOUND` when the provider is not connected for the brand. + * + * **See also** `connectIntegrationHook`, `listIntegrations`. + */ + get: operations["getIntegrationConnection"]; + /** + * Connect a hosted auth hook + * @description Connects a hosted auth email hook for the brand and sets up its auth app emails, so the provider hands every auth email to Brew: the Supabase Auth Send Email Hook (`supabase_auth`) or Clerk’s `email.created` (`clerk`). + * + * **Input** `signingSecret`, the secret the provider signs deliveries with (Supabase: `v1,whsec_…` from Authentication > Hooks; Clerk: `whsec_…` from the endpoint). Supabase only: `settings.projectUrl`, the URL confirmation links point at (default: the one Supabase sends with each email). + * + * **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). Until the first signed delivery the connection is `pending_verification`. + * + * **Scopes** an API key needs `emails` and `automations` (`x-brew-scopes`): the call also runs the app email kit. + * + * **Returns** `200` with `provider`, `connectionId`, `webhookUrl`, `status` and `authEmails[]`: each auth key with `ok`, or its `error`; connecting again finishes a failed key. + * + * **Errors** `400 INVALID_REQUEST` for an unsupported provider, a secret in the wrong format or a project URL that is not https. + * + * **See also** `getIntegrationConnection`, `setUpAppEmailKit`, `listAppEmails`. + */ + put: operations["connectIntegrationHook"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/api-keys": { parameters: { query?: never; @@ -2981,8 +3153,13 @@ export interface components { emailId: string; /** @description Email version id to send — required. A pin to the latest version follows new versions (prompt edit, restore) into an unpublished draft; older pins stay. */ emailVersionId: string; - /** @description Owned sending-domain id — optional at authoring; required, and verified (sendable: true), at publish and on every live run. A marketing domain enforces the unsubscribe link; a sendingPurpose "transactional" domain skips it and delivers even to unsubscribed contacts, so choose one only for genuinely transactional flows (receipts, password resets). */ + /** @description Verified owned domain id, required at publish unless senderMode is sandbox. Transactional domains skip unsubscribe links and reach unsubscribed contacts: only for receipts, resets. */ domainId?: string; + /** + * @description sandbox: until a domain verifies, event automations email your organization members only. + * @enum {string} + */ + senderMode?: "domain" | "sandbox"; /** @description Inbox subject — required. Supports {{var | fallback}} interpolation. */ subject: string; /** @description Inbox preheader — optional; the email design's is the source of truth and wins when present. */ @@ -3346,6 +3523,8 @@ export interface components { fromName?: string; fromAddress?: string; domainId?: string; + /** @enum {string} */ + senderMode?: "domain" | "sandbox"; replyTo?: string; to?: unknown; html?: string; @@ -3466,9 +3645,11 @@ export interface components { }; ApiWarning: { /** @enum {string} */ - code: "DRAFT_SAVED_NOT_LIVE" | "NODE_ID_REPLACED" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "FIELD_TYPE_COERCED" | "CSV_ROW_SKIPPED" | "CONTACT_EXISTS_SKIPPED" | "CSV_COLUMN_IGNORED" | "VALIDATION_SKIPPED" | "RECIPIENTS_EXCLUDED" | "CONSENT_RECORD_MISSING"; + code: "DRAFT_SAVED_NOT_LIVE" | "NODE_ID_REPLACED" | "TRIGGER_EVENT_NOT_RECEIVED" | "ENGAGEMENT_NOT_TRACKED" | "ENFORCEMENT_LOOSENED_WHILE_PUBLISHED" | "REQUIRED_FIELD_SATISFIED_BY_FALLBACK" | "RESUBSCRIBE_SKIPPED" | "CORE_FIELD_IGNORED" | "DATE_ORDER_ASSUMED" | "FIELD_TYPE_COERCED" | "CSV_ROW_SKIPPED" | "CONTACT_EXISTS_SKIPPED" | "CSV_COLUMN_IGNORED" | "VALIDATION_SKIPPED" | "RECIPIENTS_EXCLUDED" | "DKIM_SELECTOR_IN_USE" | "SANDBOX_SENDER" | "APP_EMAIL_NOT_PUBLISHED" | "DESIGN_EDITED_IN_BREW" | "CONSENT_RECORD_MISSING"; message: string; field?: string; + /** @description Names to use instead of the one the request sent (`DKIM_SELECTOR_IN_USE`: subdomains of the added domain). */ + suggestedNames?: string[]; }; AutomationDryRunReport: { /** @description Whether the graph would publish cleanly. false when any `blockers[]` entry or any `blockingIssues[]` entry is present. */ @@ -3638,6 +3819,19 @@ export interface components { }; TriggerRow: { triggerEventId: string; + /** @description Stable developer key your code names this trigger by; unique in the brand. Fire by it with `POST /v1/automations/triggers/by-key/{triggerKey}/fire`. Absent when the trigger has none. */ + key?: string; + /** @description Present when an app-email setup wired this keyed trigger to an automation and design. */ + appEmail?: { + /** @description The automation that sends this app email. */ + automationId: string; + /** @description The email design it sends. */ + emailId: string; + /** @description The starter-kit template the design came from, if any. */ + starterId?: string; + /** @description Version of that starter template. */ + starterVersion?: number; + }; title: string; description?: string; /** @enum {string} */ @@ -4468,7 +4662,7 @@ export interface components { }; }; /** @enum {string} */ - ApiErrorCode: "ACCOUNT_SUSPENDED" | "API_KEY_REVOKED" | "AUDIENCE_BUILD_ACTIVE" | "AUDIENCE_BUILD_ALREADY_ACTIVE" | "AUDIENCE_EDIT_CONFLICT" | "AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE" | "AUDIENCE_NOT_FOUND" | "AUDIENCE_RUN_NOT_FOUND" | "AUDIT_NOT_FOUND" | "AUTHENTICATION_REQUIRED" | "AUTOMATION_GRAPH_INVALID" | "AUTOMATION_NOT_FOUND" | "AUTOMATION_NOT_PAUSABLE" | "AUTOMATION_NOT_PUBLISHED" | "AUTOMATION_RUN_NOT_FOUND" | "AUTOMATION_VERSION_CONFLICT" | "AUTOMATION_VERSION_NOT_FOUND" | "BATCH_TOO_LARGE" | "BRAND_DOMAIN_CONFLICT" | "BRAND_ID_REQUIRED" | "BRAND_LIMIT_REACHED" | "BRAND_NOT_FOUND" | "BRAND_NOT_READY" | "BRAND_SCOPE_MISMATCH" | "CHAT_NOT_FOUND" | "COMMENT_NOT_FOUND" | "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND" | "CONTENT_OPERATION_FAILED" | "CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS" | "CORE_FIELD_IMMUTABLE" | "DOMAIN_ALREADY_EXISTS" | "DOMAIN_CLAIMED_ELSEWHERE" | "DOMAIN_NOT_FOUND" | "DOMAIN_NOT_READY" | "DOMAIN_OTHER_BRAND" | "DOMAIN_PROVIDER_ERROR" | "DOMAIN_PURPOSE_NOT_ALLOWED" | "DOMAIN_VERIFICATION_FAILED" | "DOMAIN_VERIFIED_ELSEWHERE" | "EMAIL_GENERATION_FAILED" | "EMAIL_GROUP_NAME_CONFLICT" | "EMAIL_GROUP_NOT_FOUND" | "EMAIL_IMAGES_MISSING" | "EMAIL_IMPORT_FAILED" | "EMAIL_IN_PROGRESS" | "EMAIL_IN_USE_BY_AUTOMATION" | "EMAIL_NOT_FOUND" | "EMAIL_NOT_READY" | "EMAIL_RESULT_UNAVAILABLE" | "EMAIL_RUN_AMBIGUOUS" | "EMAIL_TEMPLATE_INVALID" | "EMAIL_VERSION_NOT_FOUND" | "EXPORT_PROVIDER_ERROR" | "EXPORT_UNSUPPORTED" | "FIELD_NAME_COLLISION" | "FIELD_NOT_FOUND" | "FIELD_TYPE_MISMATCH" | "FIGMA_ACCESS_DENIED" | "FIGMA_CONVERSION_FAILED" | "FIGMA_FRAME_NOT_FOUND" | "FIGMA_NOT_CONNECTED" | "FIGMA_UNAVAILABLE" | "FIGMA_URL_INVALID" | "FLOW_NOT_FOUND" | "IDEMPOTENCY_CONFLICT" | "IDEMPOTENCY_IN_PROGRESS" | "INSIGHT_NOT_FOUND" | "INSUFFICIENT_CREDITS" | "INSUFFICIENT_PERMISSIONS" | "INSUFFICIENT_ROLE" | "INTEGRATION_NOT_CONNECTED" | "INTERNAL_ERROR" | "INVALID_API_KEY" | "INVALID_EMAIL" | "INVALID_PAYLOAD" | "INVALID_REQUEST" | "LIQUID_RENDER_ERROR" | "METHOD_NOT_ALLOWED" | "MISSING_EMAIL" | "NO_ELIGIBLE_RECIPIENTS" | "NO_PUBLISHED_AUTOMATION" | "NOT_FOUND" | "NOT_IMPLEMENTED" | "ORG_SCOPE_REQUIRED" | "PAYLOAD_SCHEMA_EMAIL_REQUIRED" | "PAYLOAD_TOO_LARGE" | "PREVIEW_NOT_FOUND" | "PUBLISH_VALIDATION_FAILED" | "RATE_LIMITED" | "RECIPIENT_UNSUBSCRIBED" | "REFERENCE_EMAIL_NOT_FOUND" | "RESUBSCRIBE_NOT_ALLOWED" | "RUN_IN_PROGRESS" | "RUN_NOT_CANCELLABLE" | "RUN_NOT_PAUSABLE" | "RUN_NOT_PAUSED" | "RUN_NOT_RESUMABLE" | "RUN_START_FAILED" | "RUN_STOP_FAILED" | "SEND_NOT_CANCELLABLE" | "SEND_NOT_FOUND" | "SEND_NOT_PAUSABLE" | "SEND_NOT_RESUMABLE" | "SEND_QUOTA_EXCEEDED" | "SERVICE_UNAVAILABLE" | "TEMPLATE_NOT_FOUND" | "TRIGGER_ALREADY_EXISTS" | "TRIGGER_EVENT_NOT_FOUND" | "TRIGGER_HAS_DEPENDENT_AUTOMATIONS" | "TRIGGER_IMMUTABLE" | "TRIGGER_INSTANCE_NOT_FOUND" | "TRIGGER_LIMIT_REACHED" | "TRIGGER_NOT_FIREABLE" | "UPLOAD_IN_PROGRESS" | "UPLOAD_NOT_FOUND" | "UPLOAD_NOT_RECEIVED"; + ApiErrorCode: "ACCOUNT_SUSPENDED" | "API_KEY_REVOKED" | "APP_EMAIL_DOMAIN_PURPOSE_MISMATCH" | "APP_EMAIL_EDITED_IN_BREW" | "APP_EMAIL_SETUP_IN_PROGRESS" | "APP_EMAIL_STARTER_UNKNOWN" | "APP_EMAIL_TRIGGER_IN_USE" | "AUDIENCE_BUILD_ACTIVE" | "AUDIENCE_BUILD_ALREADY_ACTIVE" | "AUDIENCE_EDIT_CONFLICT" | "AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE" | "AUDIENCE_NOT_FOUND" | "AUDIENCE_RUN_NOT_FOUND" | "AUDIT_NOT_FOUND" | "AUTHENTICATION_REQUIRED" | "AUTOMATION_GRAPH_INVALID" | "AUTOMATION_NOT_FOUND" | "AUTOMATION_NOT_PAUSABLE" | "AUTOMATION_NOT_PUBLISHED" | "AUTOMATION_RUN_NOT_FOUND" | "AUTOMATION_VERSION_CONFLICT" | "AUTOMATION_VERSION_NOT_FOUND" | "BATCH_TOO_LARGE" | "BRAND_DOMAIN_CONFLICT" | "BRAND_ID_REQUIRED" | "BRAND_LIMIT_REACHED" | "BRAND_NOT_FOUND" | "BRAND_NOT_READY" | "BRAND_SCOPE_MISMATCH" | "CHAT_NOT_FOUND" | "COMMENT_NOT_FOUND" | "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND" | "CONTENT_OPERATION_FAILED" | "CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS" | "CORE_FIELD_IMMUTABLE" | "DOMAIN_ALREADY_EXISTS" | "DOMAIN_CLAIMED_ELSEWHERE" | "DOMAIN_NOT_FOUND" | "DOMAIN_NOT_READY" | "DOMAIN_OTHER_BRAND" | "DOMAIN_PROVIDER_ERROR" | "DOMAIN_PURPOSE_NOT_ALLOWED" | "DOMAIN_REQUIRED" | "DOMAIN_VERIFICATION_FAILED" | "DOMAIN_VERIFIED_ELSEWHERE" | "EMAIL_GENERATION_FAILED" | "EMAIL_GROUP_NAME_CONFLICT" | "EMAIL_GROUP_NOT_FOUND" | "EMAIL_IMAGES_MISSING" | "EMAIL_IMPORT_FAILED" | "EMAIL_IN_PROGRESS" | "EMAIL_IN_USE_BY_AUTOMATION" | "EMAIL_NOT_FOUND" | "EMAIL_NOT_READY" | "EMAIL_RESULT_UNAVAILABLE" | "EMAIL_RUN_AMBIGUOUS" | "EMAIL_TEMPLATE_INVALID" | "EMAIL_VERSION_NOT_FOUND" | "EXPORT_PROVIDER_ERROR" | "EXPORT_UNSUPPORTED" | "FIELD_NAME_COLLISION" | "FIELD_NOT_FOUND" | "FIELD_TYPE_MISMATCH" | "FIGMA_ACCESS_DENIED" | "FIGMA_CONVERSION_FAILED" | "FIGMA_FRAME_NOT_FOUND" | "FIGMA_NOT_CONNECTED" | "FIGMA_UNAVAILABLE" | "FIGMA_URL_INVALID" | "FLOW_NOT_FOUND" | "IDEMPOTENCY_CONFLICT" | "IDEMPOTENCY_IN_PROGRESS" | "INSIGHT_NOT_FOUND" | "INSUFFICIENT_CREDITS" | "INSUFFICIENT_PERMISSIONS" | "INSUFFICIENT_ROLE" | "INTEGRATION_NOT_CONNECTED" | "INTERNAL_ERROR" | "INVALID_API_KEY" | "INVALID_EMAIL" | "INVALID_PAYLOAD" | "INVALID_REQUEST" | "LIQUID_RENDER_ERROR" | "METHOD_NOT_ALLOWED" | "MISSING_EMAIL" | "NO_ELIGIBLE_RECIPIENTS" | "NO_PUBLISHED_AUTOMATION" | "NOT_FOUND" | "NOT_IMPLEMENTED" | "ORG_SCOPE_REQUIRED" | "PAYLOAD_SCHEMA_EMAIL_REQUIRED" | "PAYLOAD_TOO_LARGE" | "PREVIEW_NOT_FOUND" | "PUBLISH_VALIDATION_FAILED" | "RATE_LIMITED" | "RECIPIENT_UNSUBSCRIBED" | "REFERENCE_EMAIL_NOT_FOUND" | "RESUBSCRIBE_NOT_ALLOWED" | "RUN_IN_PROGRESS" | "RUN_NOT_CANCELLABLE" | "RUN_NOT_PAUSABLE" | "RUN_NOT_PAUSED" | "RUN_NOT_RESUMABLE" | "RUN_START_FAILED" | "RUN_STOP_FAILED" | "SANDBOX_DAILY_LIMIT" | "SEND_NOT_CANCELLABLE" | "SEND_NOT_FOUND" | "SEND_NOT_PAUSABLE" | "SEND_NOT_RESUMABLE" | "SEND_QUOTA_EXCEEDED" | "SERVICE_UNAVAILABLE" | "TEMPLATE_NOT_FOUND" | "TRIGGER_ALREADY_EXISTS" | "TRIGGER_EVENT_NOT_FOUND" | "TRIGGER_HAS_DEPENDENT_AUTOMATIONS" | "TRIGGER_IMMUTABLE" | "TRIGGER_INSTANCE_NOT_FOUND" | "TRIGGER_KEY_NOT_FOUND" | "TRIGGER_KEY_RESERVED" | "TRIGGER_KEY_TAKEN" | "TRIGGER_LIMIT_REACHED" | "TRIGGER_NOT_FIREABLE" | "UPLOAD_IN_PROGRESS" | "UPLOAD_NOT_FOUND" | "UPLOAD_NOT_RECEIVED"; EmailGenerateTextResponse: { response: string; }; @@ -4530,12 +4724,17 @@ export interface components { dropped?: number; warnings: { /** @enum {string} */ - code: "fetch_failed_retained" | "non_image_resource_retained" | "social_icon_substituted" | "tracking_pixel_dropped" | "unsafe_url_stripped" | "unsupported_resource_retained" | "upload_failed_retained" | "inline_asset_retained" | "attachment_dropped" | "embedded_reference_unresolved" | "tailwind_config_unresolved" | "tailwind_class_dropped" | "foreign_component_dropped" | "element_dropped"; + code: "fetch_failed_retained" | "non_image_resource_retained" | "social_icon_substituted" | "tracking_pixel_dropped" | "unsafe_url_stripped" | "unsupported_resource_retained" | "upload_failed_retained" | "inline_asset_retained" | "attachment_dropped" | "embedded_reference_unresolved" | "tailwind_config_unresolved" | "tailwind_class_dropped" | "foreign_component_dropped" | "dynamic_prop_folded" | "dynamic_control_flow_unsupported" | "escaping_changed" | "placeholder_unmapped" | "element_dropped"; /** @enum {string} */ location: "background" | "css-url" | "data-src" | "data-srcset" | "markup" | "module" | "preload" | "src" | "srcset" | "stylesheet" | "vml-src"; host?: string; }[]; }; + /** @description Merge variables the design and subject line read (`trigger.*` from the trigger payload, `contact.*` from the recipient), with their fallbacks. */ + variables?: { + path: string; + fallback?: string; + }[]; }; EmailImportRequest: { /** @@ -4549,6 +4748,21 @@ export interface components { subjectLine?: string; /** Format: uri */ baseUrl?: string; + /** + * @description `jsx` only. How the root component's props land: `fold` (default) folds each to its `PreviewProps` value or default; `merge_tags` keeps them as `{{ trigger. }}` merge tags filled from the trigger payload, with defaults and `??` literals as fallbacks. + * @enum {string} + */ + props?: "fold" | "merge_tags"; + /** + * @description `resend` rewrites Resend template placeholders (`{{{NAME}}}`, `{{{NAME|fallback}}}`) to Brew merge tags (`{{ trigger.NAME }}`; `FIRST_NAME`, `LAST_NAME` and `EMAIL` read the contact). + * @enum {string} + */ + placeholders?: "resend"; + /** @description Fallback text for variables the source gives none, e.g. a Resend template's `variables`. Needs `placeholders` or `props: "merge_tags"`. */ + variables?: { + key: string; + fallbackValue?: string; + }[]; /** @description File the design under an existing group (`grp_…`), or `ungrouped`. Mutually exclusive with `groupName`. */ groupId?: string; /** @description File the design under a group resolved (or created) by this name. Mutually exclusive with `groupId`. */ @@ -5581,8 +5795,13 @@ export interface components { emailId: string; /** @description Email version id to send — required. A pin to the latest version follows new versions (prompt edit, restore) into an unpublished draft; older pins stay. */ emailVersionId: string; - /** @description Owned sending-domain id — optional at authoring; required, and verified (sendable: true), at publish and on every live run. A marketing domain enforces the unsubscribe link; a sendingPurpose "transactional" domain skips it and delivers even to unsubscribed contacts, so choose one only for genuinely transactional flows (receipts, password resets). */ + /** @description Verified owned domain id, required at publish unless senderMode is sandbox. Transactional domains skip unsubscribe links and reach unsubscribed contacts: only for receipts, resets. */ domainId?: string; + /** + * @description sandbox: until a domain verifies, event automations email your organization members only. + * @enum {string} + */ + senderMode?: "domain" | "sandbox"; /** @description Inbox subject — required. Supports {{var | fallback}} interpolation. */ subject: string; /** @description Inbox preheader — optional; the email design's is the source of truth and wins when present. */ @@ -5951,6 +6170,8 @@ export interface components { fromName?: string; fromAddress?: string; domainId?: string; + /** @enum {string} */ + senderMode?: "domain" | "sandbox"; replyTo?: string; to?: unknown; html?: string; @@ -6110,8 +6331,13 @@ export interface components { emailId: string; /** @description Email version id to send — required. A pin to the latest version follows new versions (prompt edit, restore) into an unpublished draft; older pins stay. */ emailVersionId: string; - /** @description Owned sending-domain id — optional at authoring; required, and verified (sendable: true), at publish and on every live run. A marketing domain enforces the unsubscribe link; a sendingPurpose "transactional" domain skips it and delivers even to unsubscribed contacts, so choose one only for genuinely transactional flows (receipts, password resets). */ + /** @description Verified owned domain id, required at publish unless senderMode is sandbox. Transactional domains skip unsubscribe links and reach unsubscribed contacts: only for receipts, resets. */ domainId?: string; + /** + * @description sandbox: until a domain verifies, event automations email your organization members only. + * @enum {string} + */ + senderMode?: "domain" | "sandbox"; /** @description Inbox subject — required. Supports {{var | fallback}} interpolation. */ subject: string; /** @description Inbox preheader — optional; the email design's is the source of truth and wins when present. */ @@ -6803,6 +7029,19 @@ export interface components { }; BrewEventTriggerSaved: { triggerEventId: string; + /** @description Stable developer key your code names this trigger by; unique in the brand. Fire by it with `POST /v1/automations/triggers/by-key/{triggerKey}/fire`. Absent when the trigger has none. */ + key?: string; + /** @description Present when an app-email setup wired this keyed trigger to an automation and design. */ + appEmail?: { + /** @description The automation that sends this app email. */ + automationId: string; + /** @description The email design it sends. */ + emailId: string; + /** @description The starter-kit template the design came from, if any. */ + starterId?: string; + /** @description Version of that starter template. */ + starterVersion?: number; + }; title: string; description?: string; /** @enum {string} */ @@ -6852,6 +7091,8 @@ export interface components { TriggersPostRequest: { title: string; description?: string; + /** @description Stable developer key for identifying this trigger, unique in the brand (for example `order.paid`). A fire can name the trigger by this key or by the returned `triggerEventId`. Lowercase letters, digits, `.`, `_` and `-`, up to 64 characters. `kit`, `by-key`, `brew.*` and `auth.*` keys other than the starter-kit auth keys are reserved. */ + key?: string; payloadSchema: { /** @enum {string} */ type: "object"; @@ -6861,6 +7102,19 @@ export interface components { TriggersListResponse: { data: { triggerEventId: string; + /** @description Stable developer key your code names this trigger by; unique in the brand. Fire by it with `POST /v1/automations/triggers/by-key/{triggerKey}/fire`. Absent when the trigger has none. */ + key?: string; + /** @description Present when an app-email setup wired this keyed trigger to an automation and design. */ + appEmail?: { + /** @description The automation that sends this app email. */ + automationId: string; + /** @description The email design it sends. */ + emailId: string; + /** @description The starter-kit template the design came from, if any. */ + starterId?: string; + /** @description Version of that starter template. */ + starterVersion?: number; + }; title: string; description?: string; /** @enum {string} */ @@ -6900,6 +7154,8 @@ export interface components { TriggersPatchRequest: { title?: string; description?: string; + /** @description Set or rename the developer key; `null` clears it (and the app-email link that depends on it). Omit to keep the current key. */ + key?: string | null; payloadSchema?: { /** @enum {string} */ type: "object"; @@ -6956,6 +7212,8 @@ export interface components { /** @description The persisted inbound row — `GET /v1/automations/trigger-instances/{triggerInstanceId}` for its match state. */ triggerInstanceId: string; triggerEventId: string; + /** @description Present on a by-key fire: the developer key the trigger was named by. */ + triggerKey?: string; /** * @description `triggered` = this call accepted and matched the event; `automationRunIds` lists the runs it started and `notStarted` any it could not. `replayed` = the same `Idempotency-Key` already fired; nothing ran twice. * @enum {string} @@ -6971,6 +7229,11 @@ export interface components { automationId: string; /** @description Why its run did not start. A refusal (a merge tag or condition field the trigger does not declare, a step the run gate finds incomplete) repeats on every fire until the automation or trigger is fixed; an unexpected error is retried automatically. */ reason: string; + /** + * @description DOMAIN_REQUIRED: this automation sends from the Brew sandbox until its domain is verified, and the recipient is not a member of your organization. + * @enum {string} + */ + code?: "DOMAIN_REQUIRED"; }[]; publishedAutomations: ({ automationId: string; @@ -7123,23 +7386,34 @@ export interface components { /** @enum {string} */ subjectKind?: "trigger"; }; - TriggerInstancesListResponse: { + AppEmailsListResponse: { data: { - triggerInstanceId: string; - /** @enum {string} */ - source: "api" | "integration" | "brew_event"; - provider?: string; - triggerEventId?: string; - /** @enum {string} */ - state: "received" | "verified" | "matched" | "partially_fired" | "fired" | "rejected" | "dead_letter"; - matchedAutomationIds: string[]; - automationRunIds: string[]; - rejectionReason?: string; - attempts: number; - /** Format: date-time */ - receivedAt: string; + triggerKey: string; + triggerEventId: string; + title: string; + emailId: string; + automationId: string; + starterId?: string; + starterVersion?: number; + /** @description The starter has a newer version; running the setup again upgrades an unedited design. */ + upgradeAvailable: boolean; + published: boolean; + sender: { + /** @enum {string} */ + mode: "domain" | "sandbox" | "none"; + domainId?: string; + /** @enum {string} */ + domainStatus?: "not_started" | "pending" | "verified" | "failed" | "temporary_failure" | "partially_verified" | "partially_failed"; + }; + readiness: { + /** @description A fire by this key sends the email now. */ + ready: boolean; + /** @description Sends reach organization members only until a domain verifies. */ + teamOnly: boolean; + blockers: ("AUTOMATION_MISSING" | "AUTOMATION_NOT_PUBLISHED" | "DOMAIN_REQUIRED")[]; + }; /** Format: date-time */ - processedAt?: string; + updatedAt: string; }[]; pagination: { limit: number; @@ -7147,78 +7421,334 @@ export interface components { hasMore: boolean; }; }; - ContactsPostBatchResponse: { - summary: { - inserted: number; - updated: number; - failed: number; + AppEmailRow: { + triggerKey: string; + triggerEventId: string; + title: string; + emailId: string; + automationId: string; + starterId?: string; + starterVersion?: number; + /** @description The starter has a newer version; running the setup again upgrades an unedited design. */ + upgradeAvailable: boolean; + published: boolean; + sender: { + /** @enum {string} */ + mode: "domain" | "sandbox" | "none"; + domainId?: string; + /** @enum {string} */ + domainStatus?: "not_started" | "pending" | "verified" | "failed" | "temporary_failure" | "partially_verified" | "partially_failed"; }; - fieldsCreated: string[]; - errors: { - email: string; - /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted). */ - row?: number; - code: string; - field?: string; - message: string; - }[]; - warnings: { - code: string; - email?: string; - /** @description The 1-based row it is about (CSV: data row; header and blank lines not counted). */ - row?: number; - field?: string; - message: string; - from?: string; - to?: string; - original?: string; - normalized?: string; - }[]; - validation?: { - valid: number; - risky: number; - invalid: number; - unscored: number; - persistence?: { - matched: number; - modified: number; - failed: number; - }; + readiness: { + /** @description A fire by this key sends the email now. */ + ready: boolean; + /** @description Sends reach organization members only until a domain verifies. */ + teamOnly: boolean; + blockers: ("AUTOMATION_MISSING" | "AUTOMATION_NOT_PUBLISHED" | "DOMAIN_REQUIRED")[]; }; - validationJobId?: string; + /** Format: date-time */ + updatedAt: string; }; - ContactsPostSingleResponse: { - contact: { - email: string; - firstName?: string; - lastName?: string; - /** @default true */ - subscribed?: boolean; - consent?: { - /** - * @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in). - * @enum {string} - */ - source: "api" | "form" | "import" | "app" | "integration"; - /** - * Format: date-time - * @description When it was captured. - */ - capturedAt: string; - /** @description The policy or terms version in force when it was captured. */ - policyVersion?: string; - /** @description Free-text evidence: a form URL, a ticket, a checkbox label. */ - evidence?: string; - }; + AppEmailSetupResult: { + triggerKey: string; + triggerEventId: string; + emailId: string; + emailVersionId: string; + automationId: string; + /** @description A fire by this key starts the automation. */ + published: boolean; + /** @description What this call created; false means it already existed. */ + created: { + trigger: boolean; + email: boolean; + automation: boolean; + }; + sender: { /** - * @description The latest verdict. `valid` only ever comes from a deliverability check (`validate: true` on ingest, or POST /v1/contacts/validate), which also sets `lastValidatedAt`. Without a check the free format check on ingest stores `risky` (disposable or role address) or `invalid`, and leaves the field unset otherwise: not validated. + * @description `sandbox`: sends reach members of your organization only, under a daily cap, until a domain verifies. * @enum {string} */ - validationStatus?: "valid" | "risky" | "invalid"; - /** @default false */ - suppressed?: boolean; - suppressedReason?: string | null; - /** @default [] */ + mode: "domain" | "sandbox"; + /** @description The address the email is sent from. */ + from: string; + fromName?: string; + /** @description The sending domain, or in sandbox mode the domain still waiting to verify. */ + domainId?: string; + /** @enum {string} */ + domainStatus?: "not_started" | "pending" | "verified" | "failed" | "temporary_failure" | "partially_verified" | "partially_failed"; + sandbox?: { + dailyLimit: number; + remainingToday: number; + }; + }; + payloadSchema: { + /** @enum {string} */ + type: "object"; + fields: components["schemas"]["TriggerPayloadField"][]; + }; + fireExample: { + /** @enum {string} */ + method: "POST"; + path: string; + examplePayload: { + [key: string]: unknown; + }; + curl: string; + sdk: string; + }; + warnings: components["schemas"]["ApiWarning"][]; + }; + AppEmailPutRequest: { + /** @description Starter-kit template key. On a kit key it must equal the key and is the default; any kit template works on your own keys. */ + starter?: string; + /** @description Your own design instead of a starter. */ + source?: { + /** + * @description `html`: an HTML email; `mjml`: MJML markup; `jsx`: React Email (a module with a default-exported component, or a bare `` element). + * @enum {string} + */ + format: "html" | "mjml" | "jsx"; + /** @description The email source. `{{ trigger. }}` merge tags read the payload. */ + content: string; + /** + * @description `jsx` only. How the root component's props land: `fold` (default) folds each to its `PreviewProps` value or default; `merge_tags` keeps them as `{{ trigger. }}` merge tags filled from the trigger payload, with defaults and `??` literals as fallbacks. + * @enum {string} + */ + props?: "fold" | "merge_tags"; + /** + * @description `resend` rewrites Resend template placeholders (`{{{NAME}}}`, `{{{NAME|fallback}}}`) to Brew merge tags (`{{ trigger.NAME }}`; `FIRST_NAME`, `LAST_NAME` and `EMAIL` read the contact). + * @enum {string} + */ + placeholders?: "resend"; + /** @description Fallback text for variables the source gives none, e.g. a Resend template's `variables`. Needs `placeholders` or `props: "merge_tags"`. */ + variables?: { + key: string; + fallbackValue?: string; + }[]; + }; + title?: string; + subject?: string; + previewText?: string; + /** @description Flat payload fields. `email` (the recipient) is added when missing. Every other field is template data only and never written to the contact. */ + payloadSchema?: { + fields: { + /** @description Field name, as templates read it: `{{ trigger. }}`. */ + key: string; + /** @enum {string} */ + type: "string" | "int" | "boolean" | "date"; + /** @description Defaults to false. */ + required?: boolean; + /** + * @description `high` keeps the value out of logs and stored copies. + * @enum {string} + */ + pii?: "none" | "low" | "high"; + /** @description True for a one-time code or sign-in link: used only to render the email, redacted in stored copies, logs and analytics. Never on `email`. Omitted: `pii: high` counts as secret, and a field the starter kit marks secret stays secret. */ + secret?: boolean; + description?: string; + }[]; + }; + /** @description Sending domain. Defaults to a verified domain whose purpose matches the email, else the team-only sandbox. */ + domainId?: string; + from?: { + /** @description Part before the @ on the sending domain, such as `hello`. */ + localPart?: string; + name?: string; + }; + /** @description Publish the automation. Defaults to true. */ + publish?: boolean; + /** @description Replace a design edited in Brew since setup. Defaults to false. */ + overwrite?: boolean; + }; + AppEmailsKitResponse: { + results: ({ + triggerKey: string; + triggerEventId: string; + emailId: string; + emailVersionId: string; + automationId: string; + /** @description A fire by this key starts the automation. */ + published: boolean; + /** @description What this call created; false means it already existed. */ + created: { + trigger: boolean; + email: boolean; + automation: boolean; + }; + sender: { + /** + * @description `sandbox`: sends reach members of your organization only, under a daily cap, until a domain verifies. + * @enum {string} + */ + mode: "domain" | "sandbox"; + /** @description The address the email is sent from. */ + from: string; + fromName?: string; + /** @description The sending domain, or in sandbox mode the domain still waiting to verify. */ + domainId?: string; + /** @enum {string} */ + domainStatus?: "not_started" | "pending" | "verified" | "failed" | "temporary_failure" | "partially_verified" | "partially_failed"; + sandbox?: { + dailyLimit: number; + remainingToday: number; + }; + }; + payloadSchema: { + /** @enum {string} */ + type: "object"; + fields: components["schemas"]["TriggerPayloadField"][]; + }; + fireExample: { + /** @enum {string} */ + method: "POST"; + path: string; + examplePayload: { + [key: string]: unknown; + }; + curl: string; + sdk: string; + }; + warnings: components["schemas"]["ApiWarning"][]; + /** @enum {boolean} */ + ok: true; + } | { + triggerKey: string; + /** @enum {boolean} */ + ok: false; + error: { + code: string; + message: string; + details?: { + [key: string]: unknown; + }; + }; + })[]; + stack: { + /** @enum {string} */ + provider: "better-auth" | "authjs" | "supabase" | "clerk" | "firebase" | "custom"; + /** + * @description `code`: your app, or a hook it hosts, sends the auth keys; `hosted-webhook`: the provider hands them to Brew once connected. + * @enum {string} + */ + authDelivery: "code" | "hosted-webhook"; + note: string; + keys: string[]; + snippets: { + title: string; + file: string; + code: string; + }[]; + }; + }; + AppEmailsKitRequest: { + /** @description Kit sets to set up. Defaults to both. */ + sets?: ("auth" | "lifecycle")[]; + /** + * @description Your auth stack: narrows the auth keys to the ones it sends and returns its code snippets. Defaults to `custom` (every auth key). + * @enum {string} + */ + stack?: "better-auth" | "authjs" | "supabase" | "clerk" | "firebase" | "custom"; + /** @description Exactly these kit keys, instead of `sets` and `stack`. */ + keys?: ("auth.verify-email" | "auth.magic-link" | "auth.otp" | "auth.password-reset" | "auth.invite" | "auth.email-change" | "auth.reauthentication" | "auth.security-notice" | "lifecycle.welcome" | "lifecycle.trial-ending" | "lifecycle.payment-failed" | "lifecycle.win-back")[]; + domainId?: string; + overwrite?: boolean; + }; + TriggerInstancesListResponse: { + data: { + triggerInstanceId: string; + /** @enum {string} */ + source: "api" | "integration" | "brew_event"; + provider?: string; + triggerEventId?: string; + /** @enum {string} */ + state: "received" | "verified" | "matched" | "partially_fired" | "fired" | "rejected" | "dead_letter"; + matchedAutomationIds: string[]; + automationRunIds: string[]; + rejectionReason?: string; + attempts: number; + /** Format: date-time */ + receivedAt: string; + /** Format: date-time */ + processedAt?: string; + }[]; + pagination: { + limit: number; + cursor: string | null; + hasMore: boolean; + }; + }; + ContactsPostBatchResponse: { + summary: { + inserted: number; + updated: number; + failed: number; + }; + fieldsCreated: string[]; + errors: { + email: string; + /** @description The 1-based row that failed: `contacts[row - 1]`, or the CSV data row (header and blank lines not counted). */ + row?: number; + code: string; + field?: string; + message: string; + }[]; + warnings: { + code: string; + email?: string; + /** @description The 1-based row it is about (CSV: data row; header and blank lines not counted). */ + row?: number; + field?: string; + message: string; + from?: string; + to?: string; + original?: string; + normalized?: string; + }[]; + validation?: { + valid: number; + risky: number; + invalid: number; + unscored: number; + persistence?: { + matched: number; + modified: number; + failed: number; + }; + }; + validationJobId?: string; + }; + ContactsPostSingleResponse: { + contact: { + email: string; + firstName?: string; + lastName?: string; + /** @default true */ + subscribed?: boolean; + consent?: { + /** + * @description Who captured it: `api` (an API caller asserted it), `form` (a signup form), `import` (a bulk import), `app` (the in-app CSV wizard checkbox), `integration` (an integration sync such as Stripe created the contact; provenance only, not an opt-in). + * @enum {string} + */ + source: "api" | "form" | "import" | "app" | "integration"; + /** + * Format: date-time + * @description When it was captured. + */ + capturedAt: string; + /** @description The policy or terms version in force when it was captured. */ + policyVersion?: string; + /** @description Free-text evidence: a form URL, a ticket, a checkbox label. */ + evidence?: string; + }; + /** + * @description The latest verdict. `valid` only ever comes from a deliverability check (`validate: true` on ingest, or POST /v1/contacts/validate), which also sets `lastValidatedAt`. Without a check the free format check on ingest stores `risky` (disposable or role address) or `invalid`, and leaves the field unset otherwise: not validated. + * @enum {string} + */ + validationStatus?: "valid" | "risky" | "invalid"; + /** @default false */ + suppressed?: boolean; + suppressedReason?: string | null; + /** @default [] */ unsubscribedDomains?: string[]; /** Format: date-time */ lastValidatedAt?: string; @@ -8230,6 +8760,41 @@ export interface components { hasMore: boolean; }; }; + DomainsPostResponse: { + domainId: string; + /** Format: uri */ + domainUrl: string; + name: string; + region: string; + /** @enum {string} */ + status: "not_started" | "pending" | "verified" | "failed" | "temporary_failure" | "partially_verified" | "partially_failed"; + sendingEnabled: boolean; + sendable: boolean; + /** + * @description marketing (default) or transactional. Transactional domains skip unsubscribe and send via automation sendEmail nodes (or a test send). + * @enum {string} + */ + sendingPurpose: "marketing" | "transactional"; + records: { + record: string; + name: string; + type: string; + ttl: string; + status: string; + value: string; + priority?: number; + }[]; + openTracking?: boolean; + clickTracking?: boolean; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + /** Format: date-time */ + verifiedAt?: string; + /** @description Present only with a caveat about the domain just added. `DKIM_SELECTOR_IN_USE`: the domain already publishes a different DKIM key where Brew asks for its record; `suggestedNames` lists subdomains to add instead. */ + warnings?: components["schemas"]["ApiWarning"][]; + }; DomainsPostRequest: { name: string; /** @enum {string} */ @@ -8940,7 +9505,7 @@ export interface components { IntegrationsListResponse: { data: { /** @enum {string} */ - provider: "braze" | "brevo" | "clerk" | "customerio" | "figma" | "framer" | "slack" | "hubspot" | "typeform" | "klaviyo" | "iterable" | "postmark" | "onesignal" | "mailgun" | "sendgrid" | "mailjet" | "mailchimp" | "loops" | "stripe" | "stytch" | "supabase" | "workos" | "shopify" | "revenuecat" | "notion"; + provider: "braze" | "brevo" | "clerk" | "customerio" | "figma" | "framer" | "slack" | "hubspot" | "typeform" | "klaviyo" | "iterable" | "postmark" | "onesignal" | "mailgun" | "sendgrid" | "mailjet" | "mailchimp" | "loops" | "stripe" | "stytch" | "supabase" | "supabase_auth" | "workos" | "shopify" | "revenuecat" | "notion"; name: string; /** @enum {string} */ category: "data_in" | "data_out"; @@ -8963,6 +9528,52 @@ export interface components { hasMore: boolean; }; }; + IntegrationConnection: { + /** @enum {string} */ + provider: "supabase_auth" | "clerk"; + /** @description Server-derived, stable per brand and provider. */ + connectionId: string; + /** @description Where the provider delivers: `https://brew.new/api/integrations/{provider}/webhook/{connectionId}`. Paste it as the Supabase hook URL or the Clerk endpoint URL. */ + webhookUrl: string; + /** + * @description `pending_verification` until the first signed delivery arrives, then `active`. + * @enum {string} + */ + status: "pending_verification" | "active"; + }; + IntegrationConnectionPutResponse: { + /** @enum {string} */ + provider: "supabase_auth" | "clerk"; + /** @description Server-derived, stable per brand and provider. */ + connectionId: string; + /** @description Where the provider delivers: `https://brew.new/api/integrations/{provider}/webhook/{connectionId}`. Paste it as the Supabase hook URL or the Clerk endpoint URL. */ + webhookUrl: string; + /** + * @description `pending_verification` until the first signed delivery arrives, then `active`. + * @enum {string} + */ + status: "pending_verification" | "active"; + /** @description The auth app emails this provider sends, each set up from the starter kit or kept as it is. A key that failed carries its `error`; connecting again finishes it. */ + authEmails: { + triggerKey: string; + ok: boolean; + error?: { + code: string; + message: string; + }; + }[]; + }; + IntegrationConnectionPutRequest: { + /** @description The secret the provider signs deliveries with: Supabase shows `v1,whsec_…` for the hook, Clerk `whsec_…` for the endpoint. */ + signingSecret: string; + settings?: { + /** + * Format: uri + * @description Supabase only: the project URL confirmation links point at (`https://.supabase.co` or a custom auth domain). Omitted: the URL Supabase sends with each email. + */ + projectUrl?: string; + }; + }; ApiKeysListResponse: { data: { keyId: string; @@ -10147,6 +10758,15 @@ export interface operations { * "stripped": 0, * "warnings": [] * }, + * "variables": [ + * { + * "path": "trigger.name", + * "fallback": "there" + * }, + * { + * "path": "trigger.inviteLink" + * } + * ], * "group": null * } */ @@ -18677,6 +19297,8 @@ export interface operations { * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. * * `PAYLOAD_SCHEMA_EMAIL_REQUIRED`: A trigger payload schema must declare a required string `email` field. + * + * `TRIGGER_KEY_RESERVED`: That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit. */ 400: { headers: { @@ -18742,6 +19364,8 @@ export interface operations { * * `TRIGGER_ALREADY_EXISTS`: A trigger with that id already exists in the brand. * + * `TRIGGER_KEY_TAKEN`: Another trigger in the brand already uses that key. + * * `TRIGGER_LIMIT_REACHED`: The brand already holds the maximum number of trigger events. */ 409: { @@ -19181,6 +19805,8 @@ export interface operations { * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. * * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + * + * `TRIGGER_KEY_RESERVED`: That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit. */ 400: { headers: { @@ -19243,7 +19869,11 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads. */ + /** + * @description `CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads. + * + * `TRIGGER_KEY_TAKEN`: Another trigger in the brand already uses that key. + */ 409: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -19674,7 +20304,11 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `NO_PUBLISHED_AUTOMATION`: The trigger fired, but no published automation listens for it, so nothing would run. */ + /** + * @description `DOMAIN_REQUIRED`: The matched automations send from the Brew sandbox until a sending domain is verified, and the sandbox only emails members of your organization; `details` carries `recipient`, `allowed` and `domainsUrl`. + * + * `NO_PUBLISHED_AUTOMATION`: The trigger fired, but no published automation listens for it, so nothing would run. + */ 422: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -19685,7 +20319,11 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + /** + * @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + * + * `SANDBOX_DAILY_LIMIT`: Your organization used all of today’s Brew sandbox sends (`details.limit`, default 50 a UTC day); `details.resetsAt` is when the count resets, and `Retry-After` the seconds until then. + */ 429: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -19730,9 +20368,273 @@ export interface operations { }; }; }; - getTriggerContract: { + fireTriggerByKey: { parameters: { - query?: { + query?: never; + header?: { + /** + * @description Optional idempotency key for safe retries of the same trigger fire and request body. The shared response cache defaults to 24 hours; the trigger receipt replays the original run ids for as long as it is stored (90 days). Use one key per business action and never reuse it for a different fire. + * @example api-request-2026-04-08-001 + */ + "Idempotency-Key"?: string; + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path: { + /** @description The developer key set on the trigger (`key` on the trigger row). Resolved inside the brand the credential acts for. */ + triggerKey: string; + }; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "payload": { + * "email": "jane@example.com", + * "orderId": "ord_1042" + * } + * } + */ + "application/json": components["schemas"]["TriggerFireRequest"]; + }; + }; + responses: { + /** @description Idempotent replay: this `Idempotency-Key` already fired this trigger. The ids are the original run’s. */ + 200: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "triggerInstanceId": "tin_4fZ2kQ9pLm7xR1tV8wYcD", + * "triggerEventId": "Hq2LmP4xR7vB1nC5zKdWa", + * "triggerKey": "order.paid", + * "status": "replayed", + * "automationRunIds": [ + * "jh7acthhe99pq4448xh83gn54x8eennb" + * ], + * "publishedAutomations": [ + * { + * "automationId": "M3nRk8LqW2xT5vBp9YcZd", + * "name": "Receipt flow" + * } + * ], + * "counts": { + * "automations": 1, + * "skipped": 0 + * }, + * "warnings": [], + * "receivedAt": "2026-04-08T12:34:56.789Z" + * } + */ + "application/json": components["schemas"]["TriggerFireAccepted"]; + }; + }; + /** @description Accepted: one run started per matched published automation. */ + 202: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "triggerInstanceId": "tin_4fZ2kQ9pLm7xR1tV8wYcD", + * "triggerEventId": "Hq2LmP4xR7vB1nC5zKdWa", + * "triggerKey": "order.paid", + * "status": "triggered", + * "automationRunIds": [ + * "jh7acthhe99pq4448xh83gn54x8eennb" + * ], + * "publishedAutomations": [ + * { + * "automationId": "M3nRk8LqW2xT5vBp9YcZd", + * "name": "Receipt flow" + * } + * ], + * "counts": { + * "automations": 1, + * "skipped": 0 + * }, + * "warnings": [], + * "receivedAt": "2026-04-08T12:34:56.789Z" + * } + */ + "application/json": components["schemas"]["TriggerFireAccepted"]; + }; + }; + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_PAYLOAD`: The test payload does not match the trigger’s payload schema. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ + 400: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `API_KEY_REVOKED`: The API key was revoked. + * + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + * + * `INVALID_API_KEY`: The API key is malformed or unknown. + */ + 401: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ + 403: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + * + * `TRIGGER_KEY_NOT_FOUND`: No trigger in the brand has that key. + */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + * + * `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + * + * `TRIGGER_NOT_FIREABLE`: Brew starts this trigger itself when its event happens, so it cannot be fired over the API. + */ + 409: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `DOMAIN_REQUIRED`: The matched automations send from the Brew sandbox until a sending domain is verified, and the sandbox only emails members of your organization; `details` carries `recipient`, `allowed` and `domainsUrl`. + * + * `NO_PUBLISHED_AUTOMATION`: The trigger fired, but no published automation listens for it, so nothing would run. + */ + 422: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. + * + * `SANDBOX_DAILY_LIMIT`: Your organization used all of today’s Brew sandbox sends (`details.limit`, default 50 a UTC day); `details.resetsAt` is when the count resets, and `Retry-After` the seconds until then. + */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. */ + 503: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + getTriggerContract: { + parameters: { + query?: { /** @description Rendering. `json` (default) returns the contract object; `ts` | `zod` | `jsonschema` | `skill` return `{format, content}` — a generated TypeScript type, Zod schema, JSON Schema document, or SKILL.md wiring brief. */ format?: "json" | "ts" | "zod" | "jsonschema" | "skill"; }; @@ -20482,10 +21384,9 @@ export interface operations { }; }; }; - listTriggerInstances: { + listAppEmails: { parameters: { query?: { - triggerEventId?: string; /** * @description Page size (1-100). Defaults to 100. * @example 50 @@ -20505,7 +21406,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description A page of fired trigger-event instances. */ + /** @description A page of app emails. */ 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -20523,20 +21424,24 @@ export interface operations { * @example { * "data": [ * { - * "triggerInstanceId": "tin_4fZ2kQ9pLm7xR1tV8wYcD", - * "source": "api", - * "provider": "brew_api", - * "triggerEventId": "user_signed_up", - * "state": "processed", - * "matchedAutomationIds": [ - * "M3nRk8LqW2xT5vBp9YcZd" - * ], - * "automationRunIds": [ - * "jh7acthhe99pq4448xh83gn54x8eennb" - * ], - * "attempts": 1, - * "receivedAt": "2026-04-08T12:34:56.789Z", - * "processedAt": "2026-04-08T12:34:57.100Z" + * "triggerKey": "auth.password-reset", + * "triggerEventId": "Kq3vXf9LbN2mR7tY1wPzA", + * "title": "Password reset", + * "emailId": "V1StGXR8_Z5jdHi6B-myT", + * "automationId": "aZ8xQ4mN1pR6tY3vL0cBw", + * "starterId": "auth.password-reset", + * "starterVersion": 1, + * "upgradeAvailable": false, + * "published": true, + * "sender": { + * "mode": "sandbox" + * }, + * "readiness": { + * "ready": true, + * "teamOnly": true, + * "blockers": [] + * }, + * "updatedAt": "2026-10-13T12:00:00.000Z" * } * ], * "pagination": { @@ -20546,7 +21451,7 @@ export interface operations { * } * } */ - "application/json": components["schemas"]["TriggerInstancesListResponse"]; + "application/json": components["schemas"]["AppEmailsListResponse"]; }; }; /** @@ -20643,7 +21548,7 @@ export interface operations { }; }; }; - getTriggerInstance: { + getAppEmail: { parameters: { query?: never; header?: { @@ -20654,13 +21559,14 @@ export interface operations { "X-Brand-Id"?: string; }; path: { - triggerInstanceId: string; + /** @description The trigger key your code sends the email by. Starter-kit keys (`auth.password-reset`, `lifecycle.welcome`, …) default to their own template. */ + triggerKey: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The fired trigger-event instance. */ + /** @description The app email. */ 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -20676,23 +21582,27 @@ export interface operations { content: { /** * @example { - * "triggerInstanceId": "tin_4fZ2kQ9pLm7xR1tV8wYcD", - * "source": "api", - * "provider": "brew_api", - * "triggerEventId": "user_signed_up", - * "state": "processed", - * "matchedAutomationIds": [ - * "M3nRk8LqW2xT5vBp9YcZd" - * ], - * "automationRunIds": [ - * "jh7acthhe99pq4448xh83gn54x8eennb" - * ], - * "attempts": 1, - * "receivedAt": "2026-04-08T12:34:56.789Z", - * "processedAt": "2026-04-08T12:34:57.100Z" + * "triggerKey": "auth.password-reset", + * "triggerEventId": "Kq3vXf9LbN2mR7tY1wPzA", + * "title": "Password reset", + * "emailId": "V1StGXR8_Z5jdHi6B-myT", + * "automationId": "aZ8xQ4mN1pR6tY3vL0cBw", + * "starterId": "auth.password-reset", + * "starterVersion": 1, + * "upgradeAvailable": false, + * "published": true, + * "sender": { + * "mode": "sandbox" + * }, + * "readiness": { + * "ready": true, + * "teamOnly": true, + * "blockers": [] + * }, + * "updatedAt": "2026-10-13T12:00:00.000Z" * } */ - "application/json": components["schemas"]["TriggerInstance"]; + "application/json": components["schemas"]["AppEmailRow"]; }; }; /** @@ -20749,7 +21659,7 @@ export interface operations { /** * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). * - * `TRIGGER_INSTANCE_NOT_FOUND`: No fired trigger instance with that id exists in the brand. + * `TRIGGER_KEY_NOT_FOUND`: No trigger in the brand has that key. */ 404: { headers: { @@ -20793,22 +21703,9 @@ export interface operations { }; }; }; - listContacts: { + upsertAppEmail: { parameters: { - query?: { - /** @description A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. */ - search?: string; - /** @description Only members of this saved audience. */ - audienceId?: string; - sort?: string; - order?: "asc" | "desc"; - /** - * @description Page size (1-100). Defaults to 100. - * @example 50 - */ - limit?: number; - cursor?: string; - }; + query?: never; header?: { /** * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. @@ -20816,12 +21713,19 @@ export interface operations { */ "X-Brand-Id"?: string; }; - path?: never; + path: { + /** @description The trigger key your code sends the email by. Starter-kit keys (`auth.password-reset`, `lifecycle.welcome`, …) default to their own template. */ + triggerKey: string; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AppEmailPutRequest"]; + }; + }; responses: { - /** @description A page of contacts. */ + /** @description Re-run: the app email as it now stands. */ 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -20837,65 +21741,154 @@ export interface operations { content: { /** * @example { - * "data": [ - * { - * "email": "jane@example.com", - * "firstName": "Jane", - * "lastName": "Doe", - * "subscribed": true, - * "validationStatus": "valid", - * "lastValidatedAt": "2026-04-08T12:01:00.000Z", - * "suppressed": false, - * "suppressedReason": null, - * "consent": { - * "source": "form", - * "capturedAt": "2026-04-08T12:00:00.000Z", - * "policyVersion": "2026-03" + * "triggerKey": "auth.password-reset", + * "triggerEventId": "Kq3vXf9LbN2mR7tY1wPzA", + * "emailId": "V1StGXR8_Z5jdHi6B-myT", + * "emailVersionId": "h7Nc2pLq9XvB4sT8mWk1E", + * "automationId": "aZ8xQ4mN1pR6tY3vL0cBw", + * "published": true, + * "created": { + * "trigger": false, + * "email": false, + * "automation": false + * }, + * "sender": { + * "mode": "sandbox", + * "from": "sandbox@email.brew.new", + * "fromName": "Acme via Brew sandbox", + * "sandbox": { + * "dailyLimit": 50, + * "remainingToday": 50 + * } + * }, + * "payloadSchema": { + * "type": "object", + * "fields": [ + * { + * "key": "email", + * "type": "string", + * "required": true, + * "pii": "high" * }, - * "createdAt": "2026-04-08T12:00:00.000Z", - * "updatedAt": "2026-04-08T12:05:00.000Z", - * "importId": null, - * "customFields": { - * "plan": "enterprise", - * "revenue": 4200 + * { + * "key": "confirmationUrl", + * "type": "string", + * "required": false, + * "pii": "high", + * "secret": true, + * "syncToContact": false * } + * ] + * }, + * "fireExample": { + * "method": "POST", + * "path": "/v1/automations/triggers/by-key/auth.password-reset/fire", + * "examplePayload": { + * "email": "ada@example.com", + * "confirmationUrl": "https://app.example.com/auth/confirm?token=4f9c2a" * }, + * "curl": "curl -X POST 'https://brew.new/api/v1/automations/triggers/by-key/auth.password-reset/fire' \\\n -H \"Authorization: Bearer $BREW_API_KEY\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"payload\":{\"email\":\"ada@example.com\"}}'", + * "sdk": "await brew.send('auth.password-reset', { email: 'ada@example.com', confirmationUrl: 'https://app.example.com/auth/confirm?token=4f9c2a' })" + * }, + * "warnings": [ * { - * "email": "john@example.com", - * "firstName": "John", - * "lastName": "Smith", - * "subscribed": true, - * "validationStatus": "valid", - * "lastValidatedAt": "2026-04-08T12:01:00.000Z", - * "suppressed": false, - * "suppressedReason": null, - * "consent": { - * "source": "form", - * "capturedAt": "2026-04-08T12:00:00.000Z", - * "policyVersion": "2026-03" + * "code": "SANDBOX_SENDER", + * "message": "\"auth.password-reset\" sends from the Brew sandbox to members of your organization only. Add and verify a transactional sending domain, then run the setup again.", + * "field": "domainId" + * } + * ] + * } + */ + "application/json": components["schemas"]["AppEmailSetupResult"]; + }; + }; + /** @description Set up for the first time. */ + 201: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "triggerKey": "auth.password-reset", + * "triggerEventId": "Kq3vXf9LbN2mR7tY1wPzA", + * "emailId": "V1StGXR8_Z5jdHi6B-myT", + * "emailVersionId": "h7Nc2pLq9XvB4sT8mWk1E", + * "automationId": "aZ8xQ4mN1pR6tY3vL0cBw", + * "published": true, + * "created": { + * "trigger": true, + * "email": true, + * "automation": true + * }, + * "sender": { + * "mode": "sandbox", + * "from": "sandbox@email.brew.new", + * "fromName": "Acme via Brew sandbox", + * "sandbox": { + * "dailyLimit": 50, + * "remainingToday": 50 + * } + * }, + * "payloadSchema": { + * "type": "object", + * "fields": [ + * { + * "key": "email", + * "type": "string", + * "required": true, + * "pii": "high" * }, - * "createdAt": "2026-04-08T12:00:00.000Z", - * "updatedAt": "2026-04-08T12:05:00.000Z", - * "importId": null, - * "customFields": { - * "plan": "starter" + * { + * "key": "confirmationUrl", + * "type": "string", + * "required": false, + * "pii": "high", + * "secret": true, + * "syncToContact": false * } + * ] + * }, + * "fireExample": { + * "method": "POST", + * "path": "/v1/automations/triggers/by-key/auth.password-reset/fire", + * "examplePayload": { + * "email": "ada@example.com", + * "confirmationUrl": "https://app.example.com/auth/confirm?token=4f9c2a" + * }, + * "curl": "curl -X POST 'https://brew.new/api/v1/automations/triggers/by-key/auth.password-reset/fire' \\\n -H \"Authorization: Bearer $BREW_API_KEY\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"payload\":{\"email\":\"ada@example.com\"}}'", + * "sdk": "await brew.send('auth.password-reset', { email: 'ada@example.com', confirmationUrl: 'https://app.example.com/auth/confirm?token=4f9c2a' })" + * }, + * "warnings": [ + * { + * "code": "SANDBOX_SENDER", + * "message": "\"auth.password-reset\" sends from the Brew sandbox to members of your organization only. Add and verify a transactional sending domain, then run the setup again.", + * "field": "domainId" * } - * ], - * "pagination": { - * "limit": 50, - * "cursor": "eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0=", - * "hasMore": true - * } + * ] * } */ - "application/json": components["schemas"]["ContactsListResponse"]; + "application/json": components["schemas"]["AppEmailSetupResult"]; }; }; /** - * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * @description `APP_EMAIL_STARTER_UNKNOWN`: The starter names no starter-kit template, or a different one than the kit key being set up. + * + * `AUTOMATION_GRAPH_INVALID`: The nodes and connections do not form a valid graph; `details.issues[]` lists each problem with its `nodeId` and `kind`. + * + * `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. * * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + * + * `TRIGGER_KEY_RESERVED`: That trigger key is reserved by Brew: `kit`, `by-key`, `brew.*`, or an `auth.*` key outside the starter kit. */ 400: { headers: { @@ -20943,7 +21936,17 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + /** + * @description `AUTOMATION_NOT_FOUND`: No automation with that id exists in the brand. + * + * `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + * + * `DOMAIN_NOT_FOUND`: No domain with that id exists in the brand. + * + * `NOT_FOUND`: No v1 resource lives at this path. + * + * `TRIGGER_EVENT_NOT_FOUND`: No trigger with that id exists in the brand. + */ 404: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -20954,6 +21957,55 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; + /** + * @description `APP_EMAIL_EDITED_IN_BREW`: The app email’s design was edited in Brew since it was set up, so the setup did not replace it. + * + * `APP_EMAIL_SETUP_IN_PROGRESS`: Other setups of this app email’s key were writing at the same time, so this one stopped before finishing. + * + * `APP_EMAIL_TRIGGER_IN_USE`: The trigger with this key already starts a published automation that no app-email setup made, so setting the key up would send a second email on every fire. + * + * `CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: A published automation consumes this trigger, so the contract change must stay backward compatible: enforcement cannot tighten, a referenced field cannot be removed, retyped, or made required without a fallback, and an open object or list cannot gain a shape that drops a field a published automation reads. + * + * `TRIGGER_KEY_TAKEN`: Another trigger in the brand already uses that key. + * + * `TRIGGER_LIMIT_REACHED`: The brand already holds the maximum number of trigger events. + */ + 409: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `PAYLOAD_TOO_LARGE`: The request body exceeds the route cap. */ + 413: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: The `domainId` names a marketing domain for a transactional app email (auth, trial ending, payment failed), which would send it with an unsubscribe link and never to anyone who unsubscribed. + * + * `EMAIL_IMPORT_FAILED`: The supplied markup could not be imported as an email. + */ + 422: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ 429: { headers: { @@ -20986,7 +22038,7 @@ export interface operations { }; }; }; - upsertContacts: { + setUpAppEmailKit: { parameters: { query?: never; header?: { @@ -21006,11 +22058,20 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ContactsPostRequest"]; + /** + * @example { + * "sets": [ + * "auth", + * "lifecycle" + * ], + * "stack": "better-auth" + * } + */ + "application/json": components["schemas"]["AppEmailsKitRequest"]; }; }; responses: { - /** @description Batch upsert applied (every row succeeded). */ + /** @description One result per key, and the stack’s snippets. */ 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -21026,100 +22087,86 @@ export interface operations { content: { /** * @example { - * "summary": { - * "inserted": 2, - * "updated": 0, - * "failed": 0 - * }, - * "fieldsCreated": [], - * "errors": [], - * "warnings": [] - * } - */ - "application/json": components["schemas"]["ContactsPostBatchResponse"]; - }; - }; - /** @description Single upsert applied. */ - 201: { - headers: { - /** @description Unique request identifier. Share this with support when debugging a request. */ - "x-request-id": string; - /** @description Requests allowed in the current rolling rate limit window. */ - "X-RateLimit-Limit": number; - /** @description Requests remaining in the current rolling rate limit window. */ - "X-RateLimit-Remaining": number; - /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ - "X-RateLimit-Reset": number; - [name: string]: unknown; - }; - content: { - /** - * @example { - * "contact": { - * "email": "jane@example.com", - * "firstName": "Jane", - * "lastName": "Doe", - * "subscribed": true, - * "validationStatus": "valid", - * "lastValidatedAt": "2026-04-08T12:01:00.000Z", - * "suppressed": false, - * "suppressedReason": null, - * "consent": { - * "source": "form", - * "capturedAt": "2026-04-08T12:00:00.000Z", - * "policyVersion": "2026-03" - * }, - * "createdAt": "2026-04-08T12:00:00.000Z", - * "updatedAt": "2026-04-08T12:05:00.000Z", - * "importId": null, - * "customFields": { - * "plan": "enterprise", - * "revenue": 4200 - * } - * }, - * "created": true, - * "fieldsCreated": [ - * "plan" - * ], - * "warnings": [] - * } - */ - "application/json": components["schemas"]["ContactsPostSingleResponse"]; - }; - }; - /** @description Batch partially applied — inspect `errors[]` for each failed row. */ - 207: { - headers: { - /** @description Unique request identifier. Share this with support when debugging a request. */ - "x-request-id": string; - [name: string]: unknown; - }; - content: { - /** - * @example { - * "summary": { - * "inserted": 1, - * "updated": 0, - * "failed": 2 - * }, - * "fieldsCreated": [], - * "errors": [ - * { - * "email": "not-an-email", - * "code": "INVALID_EMAIL", - * "message": "not-an-email is not a valid email address." - * }, + * "results": [ * { - * "email": "john@example.com", - * "code": "FIELD_TYPE_MISMATCH", - * "field": "customFields.plan", - * "message": "Field 'customFields.plan' is type 'number' but received string '$49'." + * "ok": true, + * "triggerKey": "auth.password-reset", + * "triggerEventId": "Kq3vXf9LbN2mR7tY1wPzA", + * "emailId": "V1StGXR8_Z5jdHi6B-myT", + * "emailVersionId": "h7Nc2pLq9XvB4sT8mWk1E", + * "automationId": "aZ8xQ4mN1pR6tY3vL0cBw", + * "published": true, + * "created": { + * "trigger": true, + * "email": true, + * "automation": true + * }, + * "sender": { + * "mode": "sandbox", + * "from": "sandbox@email.brew.new", + * "fromName": "Acme via Brew sandbox", + * "sandbox": { + * "dailyLimit": 50, + * "remainingToday": 50 + * } + * }, + * "payloadSchema": { + * "type": "object", + * "fields": [ + * { + * "key": "email", + * "type": "string", + * "required": true, + * "pii": "high" + * }, + * { + * "key": "confirmationUrl", + * "type": "string", + * "required": false, + * "pii": "high", + * "secret": true, + * "syncToContact": false + * } + * ] + * }, + * "fireExample": { + * "method": "POST", + * "path": "/v1/automations/triggers/by-key/auth.password-reset/fire", + * "examplePayload": { + * "email": "ada@example.com", + * "confirmationUrl": "https://app.example.com/auth/confirm?token=4f9c2a" + * }, + * "curl": "curl -X POST 'https://brew.new/api/v1/automations/triggers/by-key/auth.password-reset/fire' \\\n -H \"Authorization: Bearer $BREW_API_KEY\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"payload\":{\"email\":\"ada@example.com\"}}'", + * "sdk": "await brew.send('auth.password-reset', { email: 'ada@example.com', confirmationUrl: 'https://app.example.com/auth/confirm?token=4f9c2a' })" + * }, + * "warnings": [ + * { + * "code": "SANDBOX_SENDER", + * "message": "\"auth.password-reset\" sends from the Brew sandbox to members of your organization only. Add and verify a transactional sending domain, then run the setup again.", + * "field": "domainId" + * } + * ] * } * ], - * "warnings": [] + * "stack": { + * "provider": "better-auth", + * "authDelivery": "code", + * "note": "Better Auth hands each hook the link or code and Brew sends the email.", + * "keys": [ + * "auth.verify-email", + * "auth.password-reset" + * ], + * "snippets": [ + * { + * "title": "Email and password", + * "file": "lib/auth.ts", + * "code": "waitUntil(brew.send('auth.password-reset', { email: user.email, confirmationUrl: url }))" + * } + * ] + * } * } */ - "application/json": components["schemas"]["ContactsPostBatchResponse"]; + "application/json": components["schemas"]["AppEmailsKitResponse"]; }; }; /** @@ -21154,17 +22201,6 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed. */ - 402: { - headers: { - /** @description Unique request identifier. Share this with support when debugging a request. */ - "x-request-id": string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ApiErrorEnvelope"]; - }; - }; /** * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. * @@ -21196,9 +22232,7 @@ export interface operations { }; }; /** - * @description `FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field. - * - * `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + * @description `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. * * `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. */ @@ -21212,29 +22246,6 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** - * @description `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. - * - * `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. - * - * `FIELD_NAME_COLLISION`: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. - * - * `INVALID_EMAIL`: A contact email is not a deliverable address shape. - * - * `MISSING_EMAIL`: A contact row has no `email`, the primary key. - * - * `RESUBSCRIBE_NOT_ALLOWED`: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. - */ - 422: { - headers: { - /** @description Unique request identifier. Share this with support when debugging a request. */ - "x-request-id": string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ApiErrorEnvelope"]; - }; - }; /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ 429: { headers: { @@ -21265,26 +22276,18 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. */ - 503: { - headers: { - /** @description Unique request identifier. Share this with support when debugging a request. */ - "x-request-id": string; - /** @description Seconds to wait before retrying the request. */ - "Retry-After": number; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ApiErrorEnvelope"]; - }; - }; }; }; - getContact: { + listTriggerInstances: { parameters: { query?: { - /** @description Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well). */ - include?: string; + triggerEventId?: string; + /** + * @description Page size (1-100). Defaults to 100. + * @example 50 + */ + limit?: number; + cursor?: string; }; header?: { /** @@ -21293,15 +22296,12 @@ export interface operations { */ "X-Brand-Id"?: string; }; - path: { - /** @description The contact’s email address (URL-encoded). Email is the contact primary key. */ - email: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description The contact row, with `openProfile` when `include=openProfile`. */ + /** @description A page of fired trigger-event instances. */ 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -21317,29 +22317,32 @@ export interface operations { content: { /** * @example { - * "email": "jane@example.com", - * "firstName": "Jane", - * "lastName": "Doe", - * "subscribed": true, - * "validationStatus": "valid", - * "lastValidatedAt": "2026-04-08T12:01:00.000Z", - * "suppressed": false, - * "suppressedReason": null, - * "consent": { - * "source": "form", - * "capturedAt": "2026-04-08T12:00:00.000Z", - * "policyVersion": "2026-03" - * }, - * "createdAt": "2026-04-08T12:00:00.000Z", - * "updatedAt": "2026-04-08T12:05:00.000Z", - * "importId": null, - * "customFields": { - * "plan": "enterprise", - * "revenue": 4200 + * "data": [ + * { + * "triggerInstanceId": "tin_4fZ2kQ9pLm7xR1tV8wYcD", + * "source": "api", + * "provider": "brew_api", + * "triggerEventId": "user_signed_up", + * "state": "processed", + * "matchedAutomationIds": [ + * "M3nRk8LqW2xT5vBp9YcZd" + * ], + * "automationRunIds": [ + * "jh7acthhe99pq4448xh83gn54x8eennb" + * ], + * "attempts": 1, + * "receivedAt": "2026-04-08T12:34:56.789Z", + * "processedAt": "2026-04-08T12:34:57.100Z" + * } + * ], + * "pagination": { + * "limit": 100, + * "cursor": null, + * "hasMore": false * } * } */ - "application/json": components["schemas"]["ContactWithOpenProfile"]; + "application/json": components["schemas"]["TriggerInstancesListResponse"]; }; }; /** @@ -21393,11 +22396,7 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** - * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - * - * `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. - */ + /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ 404: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -21440,7 +22439,7 @@ export interface operations { }; }; }; - deleteContact: { + getTriggerInstance: { parameters: { query?: never; header?: { @@ -21451,14 +22450,13 @@ export interface operations { "X-Brand-Id"?: string; }; path: { - /** @description The contact’s email address (URL-encoded). Email is the contact primary key. */ - email: string; + triggerInstanceId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Deleted (or already gone). */ + /** @description The fired trigger-event instance. */ 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -21474,11 +22472,23 @@ export interface operations { content: { /** * @example { - * "email": "jane@example.com", - * "deleted": true + * "triggerInstanceId": "tin_4fZ2kQ9pLm7xR1tV8wYcD", + * "source": "api", + * "provider": "brew_api", + * "triggerEventId": "user_signed_up", + * "state": "processed", + * "matchedAutomationIds": [ + * "M3nRk8LqW2xT5vBp9YcZd" + * ], + * "automationRunIds": [ + * "jh7acthhe99pq4448xh83gn54x8eennb" + * ], + * "attempts": 1, + * "receivedAt": "2026-04-08T12:34:56.789Z", + * "processedAt": "2026-04-08T12:34:57.100Z" * } */ - "application/json": components["schemas"]["ContactDeleteResponse"]; + "application/json": components["schemas"]["TriggerInstance"]; }; }; /** @@ -21532,7 +22542,11 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + /** + * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + * + * `TRIGGER_INSTANCE_NOT_FOUND`: No fired trigger instance with that id exists in the brand. + */ 404: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -21575,15 +22589,797 @@ export interface operations { }; }; }; - updateContact: { + listContacts: { parameters: { - query?: never; - header?: { - /** - * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. - * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg - */ - "X-Brand-Id"?: string; + query?: { + /** @description A whole email address matches only that contact. Other text matches the contacts whose email, first or last name contains any of its words, in `sort` order. Encode an address's `+` as `%2B`. */ + search?: string; + /** @description Only members of this saved audience. */ + audienceId?: string; + sort?: string; + order?: "asc" | "desc"; + /** + * @description Page size (1-100). Defaults to 100. + * @example 50 + */ + limit?: number; + cursor?: string; + }; + header?: { + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A page of contacts. */ + 200: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "data": [ + * { + * "email": "jane@example.com", + * "firstName": "Jane", + * "lastName": "Doe", + * "subscribed": true, + * "validationStatus": "valid", + * "lastValidatedAt": "2026-04-08T12:01:00.000Z", + * "suppressed": false, + * "suppressedReason": null, + * "consent": { + * "source": "form", + * "capturedAt": "2026-04-08T12:00:00.000Z", + * "policyVersion": "2026-03" + * }, + * "createdAt": "2026-04-08T12:00:00.000Z", + * "updatedAt": "2026-04-08T12:05:00.000Z", + * "importId": null, + * "customFields": { + * "plan": "enterprise", + * "revenue": 4200 + * } + * }, + * { + * "email": "john@example.com", + * "firstName": "John", + * "lastName": "Smith", + * "subscribed": true, + * "validationStatus": "valid", + * "lastValidatedAt": "2026-04-08T12:01:00.000Z", + * "suppressed": false, + * "suppressedReason": null, + * "consent": { + * "source": "form", + * "capturedAt": "2026-04-08T12:00:00.000Z", + * "policyVersion": "2026-03" + * }, + * "createdAt": "2026-04-08T12:00:00.000Z", + * "updatedAt": "2026-04-08T12:05:00.000Z", + * "importId": null, + * "customFields": { + * "plan": "starter" + * } + * } + * ], + * "pagination": { + * "limit": 50, + * "cursor": "eyJsYXN0SWQiOiIxMjMiLCJsYXN0U29ydCI6MTcxMjU5MjAwMDAwMH0=", + * "hasMore": true + * } + * } + */ + "application/json": components["schemas"]["ContactsListResponse"]; + }; + }; + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ + 400: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `API_KEY_REVOKED`: The API key was revoked. + * + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + * + * `INVALID_API_KEY`: The API key is malformed or unknown. + */ + 401: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ + 403: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + upsertContacts: { + parameters: { + query?: never; + header?: { + /** + * @description Optional idempotency key for safe retries. Reusing the same key with the same request body returns the original response for 24 hours. + * @example api-request-2026-04-08-001 + */ + "Idempotency-Key"?: string; + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContactsPostRequest"]; + }; + }; + responses: { + /** @description Batch upsert applied (every row succeeded). */ + 200: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "summary": { + * "inserted": 2, + * "updated": 0, + * "failed": 0 + * }, + * "fieldsCreated": [], + * "errors": [], + * "warnings": [] + * } + */ + "application/json": components["schemas"]["ContactsPostBatchResponse"]; + }; + }; + /** @description Single upsert applied. */ + 201: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "contact": { + * "email": "jane@example.com", + * "firstName": "Jane", + * "lastName": "Doe", + * "subscribed": true, + * "validationStatus": "valid", + * "lastValidatedAt": "2026-04-08T12:01:00.000Z", + * "suppressed": false, + * "suppressedReason": null, + * "consent": { + * "source": "form", + * "capturedAt": "2026-04-08T12:00:00.000Z", + * "policyVersion": "2026-03" + * }, + * "createdAt": "2026-04-08T12:00:00.000Z", + * "updatedAt": "2026-04-08T12:05:00.000Z", + * "importId": null, + * "customFields": { + * "plan": "enterprise", + * "revenue": 4200 + * } + * }, + * "created": true, + * "fieldsCreated": [ + * "plan" + * ], + * "warnings": [] + * } + */ + "application/json": components["schemas"]["ContactsPostSingleResponse"]; + }; + }; + /** @description Batch partially applied — inspect `errors[]` for each failed row. */ + 207: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "summary": { + * "inserted": 1, + * "updated": 0, + * "failed": 2 + * }, + * "fieldsCreated": [], + * "errors": [ + * { + * "email": "not-an-email", + * "code": "INVALID_EMAIL", + * "message": "not-an-email is not a valid email address." + * }, + * { + * "email": "john@example.com", + * "code": "FIELD_TYPE_MISMATCH", + * "field": "customFields.plan", + * "message": "Field 'customFields.plan' is type 'number' but received string '$49'." + * } + * ], + * "warnings": [] + * } + */ + "application/json": components["schemas"]["ContactsPostBatchResponse"]; + }; + }; + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ + 400: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `API_KEY_REVOKED`: The API key was revoked. + * + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + * + * `INVALID_API_KEY`: The API key is malformed or unknown. + */ + 401: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INSUFFICIENT_CREDITS`: The credit balance is below what this operation requires; `details.cost` is the amount needed. */ + 402: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ + 403: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `FIELD_TYPE_MISMATCH`: A value does not match the declared type of its custom field. + * + * `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + * + * `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + */ + 409: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `BATCH_TOO_LARGE`: A batch write exceeds the per-request row cap. + * + * `CORE_FIELD_IMMUTABLE`: The field is a core contact column and cannot be created, changed, or deleted as a custom field. + * + * `FIELD_NAME_COLLISION`: Two keys in one request name the same contact field (`plan` and `Plan`, `team_size` and `Team Size`), so one value would be lost. + * + * `INVALID_EMAIL`: A contact email is not a deliverable address shape. + * + * `MISSING_EMAIL`: A contact row has no `email`, the primary key. + * + * `RESUBSCRIBE_NOT_ALLOWED`: The write set `subscribed` to true on a contact who unsubscribed, and Brew never re-subscribes an opt-out through the API, MCP or an import; `details.email` names the contact. + */ + 422: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `SERVICE_UNAVAILABLE`: A dependency the operation must consult (billing, the idempotency store) is temporarily unavailable, so the request was refused rather than run unmetered. */ + 503: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + getContact: { + parameters: { + query?: { + /** @description Expansions: `openProfile` attaches the contact's smart-send open-time profile (needs the `emails` permission as well). */ + include?: string; + }; + header?: { + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path: { + /** @description The contact’s email address (URL-encoded). Email is the contact primary key. */ + email: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The contact row, with `openProfile` when `include=openProfile`. */ + 200: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "email": "jane@example.com", + * "firstName": "Jane", + * "lastName": "Doe", + * "subscribed": true, + * "validationStatus": "valid", + * "lastValidatedAt": "2026-04-08T12:01:00.000Z", + * "suppressed": false, + * "suppressedReason": null, + * "consent": { + * "source": "form", + * "capturedAt": "2026-04-08T12:00:00.000Z", + * "policyVersion": "2026-03" + * }, + * "createdAt": "2026-04-08T12:00:00.000Z", + * "updatedAt": "2026-04-08T12:05:00.000Z", + * "importId": null, + * "customFields": { + * "plan": "enterprise", + * "revenue": 4200 + * } + * } + */ + "application/json": components["schemas"]["ContactWithOpenProfile"]; + }; + }; + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ + 400: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `API_KEY_REVOKED`: The API key was revoked. + * + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + * + * `INVALID_API_KEY`: The API key is malformed or unknown. + */ + 401: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ + 403: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + * + * `CONTACT_NOT_FOUND`: No contact with that email exists in the brand. + */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + deleteContact: { + parameters: { + query?: never; + header?: { + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path: { + /** @description The contact’s email address (URL-encoded). Email is the contact primary key. */ + email: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted (or already gone). */ + 200: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "email": "jane@example.com", + * "deleted": true + * } + */ + "application/json": components["schemas"]["ContactDeleteResponse"]; + }; + }; + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ + 400: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `API_KEY_REVOKED`: The API key was revoked. + * + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + * + * `INVALID_API_KEY`: The API key is malformed or unknown. + */ + 401: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ + 403: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + updateContact: { + parameters: { + query?: never; + header?: { + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; }; path: { /** @description The contact’s email address (URL-encoded). Email is the contact primary key. */ @@ -24922,7 +26718,7 @@ export interface operations { }; }; responses: { - /** @description Added — publish the returned DNS records, then verify. */ + /** @description Added. Publish the returned DNS records, then verify. `warnings` appears only with a caveat about the domain just added. */ 201: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -24960,7 +26756,7 @@ export interface operations { * "updatedAt": "2026-04-08T12:34:56.789Z" * } */ - "application/json": components["schemas"]["Domain"]; + "application/json": components["schemas"]["DomainsPostResponse"]; }; }; /** @@ -29491,15 +31287,287 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ApiErrorEnvelope"]; + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + * + * `UPLOAD_NOT_FOUND`: The image upload is unknown, expired, or belongs to another brand. + */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. + * + * `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + * + * `UPLOAD_IN_PROGRESS`: Another request is still adding this uploaded image. + * + * `UPLOAD_NOT_RECEIVED`: The image upload has not received its file yet. + */ + 409: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `PAYLOAD_TOO_LARGE`: The request body exceeds the route cap. */ + 413: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `CONTENT_OPERATION_FAILED`: The media operation could not be completed for the given input. */ + 422: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + createImageUpload: { + parameters: { + query?: never; + header?: { + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path?: never; + cookie?: never; + }; + /** @description The file to upload: its name, type and size in bytes. */ + requestBody: { + content: { + "application/json": components["schemas"]["ContentImageUploadCreateRequest"]; + }; + }; + responses: { + /** @description The upload is open. POST the raw file bytes to `uploadUrl`, then call `POST /v1/content/add-image` with `uploadId`. */ + 201: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "uploadId": "imgup_V1StGXR8_Z5jdHi6B-myT", + * "uploadUrl": "https://example-deployment-123.convex.site/uploads/brand-image?uploadId=imgup_V1StGXR8_Z5jdHi6B-myT&token=9b1c4f6e2a7d8e3f0a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f", + * "expiresAt": "2026-10-01T18:15:00.000Z", + * "maxBytes": 20000000 + * } + */ + "application/json": components["schemas"]["ContentImageUploadCreateResponse"]; + }; + }; + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ + 400: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `API_KEY_REVOKED`: The API key was revoked. + * + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. + * + * `INVALID_API_KEY`: The API key is malformed or unknown. + */ + 401: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ + 403: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ + 429: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + /** @description Seconds to wait before retrying the request. */ + "Retry-After": number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + /** @description `INTERNAL_ERROR`: An unexpected failure; the x-request-id header identifies it. */ + 500: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; + }; + }; + getUsage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Usage snapshot — plan, credits, and email-send quota. */ + 200: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + /** @description Requests allowed in the current rolling rate limit window. */ + "X-RateLimit-Limit": number; + /** @description Requests remaining in the current rolling rate limit window. */ + "X-RateLimit-Remaining": number; + /** @description Unix timestamp in seconds when the oldest recorded request leaves the rolling window. */ + "X-RateLimit-Reset": number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "plan": { + * "key": "pro", + * "name": "Pro" + * }, + * "credits": { + * "limit": 50000, + * "used": 364, + * "remaining": 49636 + * }, + * "emailSends": { + * "limit": 200000, + * "used": 911, + * "reserved": 27880, + * "remaining": 171209 + * }, + * "period": { + * "start": "2026-05-28T18:10:35.000Z", + * "end": "2026-06-28T18:10:35.000Z" + * } + * } + */ + "application/json": components["schemas"]["UsageGetResponse"]; }; }; - /** - * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). - * - * `UPLOAD_NOT_FOUND`: The image upload is unknown, expired, or belongs to another brand. - */ - 404: { + /** @description `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. */ + 400: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ "x-request-id": string; @@ -29510,26 +31578,13 @@ export interface operations { }; }; /** - * @description `IDEMPOTENCY_CONFLICT`: The same Idempotency-Key was reused with a different request body. - * - * `IDEMPOTENCY_IN_PROGRESS`: A request with this Idempotency-Key is still executing. + * @description `API_KEY_REVOKED`: The API key was revoked. * - * `UPLOAD_IN_PROGRESS`: Another request is still adding this uploaded image. + * `AUTHENTICATION_REQUIRED`: No API key or session accompanied the request. * - * `UPLOAD_NOT_RECEIVED`: The image upload has not received its file yet. + * `INVALID_API_KEY`: The API key is malformed or unknown. */ - 409: { - headers: { - /** @description Unique request identifier. Share this with support when debugging a request. */ - "x-request-id": string; - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ApiErrorEnvelope"]; - }; - }; - /** @description `PAYLOAD_TOO_LARGE`: The request body exceeds the route cap. */ - 413: { + 401: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ "x-request-id": string; @@ -29539,8 +31594,8 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `CONTENT_OPERATION_FAILED`: The media operation could not be completed for the given input. */ - 422: { + /** @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. */ + 403: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ "x-request-id": string; @@ -29582,9 +31637,16 @@ export interface operations { }; }; }; - createImageUpload: { + listIntegrations: { parameters: { - query?: never; + query?: { + /** + * @description Page size (1-100). Defaults to 100. + * @example 50 + */ + limit?: number; + cursor?: string; + }; header?: { /** * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. @@ -29595,15 +31657,10 @@ export interface operations { path?: never; cookie?: never; }; - /** @description The file to upload: its name, type and size in bytes. */ - requestBody: { - content: { - "application/json": components["schemas"]["ContentImageUploadCreateRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description The upload is open. POST the raw file bytes to `uploadUrl`, then call `POST /v1/content/add-image` with `uploadId`. */ - 201: { + /** @description Catalog rows for every known provider. */ + 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ "x-request-id": string; @@ -29618,13 +31675,50 @@ export interface operations { content: { /** * @example { - * "uploadId": "imgup_V1StGXR8_Z5jdHi6B-myT", - * "uploadUrl": "https://example-deployment-123.convex.site/uploads/brand-image?uploadId=imgup_V1StGXR8_Z5jdHi6B-myT&token=9b1c4f6e2a7d8e3f0a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f", - * "expiresAt": "2026-10-01T18:15:00.000Z", - * "maxBytes": 20000000 + * "data": [ + * { + * "provider": "hubspot", + * "name": "HubSpot", + * "category": "data_out", + * "connected": true, + * "installationCount": 1, + * "capabilities": [ + * { + * "capabilityId": "contacts_import", + * "isReady": true + * }, + * { + * "capabilityId": "crm_context", + * "isReady": true + * }, + * { + * "capabilityId": "export_template", + * "isReady": true + * } + * ] + * }, + * { + * "provider": "klaviyo", + * "name": "Klaviyo", + * "category": "data_out", + * "connected": true + * }, + * { + * "provider": "typeform", + * "name": "Typeform", + * "category": "data_in", + * "connected": false, + * "comingSoon": true + * } + * ], + * "pagination": { + * "limit": 100, + * "cursor": null, + * "hasMore": false + * } * } */ - "application/json": components["schemas"]["ContentImageUploadCreateResponse"]; + "application/json": components["schemas"]["IntegrationsListResponse"]; }; }; /** @@ -29678,7 +31772,11 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). */ + /** + * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + * + * `NOT_FOUND`: No v1 resource lives at this path. + */ 404: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -29721,16 +31819,25 @@ export interface operations { }; }; }; - getUsage: { + getIntegrationConnection: { parameters: { query?: never; - header?: never; - path?: never; + header?: { + /** + * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. + * @example kx7b3s7fapqz8mjm12ekz1kxdx87yceg + */ + "X-Brand-Id"?: string; + }; + path: { + /** @description `supabase_auth` (the Supabase Auth Send Email Hook) or `clerk` (Clerk webhooks; `email.created` carries the auth emails). */ + provider: "supabase_auth" | "clerk"; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Usage snapshot — plan, credits, and email-send quota. */ + /** @description The connection. */ 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -29746,31 +31853,20 @@ export interface operations { content: { /** * @example { - * "plan": { - * "key": "pro", - * "name": "Pro" - * }, - * "credits": { - * "limit": 50000, - * "used": 364, - * "remaining": 49636 - * }, - * "emailSends": { - * "limit": 200000, - * "used": 911, - * "reserved": 27880, - * "remaining": 171209 - * }, - * "period": { - * "start": "2026-05-28T18:10:35.000Z", - * "end": "2026-06-28T18:10:35.000Z" - * } + * "provider": "supabase_auth", + * "connectionId": "iac_Q2tX9vL1mB7rN4cP8wZsA", + * "webhookUrl": "https://brew.new/api/integrations/supabase_auth/webhook/iac_Q2tX9vL1mB7rN4cP8wZsA", + * "status": "pending_verification" * } */ - "application/json": components["schemas"]["UsageGetResponse"]; + "application/json": components["schemas"]["IntegrationConnection"]; }; }; - /** @description `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. */ + /** + * @description `BRAND_ID_REQUIRED`: An organization-scoped credential called a brand-scoped operation without naming the brand. + * + * `INVALID_REQUEST`: The body or query failed validation: an unknown key, a wrong type, or a missing required field. `param` names the offender. + */ 400: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -29798,7 +31894,15 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; - /** @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. */ + /** + * @description `ACCOUNT_SUSPENDED`: The organization behind the credential is suspended. + * + * `BRAND_SCOPE_MISMATCH`: A brand-scoped credential named a brand other than the one it is bound to. + * + * `INSUFFICIENT_PERMISSIONS`: The credential lacks the permission scope the operation needs. + * + * `INSUFFICIENT_ROLE`: The caller lacks the access the operation needs: `param` names `member` (access to the brand) or `org_admin` (the organization role). + */ 403: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -29809,6 +31913,21 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; + /** + * @description `BRAND_NOT_FOUND`: The named or bound brand does not exist in this organization (unknown, deleting, or another organization). + * + * `NOT_FOUND`: No v1 resource lives at this path. + */ + 404: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ 429: { headers: { @@ -29841,16 +31960,9 @@ export interface operations { }; }; }; - listIntegrations: { + connectIntegrationHook: { parameters: { - query?: { - /** - * @description Page size (1-100). Defaults to 100. - * @example 50 - */ - limit?: number; - cursor?: string; - }; + query?: never; header?: { /** * @description The brand this request acts on. REQUIRED for organization-scoped credentials (otherwise `400 BRAND_ID_REQUIRED` — there is no default brand); list ids with `GET /v1/brands`. Brand-scoped credentials may omit it, and sending a different brand returns `403 BRAND_SCOPE_MISMATCH`. A brand outside your organization returns `404 BRAND_NOT_FOUND`. @@ -29858,12 +31970,19 @@ export interface operations { */ "X-Brand-Id"?: string; }; - path?: never; + path: { + /** @description `supabase_auth` (the Supabase Auth Send Email Hook) or `clerk` (Clerk webhooks; `email.created` carries the auth emails). */ + provider: "supabase_auth" | "clerk"; + }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["IntegrationConnectionPutRequest"]; + }; + }; responses: { - /** @description Catalog rows for every known provider. */ + /** @description Connected; the auth app emails are set up. */ 200: { headers: { /** @description Unique request identifier. Share this with support when debugging a request. */ @@ -29879,50 +31998,27 @@ export interface operations { content: { /** * @example { - * "data": [ + * "provider": "supabase_auth", + * "connectionId": "iac_Q2tX9vL1mB7rN4cP8wZsA", + * "webhookUrl": "https://brew.new/api/integrations/supabase_auth/webhook/iac_Q2tX9vL1mB7rN4cP8wZsA", + * "status": "pending_verification", + * "authEmails": [ * { - * "provider": "hubspot", - * "name": "HubSpot", - * "category": "data_out", - * "connected": true, - * "installationCount": 1, - * "capabilities": [ - * { - * "capabilityId": "contacts_import", - * "isReady": true - * }, - * { - * "capabilityId": "crm_context", - * "isReady": true - * }, - * { - * "capabilityId": "export_template", - * "isReady": true - * } - * ] + * "triggerKey": "auth.verify-email", + * "ok": true * }, * { - * "provider": "klaviyo", - * "name": "Klaviyo", - * "category": "data_out", - * "connected": true + * "triggerKey": "auth.magic-link", + * "ok": true * }, * { - * "provider": "typeform", - * "name": "Typeform", - * "category": "data_in", - * "connected": false, - * "comingSoon": true + * "triggerKey": "auth.otp", + * "ok": true * } - * ], - * "pagination": { - * "limit": 100, - * "cursor": null, - * "hasMore": false - * } + * ] * } */ - "application/json": components["schemas"]["IntegrationsListResponse"]; + "application/json": components["schemas"]["IntegrationConnectionPutResponse"]; }; }; /** From 9bec9c3f392d41ecb9adafb7387a51ec0997efc1 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit Date: Tue, 13 Oct 2026 10:27:59 +0200 Subject: [PATCH 02/26] feat(cli): send by key, app-emails list|get|upsert|kit, trigger keys - `send ` binds `brew.send` (the by-key fire): `--to` sets payload.email over `--data `, `--idempotency-key`, destructive with a confirm summary naming key and recipient; no recipient exits 2. `automations.triggers.fireByKey` is a reviewed SDK skip (alias of send). - `app-emails list|get` (sender + readiness table), `upsert ` (starter or --source-file with format from the extension, --no-publish skips the gate, --overwrite), `kit` (--sets/--stack/--keys/--domain/ --overwrite; exits 1 when a key failed). Upsert and kit take the SDK's 5-minute defaults as their deadline. - `--key` on `automations triggers create|update`; KEY column in list. - `types` appends a BrewTriggerKeyRegistry augmentation for keyed triggers (byte-identical output when none, pinned by a golden test; `--no-registry` opts out). The file builder and the trigger pager are exported for `init`. - Dependency range `@brew.new/sdk ^12.3.0`; bun.lock is refreshed once 12.3.0 is on npm. Co-Authored-By: Claude Opus 5.5 --- docs/commands/README.md | 111 +++- skills/brew-cli/SKILL.md | 21 + src/cli.ts | 2 + src/commands/app-emails/get.ts | 26 + src/commands/app-emails/kit.ts | 139 +++++ src/commands/app-emails/list.ts | 75 +++ src/commands/app-emails/upsert.ts | 186 ++++++ src/commands/automations/triggers/create.ts | 7 + src/commands/automations/triggers/list.ts | 1 + src/commands/automations/triggers/update.ts | 7 + src/commands/send.ts | 98 +++ src/commands/types.ts | 184 ++++-- src/generated/skills.ts | 2 +- src/lib/long-running.ts | 10 + src/registry.ts | 10 + src/skip-list.ts | 5 + tests/commands/app-emails.test.ts | 629 ++++++++++++++++++++ tests/commands/types.test.ts | 71 +++ 18 files changed, 1536 insertions(+), 48 deletions(-) create mode 100644 src/commands/app-emails/get.ts create mode 100644 src/commands/app-emails/kit.ts create mode 100644 src/commands/app-emails/list.ts create mode 100644 src/commands/app-emails/upsert.ts create mode 100644 src/commands/send.ts create mode 100644 tests/commands/app-emails.test.ts diff --git a/docs/commands/README.md b/docs/commands/README.md index fd46d0ca..47f69a1e 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ -150 commands. Classes: read (always safe), write +155 commands. Classes: read (always safe), write (mutating, retry-safe), destructive (irreversible — the confirmation protocol applies: interactive y/N on a TTY, exit 4 + JSON envelope with a `confirmCommand` otherwise, `--yes` to proceed). @@ -75,7 +75,7 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli sends cancel` | destructive | `POST /v1/sends/{sendId}/cancel` | Cancel a scheduled or queued send before it goes out | | `brew-cli sends pause` | write | `POST /v1/sends/{sendId}/pause` | Pause an in-flight or scheduled send (resumable) | | `brew-cli sends resume` | write | `POST /v1/sends/{sendId}/resume` | Resume a paused gradual send (the unsent tail is re-spread); the send reports `running` again | -| `brew-cli types` | read | — | Generate TypeScript payload contracts for this workspace's triggers into your codebase; --check is the CI drift gate (exit 1 on drift). Needs the automations scope | +| `brew-cli types` | read | — | Generate TypeScript payload contracts for this workspace's triggers (plus the brew.send key registry for keyed triggers) into your codebase; --check is the CI drift gate (exit 1 on drift). Needs the automations scope | | `brew-cli audiences list` | read | `GET /v1/audiences` | List audience segments; one segment is `audiences get` | | `brew-cli audiences get` | read | `GET /v1/audiences/{audienceId}` | Fetch one audience segment by id — the bare row | | `brew-cli audiences create` | write | `POST /v1/audiences` | Create an audience segment from a filter definition | @@ -104,6 +104,11 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli automations triggers update` | write | `PATCH /v1/automations/triggers/{triggerEventId}` | Update a trigger event (title, description, payload schema) | | `brew-cli automations triggers delete` | destructive | `DELETE /v1/automations/triggers/{triggerEventId}` | Delete a trigger event (rejected while automations depend on it) | | `brew-cli automations triggers fire` | destructive | `POST /v1/automations/triggers/{triggerEventId}/fire` | Fire a trigger event with a payload (starts LIVE runs) | +| `brew-cli send` | destructive | `POST /v1/automations/triggers/by-key/{triggerKey}/fire` | Send the app email behind a trigger key (fires the key: starts LIVE runs) | +| `brew-cli app-emails list` | read | `GET /v1/app-emails` | List the app emails your code sends by key, with sender and readiness | +| `brew-cli app-emails get` | read | `GET /v1/app-emails/{triggerKey}` | Fetch one app email by its trigger key (404 TRIGGER_KEY_NOT_FOUND when none is set up) | +| `brew-cli app-emails upsert` | destructive | `PUT /v1/app-emails/{triggerKey}` | Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it | +| `brew-cli app-emails kit` | destructive | `POST /v1/app-emails/kit` | Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish) | | `brew-cli automations runs list` | read | `GET /v1/automations/runs` | List automation runs (live + test history); one run is `automations runs get` | | `brew-cli automations runs get` | read | `GET /v1/automations/runs/{automationRunId}` | Fetch one automation run by id — the bare row | | `brew-cli automations runs cancel` | destructive | `POST /v1/automations/runs/{automationRunId}/cancel` | Cancel one in-flight automation run (event execution or test run) — nothing further is sent, and it can never be resumed | @@ -1128,11 +1133,12 @@ brew-cli sends resume snd_123 ### brew-cli types -Generate TypeScript payload contracts for this workspace's triggers into your codebase; --check is the CI drift gate (exit 1 on drift). Needs the automations scope +Generate TypeScript payload contracts for this workspace's triggers (plus the brew.send key registry for keyed triggers) into your codebase; --check is the CI drift gate (exit 1 on drift). Needs the automations scope - Class: read - Derived from `brew.automations.triggers.list(...)`, `brew.automations.triggers.getContract(...)` - `--out ` — Output file (default brew-contracts.ts) +- `--no-registry` — Leave out the BrewTriggerKeyRegistry augmentation for keyed triggers (for code that does not use @brew.new/sdk's send) - `--check` — Verify the output file is up to date instead of writing; exits 1 on drift ```bash @@ -1526,10 +1532,12 @@ Create a trigger event (title + typed payload schema) - SDK: `brew.automations.triggers.create(...)` - `--title ` — Trigger event title - `--description <text>` — Trigger event description +- `--key <key>` — Stable trigger key your code fires by (`send <key>`), e.g. order.paid; unique per brand - `--input <json>` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key <key>` — Idempotency-Key for safe retries (auto-generated otherwise) ```bash +brew-cli automations triggers create --title "Order paid" --key order.paid --input '{"payloadSchema":{"type":"object","fields":[{"key":"email","type":"string","required":true}]}}' brew-cli automations triggers create --title user.signup --input '{"payloadSchema":{"type":"object","fields":[{"key":"userId","type":"string","required":true}]}}' ``` @@ -1560,10 +1568,12 @@ Update a trigger event (title, description, payload schema) - Argument `triggerEventId` — Id of the trigger event to update - `--title <title>` — New title - `--description <text>` — New description +- `--key <key>` — Set the trigger key your code fires by (clear it with --input '{"key":null}') - `--input <json>` — Full JSON request body, or - to read stdin (flags override it) ```bash brew-cli automations triggers update tev_123 --title user.signup.v2 +brew-cli automations triggers update tev_123 --key user.signup brew-cli automations triggers update tev_123 --input '{"payloadSchema":{"type":"object","fields":[]}}' ``` @@ -1595,6 +1605,100 @@ Fire a trigger event with a payload (starts LIVE runs) brew-cli automations triggers fire tri_signup --input '{"payload":{"email":"jane@example.com"}}' --yes ``` +### brew-cli send + +Send the app email behind a trigger key (fires the key: starts LIVE runs) + +- Route: `POST /v1/automations/triggers/by-key/{triggerKey}/fire` +- Class: destructive +- SDK: `brew.send(...)` +- Argument `key` — Trigger key, e.g. auth.password-reset or order.paid +- `--to <email>` — Recipient address (sets payload.email; overrides --data) +- `--data <json>` — Payload JSON object (the values the email reads as {{ trigger.* }}), or - to read stdin +- `--idempotency-key <key>` — Idempotency-Key for safe retries (auto-generated otherwise) + +```bash +brew-cli send lifecycle.welcome --to jane@example.com --yes +brew-cli send auth.password-reset --to jane@example.com --data '{"userName":"Jane","confirmationUrl":"https://app.example.com/reset?token=…"}' --yes +echo '{"email":"jane@example.com","orderId":"ord_1"}' | brew-cli send order.paid --data - --idempotency-key order.paid:ord_1 --yes +``` + +### brew-cli app-emails list + +List the app emails your code sends by key, with sender and readiness + +- Route: `GET /v1/app-emails` +- Class: read +- SDK: `brew.appEmails.list(...)` +- `--limit <n>` — Page size, 1-100 (default 100) +- `--cursor <cursor>` — Opaque pagination cursor from a previous page +- `--all` — Follow the cursor and return every page as one result + +```bash +brew-cli app-emails list +brew-cli app-emails list --all --json +``` + +### brew-cli app-emails get + +Fetch one app email by its trigger key (404 TRIGGER_KEY_NOT_FOUND when none is set up) + +- Route: `GET /v1/app-emails/{triggerKey}` +- Class: read +- SDK: `brew.appEmails.get(...)` +- Argument `triggerKey` — Trigger key, e.g. auth.password-reset + +```bash +brew-cli app-emails get auth.password-reset +brew-cli app-emails get order.paid --json +``` + +### brew-cli app-emails upsert + +Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it + +- Route: `PUT /v1/app-emails/{triggerKey}` +- Class: destructive +- SDK: `brew.appEmails.upsert(...)` +- Argument `triggerKey` — Trigger key your code sends by: a kit key (auth.password-reset) or your own (order.paid) +- `--starter <id>` — Starter-kit template to use (a kit key alone uses its own starter) +- `--source-file <path>` — Your own design source (HTML, MJML or React Email JSX), or - for stdin +- `--format <format>` — Source format: html | mjml | jsx (default: from the file extension) +- `--title <title>` — App email title in Brew +- `--subject <text>` — Subject line +- `--preview-text <text>` — Inbox preview text +- `--domain <domainId>` — Sending domain id (default: a verified domain for the purpose, else the team-only sandbox) +- `--no-publish` — Save the setup without publishing the automation +- `--overwrite` — Replace a design that was edited in Brew since setup +- `--input <json>` — Full JSON request body, or - to read stdin (flags override it) + +```bash +brew-cli app-emails upsert auth.password-reset --yes +brew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Your order is confirmed" --yes +brew-cli app-emails upsert lifecycle.welcome --overwrite --yes +``` + +### brew-cli app-emails kit + +Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish) + +- Route: `POST /v1/app-emails/kit` +- Class: destructive +- SDK: `brew.appEmails.setUpKit(...)` +- `--sets <sets>` — Comma-separated kit sets: auth, lifecycle (default both) +- `--stack <provider>` — Auth library: better-auth | authjs | supabase | clerk | firebase | custom (narrows the auth keys, picks the snippets) +- `--keys <keys>` — Comma-separated exact kit keys instead of sets (auth.password-reset,lifecycle.welcome) +- `--domain <domainId>` — Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox) +- `--overwrite` — Replace designs that were edited in Brew since setup +- `--input <json>` — Full JSON request body, or - to read stdin (flags override it) +- `--idempotency-key <key>` — Idempotency-Key for safe retries (auto-generated otherwise) + +```bash +brew-cli app-emails kit --stack better-auth --yes +brew-cli app-emails kit --sets lifecycle --yes --json +brew-cli app-emails kit --keys auth.password-reset,auth.verify-email --domain dom_123 --yes +``` + ### brew-cli automations runs list List automation runs (live + test history); one run is `automations runs get` @@ -2752,6 +2856,7 @@ SDK methods intentionally without a dedicated command: - `chats.listAll` — auto-pager covered by `chats list --all` - `emails.comments.listAllMessages` — auto-pager covered by `emails comments list <emailId> --comment-id <id> --all` - `notifications.listAll` — auto-pager covered by `notifications list --all` +- `automations.triggers.fireByKey` — alias of `send` (the same by-key fire route), exposed as `send <key>` - `brand.update` — SDK alias of brand.patch, exposed as `brand update` - `brands.waitForExtraction` — read-only SDK polling helper over brands.get; lifecycle reads are exposed by `brands get` - `withBrand` — client scoping helper activated by the global `--brand`; not an API command diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index b34a0d08..62707230 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -65,6 +65,27 @@ the result's `nextStep` (`codex mcp login brew`). Results: `created | updated | unchanged | manual | printed`, plus one-click `deeplink`s for Cursor and VS Code. +## App emails: send by key + +An app email is a keyed trigger wired to a design and a published +automation. Code sends it with `brew.send(key, payload)` (SDK) or: + +```bash +brew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run +brew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source +brew-cli app-emails list # sender (domain | sandbox | none) and readiness per key +brew-cli send auth.password-reset --to you@example.com --data '{"confirmationUrl":"https://…"}' --yes +brew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation +``` + +Before a sending domain verifies, app emails use the team-only sandbox: +they reach members of the Brew organization only, under a daily cap +(`sender.mode: "sandbox"`). `send` is destructive (real email): it exits 4 +without `--yes`. `upsert` and `kit` publish, so they confirm too; +`upsert --no-publish` saves without the gate. `409 APP_EMAIL_EDITED_IN_BREW` +means a teammate edited the design in Brew: keep it, or pass `--overwrite`. +`automations triggers create --key <key>` gives any trigger a key. + ## The contract you can rely on - Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation diff --git a/src/cli.ts b/src/cli.ts index ac586e89..50f56ebb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,6 +32,8 @@ const GROUP_SUMMARIES: Readonly<Record<string, string>> = { analytics: 'Reports: overview, events, and automation analytics', 'audience-runs': 'Manual-audience launches: read, pause, resume, cancel', 'api-keys': 'Manage organization API keys', + 'app-emails': + 'App emails your code sends by key: the starter kit, one-call setup, readiness', audiences: 'Manage audience segments', automations: 'Manage automation graphs, triggers, and runs', brand: "Read and update the active brand's design context", diff --git a/src/commands/app-emails/get.ts b/src/commands/app-emails/get.ts new file mode 100644 index 00000000..81439ed2 --- /dev/null +++ b/src/commands/app-emails/get.ts @@ -0,0 +1,26 @@ +import { defineCommand } from '../../lib/define-command' +import { renderAppEmails } from './list' + +export const appEmailsGetCommand = defineCommand({ + path: ['app-emails', 'get'], + summary: + 'Fetch one app email by its trigger key (404 TRIGGER_KEY_NOT_FOUND when none is set up)', + sdkMethod: 'appEmails.get', + route: { method: 'GET', path: '/v1/app-emails/{triggerKey}' }, + commandClass: 'read', + args: [ + { + name: 'triggerKey', + summary: 'Trigger key, e.g. auth.password-reset', + isRequired: true, + }, + ], + examples: [ + 'brew-cli app-emails get auth.password-reset', + 'brew-cli app-emails get order.paid --json', + ], + run: async ({ ctx, args }) => { + const row = await ctx.client().appEmails.get(args.triggerKey ?? '') + return { data: row, human: renderAppEmails([row]) } + }, +}) diff --git a/src/commands/app-emails/kit.ts b/src/commands/app-emails/kit.ts new file mode 100644 index 00000000..ef220d91 --- /dev/null +++ b/src/commands/app-emails/kit.ts @@ -0,0 +1,139 @@ +import type { + SetUpAppEmailKitInput, + SetUpAppEmailKitResponse, +} from '@brew.new/sdk' +import { SET_UP_APP_EMAIL_KIT_DEFAULT_TIMEOUT_MS } from '@brew.new/sdk' +import { defineCommand } from '../../lib/define-command' +import { + asSdkInput, + flagString, + IDEMPOTENCY_FLAG, + INPUT_FLAG, + mergeInput, + readJsonFlag, + requestOptions, +} from '../../lib/input' +import { getInvocationPrefix } from '../../lib/invocation' +import { renderTable } from '../../lib/output' + +export const appEmailsKitCommand = defineCommand({ + path: ['app-emails', 'kit'], + summary: + 'Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish)', + sdkMethod: 'appEmails.setUpKit', + route: { method: 'POST', path: '/v1/app-emails/kit' }, + commandClass: 'destructive', + defaultTimeoutMs: SET_UP_APP_EMAIL_KIT_DEFAULT_TIMEOUT_MS, + flags: [ + { + flag: '--sets <sets>', + summary: 'Comma-separated kit sets: auth, lifecycle (default both)', + }, + { + flag: '--stack <provider>', + summary: + 'Auth library: better-auth | authjs | supabase | clerk | firebase | custom (narrows the auth keys, picks the snippets)', + }, + { + flag: '--keys <keys>', + summary: + 'Comma-separated exact kit keys instead of sets (auth.password-reset,lifecycle.welcome)', + }, + { + flag: '--domain <domainId>', + summary: + 'Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox)', + }, + { + flag: '--overwrite', + summary: 'Replace designs that were edited in Brew since setup', + }, + INPUT_FLAG, + IDEMPOTENCY_FLAG, + ], + examples: [ + 'brew-cli app-emails kit --stack better-auth --yes', + 'brew-cli app-emails kit --sets lifecycle --yes --json', + 'brew-cli app-emails kit --keys auth.password-reset,auth.verify-email --domain dom_123 --yes', + ], + confirmSummary: ({ flags }) => { + const keys = list(flags.keys) + const sets = list(flags.sets) + const what = + keys !== undefined + ? keys.join(', ') + : `the ${(sets ?? ['auth', 'lifecycle']).join(' + ')} kit` + const stack = flagString(flags.stack) + return `Set up and publish ${what}${stack === undefined ? '' : ` for ${stack}`}${flags.overwrite === true ? ', replacing designs edited in Brew' : ''}: each key sends real email once your code fires it.` + }, + run: async ({ ctx, flags }) => { + const base = await readJsonFlag(ctx, flags.input, '--input') + const input = mergeInput(base, { + sets: list(flags.sets), + stack: flagString(flags.stack), + keys: list(flags.keys), + domainId: flagString(flags.domain), + overwrite: flags.overwrite === true ? true : undefined, + }) + const result = await ctx + .client() + .appEmails.setUpKit( + asSdkInput<SetUpAppEmailKitInput>(input), + requestOptions(flags) + ) + const failed = result.results.filter((entry) => !entry.ok) + return { + data: result, + human: renderKit(result, getInvocationPrefix(ctx.io.env)), + ...(failed.length > 0 ? { exitCode: 1 } : {}), + } + }, +}) + +function list(value: unknown): string[] | undefined { + const text = flagString(value) + if (text === undefined) { + return + } + const items = text + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== '') + return items.length === 0 ? undefined : items +} + +function renderKit(result: SetUpAppEmailKitResponse, prefix: string): string { + const rows = result.results.map((entry) => + entry.ok + ? { + key: entry.triggerKey, + status: entry.published ? 'published' : 'saved', + sender: entry.sender.mode, + } + : { + key: entry.triggerKey, + status: `failed: ${entry.error.code}`, + sender: '', + } + ) + const failed = result.results.filter((entry) => !entry.ok).length + const lines = [ + renderTable(rows, [ + { key: 'key', header: 'KEY' }, + { key: 'status', header: 'STATUS' }, + { key: 'sender', header: 'SENDER' }, + ]), + '', + `Stack ${result.stack.provider} (${result.stack.authDelivery}): ${result.stack.note}`, + ] + for (const snippet of result.stack.snippets) { + lines.push(`Snippet: ${snippet.title} (${snippet.file})`) + } + if (failed > 0) { + lines.push( + '', + `${failed} key${failed === 1 ? '' : 's'} failed. Run \`${prefix} app-emails kit\` again with the same flags to finish them.` + ) + } + return lines.join('\n') +} diff --git a/src/commands/app-emails/list.ts b/src/commands/app-emails/list.ts new file mode 100644 index 00000000..f24a2290 --- /dev/null +++ b/src/commands/app-emails/list.ts @@ -0,0 +1,75 @@ +import type { AppEmail, ListAppEmailsInput } from '@brew.new/sdk' +import { defineCommand } from '../../lib/define-command' +import { asSdkInput, flagInt, flagString } from '../../lib/input' +import { renderTable } from '../../lib/output' +import { + ALL_FLAG, + CURSOR_FLAG, + collectAll, + LIMIT_FLAG, +} from '../../lib/paginate' + +export const appEmailsListCommand = defineCommand({ + path: ['app-emails', 'list'], + summary: + 'List the app emails your code sends by key, with sender and readiness', + sdkMethod: 'appEmails.list', + route: { method: 'GET', path: '/v1/app-emails' }, + commandClass: 'read', + flags: [LIMIT_FLAG, CURSOR_FLAG, ALL_FLAG], + examples: [ + 'brew-cli app-emails list', + 'brew-cli app-emails list --all --json', + ], + run: async ({ ctx, flags }) => { + const limit = flagInt(flags.limit, '--limit') + const cursor = flagString(flags.cursor) + const input = { + ...(limit === undefined ? {} : { limit }), + ...(cursor === undefined ? {} : { cursor }), + } + const appEmails = ctx.client().appEmails + if (flags.all === true) { + const rows = await collectAll(ctx, (next) => + appEmails.list( + asSdkInput<ListAppEmailsInput>({ + ...input, + ...(next === undefined ? {} : { cursor: next }), + }) + ) + ) + return { + data: { data: rows, pagination: { cursor: null, hasMore: false } }, + human: renderAppEmails(rows), + } + } + const result = await appEmails.list(asSdkInput<ListAppEmailsInput>(input)) + return { data: result, human: renderAppEmails(result.data) } + }, +}) + +export function renderAppEmails(rows: ReadonlyArray<AppEmail>): string { + if (rows.length === 0) { + return 'No app emails set up yet. Run `app-emails kit` or `app-emails upsert <key>`.' + } + return renderTable( + rows.map((row) => ({ + triggerKey: row.triggerKey, + title: row.title, + published: row.published ? 'yes' : 'no', + sender: row.sender.mode, + ready: row.readiness.ready + ? row.readiness.teamOnly + ? 'team only' + : 'yes' + : row.readiness.blockers.join(', '), + })), + [ + { key: 'triggerKey', header: 'KEY' }, + { key: 'title', header: 'TITLE' }, + { key: 'published', header: 'PUBLISHED' }, + { key: 'sender', header: 'SENDER' }, + { key: 'ready', header: 'READY' }, + ] + ) +} diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts new file mode 100644 index 00000000..5125b74e --- /dev/null +++ b/src/commands/app-emails/upsert.ts @@ -0,0 +1,186 @@ +import { extname } from 'node:path' +import type { UpsertAppEmailInput, UpsertAppEmailResponse } from '@brew.new/sdk' +import { UPSERT_APP_EMAIL_DEFAULT_TIMEOUT_MS } from '@brew.new/sdk' +import { defineCommand } from '../../lib/define-command' +import { CliUsageError } from '../../lib/errors' +import { + asSdkInput, + flagString, + INPUT_FLAG, + mergeInput, + readJsonFlag, + readTextFlag, +} from '../../lib/input' +import { getInvocationPrefix } from '../../lib/invocation' + +const FORMAT_BY_EXTENSION: Readonly<Record<string, 'html' | 'jsx' | 'mjml'>> = { + '.htm': 'html', + '.html': 'html', + '.jsx': 'jsx', + '.mjml': 'mjml', + '.tsx': 'jsx', +} + +export const appEmailsUpsertCommand = defineCommand({ + path: ['app-emails', 'upsert'], + summary: + 'Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it', + sdkMethod: 'appEmails.upsert', + route: { method: 'PUT', path: '/v1/app-emails/{triggerKey}' }, + commandClass: 'destructive', + defaultTimeoutMs: UPSERT_APP_EMAIL_DEFAULT_TIMEOUT_MS, + args: [ + { + name: 'triggerKey', + summary: + 'Trigger key your code sends by: a kit key (auth.password-reset) or your own (order.paid)', + isRequired: true, + }, + ], + flags: [ + { + flag: '--starter <id>', + summary: + 'Starter-kit template to use (a kit key alone uses its own starter)', + }, + { + flag: '--source-file <path>', + summary: + 'Your own design source (HTML, MJML or React Email JSX), or - for stdin', + }, + { + flag: '--format <format>', + summary: + 'Source format: html | mjml | jsx (default: from the file extension)', + }, + { flag: '--title <title>', summary: 'App email title in Brew' }, + { flag: '--subject <text>', summary: 'Subject line' }, + { flag: '--preview-text <text>', summary: 'Inbox preview text' }, + { + flag: '--domain <domainId>', + summary: + 'Sending domain id (default: a verified domain for the purpose, else the team-only sandbox)', + }, + { + flag: '--no-publish', + summary: 'Save the setup without publishing the automation', + }, + { + flag: '--overwrite', + summary: 'Replace a design that was edited in Brew since setup', + }, + INPUT_FLAG, + ], + examples: [ + 'brew-cli app-emails upsert auth.password-reset --yes', + 'brew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Your order is confirmed" --yes', + 'brew-cli app-emails upsert lifecycle.welcome --overwrite --yes', + ], + confirmSummary: ({ args, flags }) => { + const inline = inlineInput(flags.input) + const publishes = flags.publish !== false && inline.publish !== false + const overwrites = flags.overwrite === true || inline.overwrite === true + if (!(publishes || overwrites)) { + return + } + const key = args.triggerKey ?? '' + const parts = [ + `Set up app email ${key}`, + overwrites ? ', replacing a design edited in Brew,' : '', + publishes + ? ` and publish it: \`send ${key}\` sends real email from then on.` + : ' without publishing it.', + ] + return parts.join('') + }, + run: async ({ ctx, args, flags }) => { + const base = await readJsonFlag(ctx, flags.input, '--input') + const sourcePath = flagString(flags.sourceFile) + const content = await readTextFlag(ctx, sourcePath, '--source-file') + let source: { format: string; content: string } | undefined + if (content !== undefined) { + const format = + flagString(flags.format) ?? + (sourcePath === undefined || sourcePath === '-' + ? undefined + : FORMAT_BY_EXTENSION[extname(sourcePath).toLowerCase()]) + if (format === undefined) { + throw new CliUsageError( + '--format is required for this source: html | mjml | jsx.' + ) + } + source = { format, content } + } else if (flagString(flags.format) !== undefined) { + throw new CliUsageError('--format applies to --source-file only.') + } + const input = mergeInput(base, { + starter: flagString(flags.starter), + source, + title: flagString(flags.title), + subject: flagString(flags.subject), + previewText: flagString(flags.previewText), + domainId: flagString(flags.domain), + publish: flags.publish === false ? false : undefined, + overwrite: flags.overwrite === true ? true : undefined, + }) + if (input.starter !== undefined && input.source !== undefined) { + throw new CliUsageError( + 'Pass --starter or --source-file, not both: one design per app email.' + ) + } + const result = await ctx.client().appEmails.upsert( + asSdkInput<UpsertAppEmailInput>({ + ...input, + triggerKey: args.triggerKey ?? '', + }) + ) + return { + data: result, + human: renderSetup(result, getInvocationPrefix(ctx.io.env)), + } + }, +}) + +/** `--input` given inline (not stdin), for the confirmation summary only. */ +function inlineInput(value: unknown): Record<string, unknown> { + if (typeof value !== 'string' || value === '-') { + return {} + } + try { + const parsed = JSON.parse(value) as unknown + return parsed !== null && typeof parsed === 'object' + ? (parsed as Record<string, unknown>) + : {} + } catch { + return {} + } +} + +export function renderSetup( + result: Pick< + UpsertAppEmailResponse, + 'triggerKey' | 'published' | 'created' | 'sender' | 'warnings' + >, + prefix: string +): string { + const made = Object.entries(result.created) + .filter(([, created]) => created) + .map(([piece]) => piece) + const sender = + result.sender.mode === 'sandbox' + ? `sandbox ${result.sender.from} (team only until a domain verifies)` + : `${result.sender.from}` + const lines = [ + `${result.triggerKey}: ${result.published ? 'published' : 'saved, not published'}; sender ${sender}.`, + made.length === 0 + ? 'Nothing new: every piece already existed.' + : `Created: ${made.join(', ')}.`, + ] + for (const warning of result.warnings) { + lines.push(`warning: ${warning.message}`) + } + if (result.published) { + lines.push(`Send it: ${prefix} send ${result.triggerKey} --to <email>`) + } + return lines.join('\n') +} diff --git a/src/commands/automations/triggers/create.ts b/src/commands/automations/triggers/create.ts index 729ddc78..fe054ae7 100644 --- a/src/commands/automations/triggers/create.ts +++ b/src/commands/automations/triggers/create.ts @@ -20,10 +20,16 @@ export const automationsTriggersCreateCommand = defineCommand({ flags: [ { flag: '--title <title>', summary: 'Trigger event title' }, { flag: '--description <text>', summary: 'Trigger event description' }, + { + flag: '--key <key>', + summary: + 'Stable trigger key your code fires by (`send <key>`), e.g. order.paid; unique per brand', + }, INPUT_FLAG, IDEMPOTENCY_FLAG, ], examples: [ + `brew-cli automations triggers create --title "Order paid" --key order.paid --input '{"payloadSchema":{"type":"object","fields":[{"key":"email","type":"string","required":true}]}}'`, `brew-cli automations triggers create --title user.signup --input '{"payloadSchema":{"type":"object","fields":[{"key":"userId","type":"string","required":true}]}}'`, ], run: async ({ ctx, flags }) => { @@ -31,6 +37,7 @@ export const automationsTriggersCreateCommand = defineCommand({ const input = mergeInput(base, { title: flagString(flags.title), description: flagString(flags.description), + key: flagString(flags.key), }) if (typeof input.title !== 'string' || input.title === '') { throw new CliUsageError('A title is required (--title or --input).') diff --git a/src/commands/automations/triggers/list.ts b/src/commands/automations/triggers/list.ts index 281fba53..ed7b2316 100644 --- a/src/commands/automations/triggers/list.ts +++ b/src/commands/automations/triggers/list.ts @@ -84,6 +84,7 @@ function renderTriggers(rows: ReadonlyArray<unknown>): string { } return renderTable(rows as ReadonlyArray<Record<string, unknown>>, [ { key: 'triggerEventId', header: 'TRIGGER' }, + { key: 'key', header: 'KEY' }, { key: 'title', header: 'TITLE' }, { key: 'provider', header: 'PROVIDER' }, ]) diff --git a/src/commands/automations/triggers/update.ts b/src/commands/automations/triggers/update.ts index 0fa7cb89..ff943f8a 100644 --- a/src/commands/automations/triggers/update.ts +++ b/src/commands/automations/triggers/update.ts @@ -28,10 +28,16 @@ export const automationsTriggersUpdateCommand = defineCommand({ flags: [ { flag: '--title <title>', summary: 'New title' }, { flag: '--description <text>', summary: 'New description' }, + { + flag: '--key <key>', + summary: + 'Set the trigger key your code fires by (clear it with --input \'{"key":null}\')', + }, INPUT_FLAG, ], examples: [ 'brew-cli automations triggers update tev_123 --title user.signup.v2', + 'brew-cli automations triggers update tev_123 --key user.signup', `brew-cli automations triggers update tev_123 --input '{"payloadSchema":{"type":"object","fields":[]}}'`, ], run: async ({ ctx, args, flags }) => { @@ -39,6 +45,7 @@ export const automationsTriggersUpdateCommand = defineCommand({ const input = mergeInput(base, { title: flagString(flags.title), description: flagString(flags.description), + key: flagString(flags.key), }) if (Object.keys(input).length === 0) { throw new CliUsageError('Nothing to update — pass flags or --input.') diff --git a/src/commands/send.ts b/src/commands/send.ts new file mode 100644 index 00000000..c4cb36a0 --- /dev/null +++ b/src/commands/send.ts @@ -0,0 +1,98 @@ +import type { BrewDefaultSendPayload, BrewSendResponse } from '@brew.new/sdk' +import { defineCommand } from '../lib/define-command' +import { CliUsageError } from '../lib/errors' +import { + asSdkInput, + flagString, + IDEMPOTENCY_FLAG, + readJsonFlag, + requestOptions, +} from '../lib/input' + +export const sendCommand = defineCommand({ + path: ['send'], + summary: + 'Send the app email behind a trigger key (fires the key: starts LIVE runs)', + sdkMethod: 'send', + route: { + method: 'POST', + path: '/v1/automations/triggers/by-key/{triggerKey}/fire', + }, + commandClass: 'destructive', + args: [ + { + name: 'key', + summary: 'Trigger key, e.g. auth.password-reset or order.paid', + isRequired: true, + }, + ], + flags: [ + { + flag: '--to <email>', + summary: 'Recipient address (sets payload.email; overrides --data)', + }, + { + flag: '--data <json>', + summary: + 'Payload JSON object (the values the email reads as {{ trigger.* }}), or - to read stdin', + }, + IDEMPOTENCY_FLAG, + ], + examples: [ + 'brew-cli send lifecycle.welcome --to jane@example.com --yes', + `brew-cli send auth.password-reset --to jane@example.com --data '{"userName":"Jane","confirmationUrl":"https://app.example.com/reset?token=…"}' --yes`, + `echo '{"email":"jane@example.com","orderId":"ord_1"}' | brew-cli send order.paid --data - --idempotency-key order.paid:ord_1 --yes`, + ], + confirmSummary: ({ args, flags }) => { + const to = flagString(flags.to) + return `Send ${args.key ?? ''}${to === undefined ? '' : ` to ${to}`}: starts REAL runs on every published automation listening to this key (the email goes out).` + }, + run: async ({ ctx, args, flags }) => { + const data = await readJsonFlag(ctx, flags.data, '--data') + if ( + data !== undefined && + (data === null || typeof data !== 'object' || Array.isArray(data)) + ) { + throw new CliUsageError( + '--data must be a JSON object (the payload, without a { payload } envelope).' + ) + } + const payload: Record<string, unknown> = { + ...((data as Record<string, unknown> | undefined) ?? {}), + } + const to = flagString(flags.to) + if (to !== undefined) { + payload.email = to + } + if (typeof payload.email !== 'string' || payload.email.trim() === '') { + throw new CliUsageError( + 'A recipient is required: --to <email>, or an "email" field in --data.' + ) + } + const key = args.key ?? '' + const result = await ctx + .client() + .send( + key, + asSdkInput<BrewDefaultSendPayload>(payload), + requestOptions(flags) + ) + return { data: result, human: renderSend(result) } + }, +}) + +function renderSend(result: BrewSendResponse): string { + const runs = result.automationRunIds?.length ?? 0 + const lines = [ + `${result.status === 'replayed' ? 'Replayed' : 'Sent'} ${result.triggerKey}: ${runs} run${runs === 1 ? '' : 's'} started (instance ${result.triggerInstanceId}).`, + ] + for (const skipped of result.notStarted ?? []) { + lines.push( + `not started: ${skipped.automationId} (${skipped.code ?? skipped.reason})` + ) + } + for (const warning of result.warnings ?? []) { + lines.push(`warning: ${warning.message ?? warning.code ?? ''}`) + } + return lines.join('\n') +} diff --git a/src/commands/types.ts b/src/commands/types.ts index 2fbc5549..981da849 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -1,11 +1,19 @@ import { createHash } from 'node:crypto' import { readFileSync, writeFileSync } from 'node:fs' -import type { ListTriggersInput, ListTriggersResponse } from '@brew.new/sdk' +import type { + BrewClient, + ListTriggersInput, + ListTriggersResponse, + Trigger, +} from '@brew.new/sdk' import { defineCommand } from '../lib/define-command' import { asSdkInput } from '../lib/input' import { getInvocationPrefix } from '../lib/invocation' import { collectAll } from '../lib/paginate' +import type { CliContext } from '../lib/types' + +type TriggerRow = Trigger /** * `brew-cli types` — generate TypeScript payload contracts for this @@ -17,14 +25,20 @@ import { collectAll } from '../lib/paginate' const HEADER_PREFIX = '// brew:contracts sha256:' -type ContractArtifact = { +export type ContractArtifact = { readonly triggerEventId: string readonly typeName: string readonly content: string + /** The trigger's developer key (`order.paid`), when it has one. */ + readonly key?: string } const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/ +function propertyKey(key: string): string { + return IDENTIFIER_RE.test(key) ? key : JSON.stringify(key) +} + /** Trigger ids supply deterministic suffixes when stored type names collide. */ function identifierSuffix(value: string): string { return value @@ -86,8 +100,43 @@ function uniqueNames( }) } -function buildFileText(args: { - artifacts: ReadonlyArray<ContractArtifact> +/** + * The `BrewTriggerKeyRegistry` augmentation for every keyed trigger, so + * `brew.send('order.paid', …)` type-checks its key and payload. Empty (no + * block at all) when no trigger has a key: the file stays byte-identical to + * what a key-less workspace always produced. + */ +function registryBlock( + entries: ReadonlyArray<{ key: string | undefined; name: string }> +): string | undefined { + const keyed = entries + .flatMap((entry) => + entry.key === undefined || entry.key === '' + ? [] + : [{ key: entry.key, name: entry.name }] + ) + .sort((a, b) => byCodepoint(a.key, b.key)) + if (keyed.length === 0) { + return + } + return [ + '/** `brew.send(key, payload)` checks the key and its payload against these. */', + "declare module '@brew.new/sdk' {", + ' interface BrewTriggerKeyRegistry {', + ...keyed.map(({ key, name }) => ` ${propertyKey(key)}: ${name}`), + ' }', + '}', + ].join('\n') +} + +/** + * The generated contracts file: one payload type per trigger contract, plus + * the `BrewTriggerKeyRegistry` augmentation for keyed triggers unless + * `registry` is false. Deterministic: same contracts, same bytes. + */ +export function buildContractsFile(args: { + readonly artifacts: ReadonlyArray<ContractArtifact> + readonly registry?: boolean }): string { const artifacts = [...args.artifacts].sort((a, b) => byCodepoint(a.triggerEventId, b.triggerEventId) @@ -102,22 +151,96 @@ function buildFileText(args: { const triggerBlocks = artifacts.map((artifact, i) => emitArtifact(artifact, names[i] ?? artifact.typeName) ) + const registry = + args.registry === false + ? undefined + : registryBlock( + artifacts.map((artifact, i) => ({ + key: artifact.key, + name: names[i] ?? artifact.typeName, + })) + ) const body = [ '// Generated by `brew-cli types` — do not edit by hand.', '// Re-run `brew-cli types` after changing a trigger schema; gate', '// drift in CI with `brew-cli types --check`.', '', triggerBlocks.join('\n\n'), + ...(registry === undefined ? [] : ['', registry]), '', ].join('\n') const hash = createHash('sha256').update(body).digest('hex') return `${HEADER_PREFIX}${hash}\n${body}` } +/** + * Every trigger in the brand, following the cursor to the end: a silent + * 100-trigger cap would make `--check` certify an incomplete file + * (integration catalogs alone can cross 100). Shared pagination rejects + * cursor cycles and observes the command's stop signal. + */ +export async function listAllTriggers( + ctx: CliContext, + client: BrewClient +): Promise<ReadonlyArray<TriggerRow>> { + return await collectAll(ctx, async (cursor) => { + const response: ListTriggersResponse = + await client.automations.triggers.list( + asSdkInput<ListTriggersInput>({ + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }) + ) + return { data: response.data, pagination: response.pagination } + }) +} + +/** + * The authoritative TypeScript contract of every trigger, eight requests at + * a time, each tagged with the trigger's key for the send registry. + */ +export async function fetchContractArtifacts( + client: BrewClient, + triggers: ReadonlyArray<TriggerRow>, + signal?: AbortSignal +): Promise<Array<ContractArtifact>> { + const artifacts: Array<ContractArtifact> = [] + for (let index = 0; index < triggers.length; index += 8) { + signal?.throwIfAborted() + const batch = await Promise.all( + triggers.slice(index, index + 8).map(async (trigger) => { + const contract = await client.automations.triggers.getContract({ + triggerEventId: trigger.triggerEventId, + format: 'ts', + }) + if ( + contract.format !== 'ts' || + typeof contract.content !== 'string' || + !IDENTIFIER_RE.test(contract.typeName) + ) { + throw new Error( + `Unexpected TypeScript contract response for ${trigger.triggerEventId}.` + ) + } + return { + triggerEventId: trigger.triggerEventId, + typeName: contract.typeName, + content: contract.content, + ...(typeof trigger.key === 'string' && trigger.key !== '' + ? { key: trigger.key } + : {}), + } + }) + ) + artifacts.push(...batch) + } + return artifacts +} + export const typesCommand = defineCommand({ path: ['types'], summary: - "Generate TypeScript payload contracts for this workspace's triggers into your codebase; --check is the CI drift gate (exit 1 on drift). Needs the automations scope", + "Generate TypeScript payload contracts for this workspace's triggers (plus the brew.send key registry for keyed triggers) into your codebase; --check is the CI drift gate (exit 1 on drift). Needs the automations scope", sdkMethod: null, derivedFrom: [ 'automations.triggers.list', @@ -130,6 +253,11 @@ export const typesCommand = defineCommand({ summary: 'Output file (default brew-contracts.ts)', defaultValue: 'brew-contracts.ts', }, + { + flag: '--no-registry', + summary: + "Leave out the BrewTriggerKeyRegistry augmentation for keyed triggers (for code that does not use @brew.new/sdk's send)", + }, { flag: '--check', summary: @@ -144,47 +272,15 @@ export const typesCommand = defineCommand({ run: async ({ ctx, flags }) => { // Complete the read before writing or certifying any artifact. Shared // pagination rejects cycles and observes the command's stop signal. - const triggers = await collectAll(ctx, async (cursor) => { - const response: ListTriggersResponse = await ctx - .client() - .automations.triggers.list( - asSdkInput<ListTriggersInput>({ - limit: 100, - ...(cursor === undefined ? {} : { cursor }), - }) - ) - return { data: response.data, pagination: response.pagination } - }) + const client = ctx.client() + const triggers = await listAllTriggers(ctx, client) ctx.signal.throwIfAborted() - const artifacts: Array<ContractArtifact> = [] - for (let index = 0; index < triggers.length; index += 8) { - ctx.signal.throwIfAborted() - const batch = await Promise.all( - triggers.slice(index, index + 8).map(async (trigger) => { - const contract = await ctx.client().automations.triggers.getContract({ - triggerEventId: trigger.triggerEventId, - format: 'ts', - }) - if ( - contract.format !== 'ts' || - typeof contract.content !== 'string' || - !IDENTIFIER_RE.test(contract.typeName) - ) { - throw new Error( - `Unexpected TypeScript contract response for ${trigger.triggerEventId}.` - ) - } - return { - triggerEventId: trigger.triggerEventId, - typeName: contract.typeName, - content: contract.content, - } - }) - ) - artifacts.push(...batch) - } + const artifacts = await fetchContractArtifacts(client, triggers, ctx.signal) ctx.signal.throwIfAborted() - const text = buildFileText({ artifacts }) + const text = buildContractsFile({ + artifacts, + registry: flags.registry !== false, + }) const outPath = typeof flags.out === 'string' ? flags.out : 'brew-contracts.ts' diff --git a/src/generated/skills.ts b/src/generated/skills.ts index e89ef1f5..c6b0feb4 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `409 APP_EMAIL_EDITED_IN_BREW`\nmeans a teammate edited the design in Brew: keep it, or pass `--overwrite`.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/src/lib/long-running.ts b/src/lib/long-running.ts index 5468556a..b5143d0e 100644 --- a/src/lib/long-running.ts +++ b/src/lib/long-running.ts @@ -73,6 +73,16 @@ export const LONG_RUNNING_ROUTES: ReadonlyArray<{ path: '/v1/content/add-image', timeoutMs: sdk.ADD_IMAGE_DEFAULT_TIMEOUT_MS, }, + { + method: 'PUT', + path: '/v1/app-emails/{triggerKey}', + timeoutMs: sdk.UPSERT_APP_EMAIL_DEFAULT_TIMEOUT_MS, + }, + { + method: 'POST', + path: '/v1/app-emails/kit', + timeoutMs: sdk.SET_UP_APP_EMAIL_KIT_DEFAULT_TIMEOUT_MS, + }, ] /** The long-running default for a concrete request path, if it has one. */ diff --git a/src/registry.ts b/src/registry.ts index 1cc55549..975c8ae2 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -10,6 +10,10 @@ import { apiCommand } from './commands/api' import { apiKeysCreateCommand } from './commands/api-keys/create' import { apiKeysDeleteCommand } from './commands/api-keys/delete' import { apiKeysListCommand } from './commands/api-keys/list' +import { appEmailsGetCommand } from './commands/app-emails/get' +import { appEmailsKitCommand } from './commands/app-emails/kit' +import { appEmailsListCommand } from './commands/app-emails/list' +import { appEmailsUpsertCommand } from './commands/app-emails/upsert' import { audiencesCreateCommand } from './commands/audiences/create' import { audiencesDeleteCommand } from './commands/audiences/delete' import { audiencesDuplicateCommand } from './commands/audiences/duplicate' @@ -141,6 +145,7 @@ import { loginCommand } from './commands/login' import { logoutCommand } from './commands/logout' import { mcpInstallCommand } from './commands/mcp/install' import { notificationsListCommand } from './commands/notifications/list' +import { sendCommand } from './commands/send' import { sendsCancelCommand } from './commands/sends/cancel' import { sendsGetCommand } from './commands/sends/get' import { sendsListCommand } from './commands/sends/list' @@ -241,6 +246,11 @@ export const ALL_COMMANDS: readonly CommandSpec[] = [ automationsTriggersUpdateCommand, automationsTriggersDeleteCommand, automationsTriggersFireCommand, + sendCommand, + appEmailsListCommand, + appEmailsGetCommand, + appEmailsUpsertCommand, + appEmailsKitCommand, automationsRunsListCommand, automationsRunsGetCommand, automationsRunsCancelCommand, diff --git a/src/skip-list.ts b/src/skip-list.ts index f18d053c..42903e1d 100644 --- a/src/skip-list.ts +++ b/src/skip-list.ts @@ -49,6 +49,11 @@ export const SDK_SKIP_LIST: readonly SdkSkip[] = [ sdkPath: 'notifications.listAll', reason: 'auto-pager covered by `notifications list --all`', }, + { + sdkPath: 'automations.triggers.fireByKey', + reason: + 'alias of `send` (the same by-key fire route), exposed as `send <key>`', + }, { sdkPath: 'brand.update', reason: 'SDK alias of brand.patch, exposed as `brand update`', diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts new file mode 100644 index 00000000..4dca0143 --- /dev/null +++ b/tests/commands/app-emails.test.ts @@ -0,0 +1,629 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import type { components } from '../../src/generated/openapi-types' +import { server } from '../helpers/msw-server' +import { type RunCliOptions, runCli } from '../helpers/run-cli' + +const KEY = 'brew_abcdefghijklmnopqrstuvwxyz012345' +const API = 'https://brew.new/api' + +function cli(argv: readonly string[], options: RunCliOptions = {}) { + return runCli(argv, { + ...options, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-test-')), + BREW_API_KEY: KEY, + ...options.env, + }, + }) +} + +type FireAccepted = components['schemas']['TriggerFireAccepted'] +type AppEmailRow = components['schemas']['AppEmailRow'] +type SetupResult = components['schemas']['AppEmailSetupResult'] + +const ACCEPTED: FireAccepted = { + triggerInstanceId: 'tin_1', + triggerEventId: 'tri_reset', + triggerKey: 'auth.password-reset', + status: 'triggered', + automationRunIds: ['run_1'], + notStarted: [], + publishedAutomations: [{ automationId: 'aut_1' }], + counts: { automations: 1, skipped: 0 }, + warnings: [], + receivedAt: '2026-10-13T10:00:00.000Z', +} + +const ROW: AppEmailRow = { + triggerKey: 'auth.password-reset', + triggerEventId: 'tri_reset', + title: 'Password reset', + emailId: 'eml_1', + automationId: 'aut_1', + starterId: 'auth.password-reset', + starterVersion: 1, + upgradeAvailable: false, + published: true, + sender: { mode: 'sandbox' }, + readiness: { ready: true, teamOnly: true, blockers: [] }, + updatedAt: '2026-10-13T10:00:00.000Z', +} + +const SETUP: SetupResult = { + triggerKey: 'auth.password-reset', + triggerEventId: 'tri_reset', + emailId: 'eml_1', + emailVersionId: 'emv_1', + automationId: 'aut_1', + published: true, + created: { trigger: true, email: true, automation: true }, + sender: { + mode: 'sandbox', + from: 'hello@email.brew.new', + sandbox: { dailyLimit: 50, remainingToday: 50 }, + }, + payloadSchema: { + type: 'object', + fields: [{ key: 'email', type: 'string', required: true }], + }, + fireExample: { + method: 'POST', + path: '/v1/automations/triggers/by-key/auth.password-reset/fire', + examplePayload: { email: 'jane@example.com' }, + curl: 'curl …', + sdk: "await brew.send('auth.password-reset', { email })", + }, + warnings: [], +} + +describe('send', () => { + it('exits 4 with a summary naming the key and recipient when unconfirmed', async () => { + const result = await cli([ + 'send', + 'auth.password-reset', + '--to', + 'jane@example.com', + ]) + expect(result.code).toBe(4) + const envelope = result.json as { + summary: string + confirmCommand: string + } + expect(envelope.summary).toContain( + 'auth.password-reset to jane@example.com' + ) + expect(envelope.summary).toContain('REAL') + expect(envelope.confirmCommand).toBe( + 'brew-cli send auth.password-reset --to jane@example.com --yes' + ) + }) + + it('fires by key with --to merged over --data, forwarding the idempotency key', async () => { + let body: unknown + let idempotency: string | null = null + server.use( + http.post( + `${API}/v1/automations/triggers/by-key/auth.password-reset/fire`, + async ({ request }) => { + body = await request.json() + idempotency = request.headers.get('idempotency-key') + return HttpResponse.json(ACCEPTED, { status: 202 }) + } + ) + ) + const result = await cli([ + 'send', + 'auth.password-reset', + '--to', + 'jane@example.com', + '--data', + '{"email":"old@example.com","userName":"Jane"}', + '--idempotency-key', + 'reset:abc', + '--yes', + ]) + expect(result.code).toBe(0) + expect(body).toEqual({ + payload: { email: 'jane@example.com', userName: 'Jane' }, + }) + expect(idempotency).toBe('reset:abc') + expect(result.json).toEqual(ACCEPTED) + }) + + it('reads the payload from stdin with --data -', async () => { + let body: unknown + server.use( + http.post( + `${API}/v1/automations/triggers/by-key/order.paid/fire`, + async ({ request }) => { + body = await request.json() + return HttpResponse.json( + { ...ACCEPTED, triggerKey: 'order.paid' }, + { status: 202 } + ) + } + ) + ) + const result = await cli(['send', 'order.paid', '--data', '-', '--yes'], { + stdin: '{"email":"jane@example.com","orderId":"ord_1"}', + }) + expect(result.code).toBe(0) + expect(body).toEqual({ + payload: { email: 'jane@example.com', orderId: 'ord_1' }, + }) + }) + + it('renders a one-line human summary on a TTY', async () => { + server.use( + http.post( + `${API}/v1/automations/triggers/by-key/auth.password-reset/fire`, + () => HttpResponse.json(ACCEPTED, { status: 202 }) + ) + ) + const result = await cli( + ['send', 'auth.password-reset', '--to', 'jane@example.com', '--yes'], + { ttyOut: true } + ) + expect(result.code).toBe(0) + expect(result.stdout).toBe( + 'Sent auth.password-reset: 1 run started (instance tin_1).\n' + ) + }) + + it('refuses a send with no recipient (exit 2, no request)', async () => { + const result = await cli([ + 'send', + 'auth.password-reset', + '--data', + '{"userName":"Jane"}', + '--yes', + ]) + expect(result.code).toBe(2) + expect(result.stderr + result.stdout).toContain('--to <email>') + }) + + it('refuses a non-object --data (exit 2)', async () => { + const result = await cli([ + 'send', + 'auth.password-reset', + '--data', + '["jane@example.com"]', + '--yes', + ]) + expect(result.code).toBe(2) + }) + + it('reports TRIGGER_KEY_NOT_FOUND as an API error (exit 1)', async () => { + server.use( + http.post(`${API}/v1/automations/triggers/by-key/nope/fire`, () => + HttpResponse.json( + { + error: { + code: 'TRIGGER_KEY_NOT_FOUND', + message: 'No trigger has the key "nope" in this brand.', + type: 'not_found_error', + suggestion: 'List keys with GET /v1/app-emails.', + docs: 'https://docs.brew.new', + retryable: false, + }, + }, + { status: 404 } + ) + ) + ) + const result = await cli([ + 'send', + 'nope', + '--to', + 'jane@example.com', + '--yes', + ]) + expect(result.code).toBe(1) + expect(result.stdout + result.stderr).toContain('TRIGGER_KEY_NOT_FOUND') + }) +}) + +describe('app-emails list / get', () => { + it('lists with page flags', async () => { + let url: URL | undefined + server.use( + http.get(`${API}/v1/app-emails`, ({ request }) => { + url = new URL(request.url) + return HttpResponse.json({ + data: [ROW], + pagination: { limit: 5, cursor: null, hasMore: false }, + }) + }) + ) + const result = await cli(['app-emails', 'list', '--limit', '5']) + expect(result.code).toBe(0) + expect(url?.searchParams.get('limit')).toBe('5') + expect((result.json as { data: AppEmailRow[] }).data).toEqual([ROW]) + }) + + it('follows the cursor with --all', async () => { + const seen: Array<string | null> = [] + server.use( + http.get(`${API}/v1/app-emails`, ({ request }) => { + const cursor = new URL(request.url).searchParams.get('cursor') + seen.push(cursor) + return HttpResponse.json( + cursor === null + ? { + data: [ROW], + pagination: { limit: 100, cursor: 'c2', hasMore: true }, + } + : { + data: [{ ...ROW, triggerKey: 'lifecycle.welcome' }], + pagination: { limit: 100, cursor: null, hasMore: false }, + } + ) + }) + ) + const result = await cli(['app-emails', 'list', '--all']) + expect(result.code).toBe(0) + expect(seen).toEqual([null, 'c2']) + expect( + (result.json as { data: AppEmailRow[] }).data.map((row) => row.triggerKey) + ).toEqual(['auth.password-reset', 'lifecycle.welcome']) + }) + + it('renders readiness in the human table', async () => { + server.use( + http.get(`${API}/v1/app-emails`, () => + HttpResponse.json({ + data: [ + ROW, + { + ...ROW, + triggerKey: 'order.paid', + title: 'Order paid', + published: false, + sender: { mode: 'none' }, + readiness: { + ready: false, + teamOnly: false, + blockers: ['AUTOMATION_NOT_PUBLISHED'], + }, + }, + ], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + ) + ) + const result = await cli(['app-emails', 'list'], { ttyOut: true }) + expect(result.stdout).toContain('auth.password-reset') + expect(result.stdout).toContain('team only') + expect(result.stdout).toContain('AUTOMATION_NOT_PUBLISHED') + }) + + it('gets one by key', async () => { + server.use( + http.get(`${API}/v1/app-emails/auth.password-reset`, () => + HttpResponse.json(ROW) + ) + ) + const result = await cli(['app-emails', 'get', 'auth.password-reset']) + expect(result.code).toBe(0) + expect(result.json).toEqual(ROW) + }) +}) + +describe('app-emails upsert', () => { + it('exits 4 when unconfirmed (it publishes)', async () => { + const result = await cli(['app-emails', 'upsert', 'auth.password-reset']) + expect(result.code).toBe(4) + expect((result.json as { summary: string }).summary).toContain('publish it') + }) + + it('PUTs the starter setup with flags, without an Idempotency-Key', async () => { + let body: unknown + let idempotency: string | null = 'unset' + server.use( + http.put( + `${API}/v1/app-emails/auth.password-reset`, + async ({ request }) => { + body = await request.json() + idempotency = request.headers.get('idempotency-key') + return HttpResponse.json(SETUP, { status: 201 }) + } + ) + ) + const result = await cli([ + 'app-emails', + 'upsert', + 'auth.password-reset', + '--subject', + 'Reset your password', + '--domain', + 'dom_1', + '--overwrite', + '--yes', + ]) + expect(result.code).toBe(0) + expect(body).toEqual({ + subject: 'Reset your password', + domainId: 'dom_1', + overwrite: true, + }) + expect(idempotency).toBeNull() + expect(result.json).toEqual(SETUP) + }) + + it('reads a source file and infers jsx from .tsx', async () => { + const dir = mkdtempSync(join(tmpdir(), 'brew-upsert-')) + const file = join(dir, 'order-paid.tsx') + writeFileSync(file, 'export default function E() { return <p>Hi</p> }') + let body: unknown + server.use( + http.put(`${API}/v1/app-emails/order.paid`, async ({ request }) => { + body = await request.json() + return HttpResponse.json( + { ...SETUP, triggerKey: 'order.paid' }, + { status: 201 } + ) + }) + ) + const result = await cli([ + 'app-emails', + 'upsert', + 'order.paid', + '--source-file', + file, + '--yes', + ]) + expect(result.code).toBe(0) + expect(body).toEqual({ + source: { + format: 'jsx', + content: readFileSync(file, 'utf8'), + }, + }) + }) + + it('skips the gate with --no-publish and sends publish: false', async () => { + let body: unknown + server.use( + http.put(`${API}/v1/app-emails/order.paid`, async ({ request }) => { + body = await request.json() + return HttpResponse.json({ ...SETUP, published: false }) + }) + ) + const result = await cli([ + 'app-emails', + 'upsert', + 'order.paid', + '--starter', + 'lifecycle.welcome', + '--no-publish', + ]) + expect(result.code).toBe(0) + expect(body).toEqual({ starter: 'lifecycle.welcome', publish: false }) + }) + + it('refuses --starter with --source-file (exit 2)', async () => { + const result = await cli( + [ + 'app-emails', + 'upsert', + 'order.paid', + '--starter', + 'lifecycle.welcome', + '--source-file', + '-', + '--format', + 'html', + '--yes', + ], + { stdin: '<p>Hi</p>' } + ) + expect(result.code).toBe(2) + }) + + it('surfaces APP_EMAIL_EDITED_IN_BREW (exit 1)', async () => { + server.use( + http.put(`${API}/v1/app-emails/lifecycle.welcome`, () => + HttpResponse.json( + { + error: { + code: 'APP_EMAIL_EDITED_IN_BREW', + message: 'The design was edited in Brew since setup.', + type: 'conflict_error', + suggestion: 'Pass overwrite: true to replace it.', + docs: 'https://docs.brew.new', + retryable: false, + }, + }, + { status: 409 } + ) + ) + ) + const result = await cli([ + 'app-emails', + 'upsert', + 'lifecycle.welcome', + '--yes', + ]) + expect(result.code).toBe(1) + expect(result.stdout + result.stderr).toContain('APP_EMAIL_EDITED_IN_BREW') + }) +}) + +describe('app-emails kit', () => { + const STACK = { + provider: 'better-auth' as const, + authDelivery: 'code' as const, + note: 'Wire the callbacks in your Better Auth config.', + keys: ['auth.verify-email', 'auth.password-reset'], + snippets: [ + { title: 'Better Auth callbacks', file: 'lib/auth.ts', code: '…' }, + ], + } + + it('exits 4 when unconfirmed', async () => { + const result = await cli(['app-emails', 'kit', '--stack', 'better-auth']) + expect(result.code).toBe(4) + expect((result.json as { summary: string }).summary).toContain( + 'auth + lifecycle kit for better-auth' + ) + }) + + it('POSTs the kit body with an idempotency key', async () => { + let body: unknown + let idempotency: string | null = null + server.use( + http.post(`${API}/v1/app-emails/kit`, async ({ request }) => { + body = await request.json() + idempotency = request.headers.get('idempotency-key') + return HttpResponse.json({ + results: [{ ...SETUP, ok: true }], + stack: STACK, + }) + }) + ) + const result = await cli([ + 'app-emails', + 'kit', + '--sets', + 'auth, lifecycle', + '--stack', + 'better-auth', + '--domain', + 'dom_1', + '--idempotency-key', + 'kit:1', + '--yes', + ]) + expect(result.code).toBe(0) + expect(body).toEqual({ + sets: ['auth', 'lifecycle'], + stack: 'better-auth', + domainId: 'dom_1', + }) + expect(idempotency).toBe('kit:1') + }) + + it('exits 1 with the full results when a key failed', async () => { + server.use( + http.post(`${API}/v1/app-emails/kit`, () => + HttpResponse.json({ + results: [ + { ...SETUP, ok: true }, + { + triggerKey: 'auth.verify-email', + ok: false, + error: { code: 'INTERNAL_ERROR', message: 'Import failed.' }, + }, + ], + stack: STACK, + }) + ) + ) + const result = await cli([ + 'app-emails', + 'kit', + '--keys', + 'auth.password-reset,auth.verify-email', + '--yes', + ]) + expect(result.code).toBe(1) + const data = result.json as { results: Array<{ ok: boolean }> } + expect(data.results.map((entry) => entry.ok)).toEqual([true, false]) + }) +}) + +describe('automations triggers --key', () => { + it('create sends the key', async () => { + let body: unknown + server.use( + http.post(`${API}/v1/automations/triggers`, async ({ request }) => { + body = await request.json() + return HttpResponse.json( + { + triggerEventId: 'tri_1', + key: 'order.paid', + title: 'Order paid', + provider: 'brew_api', + payloadSchema: { type: 'object', fields: [] }, + createdAt: '2026-10-13T10:00:00.000Z', + updatedAt: '2026-10-13T10:00:00.000Z', + }, + { status: 201 } + ) + }) + ) + const result = await cli([ + 'automations', + 'triggers', + 'create', + '--title', + 'Order paid', + '--key', + 'order.paid', + '--input', + '{"payloadSchema":{"type":"object","fields":[{"key":"email","type":"string","required":true}]}}', + ]) + expect(result.code).toBe(0) + expect(body).toEqual({ + title: 'Order paid', + key: 'order.paid', + payloadSchema: { + type: 'object', + fields: [{ key: 'email', type: 'string', required: true }], + }, + }) + }) + + it('update sends the key', async () => { + let body: unknown + server.use( + http.patch( + `${API}/v1/automations/triggers/tri_1`, + async ({ request }) => { + body = await request.json() + return HttpResponse.json({}) + } + ) + ) + const result = await cli([ + 'automations', + 'triggers', + 'update', + 'tri_1', + '--key', + 'user.signup', + ]) + expect(result.code).toBe(0) + expect(body).toEqual({ key: 'user.signup' }) + }) + + it('list shows the KEY column', async () => { + server.use( + http.get(`${API}/v1/automations/triggers`, () => + HttpResponse.json({ + data: [ + { + triggerEventId: 'tri_1', + key: 'order.paid', + title: 'Order paid', + provider: 'brew_api', + payloadSchema: { type: 'object', fields: [] }, + createdAt: '2026-10-13T10:00:00.000Z', + updatedAt: '2026-10-13T10:00:00.000Z', + }, + ], + pagination: { limit: 100, cursor: null, hasMore: false }, + }) + ) + ) + const result = await cli(['automations', 'triggers', 'list'], { + ttyOut: true, + }) + expect(result.stdout.split('\n')[0]).toMatch(/TRIGGER\s+KEY\s+TITLE/) + expect(result.stdout).toContain('order.paid') + }) +}) diff --git a/tests/commands/types.test.ts b/tests/commands/types.test.ts index ff467516..6d619cf9 100644 --- a/tests/commands/types.test.ts +++ b/tests/commands/types.test.ts @@ -548,3 +548,74 @@ describe('automations triggers ready', () => { expect(JSON.parse(result.stdout)).toEqual(readiness) }) }) + +describe('types — trigger key registry', () => { + const KEYED = [ + { ...TRIGGER, key: 'user.signup' }, + { + ...TRIGGER, + triggerEventId: 'tri_reset', + title: 'Password reset', + key: 'auth.password-reset', + }, + ] + + function mockTriggers(rows: ReadonlyArray<Record<string, unknown>>) { + server.use( + http.get(`${API}/v1/automations/triggers`, () => + HttpResponse.json({ + data: rows, + pagination: { cursor: null, hasMore: false }, + }) + ) + ) + } + + it('stays byte-identical to the key-less output when no trigger has a key', async () => { + mockTriggers([TRIGGER]) + const out = join(mkdtempSync(join(tmpdir(), 'brew-types-')), 'c.ts') + const result = await runCli(['types', '--out', out], { + extraCommands: [typesCommand], + env: env(), + }) + expect(result.code).toBe(0) + // Pinned from the pre-registry emitter: a key-less workspace's committed + // file must not read as drift after the upgrade. + expect(readFileSync(out, 'utf8')).toBe( + '// brew:contracts sha256:f15c86b02ba6b9c5eca91fd8c6e4c343df1be1f9cc5a204c4d61e960de7557bf\n// Generated by `brew-cli types` — do not edit by hand.\n// Re-run `brew-cli types` after changing a trigger schema; gate\n// drift in CI with `brew-cli types --check`.\n\n/** Fire: POST /v1/automations/triggers/tri_signup/fire — body { payload: UserSignedUpPayload } */\nexport type UserSignedUpPayload = {\n email: string\n seats?: number\n "kebab-key"?: string\n}\n' + ) + }) + + it('augments BrewTriggerKeyRegistry for keyed triggers, sorted by key', async () => { + mockTriggers(KEYED) + const out = join(mkdtempSync(join(tmpdir(), 'brew-types-')), 'c.ts') + const result = await runCli(['types', '--out', out], { + extraCommands: [typesCommand], + env: env(), + }) + expect(result.code).toBe(0) + const text = readFileSync(out, 'utf8') + expect(text).toContain( + [ + "declare module '@brew.new/sdk' {", + ' interface BrewTriggerKeyRegistry {', + ' "auth.password-reset": PasswordResetPayload', + ' "user.signup": UserSignedUpPayload', + ' }', + '}', + '', + ].join('\n') + ) + }) + + it('--no-registry leaves the augmentation out', async () => { + mockTriggers(KEYED) + const out = join(mkdtempSync(join(tmpdir(), 'brew-types-')), 'c.ts') + const result = await runCli(['types', '--out', out, '--no-registry'], { + extraCommands: [typesCommand], + env: env(), + }) + expect(result.code).toBe(0) + expect(readFileSync(out, 'utf8')).not.toContain('declare module') + }) +}) From b28b4f293b06bfb318d64ce5a6e90f455e79453b Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Tue, 13 Oct 2026 10:27:59 +0200 Subject: [PATCH 03/26] docs: send by key and app emails in README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 20 ++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00bf28c1..7ac4d188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,43 @@ ## Unreleased +Needs `@brew.new/sdk` `^12.3.0` (`brew.send`, +`automations.triggers.fireByKey`, `appEmails`). + ### Added +- **`brew-cli send <key>`** sends the app email behind a trigger key: the + same call as `brew.send(key, payload)` + (`POST /v1/automations/triggers/by-key/{triggerKey}/fire`). `--to + <email>` sets `payload.email` (over `--data`), `--data <json|->` carries + the rest of the payload, `--idempotency-key` makes a retry replay. + Destructive like `automations triggers fire`: exit 4 without `--yes`. + A send with no recipient exits 2 before any request. + `automations.triggers.fireByKey` is the same route, so it is a reviewed + SDK skip (alias of `send`). +- **`brew-cli app-emails list | get <key> | upsert <key> | kit`** bind + `GET /v1/app-emails`, `GET|PUT /v1/app-emails/{triggerKey}` and + `POST /v1/app-emails/kit`. `list` and `get` show each key's sender + (`domain`, `sandbox`, `none`) and readiness. `upsert` sets up one app + email (starter with `--starter`, or your own `--source-file` with + `--format` taken from `.html`, `.mjml`, `.jsx` or `.tsx`; `--title`, + `--subject`, `--preview-text`, `--domain`, `--overwrite`) and publishes + it, so it confirms; `--no-publish` saves without the gate. `kit` sets up + the auth and lifecycle starter kit (`--sets`, `--stack`, `--keys`, + `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints + per-key results and exits 1 when a key failed (run it again to finish). + Both use the SDK's 5-minute defaults as their whole-command deadline. +- `automations triggers create --key <key>` and `automations triggers + update --key <key>` set the stable key code fires by; `triggers list` + gains a `KEY` column (`get` returns `key` and `appEmail` in the row). +- `brew-cli types` appends a `BrewTriggerKeyRegistry` augmentation for + every keyed trigger, so `brew.send('order.paid', …)` checks the key and + the payload at compile time. A workspace with no keyed triggers gets + the same bytes as before (no `--check` drift); `--no-registry` leaves + the block out. +- The vendored spec now carries the by-key fire and the app-emails + routes (brew-v2 `claude/app-emails-setup`). + - **`brew-cli login` signs in through the browser.** On an interactive terminal it starts a sign-in on brew.new, prints the link and a code ("Check the code in your browser matches: WDJB-MJHT"), opens the page, diff --git a/README.md b/README.md index 9aca417b..37bc230e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,26 @@ data on stdout and progress on stderr, and documents itself: brew-cli emails send --help ``` +## Send app email by key + +An app email is a trigger with a stable key (`auth.password-reset`, +`order.paid`), wired to a design and a published automation. Your code +sends it with `brew.send(key, payload)` from `@brew.new/sdk`; the CLI does +the same from a terminal or an agent: + +```bash +brew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit, published +brew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes +brew-cli app-emails list # sender and readiness per key +brew-cli send auth.password-reset --to you@example.com --data '{"confirmationUrl":"https://…"}' --yes +brew-cli types --out lib/brew-contracts.ts # payload types + the brew.send key registry +``` + +Until a sending domain verifies, app emails go out from the team-only +sandbox: members of your Brew organization receive them, under a daily +cap. `send`, `upsert` and `kit` are destructive (they send or publish) and +exit 4 without `--yes` off a terminal. + ## Connect your AI editor to Brew (MCP) ```bash From 0accebb6622c34557a619632a76f8727cecd8066 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Fri, 16 Oct 2026 07:30:01 -0400 Subject: [PATCH 04/26] test(types): key registry over main's contract artifacts `types` now emits the platform's TypeScript contract per trigger (GetBrew/brew-cli#58). The registry block reads each trigger's key from the list row and the type name from its contract; the key-less output equals `--no-registry` byte for byte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/commands/types.test.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/commands/types.test.ts b/tests/commands/types.test.ts index 6d619cf9..07ffcc4e 100644 --- a/tests/commands/types.test.ts +++ b/tests/commands/types.test.ts @@ -68,6 +68,7 @@ beforeEach(() => { if (id === 'tri_l') name = 'Payload2026Launch' if (id === 'tri_tail' || id === 'tri_page_51') name = 'TailTriggerPayload' + if (id === 'tri_reset') name = 'PasswordResetPayload' if (id.startsWith('tri_page1_')) name = `PageOne${Number(id.slice('tri_page1_'.length))}Payload` const content = @@ -573,17 +574,25 @@ describe('types — trigger key registry', () => { it('stays byte-identical to the key-less output when no trigger has a key', async () => { mockTriggers([TRIGGER]) - const out = join(mkdtempSync(join(tmpdir(), 'brew-types-')), 'c.ts') - const result = await runCli(['types', '--out', out], { + const dir = mkdtempSync(join(tmpdir(), 'brew-types-')) + const withRegistry = join(dir, 'a.ts') + const withoutRegistry = join(dir, 'b.ts') + const first = await runCli(['types', '--out', withRegistry], { extraCommands: [typesCommand], env: env(), }) - expect(result.code).toBe(0) - // Pinned from the pre-registry emitter: a key-less workspace's committed - // file must not read as drift after the upgrade. - expect(readFileSync(out, 'utf8')).toBe( - '// brew:contracts sha256:f15c86b02ba6b9c5eca91fd8c6e4c343df1be1f9cc5a204c4d61e960de7557bf\n// Generated by `brew-cli types` — do not edit by hand.\n// Re-run `brew-cli types` after changing a trigger schema; gate\n// drift in CI with `brew-cli types --check`.\n\n/** Fire: POST /v1/automations/triggers/tri_signup/fire — body { payload: UserSignedUpPayload } */\nexport type UserSignedUpPayload = {\n email: string\n seats?: number\n "kebab-key"?: string\n}\n' + const second = await runCli( + ['types', '--out', withoutRegistry, '--no-registry'], + { extraCommands: [typesCommand], env: env() } ) + expect(first.code).toBe(0) + expect(second.code).toBe(0) + // A key-less workspace's committed file must not read as drift after + // the upgrade: the registry adds nothing, not even a blank line. + const text = readFileSync(withRegistry, 'utf8') + expect(text).not.toContain('declare module') + expect(text).toBe(readFileSync(withoutRegistry, 'utf8')) + expect(text.endsWith(' "kebab-key"?: string\n}\n')).toBe(true) }) it('augments BrewTriggerKeyRegistry for keyed triggers, sorted by key', async () => { From d4843925716b8be82904e5184042814862ad202c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Fri, 16 Oct 2026 07:30:01 -0400 Subject: [PATCH 05/26] chore: 0.17.0 on top of main's 0.16.0; app email SDK gate package.json goes to 0.17.0 and the Unreleased notes (mcp install, browser sign-in, send and app-emails, the canvas SDK methods) become 0.17.0. RELEASING.md gains the app email SDK gate: `send` and `app-emails` need the SDK release carrying GetBrew/typescript-sdk#55 and #64, which is not on npm yet, so the dependency and bun.lock stay on main's pin until it publishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 19 +++++++++++++------ RELEASING.md | 13 +++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac4d188..9f0593af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Changelog -## Unreleased +## 0.18.0 -Needs `@brew.new/sdk` `^12.3.0` (`brew.send`, -`automations.triggers.fireByKey`, `appEmails`). +Needs an `@brew.new/sdk` release that carries `brew.send` and +`automations.triggers.fireByKey` (GetBrew/typescript-sdk#55), `appEmails` +(GetBrew/typescript-sdk#64) and the canvas revocation response fix already +on the SDK's main branch. Neither PR is published yet; see the app email SDK +gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), +#2017 (fire by key), #2059 (team sandbox), #2066 (app emails) and #2060 +(browser sign-in). ### Added @@ -36,8 +41,11 @@ Needs `@brew.new/sdk` `^12.3.0` (`brew.send`, the payload at compile time. A workspace with no keyed triggers gets the same bytes as before (no `--check` drift); `--no-registry` leaves the block out. -- The vendored spec now carries the by-key fire and the app-emails - routes (brew-v2 `claude/app-emails-setup`). +- The vendored spec is brew-v2 main plus the spec delta of + GetBrew/brew-v2#2066 and the PRs it stacks on (#2013, #2017, #2059): the + by-key fire and the app-emails routes, `key` / `appEmail` on trigger + rows, and the sandbox error codes (`DOMAIN_REQUIRED`, + `SANDBOX_DAILY_LIMIT`). Re-vendor from brew-v2 main once they merge. - **`brew-cli login` signs in through the browser.** On an interactive terminal it starts a sign-in on brew.new, prints the link and a code @@ -68,7 +76,6 @@ Needs `@brew.new/sdk` `^12.3.0` (`brew.send`, login that stores a key drops an older pending sign-in. A sign-in against a non-default `--api-url` stores that URL with the key. The `Signed in as …` line shows even under `--quiet`. - - **`brew-cli mcp install`** adds the Brew MCP server to Cursor (`.cursor/mcp.json`), VS Code (`.vscode/mcp.json`), Claude Code (`.mcp.json`), Gemini CLI (`.gemini/settings.json`), and, when named, diff --git a/RELEASING.md b/RELEASING.md index 15f81701..f73567d2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -29,6 +29,19 @@ lockfile checksums must never be invented. The canvas tests must preserve `contacts.import` and the import response from SDK 13.3.0, the minimum this CLI depends on (`^13.3.0`). +### App email SDK gate + +`send`, `app-emails` and the `types` key registry call `brew.send` and +`brew.appEmails`, which no published SDK has yet: +GetBrew/typescript-sdk#55 (`send`, `automations.triggers.fireByKey`) and +GetBrew/typescript-sdk#64 (`appEmails`, the auth adapters), planned as 12.4.0 +and 12.5.0 on top of the canvas fix above. Until that release is on npm, the +dependency and `bun.lock` stay on the pin above, and typecheck and parity +fail in CI on these commands. Publish the SDK first, then +`bun add @brew.new/sdk@^<that version>` (it regenerates `bun.lock` from the +registry; never hand-write a lockfile entry), run a frozen install, the full +checks and `docs:commands:check`, and release the CLI. + ```bash # on a clean main with CI green npm version <patch|minor|major> --no-git-tag-version From 3ac0547ceced2986c839cb948460a109afd091d6 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Fri, 16 Oct 2026 07:44:19 -0400 Subject: [PATCH 06/26] fix(app-emails,send): check keys and vocabularies locally; stdin bodies confirm Review against brew-v2#2066 (c72bb45467) and typescript-sdk#64: - `upsert --input -` always confirms: stdin can set `overwrite` or `publish`, which the gate cannot read ahead (it could replace a design a teammate edited in Brew without --yes). - `kit`: an empty `--keys`/`--sets` is a usage error instead of "the whole kit"; `--keys` with `--sets`/`--stack` is refused (the API takes keys instead of both, silently); the summary reads keys, sets, stack and overwrite passed inline through `--input`. - The trigger key grammar, `--format`, `--sets`, `--stack` and kit keys are checked before any request, so a typo exits 2, not 1. A test pins the vocabularies to the vendored spec. - `upsert` and `kit` say they need the `automations` and `emails` scopes; README notes `types --no-registry` for projects without the SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 7 +- README.md | 6 +- docs/commands/README.md | 8 +- skills/brew-cli/SKILL.md | 6 +- src/commands/app-emails/get.ts | 5 +- src/commands/app-emails/kit.ts | 55 +++++++--- src/commands/app-emails/upsert.ts | 39 +++---- src/commands/automations/triggers/create.ts | 4 +- src/commands/automations/triggers/update.ts | 4 +- src/commands/send.ts | 3 +- src/commands/types.ts | 2 +- src/generated/skills.ts | 2 +- src/lib/app-email-inputs.ts | 100 +++++++++++++++++ src/lib/trigger-key.ts | 19 ++++ tests/commands/app-emails.test.ts | 112 ++++++++++++++++++++ 15 files changed, 325 insertions(+), 47 deletions(-) create mode 100644 src/lib/app-email-inputs.ts create mode 100644 src/lib/trigger-key.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0593af..53e7ec9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,12 @@ gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), the auth and lifecycle starter kit (`--sets`, `--stack`, `--keys`, `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints per-key results and exits 1 when a key failed (run it again to finish). - Both use the SDK's 5-minute defaults as their whole-command deadline. + Both use the SDK's 5-minute defaults as their whole-command deadline + and need a key with the `automations` and `emails` scopes. A key that + breaks the trigger key grammar, an unknown `--format`, `--sets`, + `--stack` or kit key, an empty list, or `--keys` with `--sets`/`--stack` + exits 2 before any request; a body on stdin (`--input -`) always + confirms. - `automations triggers create --key <key>` and `automations triggers update --key <key>` set the stable key code fires by; `triggers list` gains a `KEY` column (`get` returns `key` and `appEmail` in the row). diff --git a/README.md b/README.md index 37bc230e..5f77ae1e 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,11 @@ brew-cli types --out lib/brew-contracts.ts # payload types + the br Until a sending domain verifies, app emails go out from the team-only sandbox: members of your Brew organization receive them, under a daily cap. `send`, `upsert` and `kit` are destructive (they send or publish) and -exit 4 without `--yes` off a terminal. +exit 4 without `--yes` off a terminal. `upsert` and `kit` need a key with +both the `automations` and `emails` scopes. A mistyped key, format, set or +stack exits 2 before any request. `types` adds a +`declare module '@brew.new/sdk'` block for keyed triggers; pass +`--no-registry` in a project that does not install the SDK. ## Connect your AI editor to Brew (MCP) diff --git a/docs/commands/README.md b/docs/commands/README.md index 47f69a1e..3b80cfa0 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -107,8 +107,8 @@ a `confirmCommand` otherwise, `--yes` to proceed). | `brew-cli send` | destructive | `POST /v1/automations/triggers/by-key/{triggerKey}/fire` | Send the app email behind a trigger key (fires the key: starts LIVE runs) | | `brew-cli app-emails list` | read | `GET /v1/app-emails` | List the app emails your code sends by key, with sender and readiness | | `brew-cli app-emails get` | read | `GET /v1/app-emails/{triggerKey}` | Fetch one app email by its trigger key (404 TRIGGER_KEY_NOT_FOUND when none is set up) | -| `brew-cli app-emails upsert` | destructive | `PUT /v1/app-emails/{triggerKey}` | Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it | -| `brew-cli app-emails kit` | destructive | `POST /v1/app-emails/kit` | Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish) | +| `brew-cli app-emails upsert` | destructive | `PUT /v1/app-emails/{triggerKey}` | Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it. Needs the automations and emails scopes | +| `brew-cli app-emails kit` | destructive | `POST /v1/app-emails/kit` | Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish). Needs the automations and emails scopes | | `brew-cli automations runs list` | read | `GET /v1/automations/runs` | List automation runs (live + test history); one run is `automations runs get` | | `brew-cli automations runs get` | read | `GET /v1/automations/runs/{automationRunId}` | Fetch one automation run by id — the bare row | | `brew-cli automations runs cancel` | destructive | `POST /v1/automations/runs/{automationRunId}/cancel` | Cancel one in-flight automation run (event execution or test run) — nothing further is sent, and it can never be resumed | @@ -1655,7 +1655,7 @@ brew-cli app-emails get order.paid --json ### brew-cli app-emails upsert -Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it +Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it. Needs the automations and emails scopes - Route: `PUT /v1/app-emails/{triggerKey}` - Class: destructive @@ -1680,7 +1680,7 @@ brew-cli app-emails upsert lifecycle.welcome --overwrite --yes ### brew-cli app-emails kit -Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish) +Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish). Needs the automations and emails scopes - Route: `POST /v1/app-emails/kit` - Class: destructive diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 62707230..45b5aacd 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -82,8 +82,10 @@ Before a sending domain verifies, app emails use the team-only sandbox: they reach members of the Brew organization only, under a daily cap (`sender.mode: "sandbox"`). `send` is destructive (real email): it exits 4 without `--yes`. `upsert` and `kit` publish, so they confirm too; -`upsert --no-publish` saves without the gate. `409 APP_EMAIL_EDITED_IN_BREW` -means a teammate edited the design in Brew: keep it, or pass `--overwrite`. +`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key +with the `automations` and `emails` scopes (403 otherwise). +`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew: +keep it, or pass `--overwrite`. `automations triggers create --key <key>` gives any trigger a key. ## The contract you can rely on diff --git a/src/commands/app-emails/get.ts b/src/commands/app-emails/get.ts index 81439ed2..bffc2b35 100644 --- a/src/commands/app-emails/get.ts +++ b/src/commands/app-emails/get.ts @@ -1,4 +1,5 @@ import { defineCommand } from '../../lib/define-command' +import { triggerKeyArg } from '../../lib/trigger-key' import { renderAppEmails } from './list' export const appEmailsGetCommand = defineCommand({ @@ -20,7 +21,9 @@ export const appEmailsGetCommand = defineCommand({ 'brew-cli app-emails get order.paid --json', ], run: async ({ ctx, args }) => { - const row = await ctx.client().appEmails.get(args.triggerKey ?? '') + const row = await ctx + .client() + .appEmails.get(triggerKeyArg(args.triggerKey, 'The app email key')) return { data: row, human: renderAppEmails([row]) } }, }) diff --git a/src/commands/app-emails/kit.ts b/src/commands/app-emails/kit.ts index ef220d91..efc5219b 100644 --- a/src/commands/app-emails/kit.ts +++ b/src/commands/app-emails/kit.ts @@ -3,7 +3,16 @@ import type { SetUpAppEmailKitResponse, } from '@brew.new/sdk' import { SET_UP_APP_EMAIL_KIT_DEFAULT_TIMEOUT_MS } from '@brew.new/sdk' +import { + APP_EMAIL_AUTH_STACKS, + APP_EMAIL_KIT_KEYS, + APP_EMAIL_KIT_SETS, + inlineInput, + listFlag, + oneOfFlag, +} from '../../lib/app-email-inputs' import { defineCommand } from '../../lib/define-command' +import { CliUsageError } from '../../lib/errors' import { asSdkInput, flagString, @@ -19,7 +28,7 @@ import { renderTable } from '../../lib/output' export const appEmailsKitCommand = defineCommand({ path: ['app-emails', 'kit'], summary: - 'Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish)', + 'Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 when a key failed, re-run to finish). Needs the automations and emails scopes', sdkMethod: 'appEmails.setUpKit', route: { method: 'POST', path: '/v1/app-emails/kit' }, commandClass: 'destructive', @@ -57,21 +66,42 @@ export const appEmailsKitCommand = defineCommand({ 'brew-cli app-emails kit --keys auth.password-reset,auth.verify-email --domain dom_123 --yes', ], confirmSummary: ({ flags }) => { - const keys = list(flags.keys) - const sets = list(flags.sets) + if (flags.input === '-') { + // The body comes from stdin, which the gate cannot read ahead. + return 'Set up and publish the app emails described on stdin (possibly replacing designs edited in Brew): each key sends real email once your code fires it.' + } + const inline = inlineInput(flags.input) + const strings = (value: unknown): string[] | undefined => + Array.isArray(value) && value.every((item) => typeof item === 'string') + ? value + : undefined + const keys = selection(flags.keys) ?? strings(inline.keys) + const sets = selection(flags.sets) ?? strings(inline.sets) const what = keys !== undefined ? keys.join(', ') : `the ${(sets ?? ['auth', 'lifecycle']).join(' + ')} kit` - const stack = flagString(flags.stack) - return `Set up and publish ${what}${stack === undefined ? '' : ` for ${stack}`}${flags.overwrite === true ? ', replacing designs edited in Brew' : ''}: each key sends real email once your code fires it.` + const stack = + flagString(flags.stack) ?? + (typeof inline.stack === 'string' ? inline.stack : undefined) + const overwrites = flags.overwrite === true || inline.overwrite === true + return `Set up and publish ${what}${stack === undefined ? '' : ` for ${stack}`}${overwrites ? ', replacing designs edited in Brew' : ''}: each key sends real email once your code fires it.` }, run: async ({ ctx, flags }) => { + const sets = listFlag(flags.sets, '--sets', APP_EMAIL_KIT_SETS) + const keys = listFlag(flags.keys, '--keys', APP_EMAIL_KIT_KEYS) + const stack = oneOfFlag(flags.stack, '--stack', APP_EMAIL_AUTH_STACKS) + if (keys !== undefined && (sets !== undefined || stack !== undefined)) { + // The API takes `keys` instead of `sets` and `stack`, silently. + throw new CliUsageError( + '--keys names exact kit keys: pass it without --sets and --stack.' + ) + } const base = await readJsonFlag(ctx, flags.input, '--input') const input = mergeInput(base, { - sets: list(flags.sets), - stack: flagString(flags.stack), - keys: list(flags.keys), + sets, + stack, + keys, domainId: flagString(flags.domain), overwrite: flags.overwrite === true ? true : undefined, }) @@ -90,12 +120,9 @@ export const appEmailsKitCommand = defineCommand({ }, }) -function list(value: unknown): string[] | undefined { - const text = flagString(value) - if (text === undefined) { - return - } - const items = text +/** A list flag for the confirmation summary only (validated in `run`). */ +function selection(value: unknown): string[] | undefined { + const items = (flagString(value) ?? '') .split(',') .map((item) => item.trim()) .filter((item) => item !== '') diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index 5125b74e..04c1a435 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -1,6 +1,11 @@ import { extname } from 'node:path' import type { UpsertAppEmailInput, UpsertAppEmailResponse } from '@brew.new/sdk' import { UPSERT_APP_EMAIL_DEFAULT_TIMEOUT_MS } from '@brew.new/sdk' +import { + APP_EMAIL_SOURCE_FORMATS, + inlineInput, + oneOfFlag, +} from '../../lib/app-email-inputs' import { defineCommand } from '../../lib/define-command' import { CliUsageError } from '../../lib/errors' import { @@ -12,6 +17,7 @@ import { readTextFlag, } from '../../lib/input' import { getInvocationPrefix } from '../../lib/invocation' +import { triggerKeyArg } from '../../lib/trigger-key' const FORMAT_BY_EXTENSION: Readonly<Record<string, 'html' | 'jsx' | 'mjml'>> = { '.htm': 'html', @@ -24,7 +30,7 @@ const FORMAT_BY_EXTENSION: Readonly<Record<string, 'html' | 'jsx' | 'mjml'>> = { export const appEmailsUpsertCommand = defineCommand({ path: ['app-emails', 'upsert'], summary: - 'Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it', + 'Set up one app email by key in one idempotent call (trigger, contract, design, automation) and publish it. Needs the automations and emails scopes', sdkMethod: 'appEmails.upsert', route: { method: 'PUT', path: '/v1/app-emails/{triggerKey}' }, commandClass: 'destructive', @@ -77,6 +83,10 @@ export const appEmailsUpsertCommand = defineCommand({ 'brew-cli app-emails upsert lifecycle.welcome --overwrite --yes', ], confirmSummary: ({ args, flags }) => { + if (flags.input === '-') { + // stdin may set `overwrite` or `publish`; the gate cannot read it ahead. + return `Set up app email ${args.triggerKey ?? ''} from the body on stdin, which may publish it (\`send\` then sends real email) or replace a design edited in Brew.` + } const inline = inlineInput(flags.input) const publishes = flags.publish !== false && inline.publish !== false const overwrites = flags.overwrite === true || inline.overwrite === true @@ -94,13 +104,19 @@ export const appEmailsUpsertCommand = defineCommand({ return parts.join('') }, run: async ({ ctx, args, flags }) => { + const triggerKey = triggerKeyArg(args.triggerKey, 'The app email key') + const explicitFormat = oneOfFlag( + flags.format, + '--format', + APP_EMAIL_SOURCE_FORMATS + ) const base = await readJsonFlag(ctx, flags.input, '--input') const sourcePath = flagString(flags.sourceFile) const content = await readTextFlag(ctx, sourcePath, '--source-file') let source: { format: string; content: string } | undefined if (content !== undefined) { const format = - flagString(flags.format) ?? + explicitFormat ?? (sourcePath === undefined || sourcePath === '-' ? undefined : FORMAT_BY_EXTENSION[extname(sourcePath).toLowerCase()]) @@ -110,7 +126,7 @@ export const appEmailsUpsertCommand = defineCommand({ ) } source = { format, content } - } else if (flagString(flags.format) !== undefined) { + } else if (explicitFormat !== undefined) { throw new CliUsageError('--format applies to --source-file only.') } const input = mergeInput(base, { @@ -131,7 +147,7 @@ export const appEmailsUpsertCommand = defineCommand({ const result = await ctx.client().appEmails.upsert( asSdkInput<UpsertAppEmailInput>({ ...input, - triggerKey: args.triggerKey ?? '', + triggerKey, }) ) return { @@ -141,21 +157,6 @@ export const appEmailsUpsertCommand = defineCommand({ }, }) -/** `--input` given inline (not stdin), for the confirmation summary only. */ -function inlineInput(value: unknown): Record<string, unknown> { - if (typeof value !== 'string' || value === '-') { - return {} - } - try { - const parsed = JSON.parse(value) as unknown - return parsed !== null && typeof parsed === 'object' - ? (parsed as Record<string, unknown>) - : {} - } catch { - return {} - } -} - export function renderSetup( result: Pick< UpsertAppEmailResponse, diff --git a/src/commands/automations/triggers/create.ts b/src/commands/automations/triggers/create.ts index fe054ae7..a07e16f4 100644 --- a/src/commands/automations/triggers/create.ts +++ b/src/commands/automations/triggers/create.ts @@ -10,6 +10,7 @@ import { readJsonFlag, requestOptions, } from '../../../lib/input' +import { triggerKeyArg } from '../../../lib/trigger-key' export const automationsTriggersCreateCommand = defineCommand({ path: ['automations', 'triggers', 'create'], @@ -37,7 +38,8 @@ export const automationsTriggersCreateCommand = defineCommand({ const input = mergeInput(base, { title: flagString(flags.title), description: flagString(flags.description), - key: flagString(flags.key), + key: + flags.key === undefined ? undefined : triggerKeyArg(flags.key, '--key'), }) if (typeof input.title !== 'string' || input.title === '') { throw new CliUsageError('A title is required (--title or --input).') diff --git a/src/commands/automations/triggers/update.ts b/src/commands/automations/triggers/update.ts index ff943f8a..80447444 100644 --- a/src/commands/automations/triggers/update.ts +++ b/src/commands/automations/triggers/update.ts @@ -8,6 +8,7 @@ import { mergeInput, readJsonFlag, } from '../../../lib/input' +import { triggerKeyArg } from '../../../lib/trigger-key' export const automationsTriggersUpdateCommand = defineCommand({ path: ['automations', 'triggers', 'update'], @@ -45,7 +46,8 @@ export const automationsTriggersUpdateCommand = defineCommand({ const input = mergeInput(base, { title: flagString(flags.title), description: flagString(flags.description), - key: flagString(flags.key), + key: + flags.key === undefined ? undefined : triggerKeyArg(flags.key, '--key'), }) if (Object.keys(input).length === 0) { throw new CliUsageError('Nothing to update — pass flags or --input.') diff --git a/src/commands/send.ts b/src/commands/send.ts index c4cb36a0..b1d1ce47 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -8,6 +8,7 @@ import { readJsonFlag, requestOptions, } from '../lib/input' +import { triggerKeyArg } from '../lib/trigger-key' export const sendCommand = defineCommand({ path: ['send'], @@ -48,6 +49,7 @@ export const sendCommand = defineCommand({ return `Send ${args.key ?? ''}${to === undefined ? '' : ` to ${to}`}: starts REAL runs on every published automation listening to this key (the email goes out).` }, run: async ({ ctx, args, flags }) => { + const key = triggerKeyArg(args.key, 'The key') const data = await readJsonFlag(ctx, flags.data, '--data') if ( data !== undefined && @@ -69,7 +71,6 @@ export const sendCommand = defineCommand({ 'A recipient is required: --to <email>, or an "email" field in --data.' ) } - const key = args.key ?? '' const result = await ctx .client() .send( diff --git a/src/commands/types.ts b/src/commands/types.ts index 981da849..15dcc93c 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -2,11 +2,11 @@ import { createHash } from 'node:crypto' import { readFileSync, writeFileSync } from 'node:fs' import type { - BrewClient, ListTriggersInput, ListTriggersResponse, Trigger, } from '@brew.new/sdk' +import type { BrewClient } from '../lib/client' import { defineCommand } from '../lib/define-command' import { asSdkInput } from '../lib/input' import { getInvocationPrefix } from '../lib/invocation' diff --git a/src/generated/skills.ts b/src/generated/skills.ts index c6b0feb4..5e84eed7 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `409 APP_EMAIL_EDITED_IN_BREW`\nmeans a teammate edited the design in Brew: keep it, or pass `--overwrite`.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/src/lib/app-email-inputs.ts b/src/lib/app-email-inputs.ts new file mode 100644 index 00000000..6a0138db --- /dev/null +++ b/src/lib/app-email-inputs.ts @@ -0,0 +1,100 @@ +import { CliUsageError } from './errors' +import { flagString } from './input' + +/** + * The closed vocabularies of `/v1/app-emails` (brew-v2 + * `lib/app-emails/contracts.ts`), checked locally so a typo exits 2 before + * any request. `tests/commands/app-emails.test.ts` pins them to the + * vendored spec. + */ +export const APP_EMAIL_KIT_SETS = ['auth', 'lifecycle'] as const + +export const APP_EMAIL_AUTH_STACKS = [ + 'better-auth', + 'authjs', + 'supabase', + 'clerk', + 'firebase', + 'custom', +] as const + +export const APP_EMAIL_KIT_KEYS = [ + 'auth.verify-email', + 'auth.magic-link', + 'auth.otp', + 'auth.password-reset', + 'auth.invite', + 'auth.email-change', + 'auth.reauthentication', + 'auth.security-notice', + 'lifecycle.welcome', + 'lifecycle.trial-ending', + 'lifecycle.payment-failed', + 'lifecycle.win-back', +] as const + +export const APP_EMAIL_SOURCE_FORMATS = ['html', 'mjml', 'jsx'] as const + +/** + * A comma-separated list flag. Omitted → undefined; given but empty (`""`, + * `,`) is a usage error rather than "omitted", which would widen the call + * to the whole kit. + */ +export function listFlag( + value: unknown, + flag: string, + allowed: readonly string[] +): string[] | undefined { + if (value === undefined) { + return + } + const text = flagString(value) ?? '' + const items = text + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== '') + if (items.length === 0) { + throw new CliUsageError( + `${flag} needs at least one value: ${allowed.join(', ')}.` + ) + } + const unknown = items.filter((item) => !allowed.includes(item)) + if (unknown.length > 0) { + throw new CliUsageError( + `${flag}: unknown ${unknown.map((item) => JSON.stringify(item)).join(', ')}. Use: ${allowed.join(', ')}.` + ) + } + return [...new Set(items)] +} + +export function oneOfFlag( + value: unknown, + flag: string, + allowed: readonly string[] +): string | undefined { + const text = flagString(value) + if (text === undefined) { + return + } + if (!allowed.includes(text)) { + throw new CliUsageError( + `${flag}: unknown ${JSON.stringify(text)}. Use: ${allowed.join(', ')}.` + ) + } + return text +} + +/** `--input` given inline (not stdin), for confirmation summaries only. */ +export function inlineInput(value: unknown): Record<string, unknown> { + if (typeof value !== 'string' || value === '-') { + return {} + } + try { + const parsed = JSON.parse(value) as unknown + return parsed !== null && typeof parsed === 'object' + ? (parsed as Record<string, unknown>) + : {} + } catch { + return {} + } +} diff --git a/src/lib/trigger-key.ts b/src/lib/trigger-key.ts new file mode 100644 index 00000000..4f91f5d7 --- /dev/null +++ b/src/lib/trigger-key.ts @@ -0,0 +1,19 @@ +import { CliUsageError } from './errors' + +/** + * The trigger key grammar (brew-v2 `lib/triggers/trigger-key.ts`): 1–64 + * characters of lowercase letters, digits, `.`, `_` and `-`, starting and + * ending with a letter or digit. Checked locally so a typo exits 2 before + * any request instead of an API 400 (exit 1). + */ +export const TRIGGER_KEY_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/ + +export function triggerKeyArg(value: unknown, label: string): string { + const key = typeof value === 'string' ? value : '' + if (!TRIGGER_KEY_PATTERN.test(key)) { + throw new CliUsageError( + `${label} must be a trigger key: 1-64 lowercase letters, digits, ".", "_" or "-", starting and ending with a letter or digit (e.g. order.paid). Got ${JSON.stringify(key)}.` + ) + } + return key +} diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts index 4dca0143..091ceddc 100644 --- a/tests/commands/app-emails.test.ts +++ b/tests/commands/app-emails.test.ts @@ -627,3 +627,115 @@ describe('automations triggers --key', () => { expect(result.stdout).toContain('order.paid') }) }) + +describe('local input checks (exit 2 before any request)', () => { + it.each([ + [['send', 'Order Paid', '--to', 'jane@example.com', '--yes']], + [['send', '.', '--to', 'jane@example.com', '--yes']], + [['app-emails', 'get', 'auth/password-reset']], + [['app-emails', 'upsert', 'Order.Paid', '--yes']], + [['app-emails', 'upsert', 'order.paid', '--format', 'pdf', '--yes']], + [['app-emails', 'kit', '--keys', '', '--yes']], + [['app-emails', 'kit', '--sets', ',', '--yes']], + [['app-emails', 'kit', '--sets', 'billing', '--yes']], + [['app-emails', 'kit', '--stack', 'auth0', '--yes']], + [['app-emails', 'kit', '--keys', 'order.paid', '--yes']], + [['app-emails', 'kit', '--keys', 'auth.otp', '--sets', 'auth', '--yes']], + [ + [ + 'automations', + 'triggers', + 'create', + '--title', + 'Paid', + '--key', + 'Order', + ], + ], + ])('%j', async (argv) => { + // MSW fails any request: nothing reaches the API. + const result = await cli(argv) + expect(result.code).toBe(2) + }) + + it('upsert with the body on stdin always confirms (it may overwrite)', async () => { + const result = await cli( + ['app-emails', 'upsert', 'order.paid', '--no-publish', '--input', '-'], + { stdin: '{"overwrite":true,"starter":"lifecycle.welcome"}' } + ) + expect(result.code).toBe(4) + expect((result.json as { summary: string }).summary).toContain('stdin') + }) + + it('kit summary names keys passed inline through --input', async () => { + const result = await cli([ + 'app-emails', + 'kit', + '--input', + '{"keys":["auth.otp"],"overwrite":true}', + ]) + expect(result.code).toBe(4) + expect((result.json as { summary: string }).summary).toContain( + 'Set up and publish auth.otp, replacing designs edited in Brew' + ) + }) + + it('the local vocabularies match the vendored spec', async () => { + const { parse } = await import('yaml') + const spec = parse( + readFileSync( + join(import.meta.dirname, '../../openapi/public-api-v1.yaml'), + 'utf8' + ) + ) as { + paths: Record<string, Record<string, Record<string, unknown>>> + components: { schemas: Record<string, Record<string, unknown>> } + } + const resolve = (schema: Record<string, unknown>) => + typeof schema.$ref === 'string' + ? (spec.components.schemas[schema.$ref.split('/').pop() ?? ''] ?? {}) + : schema + type Body = { + properties: Record< + string, + { + enum?: string[] + items?: { enum?: string[] } + properties?: Record<string, { enum?: string[] }> + } + > + } + const body = (path: string, method: string): Body => { + const operation = spec.paths[path]?.[method] as + | { + requestBody: { + content: { + 'application/json': { schema: Record<string, unknown> } + } + } + } + | undefined + if (operation === undefined) { + throw new Error(`${method} ${path} is not in the vendored spec`) + } + return resolve( + operation.requestBody.content['application/json'].schema + ) as Body + } + const kit = body('/v1/app-emails/kit', 'post') + const upsert = body('/v1/app-emails/{triggerKey}', 'put') + const inputs = await import('../../src/lib/app-email-inputs') + expect(kit.properties.sets?.items?.enum).toEqual([ + ...inputs.APP_EMAIL_KIT_SETS, + ]) + expect(kit.properties.stack?.enum).toEqual([ + ...inputs.APP_EMAIL_AUTH_STACKS, + ]) + expect(kit.properties.keys?.items?.enum).toEqual([ + ...inputs.APP_EMAIL_KIT_KEYS, + ]) + expect(upsert.properties.source?.properties?.format?.enum).toEqual([ + ...inputs.APP_EMAIL_SOURCE_FORMATS, + ]) + }) +}) From f354529c920fe0e5e58d1d22faa67ec3a8839018 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Fri, 16 Oct 2026 13:46:02 -0400 Subject: [PATCH 07/26] fix(send,app-emails): SDK 13.1.0/13.2.0 gate; secret fields; not-started code - RELEASING app email SDK gate and the 0.17.0 changelog name the SDK versions: #55 ships as 13.1.0 and #64 as 13.2.0, after the SDK's 13.0.0; the CLI takes `^13.2.0`. - `app-emails upsert` passes `--input` payloadSchema fields through as given, so `secret: true` on a one-time code or sign-in link reaches the API; a test pins it (typed against the vendored spec) and a new example shows it. - A test pins `send`'s not-started line naming `DOMAIN_REQUIRED` (#2059), which SDK 13.2.0 types now that its spec includes #2059. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 17 ++++--- RELEASING.md | 6 +-- docs/commands/README.md | 1 + src/commands/app-emails/upsert.ts | 1 + tests/commands/app-emails.test.ts | 81 +++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53e7ec9b..869b4062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,12 @@ ## 0.18.0 -Needs an `@brew.new/sdk` release that carries `brew.send` and -`automations.triggers.fireByKey` (GetBrew/typescript-sdk#55), `appEmails` -(GetBrew/typescript-sdk#64) and the canvas revocation response fix already -on the SDK's main branch. Neither PR is published yet; see the app email SDK -gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), +Needs `@brew.new/sdk` 13.2.0: `brew.send` and +`automations.triggers.fireByKey` (GetBrew/typescript-sdk#55, planned +13.1.0), `appEmails` (GetBrew/typescript-sdk#64, planned 13.2.0) and the +canvas revocation response fix already on the SDK's main branch (planned +13.0.0). Neither PR is published yet; see the app email SDK gate in +`RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), #2017 (fire by key), #2059 (team sandbox), #2066 (app emails) and #2060 (browser sign-in). @@ -27,8 +28,10 @@ gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), (`domain`, `sandbox`, `none`) and readiness. `upsert` sets up one app email (starter with `--starter`, or your own `--source-file` with `--format` taken from `.html`, `.mjml`, `.jsx` or `.tsx`; `--title`, - `--subject`, `--preview-text`, `--domain`, `--overwrite`) and publishes - it, so it confirms; `--no-publish` saves without the gate. `kit` sets up + `--subject`, `--preview-text`, `--domain`, `--overwrite`; `--input` + carries the rest, such as `payloadSchema` fields marked `secret: true` + for a one-time code or sign-in link) and publishes it, so it confirms; + `--no-publish` saves without the gate. `kit` sets up the auth and lifecycle starter kit (`--sets`, `--stack`, `--keys`, `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints per-key results and exits 1 when a key failed (run it again to finish). diff --git a/RELEASING.md b/RELEASING.md index f73567d2..30e19a82 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -34,11 +34,11 @@ CLI depends on (`^13.3.0`). `send`, `app-emails` and the `types` key registry call `brew.send` and `brew.appEmails`, which no published SDK has yet: GetBrew/typescript-sdk#55 (`send`, `automations.triggers.fireByKey`) and -GetBrew/typescript-sdk#64 (`appEmails`, the auth adapters), planned as 12.4.0 -and 12.5.0 on top of the canvas fix above. Until that release is on npm, the +GetBrew/typescript-sdk#64 (`appEmails`, the auth adapters), planned as 13.1.0 +and 13.2.0, after the SDK's 13.0.0 above. Until 13.2.0 is on npm, the dependency and `bun.lock` stay on the pin above, and typecheck and parity fail in CI on these commands. Publish the SDK first, then -`bun add @brew.new/sdk@^<that version>` (it regenerates `bun.lock` from the +`bun add @brew.new/sdk@^13.2.0` (it regenerates `bun.lock` from the registry; never hand-write a lockfile entry), run a frozen install, the full checks and `docs:commands:check`, and release the CLI. diff --git a/docs/commands/README.md b/docs/commands/README.md index 3b80cfa0..e2c06b44 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -1676,6 +1676,7 @@ Set up one app email by key in one idempotent call (trigger, contract, design, a brew-cli app-emails upsert auth.password-reset --yes brew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Your order is confirmed" --yes brew-cli app-emails upsert lifecycle.welcome --overwrite --yes +brew-cli app-emails upsert account.sign-in-code --source-file emails/sign-in-code.tsx --input '{"payloadSchema":{"fields":[{"key":"code","type":"string","required":true,"secret":true}]}}' --yes ``` ### brew-cli app-emails kit diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index 04c1a435..939660f5 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -81,6 +81,7 @@ export const appEmailsUpsertCommand = defineCommand({ 'brew-cli app-emails upsert auth.password-reset --yes', 'brew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --subject "Your order is confirmed" --yes', 'brew-cli app-emails upsert lifecycle.welcome --overwrite --yes', + `brew-cli app-emails upsert account.sign-in-code --source-file emails/sign-in-code.tsx --input '{"payloadSchema":{"fields":[{"key":"code","type":"string","required":true,"secret":true}]}}' --yes`, ], confirmSummary: ({ args, flags }) => { if (flags.input === '-') { diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts index 091ceddc..e705e1ea 100644 --- a/tests/commands/app-emails.test.ts +++ b/tests/commands/app-emails.test.ts @@ -24,6 +24,7 @@ function cli(argv: readonly string[], options: RunCliOptions = {}) { type FireAccepted = components['schemas']['TriggerFireAccepted'] type AppEmailRow = components['schemas']['AppEmailRow'] type SetupResult = components['schemas']['AppEmailSetupResult'] +type PutBody = components['schemas']['AppEmailPutRequest'] const ACCEPTED: FireAccepted = { triggerInstanceId: 'tin_1', @@ -174,6 +175,37 @@ describe('send', () => { ) }) + it("names a not-started run's code (sandbox DOMAIN_REQUIRED) over its reason", async () => { + server.use( + http.post( + `${API}/v1/automations/triggers/by-key/auth.password-reset/fire`, + () => + HttpResponse.json( + { + ...ACCEPTED, + automationRunIds: [], + notStarted: [ + { + automationId: 'aut_1', + reason: 'The recipient is not in your organization.', + code: 'DOMAIN_REQUIRED', + }, + ], + } satisfies FireAccepted, + { status: 202 } + ) + ) + ) + const result = await cli( + ['send', 'auth.password-reset', '--to', 'kim@example.com', '--yes'], + { ttyOut: true } + ) + expect(result.code).toBe(0) + expect(result.stdout).toBe( + 'Sent auth.password-reset: 0 runs started (instance tin_1).\nnot started: aut_1 (DOMAIN_REQUIRED)\n' + ) + }) + it('refuses a send with no recipient (exit 2, no request)', async () => { const result = await cli([ 'send', @@ -385,6 +417,55 @@ describe('app-emails upsert', () => { }) }) + it('sends --input payloadSchema fields as given, secret: true included', async () => { + let body: unknown + server.use( + http.put( + `${API}/v1/app-emails/account.sign-in-code`, + async ({ request }) => { + body = await request.json() + return HttpResponse.json( + { ...SETUP, triggerKey: 'account.sign-in-code' }, + { status: 201 } + ) + } + ) + ) + // Typed against the vendored spec: brew-v2#2066 declares `secret`. + const fields: NonNullable<PutBody['payloadSchema']>['fields'] = [ + { + key: 'code', + type: 'string', + required: true, + pii: 'high', + secret: true, + }, + ] + const result = await cli( + [ + 'app-emails', + 'upsert', + 'account.sign-in-code', + '--source-file', + '-', + '--format', + 'jsx', + '--input', + JSON.stringify({ payloadSchema: { fields } }), + '--yes', + ], + { stdin: '<Html><Text>{{ trigger.code }}</Text></Html>' } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ + payloadSchema: { fields }, + source: { + format: 'jsx', + content: '<Html><Text>{{ trigger.code }}</Text></Html>', + }, + }) + }) + it('skips the gate with --no-publish and sends publish: false', async () => { let body: unknown server.use( From 4c882ea0bc874e1c5f5dac846b004c589c70135a Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Wed, 21 Oct 2026 01:36:28 +0900 Subject: [PATCH 08/26] fix(app-emails): resumed `list --all` refuses its starting cursor; 0.18.0 notes main's pagination guard takes the request's starting cursor so a resumed walk never fetches its first page twice; every other `--all` list passes it, and `app-emails list` now does too (added to the resumed-pagination table). The release notes and SDK gate follow main: 0.17.0 and SDK 13.0.0 are published, so this release is 0.18.0 and the dependency stays on the published ^13.0.0 until 13.2.0 is on npm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 7 +++---- RELEASING.md | 4 ++-- src/commands/app-emails/list.ts | 17 ++++++++++------- tests/commands/pagination-cycles.test.ts | 1 + 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 869b4062..e5d35b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,9 @@ Needs `@brew.new/sdk` 13.2.0: `brew.send` and `automations.triggers.fireByKey` (GetBrew/typescript-sdk#55, planned -13.1.0), `appEmails` (GetBrew/typescript-sdk#64, planned 13.2.0) and the -canvas revocation response fix already on the SDK's main branch (planned -13.0.0). Neither PR is published yet; see the app email SDK gate in -`RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), +13.1.0) and `appEmails` (GetBrew/typescript-sdk#64, planned 13.2.0), both +after the published 13.0.0. Neither PR is published yet; see the app email +SDK gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), #2017 (fire by key), #2059 (team sandbox), #2066 (app emails) and #2060 (browser sign-in). diff --git a/RELEASING.md b/RELEASING.md index 30e19a82..b4b12140 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -36,8 +36,8 @@ CLI depends on (`^13.3.0`). GetBrew/typescript-sdk#55 (`send`, `automations.triggers.fireByKey`) and GetBrew/typescript-sdk#64 (`appEmails`, the auth adapters), planned as 13.1.0 and 13.2.0, after the SDK's 13.0.0 above. Until 13.2.0 is on npm, the -dependency and `bun.lock` stay on the pin above, and typecheck and parity -fail in CI on these commands. Publish the SDK first, then +dependency and `bun.lock` stay on the published `^13.0.0`, and typecheck and +parity fail in CI on these commands. Publish the SDK first, then `bun add @brew.new/sdk@^13.2.0` (it regenerates `bun.lock` from the registry; never hand-write a lockfile entry), run a frozen install, the full checks and `docs:commands:check`, and release the CLI. diff --git a/src/commands/app-emails/list.ts b/src/commands/app-emails/list.ts index f24a2290..cacdb52d 100644 --- a/src/commands/app-emails/list.ts +++ b/src/commands/app-emails/list.ts @@ -30,13 +30,16 @@ export const appEmailsListCommand = defineCommand({ } const appEmails = ctx.client().appEmails if (flags.all === true) { - const rows = await collectAll(ctx, (next) => - appEmails.list( - asSdkInput<ListAppEmailsInput>({ - ...input, - ...(next === undefined ? {} : { cursor: next }), - }) - ) + const rows = await collectAll( + ctx, + (next) => + appEmails.list( + asSdkInput<ListAppEmailsInput>({ + ...input, + ...(next === undefined ? {} : { cursor: next }), + }) + ), + { cursor } ) return { data: { data: rows, pagination: { cursor: null, hasMore: false } }, diff --git a/tests/commands/pagination-cycles.test.ts b/tests/commands/pagination-cycles.test.ts index 5342b28b..355b9c28 100644 --- a/tests/commands/pagination-cycles.test.ts +++ b/tests/commands/pagination-cycles.test.ts @@ -22,6 +22,7 @@ describe('resumed pagination', () => { it.each([ ['analytics', 'events'], ['api-keys', 'list'], + ['app-emails', 'list'], ['audiences', 'list'], ['automations', 'audience-runs', 'list'], ['automations', 'list'], From cd0d493bc223a05f8d5326fe93177c0af9cd4928 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Wed, 21 Oct 2026 13:05:21 +0900 Subject: [PATCH 09/26] chore(spec): vendor brew-v2 #2169 @ 6f8ef50544 whole The DX stack is one platform PR now, so the bundled spec is its whole openapi/public-api-v1.yaml (brew-v2 main 2db5428b09 plus every DX route) instead of main plus per-PR deltas. Types regenerated. It also carries the hosted auth hook connection routes, which `integrations connect` / `integrations connection` bind in #63: they sit in the spec parity test's pending-build list until then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/parity-spec.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/parity-spec.test.ts b/tests/parity-spec.test.ts index 2e85bf54..6034909c 100644 --- a/tests/parity-spec.test.ts +++ b/tests/parity-spec.test.ts @@ -8,8 +8,16 @@ import { SPEC_SKIP_LIST } from '../src/skip-list' /** * Spec operations whose commands are still being built, phase by phase. * This list must ONLY shrink; it must be empty before 0.1.0 ships. + * + * The bundled spec is brew-v2 #2169 whole, which also carries the hosted + * auth hook connection routes. Their commands (`integrations connect`, + * `integrations connection`) land in GetBrew/brew-cli#63, which empties + * this list again. */ -const PENDING_BUILD_ROUTES: readonly string[] = [] +const PENDING_BUILD_ROUTES: readonly string[] = [ + 'GET /v1/integrations/{provider}/connection', + 'PUT /v1/integrations/{provider}/connection', +] /** * Commands built AHEAD of the platform: the payload-contract wave-2 API From 0a8dd76737531608f3ac1565205114131614916b Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Wed, 21 Oct 2026 13:05:21 +0900 Subject: [PATCH 10/26] fix(app-emails): adapt to #2169's refusals and the draft-over-live warning - kit: a failed key's hint says what a re-run needs: a transactional --domain (or none) for APP_EMAIL_DOMAIN_PURPOSE_MISMATCH, unpublishing error.details.automationIds for APP_EMAIL_TRIGGER_IN_USE, --overwrite for APP_EMAIL_EDITED_IN_BREW; anything else is still "run it again". - upsert: DRAFT_SAVED_NOT_LIVE reads "draft saved, the published version keeps sending" and keeps the `send` line. - --domain help on both names the transactional/marketing refusal. - MSW tests: APP_EMAIL_TRIGGER_IN_USE (details.automationIds), APP_EMAIL_DOMAIN_PURPOSE_MISMATCH, DRAFT_SAVED_NOT_LIVE, kit hints, and a DKIM_SELECTOR_IN_USE warning with suggestedNames on `domains add`. - Skill, CHANGELOG and RELEASING name brew-v2 #2169 (deployed) as the API side and spec source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 32 +++- RELEASING.md | 6 + docs/commands/README.md | 4 +- skills/brew-cli/SKILL.md | 13 +- src/commands/app-emails/kit.ts | 59 ++++++- src/commands/app-emails/upsert.ts | 16 +- src/generated/skills.ts | 2 +- tests/commands/app-emails.test.ts | 155 ++++++++++++++++++ .../commands/sends-audiences-domains.test.ts | 29 ++++ 9 files changed, 293 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d35b99..1a435b00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ Needs `@brew.new/sdk` 13.2.0: `brew.send` and `automations.triggers.fireByKey` (GetBrew/typescript-sdk#55, planned 13.1.0) and `appEmails` (GetBrew/typescript-sdk#64, planned 13.2.0), both after the published 13.0.0. Neither PR is published yet; see the app email -SDK gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), -#2017 (fire by key), #2059 (team sandbox), #2066 (app emails) and #2060 -(browser sign-in). +SDK gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2169, one PR +for trigger keys, fire by key, the team sandbox, app emails and browser +sign-in. ### Added @@ -33,7 +33,16 @@ SDK gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), `--no-publish` saves without the gate. `kit` sets up the auth and lifecycle starter kit (`--sets`, `--stack`, `--keys`, `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints - per-key results and exits 1 when a key failed (run it again to finish). + per-key results and exits 1 when a key failed. Its human output says + what each failed key needs: run it again to finish a key that failed + part way, a transactional `--domain` (or none) for + `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`, unpublishing the automations in + `error.details.automationIds` for `APP_EMAIL_TRIGGER_IN_USE`, and + `--overwrite` for `APP_EMAIL_EDITED_IN_BREW`. `upsert` exits 1 on the + same refusals, `APP_EMAIL_SETUP_IN_PROGRESS` and + `CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`; when the published version + keeps sending under a newer draft (`DRAFT_SAVED_NOT_LIVE`), it says so + and still prints the `send` line. Both use the SDK's 5-minute defaults as their whole-command deadline and need a key with the `automations` and `emails` scopes. A key that breaks the trigger key grammar, an unknown `--format`, `--sets`, @@ -48,11 +57,16 @@ SDK gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2013 (trigger keys), the payload at compile time. A workspace with no keyed triggers gets the same bytes as before (no `--check` drift); `--no-registry` leaves the block out. -- The vendored spec is brew-v2 main plus the spec delta of - GetBrew/brew-v2#2066 and the PRs it stacks on (#2013, #2017, #2059): the - by-key fire and the app-emails routes, `key` / `appEmail` on trigger - rows, and the sandbox error codes (`DOMAIN_REQUIRED`, - `SANDBOX_DAILY_LIMIT`). Re-vendor from brew-v2 main once they merge. +- The vendored spec is GetBrew/brew-v2#2169 @ `6f8ef50544` whole (brew-v2 + main `2db5428b09` plus every DX route): the by-key fire and the + app-emails routes, `key` / `appEmail` on trigger rows, the sandbox error + codes (`DOMAIN_REQUIRED`, `SANDBOX_DAILY_LIMIT`), the app email refusals + above, `DKIM_SELECTOR_IN_USE` warnings with `suggestedNames` on + `domains add` (printed verbatim), and the hosted auth hook connection + routes, pending in the spec parity test until `integrations connect` + lands. It also carries brew-v2 main's email category relabel and the + template `group` filter, which GetBrew/brew-cli#68 binds. Re-vendor from + brew-v2 main once #2169 merges. - **`brew-cli login` signs in through the browser.** On an interactive terminal it starts a sign-in on brew.new, prints the link and a code diff --git a/RELEASING.md b/RELEASING.md index b4b12140..62e05e44 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -42,6 +42,12 @@ parity fail in CI on these commands. Publish the SDK first, then registry; never hand-write a lockfile entry), run a frozen install, the full checks and `docs:commands:check`, and release the CLI. +The API side must be live first: GetBrew/brew-v2#2169 deployed (the by-key +fire, `/v1/app-emails`, the team sandbox), and the live spec +(`https://brew.new/openapi/public-api-v1.yaml`, what the spec-drift +sentinel reads) matching `openapi/public-api-v1.yaml`, +which is #2169 at `6f8ef50544`. + ```bash # on a clean main with CI green npm version <patch|minor|major> --no-git-tag-version diff --git a/docs/commands/README.md b/docs/commands/README.md index e2c06b44..1ab67cb6 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -1667,7 +1667,7 @@ Set up one app email by key in one idempotent call (trigger, contract, design, a - `--title <title>` — App email title in Brew - `--subject <text>` — Subject line - `--preview-text <text>` — Inbox preview text -- `--domain <domainId>` — Sending domain id (default: a verified domain for the purpose, else the team-only sandbox) +- `--domain <domainId>` — Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A transactional key (auth, trial ending, payment failed) refuses a marketing domain - `--no-publish` — Save the setup without publishing the automation - `--overwrite` — Replace a design that was edited in Brew since setup - `--input <json>` — Full JSON request body, or - to read stdin (flags override it) @@ -1689,7 +1689,7 @@ Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 wh - `--sets <sets>` — Comma-separated kit sets: auth, lifecycle (default both) - `--stack <provider>` — Auth library: better-auth | authjs | supabase | clerk | firebase | custom (narrows the auth keys, picks the snippets) - `--keys <keys>` — Comma-separated exact kit keys instead of sets (auth.password-reset,lifecycle.welcome) -- `--domain <domainId>` — Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox) +- `--domain <domainId>` — Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox). A marketing domain fails the transactional keys with APP_EMAIL_DOMAIN_PURPOSE_MISMATCH - `--overwrite` — Replace designs that were edited in Brew since setup - `--input <json>` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key <key>` — Idempotency-Key for safe retries (auto-generated otherwise) diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 45b5aacd..db60b664 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -84,8 +84,19 @@ they reach members of the Brew organization only, under a daily cap without `--yes`. `upsert` and `kit` publish, so they confirm too; `upsert --no-publish` saves without the gate. `upsert` and `kit` need a key with the `automations` and `emails` scopes (403 otherwise). +Refusals worth knowing (exit 1, the envelope on stderr): `409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew: -keep it, or pass `--overwrite`. +keep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's +trigger already starts a published automation no setup made, so every send +would go out twice; unpublish or delete `details.automationIds` first. +`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a transactional key (auth, trial +ending, payment failed) never sends from a marketing `--domain`; pass a +transactional one or leave `--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`: +another setup of the key was writing; run it again later. +`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops +or retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE` +warning means the published version keeps sending until the draft is +published. `automations triggers create --key <key>` gives any trigger a key. ## The contract you can rely on diff --git a/src/commands/app-emails/kit.ts b/src/commands/app-emails/kit.ts index efc5219b..dde4f4d7 100644 --- a/src/commands/app-emails/kit.ts +++ b/src/commands/app-emails/kit.ts @@ -51,7 +51,7 @@ export const appEmailsKitCommand = defineCommand({ { flag: '--domain <domainId>', summary: - 'Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox)', + 'Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox). A marketing domain fails the transactional keys with APP_EMAIL_DOMAIN_PURPOSE_MISMATCH', }, { flag: '--overwrite', @@ -143,7 +143,6 @@ function renderKit(result: SetUpAppEmailKitResponse, prefix: string): string { sender: '', } ) - const failed = result.results.filter((entry) => !entry.ok).length const lines = [ renderTable(rows, [ { key: 'key', header: 'KEY' }, @@ -156,11 +155,57 @@ function renderKit(result: SetUpAppEmailKitResponse, prefix: string): string { for (const snippet of result.stack.snippets) { lines.push(`Snippet: ${snippet.title} (${snippet.file})`) } - if (failed > 0) { - lines.push( - '', - `${failed} key${failed === 1 ? '' : 's'} failed. Run \`${prefix} app-emails kit\` again with the same flags to finish them.` - ) + const hints = kitFailureHints( + result.results.flatMap((entry) => (entry.ok ? [] : [entry])), + prefix + ) + if (hints.length > 0) { + lines.push('', ...hints) } return lines.join('\n') } + +/** + * What to do about each failed key. Running the kit again finishes a key + * that failed part way, but not one the API refused for a reason a re-run + * keeps: those name the change to make first. + */ +function kitFailureHints( + failures: ReadonlyArray<{ + readonly triggerKey: string + readonly error: { readonly code: string } + }>, + prefix: string +): string[] { + const keysWith = (code: string) => + failures + .filter((entry) => entry.error.code === code) + .map((entry) => entry.triggerKey) + const hints: string[] = [] + const mismatched = keysWith('APP_EMAIL_DOMAIN_PURPOSE_MISMATCH') + if (mismatched.length > 0) { + hints.push( + `${mismatched.join(', ')}: transactional, so they never send from a marketing domain. Run \`${prefix} app-emails kit\` again with a transactional --domain, or without --domain.` + ) + } + const inUse = keysWith('APP_EMAIL_TRIGGER_IN_USE') + if (inUse.length > 0) { + hints.push( + `${inUse.join(', ')}: the trigger already starts a published automation no setup made, so every send would go out twice. Unpublish or delete the automations in error.details.automationIds, then run the kit again.` + ) + } + const edited = keysWith('APP_EMAIL_EDITED_IN_BREW') + if (edited.length > 0) { + hints.push( + `${edited.join(', ')}: edited in Brew since setup. Keep the edits, or run the kit again with --overwrite.` + ) + } + const handled = new Set([...mismatched, ...inUse, ...edited]) + const rest = failures.filter((entry) => !handled.has(entry.triggerKey)) + if (rest.length > 0) { + hints.push( + `${rest.length} key${rest.length === 1 ? '' : 's'} failed. Run \`${prefix} app-emails kit\` again with the same flags to finish them.` + ) + } + return hints +} diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index 939660f5..e9be163e 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -65,7 +65,7 @@ export const appEmailsUpsertCommand = defineCommand({ { flag: '--domain <domainId>', summary: - 'Sending domain id (default: a verified domain for the purpose, else the team-only sandbox)', + 'Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A transactional key (auth, trial ending, payment failed) refuses a marketing domain', }, { flag: '--no-publish', @@ -172,8 +172,18 @@ export function renderSetup( result.sender.mode === 'sandbox' ? `sandbox ${result.sender.from} (team only until a domain verifies)` : `${result.sender.from}` + // A live automation with a newer draft keeps sending its published + // version: `published` is false, yet a send still goes out. + const keepsSending = + !result.published && + result.warnings.some((warning) => warning.code === 'DRAFT_SAVED_NOT_LIVE') + const state = result.published + ? 'published' + : keepsSending + ? 'draft saved, the published version keeps sending' + : 'saved, not published' const lines = [ - `${result.triggerKey}: ${result.published ? 'published' : 'saved, not published'}; sender ${sender}.`, + `${result.triggerKey}: ${state}; sender ${sender}.`, made.length === 0 ? 'Nothing new: every piece already existed.' : `Created: ${made.join(', ')}.`, @@ -181,7 +191,7 @@ export function renderSetup( for (const warning of result.warnings) { lines.push(`warning: ${warning.message}`) } - if (result.published) { + if (result.published || keepsSending) { lines.push(`Send it: ${prefix} send ${result.triggerKey} --to <email>`) } return lines.join('\n') diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 5e84eed7..2208d64e 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a transactional key (auth, trial\nending, payment failed) never sends from a marketing `--domain`; pass a\ntransactional one or leave `--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts index e705e1ea..1baff131 100644 --- a/tests/commands/app-emails.test.ts +++ b/tests/commands/app-emails.test.ts @@ -532,6 +532,127 @@ describe('app-emails upsert', () => { expect(result.code).toBe(1) expect(result.stdout + result.stderr).toContain('APP_EMAIL_EDITED_IN_BREW') }) + + it('surfaces APP_EMAIL_TRIGGER_IN_USE with the automations it names (exit 1)', async () => { + server.use( + http.put(`${API}/v1/app-emails/order.shipped`, () => + HttpResponse.json( + { + error: { + code: 'APP_EMAIL_TRIGGER_IN_USE', + message: + 'The trigger with this key already starts a published automation that no app-email setup made, so setting the key up would send a second email on every fire.', + type: 'conflict', + suggestion: + 'Unpublish or delete the automations in `details.automationIds`, then run the setup again, or keep sending from them.', + docs: 'https://docs.brew.new/api-reference/api/errors', + details: { + triggerKey: 'order.shipped', + triggerEventId: 'tri_1', + automationIds: ['aut_9'], + }, + }, + }, + { status: 409 } + ) + ) + ) + const result = await cli( + [ + 'app-emails', + 'upsert', + 'order.shipped', + '--source-file', + '-', + '--format', + 'html', + '--yes', + ], + { stdin: '<p>Shipped</p>' } + ) + expect(result.code).toBe(1) + const envelope = JSON.parse(result.stderr) as { + error: { code: string; details?: { automationIds?: string[] } } + } + expect(envelope.error.code).toBe('APP_EMAIL_TRIGGER_IN_USE') + expect(envelope.error.details?.automationIds).toEqual(['aut_9']) + }) + + it('surfaces APP_EMAIL_DOMAIN_PURPOSE_MISMATCH for a marketing --domain (exit 1)', async () => { + let body: unknown + server.use( + http.put( + `${API}/v1/app-emails/auth.password-reset`, + async ({ request }) => { + body = await request.json() + return HttpResponse.json( + { + error: { + code: 'APP_EMAIL_DOMAIN_PURPOSE_MISMATCH', + message: + 'The `domainId` names a marketing domain for a transactional app email (auth, trial ending, payment failed), which would send it with an unsubscribe link and never to anyone who unsubscribed.', + type: 'invalid_request', + docs: 'https://docs.brew.new/api-reference/api/errors', + details: { + triggerKey: 'auth.password-reset', + domainId: 'dom_mkt', + messageClass: 'transactional', + sendingPurpose: 'marketing', + }, + }, + }, + { status: 422 } + ) + } + ) + ) + const result = await cli([ + 'app-emails', + 'upsert', + 'auth.password-reset', + '--domain', + 'dom_mkt', + '--yes', + ]) + expect(result.code).toBe(1) + expect(body).toEqual({ domainId: 'dom_mkt' }) + expect(result.stderr).toContain('APP_EMAIL_DOMAIN_PURPOSE_MISMATCH') + }) + + it('renders a draft over a live version as still sending (DRAFT_SAVED_NOT_LIVE)', async () => { + server.use( + http.put(`${API}/v1/app-emails/lifecycle.welcome`, () => + HttpResponse.json({ + ...SETUP, + triggerKey: 'lifecycle.welcome', + published: false, + created: { trigger: false, email: false, automation: false }, + warnings: [ + { + code: 'DRAFT_SAVED_NOT_LIVE', + message: + 'The automation for "lifecycle.welcome" has a newer draft saved in Brew since setup, so the setup left it unpublished: live sends still use the published version. Publish the draft in Brew when it is ready.', + }, + ], + }) + ) + ) + const result = await cli( + ['app-emails', 'upsert', 'lifecycle.welcome', '--yes'], + { ttyOut: true } + ) + expect(result.code).toBe(0) + const lines = result.stdout.trimEnd().split('\n') + expect(lines[0]).toBe( + 'lifecycle.welcome: draft saved, the published version keeps sending; sender sandbox hello@email.brew.new (team only until a domain verifies).' + ) + expect(lines).toContain( + 'warning: The automation for "lifecycle.welcome" has a newer draft saved in Brew since setup, so the setup left it unpublished: live sends still use the published version. Publish the draft in Brew when it is ready.' + ) + expect(lines.at(-1)).toBe( + 'Send it: brew-cli send lifecycle.welcome --to <email>' + ) + }) }) describe('app-emails kit', () => { @@ -615,6 +736,40 @@ describe('app-emails kit', () => { const data = result.json as { results: Array<{ ok: boolean }> } expect(data.results.map((entry) => entry.ok)).toEqual([true, false]) }) + + it('names the change a refused key needs instead of a plain re-run', async () => { + const failure = (triggerKey: string, code: string) => ({ + triggerKey, + ok: false, + error: { code, message: `${code} for ${triggerKey}.` }, + }) + server.use( + http.post(`${API}/v1/app-emails/kit`, () => + HttpResponse.json({ + results: [ + { ...SETUP, triggerKey: 'lifecycle.welcome', ok: true }, + failure('auth.password-reset', 'APP_EMAIL_DOMAIN_PURPOSE_MISMATCH'), + failure('auth.verify-email', 'APP_EMAIL_DOMAIN_PURPOSE_MISMATCH'), + failure('lifecycle.win-back', 'APP_EMAIL_TRIGGER_IN_USE'), + failure('lifecycle.trial-ending', 'APP_EMAIL_SETUP_IN_PROGRESS'), + ], + stack: STACK, + }) + ) + ) + const result = await cli( + ['app-emails', 'kit', '--domain', 'dom_mkt', '--yes'], + { ttyOut: true } + ) + expect(result.code).toBe(1) + expect(result.stdout).toContain('failed: APP_EMAIL_DOMAIN_PURPOSE_MISMATCH') + const lines = result.stdout.trimEnd().split('\n') + expect(lines.slice(-3)).toEqual([ + 'auth.password-reset, auth.verify-email: transactional, so they never send from a marketing domain. Run `brew-cli app-emails kit` again with a transactional --domain, or without --domain.', + 'lifecycle.win-back: the trigger already starts a published automation no setup made, so every send would go out twice. Unpublish or delete the automations in error.details.automationIds, then run the kit again.', + '1 key failed. Run `brew-cli app-emails kit` again with the same flags to finish them.', + ]) + }) }) describe('automations triggers --key', () => { diff --git a/tests/commands/sends-audiences-domains.test.ts b/tests/commands/sends-audiences-domains.test.ts index 1223d312..c520d721 100644 --- a/tests/commands/sends-audiences-domains.test.ts +++ b/tests/commands/sends-audiences-domains.test.ts @@ -291,6 +291,35 @@ describe('domains add', () => { expect(body).toEqual({ name: 'mail.example.com' }) expect((result.json as { records: unknown[] }).records).toHaveLength(1) }) + + it('passes a DKIM_SELECTOR_IN_USE warning and its suggestedNames through verbatim', async () => { + const warning = { + code: 'DKIM_SELECTOR_IN_USE', + message: + 'example.com already publishes a different DKIM key at resend._domainkey.', + suggestedNames: ['mail.example.com', 'send.example.com'], + } + server.use( + http.post(DOMAINS_URL, () => + HttpResponse.json( + { + domainId: 'dom_new', + name: 'example.com', + status: 'pending', + records: [], + warnings: [warning], + }, + { status: 201 } + ) + ) + ) + const result = await runCli(['domains', 'add', 'example.com'], { + env: env(), + extraCommands: EXTRA, + }) + expect(result.code).toBe(0) + expect((result.json as { warnings: unknown[] }).warnings).toEqual([warning]) + }) }) describe('domains verify', () => { From 2795a3713b08b14ea870209a7a761d97d2ff2be1 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 25 Oct 2026 22:28:47 +0900 Subject: [PATCH 11/26] chore(release): 0.19.0 after main's published 0.18.0; SDK gate 13.5.0/13.6.0 npm has CLI 0.18.0 (--lang, contact imports) and SDK 13.1.0-13.3.0 without brew.send or appEmails; 13.4.0 is the SDK's pending email lang release. The vendored spec is the brew-v2 #2169 @ 6f8ef50544 DX delta on main's spec (brew-v2 87ac2c6242). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 22 ++++++++++------------ RELEASING.md | 14 ++++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a435b00..62216d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,14 @@ # Changelog -## 0.18.0 +## 0.19.0 -Needs `@brew.new/sdk` 13.2.0: `brew.send` and +Needs `@brew.new/sdk` 13.6.0: `brew.send` and `automations.triggers.fireByKey` (GetBrew/typescript-sdk#55, planned -13.1.0) and `appEmails` (GetBrew/typescript-sdk#64, planned 13.2.0), both -after the published 13.0.0. Neither PR is published yet; see the app email -SDK gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2169, one PR -for trigger keys, fire by key, the team sandbox, app emails and browser -sign-in. +13.5.0) and `appEmails` (GetBrew/typescript-sdk#64, planned 13.6.0), both +after the published 13.3.0 and the SDK's pending 13.4.0 (email `lang`). +Neither PR is published yet; see the app email SDK gate in `RELEASING.md`. +The API side is GetBrew/brew-v2#2169, one PR for trigger keys, fire by key, +the team sandbox, app emails and browser sign-in. ### Added @@ -57,16 +57,14 @@ sign-in. the payload at compile time. A workspace with no keyed triggers gets the same bytes as before (no `--check` drift); `--no-registry` leaves the block out. -- The vendored spec is GetBrew/brew-v2#2169 @ `6f8ef50544` whole (brew-v2 - main `2db5428b09` plus every DX route): the by-key fire and the +- The vendored spec is the GetBrew/brew-v2#2169 @ `6f8ef50544` DX delta on + main's spec (brew-v2 `87ac2c6242`): the by-key fire and the app-emails routes, `key` / `appEmail` on trigger rows, the sandbox error codes (`DOMAIN_REQUIRED`, `SANDBOX_DAILY_LIMIT`), the app email refusals above, `DKIM_SELECTOR_IN_USE` warnings with `suggestedNames` on `domains add` (printed verbatim), and the hosted auth hook connection routes, pending in the spec parity test until `integrations connect` - lands. It also carries brew-v2 main's email category relabel and the - template `group` filter, which GetBrew/brew-cli#68 binds. Re-vendor from - brew-v2 main once #2169 merges. + lands. Re-vendor from brew-v2 main once #2169 merges. - **`brew-cli login` signs in through the browser.** On an interactive terminal it starts a sign-in on brew.new, prints the link and a code diff --git a/RELEASING.md b/RELEASING.md index 62e05e44..38beb502 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -34,11 +34,12 @@ CLI depends on (`^13.3.0`). `send`, `app-emails` and the `types` key registry call `brew.send` and `brew.appEmails`, which no published SDK has yet: GetBrew/typescript-sdk#55 (`send`, `automations.triggers.fireByKey`) and -GetBrew/typescript-sdk#64 (`appEmails`, the auth adapters), planned as 13.1.0 -and 13.2.0, after the SDK's 13.0.0 above. Until 13.2.0 is on npm, the -dependency and `bun.lock` stay on the published `^13.0.0`, and typecheck and -parity fail in CI on these commands. Publish the SDK first, then -`bun add @brew.new/sdk@^13.2.0` (it regenerates `bun.lock` from the +GetBrew/typescript-sdk#64 (`appEmails`, the auth adapters), planned as 13.5.0 +and 13.6.0, after the published 13.3.0 and the SDK's pending 13.4.0 (email +`lang`). Until 13.6.0 is on npm, the dependency and `bun.lock` stay on the +published `^13.3.0`, and typecheck and parity fail in CI on these commands. +Publish the SDK first, then +`bun add @brew.new/sdk@^13.6.0` (it regenerates `bun.lock` from the registry; never hand-write a lockfile entry), run a frozen install, the full checks and `docs:commands:check`, and release the CLI. @@ -46,7 +47,8 @@ The API side must be live first: GetBrew/brew-v2#2169 deployed (the by-key fire, `/v1/app-emails`, the team sandbox), and the live spec (`https://brew.new/openapi/public-api-v1.yaml`, what the spec-drift sentinel reads) matching `openapi/public-api-v1.yaml`, -which is #2169 at `6f8ef50544`. +which is the #2169 @ `6f8ef50544` DX delta on main's spec (brew-v2 +`87ac2c6242`). ```bash # on a clean main with CI green From d86ccf990b4ceb9dd627a6f7bc1c2d58d0158440 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sat, 31 Oct 2026 17:13:10 -0400 Subject: [PATCH 12/26] test(app-emails): map send and app-emails flags to their body keys; main's rerun format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's body-flag parity test needs every flag on a command with a JSON body to name the key it fills, and main's confirmation commands serialize the parsed invocation (`--to=…`, operands after `--`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/commands/app-emails.test.ts | 2 +- tests/parity-body-flags.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts index 1baff131..52f8430e 100644 --- a/tests/commands/app-emails.test.ts +++ b/tests/commands/app-emails.test.ts @@ -99,7 +99,7 @@ describe('send', () => { ) expect(envelope.summary).toContain('REAL') expect(envelope.confirmCommand).toBe( - 'brew-cli send auth.password-reset --to jane@example.com --yes' + 'brew-cli send --to=jane@example.com --yes -- auth.password-reset' ) }) diff --git a/tests/parity-body-flags.test.ts b/tests/parity-body-flags.test.ts index 5898fa53..158bfa83 100644 --- a/tests/parity-body-flags.test.ts +++ b/tests/parity-body-flags.test.ts @@ -142,6 +142,14 @@ const MAPPED: Readonly< '--user-name': 'user.name', '--read-only': 'permissions', }, + send: { '--to': 'payload', '--data': 'payload' }, + 'app-emails upsert': { + '--source-file': 'source.content', + '--format': 'source.format', + '--domain': 'domainId', + '--no-publish': 'publish', + }, + 'app-emails kit': { '--domain': 'domainId' }, } type Command = (typeof ALL_COMMANDS)[number] From ffe775ddd3ec5f53c5f7af38e51cd7800449757c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sat, 31 Oct 2026 17:13:10 -0400 Subject: [PATCH 13/26] chore(release): app-emails is 0.20.0 after main's published 0.19.0 npm has CLI 0.19.0 (--input key checks) and SDK 13.4.0 (email lang), both released from main without this work, so send and app-emails ship as CLI 0.20.0 on SDK 13.5.0/13.6.0. The vendored spec is the brew-v2 #2169 @ 6f8ef50544 DX delta merged into main's spec (brew-v2 373e900a9a). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 7 ++++--- RELEASING.md | 6 +++--- package.json | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62216d8a..c4b2f0a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # Changelog -## 0.19.0 +## 0.20.0 Needs `@brew.new/sdk` 13.6.0: `brew.send` and `automations.triggers.fireByKey` (GetBrew/typescript-sdk#55, planned 13.5.0) and `appEmails` (GetBrew/typescript-sdk#64, planned 13.6.0), both -after the published 13.3.0 and the SDK's pending 13.4.0 (email `lang`). +after the published 13.4.0 (email `lang`). npm already has CLI 0.19.0 +(released from `main` without this work), so this is 0.20.0. Neither PR is published yet; see the app email SDK gate in `RELEASING.md`. The API side is GetBrew/brew-v2#2169, one PR for trigger keys, fire by key, the team sandbox, app emails and browser sign-in. @@ -58,7 +59,7 @@ the team sandbox, app emails and browser sign-in. the same bytes as before (no `--check` drift); `--no-registry` leaves the block out. - The vendored spec is the GetBrew/brew-v2#2169 @ `6f8ef50544` DX delta on - main's spec (brew-v2 `87ac2c6242`): the by-key fire and the + main's spec (brew-v2 `373e900a9a`): the by-key fire and the app-emails routes, `key` / `appEmail` on trigger rows, the sandbox error codes (`DOMAIN_REQUIRED`, `SANDBOX_DAILY_LIMIT`), the app email refusals above, `DKIM_SELECTOR_IN_USE` warnings with `suggestedNames` on diff --git a/RELEASING.md b/RELEASING.md index 38beb502..8e2b0cf5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -35,8 +35,8 @@ CLI depends on (`^13.3.0`). `brew.appEmails`, which no published SDK has yet: GetBrew/typescript-sdk#55 (`send`, `automations.triggers.fireByKey`) and GetBrew/typescript-sdk#64 (`appEmails`, the auth adapters), planned as 13.5.0 -and 13.6.0, after the published 13.3.0 and the SDK's pending 13.4.0 (email -`lang`). Until 13.6.0 is on npm, the dependency and `bun.lock` stay on the +and 13.6.0, after the published 13.4.0 (email `lang`). Until 13.6.0 is on +npm, the dependency and `bun.lock` stay on the published `^13.3.0`, and typecheck and parity fail in CI on these commands. Publish the SDK first, then `bun add @brew.new/sdk@^13.6.0` (it regenerates `bun.lock` from the @@ -48,7 +48,7 @@ fire, `/v1/app-emails`, the team sandbox), and the live spec (`https://brew.new/openapi/public-api-v1.yaml`, what the spec-drift sentinel reads) matching `openapi/public-api-v1.yaml`, which is the #2169 @ `6f8ef50544` DX delta on main's spec (brew-v2 -`87ac2c6242`). +`373e900a9a`). ```bash # on a clean main with CI green diff --git a/package.json b/package.json index ce2ce6b0..e5b57fb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@brew.new/cli", - "version": "0.19.0", + "version": "0.20.0", "description": "Official agent-first command-line interface for the Brew public API.", "license": "MIT", "author": "Brew", From 781a53ef6600b2eccfab2f7d28164a694bba1f99 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sat, 31 Oct 2026 22:56:52 -0400 Subject: [PATCH 14/26] chore: sync API spec to brew-v2 #2169 5084a5202f Vendors openapi/public-api-v1.yaml byte for byte from brew-v2 5084a5202f (sha256 dd36eab7...aaeb), the final #2169 head, which already carries main's 373e900a9a spec (no operation or schema of main's spec is dropped). Types regenerated. The delta: INTEGRATION_CONNECTED_IN_APP (409 on the integration connection PUT), APP_EMAIL_DOMAIN_PURPOSE_MISMATCH covering both purposes, and ENGAGEMENT_NOT_TRACKED naming the Brew sandbox. The 0.20.0 notes and RELEASING name the new spec source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 4 +-- RELEASING.md | 4 +-- openapi/public-api-v1.yaml | 49 +++++++++++++++++++++++++++------- src/generated/openapi-types.ts | 31 ++++++++++++++------- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b2f0a9..4f23184a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,8 +58,8 @@ the team sandbox, app emails and browser sign-in. the payload at compile time. A workspace with no keyed triggers gets the same bytes as before (no `--check` drift); `--no-registry` leaves the block out. -- The vendored spec is the GetBrew/brew-v2#2169 @ `6f8ef50544` DX delta on - main's spec (brew-v2 `373e900a9a`): the by-key fire and the +- The vendored spec is the GetBrew/brew-v2#2169 head `5084a5202f` (main's + spec, brew-v2 `373e900a9a`, plus the DX delta): the by-key fire and the app-emails routes, `key` / `appEmail` on trigger rows, the sandbox error codes (`DOMAIN_REQUIRED`, `SANDBOX_DAILY_LIMIT`), the app email refusals above, `DKIM_SELECTOR_IN_USE` warnings with `suggestedNames` on diff --git a/RELEASING.md b/RELEASING.md index 8e2b0cf5..8569bb49 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -47,8 +47,8 @@ The API side must be live first: GetBrew/brew-v2#2169 deployed (the by-key fire, `/v1/app-emails`, the team sandbox), and the live spec (`https://brew.new/openapi/public-api-v1.yaml`, what the spec-drift sentinel reads) matching `openapi/public-api-v1.yaml`, -which is the #2169 @ `6f8ef50544` DX delta on main's spec (brew-v2 -`373e900a9a`). +which is the #2169 head `5084a5202f` (main's spec, brew-v2 `373e900a9a`, +plus the DX delta). ```bash # on a clean main with CI green diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index e6c878a0..24052137 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -5305,6 +5305,7 @@ components: - INSUFFICIENT_CREDITS - INSUFFICIENT_PERMISSIONS - INSUFFICIENT_ROLE + - INTEGRATION_CONNECTED_IN_APP - INTEGRATION_NOT_CONNECTED - INTERNAL_ERROR - INVALID_API_KEY @@ -5598,11 +5599,11 @@ components: - tailwind_config_unresolved - tailwind_class_dropped - foreign_component_dropped + - element_dropped - dynamic_prop_folded - dynamic_control_flow_unsupported - escaping_changed - placeholder_unmapped - - element_dropped location: type: string enum: @@ -36439,7 +36440,7 @@ paths: **Input** Update: one or more of `name`, `description`, `nodes`, `connections`, `triggerEventId`, under the same typed condition contract as create (explicit `type`, canonical snake_case `operator`, no `value` for unary operators, type-correct values elsewhere); `dryRun: true` validates without persisting. Node ids are stored as sent, so renamed nodes keep their new ids, except the two shapes `NODE_ID_REPLACED` reports. Lifecycle: `published: true` promotes the stored latest version live (optionally pin `automationVersionId`; the graph is validated first), `published: false` unpublishes, `paused: true` or `false` freezes or resumes future send steps (`stopInFlight` also stops runs already in progress). - **Returns** the bare automation row, or with `dryRun: true` a `200 AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }`: `blockingIssues[]` lists per-node references the effective trigger or contact catalog cannot provide, and publish refuses every one of them, fallback or not (`fatal` says what the reference would do: `true` for filter or split conditions and triple-brace `{{{ }}}` body tokens would break the run, `false` for subject, previewText, fromName, replyTo and double-brace body tags would render empty); `valid` is false when any blocker or blocking issue is present. Updating a LIVE automation saves a draft and does not go live: the previously published version keeps serving until you republish, `published` describes the returned row, `isLive`, `liveVersion` and `liveAutomationVersionId` describe the automation, and `warnings[]` carries `DRAFT_SAVED_NOT_LIVE`. Publishing an automation whose integration trigger has not received its event on the current connection (a Shopify topic with no webhook) still succeeds, and `warnings[]` carries `TRIGGER_EVENT_NOT_RECEIVED` saying what to set up. An engagement filter that watches an email sent from a transactional domain (no open or click tracking) publishes with `ENGAGEMENT_NOT_TRACKED`. + **Returns** the bare automation row, or with `dryRun: true` a `200 AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }`: `blockingIssues[]` lists per-node references the effective trigger or contact catalog cannot provide, and publish refuses every one of them, fallback or not (`fatal` says what the reference would do: `true` for filter or split conditions and triple-brace `{{{ }}}` body tokens would break the run, `false` for subject, previewText, fromName, replyTo and double-brace body tags would render empty); `valid` is false when any blocker or blocking issue is present. Updating a LIVE automation saves a draft and does not go live: the previously published version keeps serving until you republish, `published` describes the returned row, `isLive`, `liveVersion` and `liveAutomationVersionId` describe the automation, and `warnings[]` carries `DRAFT_SAVED_NOT_LIVE`. Publishing an automation whose integration trigger has not received its event on the current connection (a Shopify topic with no webhook) still succeeds, and `warnings[]` carries `TRIGGER_EVENT_NOT_RECEIVED` saying what to set up. An engagement filter that watches an email sent from a transactional domain or the Brew sandbox (no open or click tracking) publishes with `ENGAGEMENT_NOT_TRACKED`. **Errors** `404 AUTOMATION_NOT_FOUND`, `AUTOMATION_VERSION_NOT_FOUND` (pin `liveAutomationVersionId`, a `versions[]` id, or a run row's `automationVersionId`), `TRIGGER_EVENT_NOT_FOUND`; `400 AUTOMATION_GRAPH_INVALID` (`details.issues[]`); `409 AUTOMATION_VERSION_CONFLICT` (re-read, reapply, retry); `422 PUBLISH_VALIDATION_FAILED` (`details.blockers[]`; a cdn.brew.new image a step would send that does not exist is a blocker, listed in `details.missingImages[]`, and a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access), `AUTOMATION_NOT_PUBLISHED` (nothing to unpublish; with `stopInFlight: true` on a draft, also no contacts in flight to stop), `AUTOMATION_NOT_PAUSABLE` (a manual-audience automation pauses through its runs). @@ -47739,15 +47740,15 @@ paths: **Use when** your code should send `brew.send(’<key>’, payload)` and nothing exists yet, or to bring an existing setup up to date. - **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger.<field> }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A transactional key never sends from a marketing domain: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. + **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger.<field> }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A kit key never sends from a domain of the other purpose: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. - **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a marketing domain picked on a transactional key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`): the setup imports its design the way `importEmail` does. **Returns** `201` when this call set the key up, `200` on a re-run, with the ids, `published`, what was `created`, the `sender`, the stored `payloadSchema`, a `fireExample` (path, example payload, curl and `brew.send`) and `warnings`. - **Errors** `400 APP_EMAIL_STARTER_UNKNOWN`, `400 TRIGGER_KEY_RESERVED`, `404 DOMAIN_NOT_FOUND`, `409 APP_EMAIL_EDITED_IN_BREW`, `409 APP_EMAIL_TRIGGER_IN_USE`, `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS` (a `payloadSchema` that drops or retypes a field the live automation reads), `409 APP_EMAIL_SETUP_IN_PROGRESS` (other setups of the key kept writing first; retry later), `409 TRIGGER_KEY_TAKEN` (another setup of the key won a race), `409 TRIGGER_LIMIT_REACHED`, `413 PAYLOAD_TOO_LARGE` and `422 EMAIL_IMPORT_FAILED` for the source, `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH` (a marketing `domainId` for a transactional key), `422 AUTOMATION_GRAPH_INVALID` and `404 AUTOMATION_NOT_FOUND`, `TRIGGER_EVENT_NOT_FOUND` or `NOT_FOUND` when the trigger, automation or design changes during the setup. + **Errors** `400 APP_EMAIL_STARTER_UNKNOWN`, `400 TRIGGER_KEY_RESERVED`, `404 DOMAIN_NOT_FOUND`, `409 APP_EMAIL_EDITED_IN_BREW`, `409 APP_EMAIL_TRIGGER_IN_USE`, `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS` (a `payloadSchema` that drops or retypes a field the live automation reads), `409 APP_EMAIL_SETUP_IN_PROGRESS` (other setups of the key kept writing first; retry later), `409 TRIGGER_KEY_TAKEN` (another setup of the key won a race), `409 TRIGGER_LIMIT_REACHED`, `413 PAYLOAD_TOO_LARGE` and `422 EMAIL_IMPORT_FAILED` for the source, `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH` (a `domainId` of the other purpose for a kit key), `422 AUTOMATION_GRAPH_INVALID` and `404 AUTOMATION_NOT_FOUND`, `TRIGGER_EVENT_NOT_FOUND` or `NOT_FOUND` when the trigger, automation or design changes during the setup. **See also** `setUpAppEmailKit`, `fireTriggerByKey`, `listAppEmails`. parameters: @@ -48331,7 +48332,7 @@ paths: docs: https://docs.brew.new/api-reference/api/errors "422": description: |- - `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: The `domainId` names a marketing domain for a transactional app email (auth, trial ending, payment failed), which would send it with an unsubscribe link and never to anyone who unsubscribed. + `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: The `domainId` names a domain of the other purpose for a kit app email: a marketing domain for a transactional one (auth, trial ending, payment failed) would send it with an unsubscribe link and never to anyone who unsubscribed; a transactional domain for a marketing one (welcome, win-back) would send it without the link, to people who unsubscribed. `EMAIL_IMPORT_FAILED`: The supplied markup could not be imported as an email. headers: @@ -48353,8 +48354,8 @@ paths: error: code: APP_EMAIL_DOMAIN_PURPOSE_MISMATCH type: invalid_request - message: The `domainId` names a marketing domain for a transactional app email (auth, trial ending, payment failed), which would send it with an unsubscribe link and never to anyone who unsubscribed. - suggestion: "Pass a transactional domain, or leave `domainId` out: the setup then picks a verified transactional domain, or the sandbox until one verifies." + message: "The `domainId` names a domain of the other purpose for a kit app email: a marketing domain for a transactional one (auth, trial ending, payment failed) would send it with an unsubscribe link and never to anyone who unsubscribed; a transactional domain for a marketing one (welcome, win-back) would send it without the link, to people who unsubscribed." + suggestion: "Pass a domain whose purpose matches the email, or leave `domainId` out: the setup then picks a verified domain of that purpose, or the sandbox until one verifies." docs: https://docs.brew.new/api-reference/api/errors details: triggerKey: auth.password-reset @@ -48470,7 +48471,7 @@ paths: description: |- Sets up the starter kit: auth emails (verify email, magic link, one-time code, password reset, invite, email change, reauthentication, security notice) and lifecycle emails (welcome, trial ending, payment failed, win-back). Each key is set up exactly as `upsertAppEmail` does with its own starter, a few at a time. - **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a marketing `domainId` sets up the marketing keys and answers each transactional key `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. + **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a `domainId` sets up the keys of its purpose and answers each key of the other class `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. @@ -70634,7 +70635,9 @@ paths: **Returns** `200` with `provider`, `connectionId`, `webhookUrl`, `status` and `authEmails[]`: each auth key with `ok`, or its `error`; connecting again finishes a failed key. - **Errors** `400 INVALID_REQUEST` for an unsupported provider, a secret in the wrong format or a project URL that is not https. + **Clerk connected in Brew** (on its integration page) keeps its signing secret there: the same secret answers the connection as it is, and another is `409 INTEGRATION_CONNECTED_IN_APP` (rotate it on the integration page). + + **Errors** `400 INVALID_REQUEST` for an unsupported provider, a secret in the wrong format or a project URL that is not https; `409 INTEGRATION_CONNECTED_IN_APP` as above. **See also** `getIntegrationConnection`, `setUpAppEmailKit`, `listAppEmails`. parameters: @@ -70900,6 +70903,32 @@ paths: message: No v1 resource lives at this path. suggestion: Check the request path and HTTP method against the API reference. docs: https://docs.brew.new/api-reference/api/api-introduction + "409": + description: "`INTEGRATION_CONNECTED_IN_APP`: The provider is connected on its integration page in Brew with another signing secret; the API connect never replaces that connection." + headers: + x-request-id: + schema: + type: string + description: Unique request identifier. Share this with support when debugging a request. + example: req_8cac13fd94e6420cacdd75a1aa403a28 + required: true + description: Unique request identifier. Share this with support when debugging a request. + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorEnvelope" + examples: + integrationConnectedInApp: + summary: INTEGRATION_CONNECTED_IN_APP + value: + error: + code: INTEGRATION_CONNECTED_IN_APP + type: conflict + message: The provider is connected on its integration page in Brew with another signing secret; the API connect never replaces that connection. + suggestion: Rotate the signing secret on the integration page, or send the secret the connection already has. + docs: https://docs.brew.new/api-reference/api/errors + details: + provider: clerk "429": description: "`RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot." headers: diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index ee6e58d7..9f96e69b 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -809,7 +809,7 @@ export interface paths { * * **Input** Update: one or more of `name`, `description`, `nodes`, `connections`, `triggerEventId`, under the same typed condition contract as create (explicit `type`, canonical snake_case `operator`, no `value` for unary operators, type-correct values elsewhere); `dryRun: true` validates without persisting. Node ids are stored as sent, so renamed nodes keep their new ids, except the two shapes `NODE_ID_REPLACED` reports. Lifecycle: `published: true` promotes the stored latest version live (optionally pin `automationVersionId`; the graph is validated first), `published: false` unpublishes, `paused: true` or `false` freezes or resumes future send steps (`stopInFlight` also stops runs already in progress). * - * **Returns** the bare automation row, or with `dryRun: true` a `200 AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }`: `blockingIssues[]` lists per-node references the effective trigger or contact catalog cannot provide, and publish refuses every one of them, fallback or not (`fatal` says what the reference would do: `true` for filter or split conditions and triple-brace `{{{ }}}` body tokens would break the run, `false` for subject, previewText, fromName, replyTo and double-brace body tags would render empty); `valid` is false when any blocker or blocking issue is present. Updating a LIVE automation saves a draft and does not go live: the previously published version keeps serving until you republish, `published` describes the returned row, `isLive`, `liveVersion` and `liveAutomationVersionId` describe the automation, and `warnings[]` carries `DRAFT_SAVED_NOT_LIVE`. Publishing an automation whose integration trigger has not received its event on the current connection (a Shopify topic with no webhook) still succeeds, and `warnings[]` carries `TRIGGER_EVENT_NOT_RECEIVED` saying what to set up. An engagement filter that watches an email sent from a transactional domain (no open or click tracking) publishes with `ENGAGEMENT_NOT_TRACKED`. + * **Returns** the bare automation row, or with `dryRun: true` a `200 AutomationDryRunReport` `{ valid, blockers[], warnings[], blockingIssues[], nodeCounts }`: `blockingIssues[]` lists per-node references the effective trigger or contact catalog cannot provide, and publish refuses every one of them, fallback or not (`fatal` says what the reference would do: `true` for filter or split conditions and triple-brace `{{{ }}}` body tokens would break the run, `false` for subject, previewText, fromName, replyTo and double-brace body tags would render empty); `valid` is false when any blocker or blocking issue is present. Updating a LIVE automation saves a draft and does not go live: the previously published version keeps serving until you republish, `published` describes the returned row, `isLive`, `liveVersion` and `liveAutomationVersionId` describe the automation, and `warnings[]` carries `DRAFT_SAVED_NOT_LIVE`. Publishing an automation whose integration trigger has not received its event on the current connection (a Shopify topic with no webhook) still succeeds, and `warnings[]` carries `TRIGGER_EVENT_NOT_RECEIVED` saying what to set up. An engagement filter that watches an email sent from a transactional domain or the Brew sandbox (no open or click tracking) publishes with `ENGAGEMENT_NOT_TRACKED`. * * **Errors** `404 AUTOMATION_NOT_FOUND`, `AUTOMATION_VERSION_NOT_FOUND` (pin `liveAutomationVersionId`, a `versions[]` id, or a run row's `automationVersionId`), `TRIGGER_EVENT_NOT_FOUND`; `400 AUTOMATION_GRAPH_INVALID` (`details.issues[]`); `409 AUTOMATION_VERSION_CONFLICT` (re-read, reapply, retry); `422 PUBLISH_VALIDATION_FAILED` (`details.blockers[]`; a cdn.brew.new image a step would send that does not exist is a blocker, listed in `details.missingImages[]`, and a dead icon whose real file sits elsewhere in its pack is moved there instead when the key has `emails` access), `AUTOMATION_NOT_PUBLISHED` (nothing to unpublish; with `stopInFlight: true` on a draft, also no contacts in flight to stop), `AUTOMATION_NOT_PAUSABLE` (a manual-audience automation pauses through its runs). * @@ -1426,15 +1426,15 @@ export interface paths { * * **Use when** your code should send `brew.send(’<key>’, payload)` and nothing exists yet, or to bring an existing setup up to date. * - * **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger.<field> }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A transactional key never sends from a marketing domain: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. + * **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger.<field> }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A kit key never sends from a domain of the other purpose: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. * - * **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a marketing domain picked on a transactional key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + * **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. * * **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`): the setup imports its design the way `importEmail` does. * * **Returns** `201` when this call set the key up, `200` on a re-run, with the ids, `published`, what was `created`, the `sender`, the stored `payloadSchema`, a `fireExample` (path, example payload, curl and `brew.send`) and `warnings`. * - * **Errors** `400 APP_EMAIL_STARTER_UNKNOWN`, `400 TRIGGER_KEY_RESERVED`, `404 DOMAIN_NOT_FOUND`, `409 APP_EMAIL_EDITED_IN_BREW`, `409 APP_EMAIL_TRIGGER_IN_USE`, `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS` (a `payloadSchema` that drops or retypes a field the live automation reads), `409 APP_EMAIL_SETUP_IN_PROGRESS` (other setups of the key kept writing first; retry later), `409 TRIGGER_KEY_TAKEN` (another setup of the key won a race), `409 TRIGGER_LIMIT_REACHED`, `413 PAYLOAD_TOO_LARGE` and `422 EMAIL_IMPORT_FAILED` for the source, `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH` (a marketing `domainId` for a transactional key), `422 AUTOMATION_GRAPH_INVALID` and `404 AUTOMATION_NOT_FOUND`, `TRIGGER_EVENT_NOT_FOUND` or `NOT_FOUND` when the trigger, automation or design changes during the setup. + * **Errors** `400 APP_EMAIL_STARTER_UNKNOWN`, `400 TRIGGER_KEY_RESERVED`, `404 DOMAIN_NOT_FOUND`, `409 APP_EMAIL_EDITED_IN_BREW`, `409 APP_EMAIL_TRIGGER_IN_USE`, `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS` (a `payloadSchema` that drops or retypes a field the live automation reads), `409 APP_EMAIL_SETUP_IN_PROGRESS` (other setups of the key kept writing first; retry later), `409 TRIGGER_KEY_TAKEN` (another setup of the key won a race), `409 TRIGGER_LIMIT_REACHED`, `413 PAYLOAD_TOO_LARGE` and `422 EMAIL_IMPORT_FAILED` for the source, `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH` (a `domainId` of the other purpose for a kit key), `422 AUTOMATION_GRAPH_INVALID` and `404 AUTOMATION_NOT_FOUND`, `TRIGGER_EVENT_NOT_FOUND` or `NOT_FOUND` when the trigger, automation or design changes during the setup. * * **See also** `setUpAppEmailKit`, `fireTriggerByKey`, `listAppEmails`. */ @@ -1459,7 +1459,7 @@ export interface paths { * Set up the app email kit * @description Sets up the starter kit: auth emails (verify email, magic link, one-time code, password reset, invite, email change, reauthentication, security notice) and lifecycle emails (welcome, trial ending, payment failed, win-back). Each key is set up exactly as `upsertAppEmail` does with its own starter, a few at a time. * - * **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a marketing `domainId` sets up the marketing keys and answers each transactional key `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. + * **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a `domainId` sets up the keys of its purpose and answers each key of the other class `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. * * **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. * @@ -2554,7 +2554,9 @@ export interface paths { * * **Returns** `200` with `provider`, `connectionId`, `webhookUrl`, `status` and `authEmails[]`: each auth key with `ok`, or its `error`; connecting again finishes a failed key. * - * **Errors** `400 INVALID_REQUEST` for an unsupported provider, a secret in the wrong format or a project URL that is not https. + * **Clerk connected in Brew** (on its integration page) keeps its signing secret there: the same secret answers the connection as it is, and another is `409 INTEGRATION_CONNECTED_IN_APP` (rotate it on the integration page). + * + * **Errors** `400 INVALID_REQUEST` for an unsupported provider, a secret in the wrong format or a project URL that is not https; `409 INTEGRATION_CONNECTED_IN_APP` as above. * * **See also** `getIntegrationConnection`, `setUpAppEmailKit`, `listAppEmails`. */ @@ -4662,7 +4664,7 @@ export interface components { }; }; /** @enum {string} */ - ApiErrorCode: "ACCOUNT_SUSPENDED" | "API_KEY_REVOKED" | "APP_EMAIL_DOMAIN_PURPOSE_MISMATCH" | "APP_EMAIL_EDITED_IN_BREW" | "APP_EMAIL_SETUP_IN_PROGRESS" | "APP_EMAIL_STARTER_UNKNOWN" | "APP_EMAIL_TRIGGER_IN_USE" | "AUDIENCE_BUILD_ACTIVE" | "AUDIENCE_BUILD_ALREADY_ACTIVE" | "AUDIENCE_EDIT_CONFLICT" | "AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE" | "AUDIENCE_NOT_FOUND" | "AUDIENCE_RUN_NOT_FOUND" | "AUDIT_NOT_FOUND" | "AUTHENTICATION_REQUIRED" | "AUTOMATION_GRAPH_INVALID" | "AUTOMATION_NOT_FOUND" | "AUTOMATION_NOT_PAUSABLE" | "AUTOMATION_NOT_PUBLISHED" | "AUTOMATION_RUN_NOT_FOUND" | "AUTOMATION_VERSION_CONFLICT" | "AUTOMATION_VERSION_NOT_FOUND" | "BATCH_TOO_LARGE" | "BRAND_DOMAIN_CONFLICT" | "BRAND_ID_REQUIRED" | "BRAND_LIMIT_REACHED" | "BRAND_NOT_FOUND" | "BRAND_NOT_READY" | "BRAND_SCOPE_MISMATCH" | "CHAT_NOT_FOUND" | "COMMENT_NOT_FOUND" | "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND" | "CONTENT_OPERATION_FAILED" | "CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS" | "CORE_FIELD_IMMUTABLE" | "DOMAIN_ALREADY_EXISTS" | "DOMAIN_CLAIMED_ELSEWHERE" | "DOMAIN_NOT_FOUND" | "DOMAIN_NOT_READY" | "DOMAIN_OTHER_BRAND" | "DOMAIN_PROVIDER_ERROR" | "DOMAIN_PURPOSE_NOT_ALLOWED" | "DOMAIN_REQUIRED" | "DOMAIN_VERIFICATION_FAILED" | "DOMAIN_VERIFIED_ELSEWHERE" | "EMAIL_GENERATION_FAILED" | "EMAIL_GROUP_NAME_CONFLICT" | "EMAIL_GROUP_NOT_FOUND" | "EMAIL_IMAGES_MISSING" | "EMAIL_IMPORT_FAILED" | "EMAIL_IN_PROGRESS" | "EMAIL_IN_USE_BY_AUTOMATION" | "EMAIL_NOT_FOUND" | "EMAIL_NOT_READY" | "EMAIL_RESULT_UNAVAILABLE" | "EMAIL_RUN_AMBIGUOUS" | "EMAIL_TEMPLATE_INVALID" | "EMAIL_VERSION_NOT_FOUND" | "EXPORT_PROVIDER_ERROR" | "EXPORT_UNSUPPORTED" | "FIELD_NAME_COLLISION" | "FIELD_NOT_FOUND" | "FIELD_TYPE_MISMATCH" | "FIGMA_ACCESS_DENIED" | "FIGMA_CONVERSION_FAILED" | "FIGMA_FRAME_NOT_FOUND" | "FIGMA_NOT_CONNECTED" | "FIGMA_UNAVAILABLE" | "FIGMA_URL_INVALID" | "FLOW_NOT_FOUND" | "IDEMPOTENCY_CONFLICT" | "IDEMPOTENCY_IN_PROGRESS" | "INSIGHT_NOT_FOUND" | "INSUFFICIENT_CREDITS" | "INSUFFICIENT_PERMISSIONS" | "INSUFFICIENT_ROLE" | "INTEGRATION_NOT_CONNECTED" | "INTERNAL_ERROR" | "INVALID_API_KEY" | "INVALID_EMAIL" | "INVALID_PAYLOAD" | "INVALID_REQUEST" | "LIQUID_RENDER_ERROR" | "METHOD_NOT_ALLOWED" | "MISSING_EMAIL" | "NO_ELIGIBLE_RECIPIENTS" | "NO_PUBLISHED_AUTOMATION" | "NOT_FOUND" | "NOT_IMPLEMENTED" | "ORG_SCOPE_REQUIRED" | "PAYLOAD_SCHEMA_EMAIL_REQUIRED" | "PAYLOAD_TOO_LARGE" | "PREVIEW_NOT_FOUND" | "PUBLISH_VALIDATION_FAILED" | "RATE_LIMITED" | "RECIPIENT_UNSUBSCRIBED" | "REFERENCE_EMAIL_NOT_FOUND" | "RESUBSCRIBE_NOT_ALLOWED" | "RUN_IN_PROGRESS" | "RUN_NOT_CANCELLABLE" | "RUN_NOT_PAUSABLE" | "RUN_NOT_PAUSED" | "RUN_NOT_RESUMABLE" | "RUN_START_FAILED" | "RUN_STOP_FAILED" | "SANDBOX_DAILY_LIMIT" | "SEND_NOT_CANCELLABLE" | "SEND_NOT_FOUND" | "SEND_NOT_PAUSABLE" | "SEND_NOT_RESUMABLE" | "SEND_QUOTA_EXCEEDED" | "SERVICE_UNAVAILABLE" | "TEMPLATE_NOT_FOUND" | "TRIGGER_ALREADY_EXISTS" | "TRIGGER_EVENT_NOT_FOUND" | "TRIGGER_HAS_DEPENDENT_AUTOMATIONS" | "TRIGGER_IMMUTABLE" | "TRIGGER_INSTANCE_NOT_FOUND" | "TRIGGER_KEY_NOT_FOUND" | "TRIGGER_KEY_RESERVED" | "TRIGGER_KEY_TAKEN" | "TRIGGER_LIMIT_REACHED" | "TRIGGER_NOT_FIREABLE" | "UPLOAD_IN_PROGRESS" | "UPLOAD_NOT_FOUND" | "UPLOAD_NOT_RECEIVED"; + ApiErrorCode: "ACCOUNT_SUSPENDED" | "API_KEY_REVOKED" | "APP_EMAIL_DOMAIN_PURPOSE_MISMATCH" | "APP_EMAIL_EDITED_IN_BREW" | "APP_EMAIL_SETUP_IN_PROGRESS" | "APP_EMAIL_STARTER_UNKNOWN" | "APP_EMAIL_TRIGGER_IN_USE" | "AUDIENCE_BUILD_ACTIVE" | "AUDIENCE_BUILD_ALREADY_ACTIVE" | "AUDIENCE_EDIT_CONFLICT" | "AUDIENCE_MEMBERSHIP_NOT_EXPRESSIBLE" | "AUDIENCE_NOT_FOUND" | "AUDIENCE_RUN_NOT_FOUND" | "AUDIT_NOT_FOUND" | "AUTHENTICATION_REQUIRED" | "AUTOMATION_GRAPH_INVALID" | "AUTOMATION_NOT_FOUND" | "AUTOMATION_NOT_PAUSABLE" | "AUTOMATION_NOT_PUBLISHED" | "AUTOMATION_RUN_NOT_FOUND" | "AUTOMATION_VERSION_CONFLICT" | "AUTOMATION_VERSION_NOT_FOUND" | "BATCH_TOO_LARGE" | "BRAND_DOMAIN_CONFLICT" | "BRAND_ID_REQUIRED" | "BRAND_LIMIT_REACHED" | "BRAND_NOT_FOUND" | "BRAND_NOT_READY" | "BRAND_SCOPE_MISMATCH" | "CHAT_NOT_FOUND" | "COMMENT_NOT_FOUND" | "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND" | "CONTENT_OPERATION_FAILED" | "CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS" | "CORE_FIELD_IMMUTABLE" | "DOMAIN_ALREADY_EXISTS" | "DOMAIN_CLAIMED_ELSEWHERE" | "DOMAIN_NOT_FOUND" | "DOMAIN_NOT_READY" | "DOMAIN_OTHER_BRAND" | "DOMAIN_PROVIDER_ERROR" | "DOMAIN_PURPOSE_NOT_ALLOWED" | "DOMAIN_REQUIRED" | "DOMAIN_VERIFICATION_FAILED" | "DOMAIN_VERIFIED_ELSEWHERE" | "EMAIL_GENERATION_FAILED" | "EMAIL_GROUP_NAME_CONFLICT" | "EMAIL_GROUP_NOT_FOUND" | "EMAIL_IMAGES_MISSING" | "EMAIL_IMPORT_FAILED" | "EMAIL_IN_PROGRESS" | "EMAIL_IN_USE_BY_AUTOMATION" | "EMAIL_NOT_FOUND" | "EMAIL_NOT_READY" | "EMAIL_RESULT_UNAVAILABLE" | "EMAIL_RUN_AMBIGUOUS" | "EMAIL_TEMPLATE_INVALID" | "EMAIL_VERSION_NOT_FOUND" | "EXPORT_PROVIDER_ERROR" | "EXPORT_UNSUPPORTED" | "FIELD_NAME_COLLISION" | "FIELD_NOT_FOUND" | "FIELD_TYPE_MISMATCH" | "FIGMA_ACCESS_DENIED" | "FIGMA_CONVERSION_FAILED" | "FIGMA_FRAME_NOT_FOUND" | "FIGMA_NOT_CONNECTED" | "FIGMA_UNAVAILABLE" | "FIGMA_URL_INVALID" | "FLOW_NOT_FOUND" | "IDEMPOTENCY_CONFLICT" | "IDEMPOTENCY_IN_PROGRESS" | "INSIGHT_NOT_FOUND" | "INSUFFICIENT_CREDITS" | "INSUFFICIENT_PERMISSIONS" | "INSUFFICIENT_ROLE" | "INTEGRATION_CONNECTED_IN_APP" | "INTEGRATION_NOT_CONNECTED" | "INTERNAL_ERROR" | "INVALID_API_KEY" | "INVALID_EMAIL" | "INVALID_PAYLOAD" | "INVALID_REQUEST" | "LIQUID_RENDER_ERROR" | "METHOD_NOT_ALLOWED" | "MISSING_EMAIL" | "NO_ELIGIBLE_RECIPIENTS" | "NO_PUBLISHED_AUTOMATION" | "NOT_FOUND" | "NOT_IMPLEMENTED" | "ORG_SCOPE_REQUIRED" | "PAYLOAD_SCHEMA_EMAIL_REQUIRED" | "PAYLOAD_TOO_LARGE" | "PREVIEW_NOT_FOUND" | "PUBLISH_VALIDATION_FAILED" | "RATE_LIMITED" | "RECIPIENT_UNSUBSCRIBED" | "REFERENCE_EMAIL_NOT_FOUND" | "RESUBSCRIBE_NOT_ALLOWED" | "RUN_IN_PROGRESS" | "RUN_NOT_CANCELLABLE" | "RUN_NOT_PAUSABLE" | "RUN_NOT_PAUSED" | "RUN_NOT_RESUMABLE" | "RUN_START_FAILED" | "RUN_STOP_FAILED" | "SANDBOX_DAILY_LIMIT" | "SEND_NOT_CANCELLABLE" | "SEND_NOT_FOUND" | "SEND_NOT_PAUSABLE" | "SEND_NOT_RESUMABLE" | "SEND_QUOTA_EXCEEDED" | "SERVICE_UNAVAILABLE" | "TEMPLATE_NOT_FOUND" | "TRIGGER_ALREADY_EXISTS" | "TRIGGER_EVENT_NOT_FOUND" | "TRIGGER_HAS_DEPENDENT_AUTOMATIONS" | "TRIGGER_IMMUTABLE" | "TRIGGER_INSTANCE_NOT_FOUND" | "TRIGGER_KEY_NOT_FOUND" | "TRIGGER_KEY_RESERVED" | "TRIGGER_KEY_TAKEN" | "TRIGGER_LIMIT_REACHED" | "TRIGGER_NOT_FIREABLE" | "UPLOAD_IN_PROGRESS" | "UPLOAD_NOT_FOUND" | "UPLOAD_NOT_RECEIVED"; EmailGenerateTextResponse: { response: string; }; @@ -4724,7 +4726,7 @@ export interface components { dropped?: number; warnings: { /** @enum {string} */ - code: "fetch_failed_retained" | "non_image_resource_retained" | "social_icon_substituted" | "tracking_pixel_dropped" | "unsafe_url_stripped" | "unsupported_resource_retained" | "upload_failed_retained" | "inline_asset_retained" | "attachment_dropped" | "embedded_reference_unresolved" | "tailwind_config_unresolved" | "tailwind_class_dropped" | "foreign_component_dropped" | "dynamic_prop_folded" | "dynamic_control_flow_unsupported" | "escaping_changed" | "placeholder_unmapped" | "element_dropped"; + code: "fetch_failed_retained" | "non_image_resource_retained" | "social_icon_substituted" | "tracking_pixel_dropped" | "unsafe_url_stripped" | "unsupported_resource_retained" | "upload_failed_retained" | "inline_asset_retained" | "attachment_dropped" | "embedded_reference_unresolved" | "tailwind_config_unresolved" | "tailwind_class_dropped" | "foreign_component_dropped" | "element_dropped" | "dynamic_prop_folded" | "dynamic_control_flow_unsupported" | "escaping_changed" | "placeholder_unmapped"; /** @enum {string} */ location: "background" | "css-url" | "data-src" | "data-srcset" | "markup" | "module" | "preload" | "src" | "srcset" | "stylesheet" | "vml-src"; host?: string; @@ -21992,7 +21994,7 @@ export interface operations { }; }; /** - * @description `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: The `domainId` names a marketing domain for a transactional app email (auth, trial ending, payment failed), which would send it with an unsubscribe link and never to anyone who unsubscribed. + * @description `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: The `domainId` names a domain of the other purpose for a kit app email: a marketing domain for a transactional one (auth, trial ending, payment failed) would send it with an unsubscribe link and never to anyone who unsubscribed; a transactional domain for a marketing one (welcome, win-back) would send it without the link, to people who unsubscribed. * * `EMAIL_IMPORT_FAILED`: The supplied markup could not be imported as an email. */ @@ -32087,6 +32089,17 @@ export interface operations { "application/json": components["schemas"]["ApiErrorEnvelope"]; }; }; + /** @description `INTEGRATION_CONNECTED_IN_APP`: The provider is connected on its integration page in Brew with another signing secret; the API connect never replaces that connection. */ + 409: { + headers: { + /** @description Unique request identifier. Share this with support when debugging a request. */ + "x-request-id": string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorEnvelope"]; + }; + }; /** @description `RATE_LIMITED`: The credential exhausted the rolling window for this route policy; Retry-After is when the oldest recorded request leaves, and the refused request does not take a slot. */ 429: { headers: { From 8186eb4b4590ccfdfe952904201e8deddf884868 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sat, 31 Oct 2026 22:57:28 -0400 Subject: [PATCH 15/26] fix(app-emails): a kit key refuses a --domain of either other purpose brew-v2 #2169 5084a5202f widens APP_EMAIL_DOMAIN_PURPOSE_MISMATCH: a transactional domain on welcome or win-back is refused too, so `kit --domain <transactional>` now fails the marketing keys. The kit hint no longer calls every refused key transactional or sends the user to a transactional --domain; it names both purposes and says to run again without --domain or with one of their purpose. The --domain summaries, the skill, the command docs and the 0.20.0 notes follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 5 +++-- docs/commands/README.md | 4 ++-- skills/brew-cli/SKILL.md | 7 ++++--- src/commands/app-emails/kit.ts | 4 ++-- src/commands/app-emails/upsert.ts | 2 +- src/generated/skills.ts | 2 +- tests/commands/app-emails.test.ts | 2 +- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f23184a..c0f9a907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,9 @@ the team sandbox, app emails and browser sign-in. `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints per-key results and exits 1 when a key failed. Its human output says what each failed key needs: run it again to finish a key that failed - part way, a transactional `--domain` (or none) for - `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`, unpublishing the automations in + part way, a `--domain` of the key's purpose (or none) for + `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH` (a transactional domain on welcome + or win-back is refused too), unpublishing the automations in `error.details.automationIds` for `APP_EMAIL_TRIGGER_IN_USE`, and `--overwrite` for `APP_EMAIL_EDITED_IN_BREW`. `upsert` exits 1 on the same refusals, `APP_EMAIL_SETUP_IN_PROGRESS` and diff --git a/docs/commands/README.md b/docs/commands/README.md index 1ab67cb6..0ece9e4a 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -1667,7 +1667,7 @@ Set up one app email by key in one idempotent call (trigger, contract, design, a - `--title <title>` — App email title in Brew - `--subject <text>` — Subject line - `--preview-text <text>` — Inbox preview text -- `--domain <domainId>` — Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A transactional key (auth, trial ending, payment failed) refuses a marketing domain +- `--domain <domainId>` — Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A kit key refuses a domain of the other purpose (transactional: auth, trial ending, payment failed; marketing: welcome, win-back) - `--no-publish` — Save the setup without publishing the automation - `--overwrite` — Replace a design that was edited in Brew since setup - `--input <json>` — Full JSON request body, or - to read stdin (flags override it) @@ -1689,7 +1689,7 @@ Set up and publish the auth + lifecycle starter kit (per-key results; exits 1 wh - `--sets <sets>` — Comma-separated kit sets: auth, lifecycle (default both) - `--stack <provider>` — Auth library: better-auth | authjs | supabase | clerk | firebase | custom (narrows the auth keys, picks the snippets) - `--keys <keys>` — Comma-separated exact kit keys instead of sets (auth.password-reset,lifecycle.welcome) -- `--domain <domainId>` — Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox). A marketing domain fails the transactional keys with APP_EMAIL_DOMAIN_PURPOSE_MISMATCH +- `--domain <domainId>` — Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox). Keys of the other purpose fail with APP_EMAIL_DOMAIN_PURPOSE_MISMATCH - `--overwrite` — Replace designs that were edited in Brew since setup - `--input <json>` — Full JSON request body, or - to read stdin (flags override it) - `--idempotency-key <key>` — Idempotency-Key for safe retries (auto-generated otherwise) diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index db60b664..8fb74ba6 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -89,9 +89,10 @@ Refusals worth knowing (exit 1, the envelope on stderr): keep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's trigger already starts a published automation no setup made, so every send would go out twice; unpublish or delete `details.automationIds` first. -`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a transactional key (auth, trial -ending, payment failed) never sends from a marketing `--domain`; pass a -transactional one or leave `--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`: +`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a +`--domain` of the other purpose (transactional: auth, trial ending, payment +failed; marketing: welcome, win-back); pass one of its purpose or leave +`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`: another setup of the key was writing; run it again later. `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops or retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE` diff --git a/src/commands/app-emails/kit.ts b/src/commands/app-emails/kit.ts index dde4f4d7..138793a6 100644 --- a/src/commands/app-emails/kit.ts +++ b/src/commands/app-emails/kit.ts @@ -51,7 +51,7 @@ export const appEmailsKitCommand = defineCommand({ { flag: '--domain <domainId>', summary: - 'Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox). A marketing domain fails the transactional keys with APP_EMAIL_DOMAIN_PURPOSE_MISMATCH', + 'Sending domain id for every key (default: a verified domain per purpose, else the team-only sandbox). Keys of the other purpose fail with APP_EMAIL_DOMAIN_PURPOSE_MISMATCH', }, { flag: '--overwrite', @@ -185,7 +185,7 @@ function kitFailureHints( const mismatched = keysWith('APP_EMAIL_DOMAIN_PURPOSE_MISMATCH') if (mismatched.length > 0) { hints.push( - `${mismatched.join(', ')}: transactional, so they never send from a marketing domain. Run \`${prefix} app-emails kit\` again with a transactional --domain, or without --domain.` + `${mismatched.join(', ')}: a kit key never sends from a domain of the other purpose (auth, trial ending and payment failed are transactional; welcome and win-back are marketing). Run \`${prefix} app-emails kit\` again without --domain, or with a --domain of their purpose.` ) } const inUse = keysWith('APP_EMAIL_TRIGGER_IN_USE') diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index e9be163e..b74f5734 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -65,7 +65,7 @@ export const appEmailsUpsertCommand = defineCommand({ { flag: '--domain <domainId>', summary: - 'Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A transactional key (auth, trial ending, payment failed) refuses a marketing domain', + 'Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A kit key refuses a domain of the other purpose (transactional: auth, trial ending, payment failed; marketing: welcome, win-back)', }, { flag: '--no-publish', diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 2208d64e..bd5c7dad 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a transactional key (auth, trial\nending, payment failed) never sends from a marketing `--domain`; pass a\ntransactional one or leave `--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts index 52f8430e..b1169bbf 100644 --- a/tests/commands/app-emails.test.ts +++ b/tests/commands/app-emails.test.ts @@ -765,7 +765,7 @@ describe('app-emails kit', () => { expect(result.stdout).toContain('failed: APP_EMAIL_DOMAIN_PURPOSE_MISMATCH') const lines = result.stdout.trimEnd().split('\n') expect(lines.slice(-3)).toEqual([ - 'auth.password-reset, auth.verify-email: transactional, so they never send from a marketing domain. Run `brew-cli app-emails kit` again with a transactional --domain, or without --domain.', + 'auth.password-reset, auth.verify-email: a kit key never sends from a domain of the other purpose (auth, trial ending and payment failed are transactional; welcome and win-back are marketing). Run `brew-cli app-emails kit` again without --domain, or with a --domain of their purpose.', 'lifecycle.win-back: the trigger already starts a published automation no setup made, so every send would go out twice. Unpublish or delete the automations in error.details.automationIds, then run the kit again.', '1 key failed. Run `brew-cli app-emails kit` again with the same flags to finish them.', ]) From f1d30ffe0447f22e7eb63e2794b0acf7ad463397 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sat, 31 Oct 2026 23:25:11 -0400 Subject: [PATCH 16/26] fix(app-emails): kit checks a selection after --input; upsert reads stdin once - kit: keys with sets or stack is refused after merging --input, so a body's keys can no longer silently replace --sets (exit 2, no request). - upsert: --input - with --source-file - exits 2 instead of reading stdin twice. - kit human output prints each stack snippet's code, names the automations to unpublish for APP_EMAIL_TRIGGER_IN_USE, and after an --idempotency-key run says a re-run needs a new key (the same key replays the partly failed result for 24 hours). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 17 ++++++----- src/commands/app-emails/kit.ts | 49 ++++++++++++++++++++++++------- src/commands/app-emails/upsert.ts | 5 ++++ tests/commands/app-emails.test.ts | 35 +++++++++++++++++----- 4 files changed, 80 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f9a907..648c61aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,13 +34,16 @@ the team sandbox, app emails and browser sign-in. `--no-publish` saves without the gate. `kit` sets up the auth and lifecycle starter kit (`--sets`, `--stack`, `--keys`, `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints - per-key results and exits 1 when a key failed. Its human output says + per-key results and the stack's code snippets, and exits 1 when a key + failed. Its human output says what each failed key needs: run it again to finish a key that failed part way, a `--domain` of the key's purpose (or none) for `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH` (a transactional domain on welcome - or win-back is refused too), unpublishing the automations in - `error.details.automationIds` for `APP_EMAIL_TRIGGER_IN_USE`, and - `--overwrite` for `APP_EMAIL_EDITED_IN_BREW`. `upsert` exits 1 on the + or win-back is refused too), unpublishing the automations it names + (`error.details.automationIds`) for `APP_EMAIL_TRIGGER_IN_USE`, and + `--overwrite` for `APP_EMAIL_EDITED_IN_BREW`; after an + `--idempotency-key` run, a new key, since the same one replays the + result for 24 hours. `upsert` exits 1 on the same refusals, `APP_EMAIL_SETUP_IN_PROGRESS` and `CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`; when the published version keeps sending under a newer draft (`DRAFT_SAVED_NOT_LIVE`), it says so @@ -48,9 +51,9 @@ the team sandbox, app emails and browser sign-in. Both use the SDK's 5-minute defaults as their whole-command deadline and need a key with the `automations` and `emails` scopes. A key that breaks the trigger key grammar, an unknown `--format`, `--sets`, - `--stack` or kit key, an empty list, or `--keys` with `--sets`/`--stack` - exits 2 before any request; a body on stdin (`--input -`) always - confirms. + `--stack` or kit key, an empty list, `keys` with `sets`/`stack` (as + flags or in `--input`), or `--input -` with `--source-file -` exits 2 + before any request; a body on stdin (`--input -`) always confirms. - `automations triggers create --key <key>` and `automations triggers update --key <key>` set the stable key code fires by; `triggers list` gains a `KEY` column (`get` returns `key` and `appEmail` in the row). diff --git a/src/commands/app-emails/kit.ts b/src/commands/app-emails/kit.ts index 138793a6..28e304a9 100644 --- a/src/commands/app-emails/kit.ts +++ b/src/commands/app-emails/kit.ts @@ -21,6 +21,7 @@ import { mergeInput, readJsonFlag, requestOptions, + toStringArray, } from '../../lib/input' import { getInvocationPrefix } from '../../lib/invocation' import { renderTable } from '../../lib/output' @@ -91,12 +92,6 @@ export const appEmailsKitCommand = defineCommand({ const sets = listFlag(flags.sets, '--sets', APP_EMAIL_KIT_SETS) const keys = listFlag(flags.keys, '--keys', APP_EMAIL_KIT_KEYS) const stack = oneOfFlag(flags.stack, '--stack', APP_EMAIL_AUTH_STACKS) - if (keys !== undefined && (sets !== undefined || stack !== undefined)) { - // The API takes `keys` instead of `sets` and `stack`, silently. - throw new CliUsageError( - '--keys names exact kit keys: pass it without --sets and --stack.' - ) - } const base = await readJsonFlag(ctx, flags.input, '--input') const input = mergeInput(base, { sets, @@ -105,6 +100,15 @@ export const appEmailsKitCommand = defineCommand({ domainId: flagString(flags.domain), overwrite: flags.overwrite === true ? true : undefined, }) + if ( + input.keys !== undefined && + (input.sets !== undefined || input.stack !== undefined) + ) { + // The API takes `keys` instead of `sets` and `stack`, silently. + throw new CliUsageError( + '--keys names exact kit keys: pass it without --sets and --stack, as flags or in --input.' + ) + } const result = await ctx .client() .appEmails.setUpKit( @@ -114,7 +118,11 @@ export const appEmailsKitCommand = defineCommand({ const failed = result.results.filter((entry) => !entry.ok) return { data: result, - human: renderKit(result, getInvocationPrefix(ctx.io.env)), + human: renderKit( + result, + getInvocationPrefix(ctx.io.env), + flags.idempotencyKey !== undefined + ), ...(failed.length > 0 ? { exitCode: 1 } : {}), } }, @@ -129,7 +137,11 @@ function selection(value: unknown): string[] | undefined { return items.length === 0 ? undefined : items } -function renderKit(result: SetUpAppEmailKitResponse, prefix: string): string { +function renderKit( + result: SetUpAppEmailKitResponse, + prefix: string, + keyed: boolean +): string { const rows = result.results.map((entry) => entry.ok ? { @@ -153,7 +165,7 @@ function renderKit(result: SetUpAppEmailKitResponse, prefix: string): string { `Stack ${result.stack.provider} (${result.stack.authDelivery}): ${result.stack.note}`, ] for (const snippet of result.stack.snippets) { - lines.push(`Snippet: ${snippet.title} (${snippet.file})`) + lines.push('', `Snippet: ${snippet.title} (${snippet.file})`, snippet.code) } const hints = kitFailureHints( result.results.flatMap((entry) => (entry.ok ? [] : [entry])), @@ -161,6 +173,13 @@ function renderKit(result: SetUpAppEmailKitResponse, prefix: string): string { ) if (hints.length > 0) { lines.push('', ...hints) + if (keyed) { + // The API stores this response under the key: a same-key re-run + // gets it back instead of retrying the failed keys. + lines.push( + 'A re-run with the same --idempotency-key replays this result for 24 hours: pass a new key.' + ) + } } return lines.join('\n') } @@ -173,7 +192,10 @@ function renderKit(result: SetUpAppEmailKitResponse, prefix: string): string { function kitFailureHints( failures: ReadonlyArray<{ readonly triggerKey: string - readonly error: { readonly code: string } + readonly error: { + readonly code: string + readonly details?: Readonly<Record<string, unknown>> + } }>, prefix: string ): string[] { @@ -190,8 +212,13 @@ function kitFailureHints( } const inUse = keysWith('APP_EMAIL_TRIGGER_IN_USE') if (inUse.length > 0) { + const automations = failures.flatMap((entry) => + entry.error.code === 'APP_EMAIL_TRIGGER_IN_USE' + ? (toStringArray(entry.error.details?.automationIds) ?? []) + : [] + ) hints.push( - `${inUse.join(', ')}: the trigger already starts a published automation no setup made, so every send would go out twice. Unpublish or delete the automations in error.details.automationIds, then run the kit again.` + `${inUse.join(', ')}: the trigger already starts a published automation no setup made, so every send would go out twice. Unpublish or delete ${automations.length > 0 ? automations.join(', ') : 'those automations'}, then run the kit again.` ) } const edited = keysWith('APP_EMAIL_EDITED_IN_BREW') diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index b74f5734..8a4b6827 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -106,6 +106,11 @@ export const appEmailsUpsertCommand = defineCommand({ }, run: async ({ ctx, args, flags }) => { const triggerKey = triggerKeyArg(args.triggerKey, 'The app email key') + if (flags.input === '-' && flags.sourceFile === '-') { + throw new CliUsageError( + 'Only one of --input and --source-file can read stdin.' + ) + } const explicitFormat = oneOfFlag( flags.format, '--format', diff --git a/tests/commands/app-emails.test.ts b/tests/commands/app-emails.test.ts index b1169bbf..a4af6f90 100644 --- a/tests/commands/app-emails.test.ts +++ b/tests/commands/app-emails.test.ts @@ -737,11 +737,12 @@ describe('app-emails kit', () => { expect(data.results.map((entry) => entry.ok)).toEqual([true, false]) }) - it('names the change a refused key needs instead of a plain re-run', async () => { + it('prints snippet code and names what each failed key needs before a re-run', async () => { + // Only APP_EMAIL_TRIGGER_IN_USE details name automations to unpublish. const failure = (triggerKey: string, code: string) => ({ triggerKey, ok: false, - error: { code, message: `${code} for ${triggerKey}.` }, + error: { code, message: code, details: { automationIds: ['aut_9'] } }, }) server.use( http.post(`${API}/v1/app-emails/kit`, () => @@ -757,17 +758,20 @@ describe('app-emails kit', () => { }) ) ) - const result = await cli( - ['app-emails', 'kit', '--domain', 'dom_mkt', '--yes'], - { ttyOut: true } - ) + const argv = 'app-emails kit --domain dom_mkt --idempotency-key kit:1 --yes' + const result = await cli(argv.split(' '), { ttyOut: true }) expect(result.code).toBe(1) expect(result.stdout).toContain('failed: APP_EMAIL_DOMAIN_PURPOSE_MISMATCH') + expect(result.stdout).toContain( + 'Snippet: Better Auth callbacks (lib/auth.ts)\n…' + ) const lines = result.stdout.trimEnd().split('\n') - expect(lines.slice(-3)).toEqual([ + expect(lines.slice(-4)).toEqual([ 'auth.password-reset, auth.verify-email: a kit key never sends from a domain of the other purpose (auth, trial ending and payment failed are transactional; welcome and win-back are marketing). Run `brew-cli app-emails kit` again without --domain, or with a --domain of their purpose.', - 'lifecycle.win-back: the trigger already starts a published automation no setup made, so every send would go out twice. Unpublish or delete the automations in error.details.automationIds, then run the kit again.', + 'lifecycle.win-back: the trigger already starts a published automation no setup made, so every send would go out twice. Unpublish or delete aut_9, then run the kit again.', '1 key failed. Run `brew-cli app-emails kit` again with the same flags to finish them.', + // The same key replays the stored, partly failed response. + 'A re-run with the same --idempotency-key replays this result for 24 hours: pass a new key.', ]) }) }) @@ -877,6 +881,11 @@ describe('local input checks (exit 2 before any request)', () => { [['app-emails', 'kit', '--stack', 'auth0', '--yes']], [['app-emails', 'kit', '--keys', 'order.paid', '--yes']], [['app-emails', 'kit', '--keys', 'auth.otp', '--sets', 'auth', '--yes']], + [ + 'app-emails kit --sets lifecycle --input {"keys":["auth.otp"]} --yes'.split( + ' ' + ), + ], [ [ 'automations', @@ -894,6 +903,16 @@ describe('local input checks (exit 2 before any request)', () => { expect(result.code).toBe(2) }) + it('upsert refuses --input - with --source-file -: stdin holds one body', async () => { + const argv = + 'app-emails upsert order.paid --input - --source-file - --format html --yes' + const result = await cli(argv.split(' '), { stdin: '{}' }) + expect(result.code).toBe(2) + expect(result.stderr).toContain( + 'Only one of --input and --source-file can read stdin.' + ) + }) + it('upsert with the body on stdin always confirms (it may overwrite)', async () => { const result = await cli( ['app-emails', 'upsert', 'order.paid', '--no-publish', '--input', '-'], From efeeba63047f8d85ab0306611e89e53b2dfbee10 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sat, 31 Oct 2026 23:25:16 -0400 Subject: [PATCH 17/26] test(types): compile the send registry against the installed SDK The generated BrewTriggerKeyRegistry augmentation must keep reaching brew.send: one valid call type-checks, and an unknown key and a payload missing a required field are errors. A registry that stopped connecting (an SDK rename) leaves both @ts-expect-error lines unused and fails the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/commands/types.test.ts | 55 ++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/tests/commands/types.test.ts b/tests/commands/types.test.ts index 07ffcc4e..82b9c580 100644 --- a/tests/commands/types.test.ts +++ b/tests/commands/types.test.ts @@ -1,8 +1,15 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { HttpResponse, http } from 'msw' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import ts from 'typescript' +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { automationsTriggersReadyCommand } from '../../src/commands/automations/triggers/ready' import { typesCommand } from '../../src/commands/types' import type { components } from '../../src/generated/openapi-types' @@ -617,6 +624,50 @@ describe('types — trigger key registry', () => { ) }) + it('type-checks brew.send keys and payloads against the installed SDK', async () => { + mockTriggers(KEYED) + // Inside the repo, so `@brew.new/sdk` resolves as it does in an app. + const cache = join(import.meta.dirname, '../../node_modules/.cache') + mkdirSync(cache, { recursive: true }) + const dir = mkdtempSync(join(cache, 'brew-types-')) + onTestFinished(() => rmSync(dir, { recursive: true, force: true })) + const out = join(dir, 'brew-contracts.ts') + const result = await runCli(['types', '--out', out], { + extraCommands: [typesCommand], + env: env(), + }) + expect(result.code).toBe(0) + const app = join(dir, 'app.ts') + writeFileSync( + app, + [ + "import { createBrewClient } from '@brew.new/sdk'", + "const brew = createBrewClient({ apiKey: 'brew_x' })", + "void brew.send('user.signup', { email: 'jane@example.com', seats: 2 })", + '// @ts-expect-error no trigger has this key', + "void brew.send('user.signin', { email: 'jane@example.com' })", + '// @ts-expect-error the payload lacks email', + "void brew.send('auth.password-reset', { seats: 2 })", + '', + ].join('\n') + ) + const program = ts.createProgram([out, app], { + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + }) + // A registry that stopped reaching `send` leaves both expect-error + // lines unused, which is itself a diagnostic. + const diagnostics = ts + .getPreEmitDiagnostics(program) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')) + expect(diagnostics).toEqual([]) + }, 30_000) + it('--no-registry leaves the augmentation out', async () => { mockTriggers(KEYED) const out = join(mkdtempSync(join(tmpdir(), 'brew-types-')), 'c.ts') From 3cef01fa894985dc5353b5ec7358d817e4ed48b7 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 00:56:50 -0400 Subject: [PATCH 18/26] fix(app-emails): kit prints each key's warnings and a draft over a live version A key whose new version is a draft while the published one keeps sending (DRAFT_SAVED_NOT_LIVE) reads 'draft saved, published version sends' instead of 'saved', and every set-up key's warnings print under the table. upsert and kit share the keepsSending check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/commands/app-emails/kit.ts | 17 +++++- src/commands/app-emails/upsert.ts | 23 +++++--- tests/commands/app-emails-kit-output.test.ts | 57 ++++++++++++++++++++ 3 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 tests/commands/app-emails-kit-output.test.ts diff --git a/src/commands/app-emails/kit.ts b/src/commands/app-emails/kit.ts index 28e304a9..e2555329 100644 --- a/src/commands/app-emails/kit.ts +++ b/src/commands/app-emails/kit.ts @@ -25,6 +25,7 @@ import { } from '../../lib/input' import { getInvocationPrefix } from '../../lib/invocation' import { renderTable } from '../../lib/output' +import { keepsSending } from './upsert' export const appEmailsKitCommand = defineCommand({ path: ['app-emails', 'kit'], @@ -146,7 +147,11 @@ function renderKit( entry.ok ? { key: entry.triggerKey, - status: entry.published ? 'published' : 'saved', + status: entry.published + ? 'published' + : keepsSending(entry) + ? 'draft saved, published version sends' + : 'saved', sender: entry.sender.mode, } : { @@ -164,6 +169,16 @@ function renderKit( '', `Stack ${result.stack.provider} (${result.stack.authDelivery}): ${result.stack.note}`, ] + const warnings = result.results.flatMap((entry) => + entry.ok + ? entry.warnings.map( + (warning) => `${entry.triggerKey}: warning: ${warning.message}` + ) + : [] + ) + if (warnings.length > 0) { + lines.push('', ...warnings) + } for (const snippet of result.stack.snippets) { lines.push('', `Snippet: ${snippet.title} (${snippet.file})`, snippet.code) } diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index 8a4b6827..43d26636 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -163,6 +163,19 @@ export const appEmailsUpsertCommand = defineCommand({ }, }) +/** + * A live automation with a newer draft keeps sending its published + * version: `published` is false, yet a send still goes out. + */ +export function keepsSending( + result: Pick<UpsertAppEmailResponse, 'published' | 'warnings'> +): boolean { + return ( + !result.published && + result.warnings.some((warning) => warning.code === 'DRAFT_SAVED_NOT_LIVE') + ) +} + export function renderSetup( result: Pick< UpsertAppEmailResponse, @@ -177,14 +190,10 @@ export function renderSetup( result.sender.mode === 'sandbox' ? `sandbox ${result.sender.from} (team only until a domain verifies)` : `${result.sender.from}` - // A live automation with a newer draft keeps sending its published - // version: `published` is false, yet a send still goes out. - const keepsSending = - !result.published && - result.warnings.some((warning) => warning.code === 'DRAFT_SAVED_NOT_LIVE') + const sends = keepsSending(result) const state = result.published ? 'published' - : keepsSending + : sends ? 'draft saved, the published version keeps sending' : 'saved, not published' const lines = [ @@ -196,7 +205,7 @@ export function renderSetup( for (const warning of result.warnings) { lines.push(`warning: ${warning.message}`) } - if (result.published || keepsSending) { + if (result.published || sends) { lines.push(`Send it: ${prefix} send ${result.triggerKey} --to <email>`) } return lines.join('\n') diff --git a/tests/commands/app-emails-kit-output.test.ts b/tests/commands/app-emails-kit-output.test.ts new file mode 100644 index 00000000..07848663 --- /dev/null +++ b/tests/commands/app-emails-kit-output.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../helpers/msw-server' +import { runCli } from '../helpers/run-cli' + +/** `app-emails kit` on a terminal: what a person reads about each key. */ + +const DRAFT_WARNING = { + code: 'DRAFT_SAVED_NOT_LIVE', + message: + 'The new version is a draft: the published version keeps sending until you publish it.', +} + +describe('app-emails kit terminal output', () => { + it('names a key whose published version keeps sending, with its warning', async () => { + server.use( + http.post('https://brew.new/api/v1/app-emails/kit', () => + HttpResponse.json({ + results: [ + { + ok: true, + triggerKey: 'lifecycle.welcome', + published: false, + sender: { mode: 'sandbox' }, + warnings: [DRAFT_WARNING], + }, + ], + stack: { + provider: 'custom', + authDelivery: 'code', + note: 'Send from your code.', + keys: [], + snippets: [], + }, + }) + ) + ) + const result = await runCli( + ['app-emails', 'kit', '--sets', 'lifecycle', '--yes'], + { + ttyOut: true, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-test-')), + BREW_API_KEY: 'brew_abcdefghijklmnopqrstuvwxyz012345', + }, + } + ) + expect(result.code).toBe(0) + expect(result.stdout).toContain('draft saved, published version sends') + expect(result.stdout).toContain( + `lifecycle.welcome: warning: ${DRAFT_WARNING.message}` + ) + }) +}) From 232600a179788d2e6427b7ca7d182a2233242c05 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 01:58:56 -0500 Subject: [PATCH 19/26] chore: sync API spec to brew-v2 #2169 b884e78157 Vendors openapi/public-api-v1.yaml byte for byte from brew-v2 b884e78157 (sha256 5ca16fc2...55ce), the final #2169 head. main's spec is unchanged since 373e900a9a. Types regenerated. The delta is description text only: an app email unpublished in Brew after setup stays unpublished (APP_EMAIL_NOT_PUBLISHED) until publish: true. upsert already prints that warning and 'saved, not published'. The 0.20.0 notes and RELEASING name the new spec head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 2 +- RELEASING.md | 2 +- openapi/public-api-v1.yaml | 6 +++--- src/generated/openapi-types.ts | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648c61aa..0ccc392c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,7 @@ the team sandbox, app emails and browser sign-in. the payload at compile time. A workspace with no keyed triggers gets the same bytes as before (no `--check` drift); `--no-registry` leaves the block out. -- The vendored spec is the GetBrew/brew-v2#2169 head `5084a5202f` (main's +- The vendored spec is the GetBrew/brew-v2#2169 head `b884e78157` (main's spec, brew-v2 `373e900a9a`, plus the DX delta): the by-key fire and the app-emails routes, `key` / `appEmail` on trigger rows, the sandbox error codes (`DOMAIN_REQUIRED`, `SANDBOX_DAILY_LIMIT`), the app email refusals diff --git a/RELEASING.md b/RELEASING.md index 8569bb49..148a3bbb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -47,7 +47,7 @@ The API side must be live first: GetBrew/brew-v2#2169 deployed (the by-key fire, `/v1/app-emails`, the team sandbox), and the live spec (`https://brew.new/openapi/public-api-v1.yaml`, what the spec-drift sentinel reads) matching `openapi/public-api-v1.yaml`, -which is the #2169 head `5084a5202f` (main's spec, brew-v2 `373e900a9a`, +which is the #2169 head `b884e78157` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). ```bash diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index 24052137..be1823e6 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -13532,7 +13532,7 @@ components: additionalProperties: false publish: type: boolean - description: Publish the automation. Defaults to true. + description: Publish the automation. Defaults to true, except that an email unpublished in Brew after a setup took it live stays unpublished (warning `APP_EMAIL_NOT_PUBLISHED`) until a call sends `true`. overwrite: type: boolean description: Replace a design edited in Brew since setup. Defaults to false. @@ -47742,7 +47742,7 @@ paths: **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger.<field> }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A kit key never sends from a domain of the other purpose: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. - **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. An email unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until `publish: true`. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`): the setup imports its design the way `importEmail` does. @@ -48473,7 +48473,7 @@ paths: **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a `domainId` sets up the keys of its purpose and answers each key of the other class `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. - **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. + **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. A key unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until `upsertAppEmail` sends `publish: true`. **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`), as for `upsertAppEmail`. diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 9f96e69b..ec533735 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -1428,7 +1428,7 @@ export interface paths { * * **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger.<field> }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A kit key never sends from a domain of the other purpose: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. * - * **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + * **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. An email unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until `publish: true`. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. * * **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`): the setup imports its design the way `importEmail` does. * @@ -1461,7 +1461,7 @@ export interface paths { * * **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a `domainId` sets up the keys of its purpose and answers each key of the other class `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. * - * **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. + * **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. A key unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until `upsertAppEmail` sends `publish: true`. * * **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`), as for `upsertAppEmail`. * @@ -7557,7 +7557,7 @@ export interface components { localPart?: string; name?: string; }; - /** @description Publish the automation. Defaults to true. */ + /** @description Publish the automation. Defaults to true, except that an email unpublished in Brew after a setup took it live stays unpublished (warning `APP_EMAIL_NOT_PUBLISHED`) until a call sends `true`. */ publish?: boolean; /** @description Replace a design edited in Brew since setup. Defaults to false. */ overwrite?: boolean; From 3d57b54935b04fe26823c0e35277ab271e216e2b Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 02:46:37 -0500 Subject: [PATCH 20/26] fix(app-emails): upsert --publish turns an email unpublished in Brew back on A re-run leaves an app email someone unpublished in Brew off (APP_EMAIL_NOT_PUBLISHED) until a call sends publish: true, but upsert only ever sent false or nothing. --publish now sends true (--no-publish false, neither leaves it out), and a confirmation command keeps whichever of the pair was given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 4 +- docs/commands/README.md | 1 + skills/brew-cli/SKILL.md | 4 +- src/commands/app-emails/upsert.ts | 9 +- src/generated/skills.ts | 2 +- src/lib/rerun.ts | 12 +-- tests/commands/app-emails-publish.test.ts | 103 ++++++++++++++++++++++ 7 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 tests/commands/app-emails-publish.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ccc392c..cd9ae7f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,9 @@ the team sandbox, app emails and browser sign-in. `--subject`, `--preview-text`, `--domain`, `--overwrite`; `--input` carries the rest, such as `payloadSchema` fields marked `secret: true` for a one-time code or sign-in link) and publishes it, so it confirms; - `--no-publish` saves without the gate. `kit` sets up + `--no-publish` saves without the gate. A re-run leaves an email + unpublished in Brew off (warning `APP_EMAIL_NOT_PUBLISHED`); `--publish` + turns it back on. `kit` sets up the auth and lifecycle starter kit (`--sets`, `--stack`, `--keys`, `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints per-key results and the stack's code snippets, and exits 1 when a key diff --git a/docs/commands/README.md b/docs/commands/README.md index 0ece9e4a..8b4dcdf6 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -1668,6 +1668,7 @@ Set up one app email by key in one idempotent call (trigger, contract, design, a - `--subject <text>` — Subject line - `--preview-text <text>` — Inbox preview text - `--domain <domainId>` — Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A kit key refuses a domain of the other purpose (transactional: auth, trial ending, payment failed; marketing: welcome, win-back) +- `--publish` — Publish it even if it was unpublished in Brew since setup, which a re-run otherwise leaves off - `--no-publish` — Save the setup without publishing the automation - `--overwrite` — Replace a design that was edited in Brew since setup - `--input <json>` — Full JSON request body, or - to read stdin (flags override it) diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 8fb74ba6..4c76dc6a 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -97,7 +97,9 @@ another setup of the key was writing; run it again later. `409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops or retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE` warning means the published version keeps sending until the draft is -published. +published. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off; +one unpublished in Brew stays off on a re-run (and `kit`) until +`upsert <key> --publish`. `automations triggers create --key <key>` gives any trigger a key. ## The contract you can rely on diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index 43d26636..0a9e8dc9 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -67,6 +67,13 @@ export const appEmailsUpsertCommand = defineCommand({ summary: 'Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A kit key refuses a domain of the other purpose (transactional: auth, trial ending, payment failed; marketing: welcome, win-back)', }, + // `--publish` comes first so the default stays unset: Commander gives a + // lone `--no-publish` a default of true. + { + flag: '--publish', + summary: + 'Publish it even if it was unpublished in Brew since setup, which a re-run otherwise leaves off', + }, { flag: '--no-publish', summary: 'Save the setup without publishing the automation', @@ -142,7 +149,7 @@ export const appEmailsUpsertCommand = defineCommand({ subject: flagString(flags.subject), previewText: flagString(flags.previewText), domainId: flagString(flags.domain), - publish: flags.publish === false ? false : undefined, + publish: typeof flags.publish === 'boolean' ? flags.publish : undefined, overwrite: flags.overwrite === true ? true : undefined, }) if (input.starter !== undefined && input.source !== undefined) { diff --git a/src/generated/skills.ts b/src/generated/skills.ts index bd5c7dad..97061ea8 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off;\none unpublished in Brew stays off on a re-run (and `kit`) until\n`upsert <key> --publish`.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/src/lib/rerun.ts b/src/lib/rerun.ts index bdf3a2eb..1d4d5e3e 100644 --- a/src/lib/rerun.ts +++ b/src/lib/rerun.ts @@ -36,18 +36,20 @@ export function buildRerunCommand( if (name === 'apiKey' || emitted.has(name)) { continue } - emitted.add(name) const value = flags[name] const spelling = option.long ?? option.short - if (spelling === undefined || value === undefined) { - continue - } if (!(option.required || option.optional)) { - if (value === !option.negate) { + // A `--x` / `--no-x` pair shares one name: the matching spelling wins. + if (spelling !== undefined && value === !option.negate) { + emitted.add(name) tokens.push(spelling) } continue } + emitted.add(name) + if (spelling === undefined || value === undefined) { + continue + } if (option.optional && value === true) { tokens.push(spelling) continue diff --git a/tests/commands/app-emails-publish.test.ts b/tests/commands/app-emails-publish.test.ts new file mode 100644 index 00000000..d9af3ad0 --- /dev/null +++ b/tests/commands/app-emails-publish.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { HttpResponse, http } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../helpers/msw-server' +import { type RunCliOptions, runCli } from '../helpers/run-cli' + +/** + * `app-emails upsert` and an email unpublished in Brew: a re-run leaves it + * off (`APP_EMAIL_NOT_PUBLISHED`), and only `publish: true` turns it back on. + */ + +function cli(argv: readonly string[], options: RunCliOptions = {}) { + return runCli(argv, { + ...options, + env: { + BREW_CLI_CONFIG_DIR: mkdtempSync(join(tmpdir(), 'brew-cli-test-')), + BREW_API_KEY: 'brew_abcdefghijklmnopqrstuvwxyz012345', + }, + }) +} + +const URL = 'https://brew.new/api/v1/app-emails/order.paid' + +const NOT_PUBLISHED = { + code: 'APP_EMAIL_NOT_PUBLISHED', + message: + '"order.paid" was unpublished in Brew after setup, so the setup left it unpublished. Publish it in Brew, or run PUT /v1/app-emails/order.paid with `publish: true`.', +} + +function setup(published: boolean, warnings: Array<typeof NOT_PUBLISHED>) { + return { + triggerKey: 'order.paid', + published, + created: { trigger: false, email: false, automation: false }, + sender: { mode: 'domain', from: 'hello@acme.com' }, + warnings, + } +} + +describe('app-emails upsert --publish', () => { + it('sends publish: true, which turns an email unpublished in Brew back on', async () => { + let body: unknown + server.use( + http.put(URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json(setup(true, [])) + }) + ) + const result = await cli( + ['app-emails', 'upsert', 'order.paid', '--publish', '--yes'], + { ttyOut: true } + ) + expect(result.code).toBe(0) + expect(body).toEqual({ publish: true }) + expect(result.stdout).toContain('order.paid: published; sender') + }) + + it('leaves publish out without a flag and prints APP_EMAIL_NOT_PUBLISHED', async () => { + let body: unknown + server.use( + http.put(URL, async ({ request }) => { + body = await request.json() + return HttpResponse.json(setup(false, [NOT_PUBLISHED])) + }) + ) + const result = await cli(['app-emails', 'upsert', 'order.paid', '--yes'], { + ttyOut: true, + }) + expect(result.code).toBe(0) + expect(body).toEqual({}) + const lines = result.stdout.trimEnd().split('\n') + expect(lines[0]).toBe( + 'order.paid: saved, not published; sender hello@acme.com.' + ) + expect(lines).toContain(`warning: ${NOT_PUBLISHED.message}`) + }) + + it('keeps --publish and --no-publish in the confirmation command', async () => { + const publish = await cli([ + 'app-emails', + 'upsert', + 'order.paid', + '--publish', + ]) + expect(publish.code).toBe(4) + expect((publish.json as { confirmCommand: string }).confirmCommand).toBe( + 'brew-cli app-emails upsert --publish --yes -- order.paid' + ) + const keepOff = await cli([ + 'app-emails', + 'upsert', + 'order.paid', + '--no-publish', + '--overwrite', + ]) + expect(keepOff.code).toBe(4) + expect((keepOff.json as { confirmCommand: string }).confirmCommand).toBe( + 'brew-cli app-emails upsert --no-publish --overwrite --yes -- order.paid' + ) + }) +}) From 49015be0347839500bdf178652be2aa691a73bce Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 02:58:45 -0500 Subject: [PATCH 21/26] fix(app-emails): upsert confirms --publish over an inline publish: false The request lets a flag win over --input, but the confirmation read an inline publish: false as no publish, so --publish --input '{"publish":false}' published without asking. Both now take the flag first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- src/commands/app-emails/upsert.ts | 5 ++++- tests/commands/app-emails-publish.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index 0a9e8dc9..799f7948 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -96,7 +96,10 @@ export const appEmailsUpsertCommand = defineCommand({ return `Set up app email ${args.triggerKey ?? ''} from the body on stdin, which may publish it (\`send\` then sends real email) or replace a design edited in Brew.` } const inline = inlineInput(flags.input) - const publishes = flags.publish !== false && inline.publish !== false + // A flag wins over `--input`, as in the request `run` sends. + const publish = + typeof flags.publish === 'boolean' ? flags.publish : inline.publish + const publishes = publish !== false const overwrites = flags.overwrite === true || inline.overwrite === true if (!(publishes || overwrites)) { return diff --git a/tests/commands/app-emails-publish.test.ts b/tests/commands/app-emails-publish.test.ts index d9af3ad0..686ecf5b 100644 --- a/tests/commands/app-emails-publish.test.ts +++ b/tests/commands/app-emails-publish.test.ts @@ -100,4 +100,17 @@ describe('app-emails upsert --publish', () => { 'brew-cli app-emails upsert --no-publish --overwrite --yes -- order.paid' ) }) + + it('confirms --publish over an inline publish: false, since the flag wins', async () => { + const result = await cli([ + 'app-emails', + 'upsert', + 'order.paid', + '--publish', + '--input', + '{"publish":false}', + ]) + expect(result.code).toBe(4) + expect((result.json as { summary: string }).summary).toContain('publish it') + }) }) From 13c93af909d80fcfb2877d56e6a718b041254b7f Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 03:34:59 -0500 Subject: [PATCH 22/26] chore: sync API spec to brew-v2 #2169 02a3946b18 Vendors openapi/public-api-v1.yaml byte for byte from brew-v2 02a3946b18 (sha256 c9982829...2d5a86), the final #2169 head. main's spec is unchanged since 373e900a9a. Types regenerated. The delta is description text only, on PUT /v1/integrations/{provider}/connection: an auth app email unpublished in Brew stays off until it is published again, and the connect response carries no warning for it (check published with app-emails list). The 0.20.0 notes and RELEASING name the new spec head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 2 +- RELEASING.md | 2 +- openapi/public-api-v1.yaml | 2 +- src/generated/openapi-types.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9ae7f4..e4fbc76f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ the team sandbox, app emails and browser sign-in. the payload at compile time. A workspace with no keyed triggers gets the same bytes as before (no `--check` drift); `--no-registry` leaves the block out. -- The vendored spec is the GetBrew/brew-v2#2169 head `b884e78157` (main's +- The vendored spec is the GetBrew/brew-v2#2169 head `02a3946b18` (main's spec, brew-v2 `373e900a9a`, plus the DX delta): the by-key fire and the app-emails routes, `key` / `appEmail` on trigger rows, the sandbox error codes (`DOMAIN_REQUIRED`, `SANDBOX_DAILY_LIMIT`), the app email refusals diff --git a/RELEASING.md b/RELEASING.md index 148a3bbb..592b5d66 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -47,7 +47,7 @@ The API side must be live first: GetBrew/brew-v2#2169 deployed (the by-key fire, `/v1/app-emails`, the team sandbox), and the live spec (`https://brew.new/openapi/public-api-v1.yaml`, what the spec-drift sentinel reads) matching `openapi/public-api-v1.yaml`, -which is the #2169 head `b884e78157` (main's spec, brew-v2 `373e900a9a`, +which is the #2169 head `02a3946b18` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). ```bash diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index be1823e6..dfc37ce1 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -70629,7 +70629,7 @@ paths: **Input** `signingSecret`, the secret the provider signs deliveries with (Supabase: `v1,whsec_…` from Authentication > Hooks; Clerk: `whsec_…` from the endpoint). Supabase only: `settings.projectUrl`, the URL confirmation links point at (default: the one Supabase sends with each email). - **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). Until the first signed delivery the connection is `pending_verification`. + **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). An auth app email unpublished in Brew stays off, so that auth email is refused until it is published again (in Brew, or `upsertAppEmail` with `publish: true`); this response carries no warning for it, so check `published` with `listAppEmails`. Until the first signed delivery the connection is `pending_verification`. **Scopes** an API key needs `emails` and `automations` (`x-brew-scopes`): the call also runs the app email kit. diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index ec533735..6ff66b46 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -2548,7 +2548,7 @@ export interface paths { * * **Input** `signingSecret`, the secret the provider signs deliveries with (Supabase: `v1,whsec_…` from Authentication > Hooks; Clerk: `whsec_…` from the endpoint). Supabase only: `settings.projectUrl`, the URL confirmation links point at (default: the one Supabase sends with each email). * - * **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). Until the first signed delivery the connection is `pending_verification`. + * **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). An auth app email unpublished in Brew stays off, so that auth email is refused until it is published again (in Brew, or `upsertAppEmail` with `publish: true`); this response carries no warning for it, so check `published` with `listAppEmails`. Until the first signed delivery the connection is `pending_verification`. * * **Scopes** an API key needs `emails` and `automations` (`x-brew-scopes`): the call also runs the app email kit. * From 4104a4c2456903d20afed07b01c9fd96c808ad97 Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 04:14:28 -0500 Subject: [PATCH 23/26] docs(app-emails): the same upsert plus --publish turns an unpublished email back on A bare `upsert <key> --publish` sends only `{ publish: true }`: the API puts a kit key back on its starter design (replacing one built from --source-file) and refuses any other key with 400. The skill, CHANGELOG and the --publish help now name the same setup command plus --publish, and the publish test sends the setup's source with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 6 +++-- docs/commands/README.md | 2 +- skills/brew-cli/SKILL.md | 6 +++-- src/commands/app-emails/upsert.ts | 2 +- src/generated/skills.ts | 2 +- tests/commands/app-emails-publish.test.ts | 31 +++++++++++++++++++---- 6 files changed, 37 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4fbc76f..9df2cc33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,10 @@ the team sandbox, app emails and browser sign-in. carries the rest, such as `payloadSchema` fields marked `secret: true` for a one-time code or sign-in link) and publishes it, so it confirms; `--no-publish` saves without the gate. A re-run leaves an email - unpublished in Brew off (warning `APP_EMAIL_NOT_PUBLISHED`); `--publish` - turns it back on. `kit` sets up + unpublished in Brew off (warning `APP_EMAIL_NOT_PUBLISHED`); the same + `upsert` command plus `--publish` turns it back on (a bare `--publish` + puts a kit key back on its starter design, and any other key is + refused). `kit` sets up the auth and lifecycle starter kit (`--sets`, `--stack`, `--keys`, `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints per-key results and the stack's code snippets, and exits 1 when a key diff --git a/docs/commands/README.md b/docs/commands/README.md index 8b4dcdf6..42093a3a 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -1668,7 +1668,7 @@ Set up one app email by key in one idempotent call (trigger, contract, design, a - `--subject <text>` — Subject line - `--preview-text <text>` — Inbox preview text - `--domain <domainId>` — Sending domain id (default: a verified domain for the purpose, else the team-only sandbox). A kit key refuses a domain of the other purpose (transactional: auth, trial ending, payment failed; marketing: welcome, win-back) -- `--publish` — Publish it even if it was unpublished in Brew since setup, which a re-run otherwise leaves off +- `--publish` — Publish it even if it was unpublished in Brew since setup, which a re-run otherwise leaves off. Pass the same --starter or --source-file the setup used - `--no-publish` — Save the setup without publishing the automation - `--overwrite` — Replace a design that was edited in Brew since setup - `--input <json>` — Full JSON request body, or - to read stdin (flags override it) diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 4c76dc6a..767d426c 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -98,8 +98,10 @@ another setup of the key was writing; run it again later. or retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE` warning means the published version keeps sending until the draft is published. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off; -one unpublished in Brew stays off on a re-run (and `kit`) until -`upsert <key> --publish`. +one unpublished in Brew stays off on a re-run (and `kit`) until you publish +it in Brew or run the same `upsert` command that set it up, plus `--publish`. +A bare `upsert <key> --publish` is no remedy: it puts a kit key back on its +starter design and is refused (400) for any other key. `automations triggers create --key <key>` gives any trigger a key. ## The contract you can rely on diff --git a/src/commands/app-emails/upsert.ts b/src/commands/app-emails/upsert.ts index 799f7948..be26b92b 100644 --- a/src/commands/app-emails/upsert.ts +++ b/src/commands/app-emails/upsert.ts @@ -72,7 +72,7 @@ export const appEmailsUpsertCommand = defineCommand({ { flag: '--publish', summary: - 'Publish it even if it was unpublished in Brew since setup, which a re-run otherwise leaves off', + 'Publish it even if it was unpublished in Brew since setup, which a re-run otherwise leaves off. Pass the same --starter or --source-file the setup used', }, { flag: '--no-publish', diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 97061ea8..4498fa57 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off;\none unpublished in Brew stays off on a re-run (and `kit`) until\n`upsert <key> --publish`.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off;\none unpublished in Brew stays off on a re-run (and `kit`) until you publish\nit in Brew or run the same `upsert` command that set it up, plus `--publish`.\nA bare `upsert <key> --publish` is no remedy: it puts a kit key back on its\nstarter design and is refused (400) for any other key.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/tests/commands/app-emails-publish.test.ts b/tests/commands/app-emails-publish.test.ts index 686ecf5b..a6ee301f 100644 --- a/tests/commands/app-emails-publish.test.ts +++ b/tests/commands/app-emails-publish.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { HttpResponse, http } from 'msw' @@ -8,7 +8,9 @@ import { type RunCliOptions, runCli } from '../helpers/run-cli' /** * `app-emails upsert` and an email unpublished in Brew: a re-run leaves it - * off (`APP_EMAIL_NOT_PUBLISHED`), and only `publish: true` turns it back on. + * off (`APP_EMAIL_NOT_PUBLISHED`), and only the same setup with + * `publish: true` turns it back on. A bare `publish: true` is no remedy: the + * API puts a kit key back on its starter design and refuses any other key. */ function cli(argv: readonly string[], options: RunCliOptions = {}) { @@ -40,7 +42,12 @@ function setup(published: boolean, warnings: Array<typeof NOT_PUBLISHED>) { } describe('app-emails upsert --publish', () => { - it('sends publish: true, which turns an email unpublished in Brew back on', async () => { + it('sends the same setup with publish: true, which turns an email unpublished in Brew back on', async () => { + const file = join( + mkdtempSync(join(tmpdir(), 'brew-cli-source-')), + 'order-paid.html' + ) + writeFileSync(file, '<p>Order {{ trigger.orderId }} is paid.</p>') let body: unknown server.use( http.put(URL, async ({ request }) => { @@ -49,11 +56,25 @@ describe('app-emails upsert --publish', () => { }) ) const result = await cli( - ['app-emails', 'upsert', 'order.paid', '--publish', '--yes'], + [ + 'app-emails', + 'upsert', + 'order.paid', + '--source-file', + file, + '--publish', + '--yes', + ], { ttyOut: true } ) expect(result.code).toBe(0) - expect(body).toEqual({ publish: true }) + expect(body).toEqual({ + source: { + format: 'html', + content: '<p>Order {{ trigger.orderId }} is paid.</p>', + }, + publish: true, + }) expect(result.stdout).toContain('order.paid: published; sender') }) From 86accadc97594190928eab301b13778c7a0ea45e Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 04:19:05 -0500 Subject: [PATCH 24/26] docs(app-emails): a bare --publish repeats a kit key's starter setup, nothing more For a kit key set up from its starter, `upsert <key> --publish` is that same setup (init's publish step relies on it). It replaces a kit key's design built from --source-file and is refused for any other key, so the skill and CHANGELOG say exactly that instead of calling it no remedy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 4 ++-- skills/brew-cli/SKILL.md | 5 +++-- src/generated/skills.ts | 2 +- tests/commands/app-emails-publish.test.ts | 5 +++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9df2cc33..90ee51b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,8 @@ the team sandbox, app emails and browser sign-in. `--no-publish` saves without the gate. A re-run leaves an email unpublished in Brew off (warning `APP_EMAIL_NOT_PUBLISHED`); the same `upsert` command plus `--publish` turns it back on (a bare `--publish` - puts a kit key back on its starter design, and any other key is - refused). `kit` sets up + sets a kit key up from its starter again, replacing a design built from + `--source-file`, and any other key is refused). `kit` sets up the auth and lifecycle starter kit (`--sets`, `--stack`, `--keys`, `--domain`, `--overwrite`, `--idempotency-key`), confirms, prints per-key results and the stack's code snippets, and exits 1 when a key diff --git a/skills/brew-cli/SKILL.md b/skills/brew-cli/SKILL.md index 767d426c..d3128727 100644 --- a/skills/brew-cli/SKILL.md +++ b/skills/brew-cli/SKILL.md @@ -100,8 +100,9 @@ warning means the published version keeps sending until the draft is published. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off; one unpublished in Brew stays off on a re-run (and `kit`) until you publish it in Brew or run the same `upsert` command that set it up, plus `--publish`. -A bare `upsert <key> --publish` is no remedy: it puts a kit key back on its -starter design and is refused (400) for any other key. +A bare `upsert <key> --publish` repeats only a kit key's starter setup: it +replaces a kit key's design built from `--source-file` with the starter, and +any other key is refused (400). `automations triggers create --key <key>` gives any trigger a key. ## The contract you can rely on diff --git a/src/generated/skills.ts b/src/generated/skills.ts index 4498fa57..d7300bc8 100644 --- a/src/generated/skills.ts +++ b/src/generated/skills.ts @@ -14,6 +14,6 @@ export const EMBEDDED_SKILLS: readonly EmbeddedSkill[] = [ name: "brew-cli", description: "Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.", path: "skills/brew-cli/SKILL.md", - content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off;\none unpublished in Brew stays off on a re-run (and `kit`) until you publish\nit in Brew or run the same `upsert` command that set it up, plus `--publish`.\nA bare `upsert <key> --publish` is no remedy: it puts a kit key back on its\nstarter design and is refused (400) for any other key.\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", + content: "---\nname: brew-cli\ndescription: Drive the Brew email platform from the terminal with brew-cli — explore, check, test, and review contacts, emails, audiences, automations, domains, and analytics through typed commands over the public API. Use whenever a task touches Brew data or verifies Brew API behavior, especially against a local dev server.\n---\n\n# brew-cli for agents\n\nOne typed command per public-API operation (the generated reference is\n`docs/commands/README.md`; `brew-cli docs --agent` lists every command).\nJSON output is automatic when stdout is piped.\n\n## Start every session with the trust check\n\n```bash\nbrew-cli doctor\n```\n\nExit 0 = API reachable, auth valid, and this CLI covers every live\noperation. Exit 1 = read the report: `DRIFT` lines list live operations\nthis build lacks (update the CLI); auth/reachability failures name the fix.\n\n## Auth and targeting\n\n- `BREW_API_KEY` env var (or `brew-cli login` once, or `--api-key`).\n- No key and a person to ask? Sign in through their browser without\n blocking on it:\n\n ```bash\n brew-cli login --no-wait --json # exit 3: {status: \"pending\", verificationUrl, userCode, expiresAt, resumeCommand}\n ```\n\n Show the person `verificationUrl` and `userCode`, and ask them to approve\n on brew.new only if the code on the page matches. Then run the\n `resumeCommand` (`brew-cli login --resume`): exit 0 stores the key and\n reports `userEmail` and `brandName`; exit 3 with `status: \"pending\"`\n means not approved yet (add `--wait 2m` to wait for it); exit 3 with\n `CLI_LOGIN_DENIED` or `CLI_LOGIN_EXPIRED` means start over. Never ask for\n or print the key itself. A plain `brew-cli login` off a terminal exits 2\n instead of waiting.\n- Default target is production. For a local dev server:\n `BREW_API_URL=http://localhost:3000/api` (Brew repo dev keys live in\n `.env.test.local` as `TEST_API_KEY`).\n- Organization-scoped keys need `--brand <brandId>` / `BREW_BRAND_ID` on\n brand-scoped commands (`brands list` shows ids).\n\n## Connect your editor to the Brew MCP server\n\n```bash\nbrew-cli mcp install # the clients this project uses (.cursor/, .vscode/, .claude/, .gemini/)\nbrew-cli mcp install --client cursor claude-code --dry-run --json\nbrew-cli mcp install --client codex --auth env # header filled from BREW_API_KEY by the client\nbrew-cli mcp install --client vscode --print # print the snippet, write nothing\n```\n\nIt merges a `brew` entry into each client's config and keeps every other\nserver. `--auth oauth` (default) writes the URL only; `--auth env` adds an\n`Authorization` header the client expands from `BREW_API_KEY` in its own\nenvironment (`${env:…}` in Cursor, VS Code, Devin Desktop), never a\nliteral key. An existing `brew` entry with other settings, a config with\ncomments, or a symbolic link leaving the project comes back `manual` with\nthe snippet to paste. Devin Desktop (`--client windsurf`,\n`~/.config/devin/mcp_config.json`) and Codex live in your home directory:\nonly written when named with `--client`. After a Codex OAuth entry, run\nthe result's `nextStep` (`codex mcp login brew`). Results: `created |\nupdated | unchanged | manual | printed`, plus one-click `deeplink`s for\nCursor and VS Code.\n\n## App emails: send by key\n\nAn app email is a keyed trigger wired to a design and a published\nautomation. Code sends it with `brew.send(key, payload)` (SDK) or:\n\n```bash\nbrew-cli app-emails kit --stack better-auth --yes # auth + lifecycle starter kit; exit 1 = a key failed, re-run\nbrew-cli app-emails upsert order.paid --source-file emails/order-paid.tsx --yes # one email from your own source\nbrew-cli app-emails list # sender (domain | sandbox | none) and readiness per key\nbrew-cli send auth.password-reset --to you@example.com --data '{\"confirmationUrl\":\"https://…\"}' --yes\nbrew-cli types --out lib/brew-contracts.ts # payload types + the BrewTriggerKeyRegistry augmentation\n```\n\nBefore a sending domain verifies, app emails use the team-only sandbox:\nthey reach members of the Brew organization only, under a daily cap\n(`sender.mode: \"sandbox\"`). `send` is destructive (real email): it exits 4\nwithout `--yes`. `upsert` and `kit` publish, so they confirm too;\n`upsert --no-publish` saves without the gate. `upsert` and `kit` need a key\nwith the `automations` and `emails` scopes (403 otherwise).\nRefusals worth knowing (exit 1, the envelope on stderr):\n`409 APP_EMAIL_EDITED_IN_BREW` means a teammate edited the design in Brew:\nkeep it, or pass `--overwrite`. `409 APP_EMAIL_TRIGGER_IN_USE`: the key's\ntrigger already starts a published automation no setup made, so every send\nwould go out twice; unpublish or delete `details.automationIds` first.\n`422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`: a kit key never sends from a\n`--domain` of the other purpose (transactional: auth, trial ending, payment\nfailed; marketing: welcome, win-back); pass one of its purpose or leave\n`--domain` out. `409 APP_EMAIL_SETUP_IN_PROGRESS`:\nanother setup of the key was writing; run it again later.\n`409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS`: a `payloadSchema` that drops\nor retypes a field the live automation reads. A `DRAFT_SAVED_NOT_LIVE`\nwarning means the published version keeps sending until the draft is\npublished. An `APP_EMAIL_NOT_PUBLISHED` warning names why the email is off;\none unpublished in Brew stays off on a re-run (and `kit`) until you publish\nit in Brew or run the same `upsert` command that set it up, plus `--publish`.\nA bare `upsert <key> --publish` repeats only a kit key's starter setup: it\nreplaces a kit key's design built from `--source-file` with the starter, and\nany other key is refused (400).\n`automations triggers create --key <key>` gives any trigger a key.\n\n## The contract you can rely on\n\n- Exit codes: 0 ok · 1 API error · 2 usage · 3 auth · 4 confirmation\n required · 130/143 interrupted by SIGINT/SIGTERM. Errors are JSON\n envelopes on stderr with stable `code`s and `requestId`.\n- A write that ends `CLI_TIMEOUT`, `CLI_CONNECTION` or `CLI_INTERRUPTED`\n (or a 5xx) may still complete on the server. Re-run the envelope's\n `retryCommand` — it carries the same `--idempotency-key`, so the API\n replays the first attempt instead of running it twice. Never re-run the\n original command with a fresh key. A `409 IDEMPOTENCY_IN_PROGRESS` means\n the first attempt is still running: wait, then re-run `retryCommand`.\n While the API's idempotency store is degraded, writes other than sends\n run without the replay guarantee: for a write that must not happen\n twice, pass `--max-retries 0` and check whether it landed before re-running.\n- `--timeout <duration>` bounds the whole command (body, retries, pages);\n use it to fit a tool-call budget. `--max-retries 0` for one attempt.\n- Destructive commands (sends, deletes, fires, cancels) never hang: they\n exit 4 with a `confirmCommand` to re-run once a human approves, or take\n `--yes`. `confirmCommand` and `retryCommand` start the way you invoked\n the CLI (`brew-cli …`, or a package-runner prefix such as\n `npx -y @brew.new/cli@<version> …`), so run them as printed. Do not pass `--yes` for real campaign sends without an explicit\n human instruction; `emails send --test --to you@x.com` is the safe lane.\n- `--input '<json>'` (or `-` for stdin) carries full request bodies;\n positional ids always win over `--input`. `--all` drains pagination.\n `--idempotency-key` makes POST retries safe (except `api-keys create`,\n whose route never replays).\n- Every collection with ids to look up has a real detail read (`<group> get\n <id>`) returning the BARE row; list routes reject id filters\n (`notifications list` and `emails comments list` are list-only). Runs, sends, audience builds and\n inbox-placement tests share ONE status vocabulary: `queued | scheduled |\n running | paused | completed | partially_completed | failed | canceled`.\n\n## Recipes\n\n```bash\nbrew-cli docs --agent # machine-readable manifest of every command\nbrew-cli docs api # the live API catalog (/v1/help)\nbrew-cli contacts search --filter email:contains:@acme.com --json\nbrew-cli emails get <emailId> --include html,versions\nbrew-cli emails get <emailId> --email-version-id <id> # a saved version (ids from --include versions)\nbrew-cli emails preview-clients <emailId> # starts a rendering job (10 credits)\nbrew-cli emails get-client-preview <previewId> # poll it; screenshots are previews[].imageUrl\nbrew-cli sends get <sendId> --include events\nbrew-cli insights list --severity critical # what Brew Insights found; insights get <id> for one in full\nbrew-cli insights list --include pulse,report,suggestions,memo --json\nbrew-cli emails comments list <emailId> --include messages # teammates' comment threads on a design\nbrew-cli emails comments list <emailId> --comment-id <cmt_…> --all --json # one thread, every message\nbrew-cli chats list # recent Brew chats; chats get <chatId> to resume one\nbrew-cli notifications list --type email_send_failed # what finished or failed (reading marks nothing read)\nbrew-cli domains health <domainId> --include scoreHistory,scoreRuns\nbrew-cli contacts get <email> --include openProfile # smart-send open-time profile (needs the emails scope)\nbrew-cli content upload-image ./logo.png # a local file into the brand library (free); prints its assetId\nbrew-cli brand delete-image <assetId> --yes # remove one library image (destructive; its URL keeps working)\nbrew-cli api GET '/v1/sends?kind=campaign' # raw escape hatch for anything else\n```\n\nTo verify an API change you just made in the Brew repo: start the dev\nserver, point `BREW_API_URL` at it, and hit the changed route through the\ndedicated command (or `brew-cli api`) — the CLI prints the exact envelope\nyour change produced.\n", }, ] diff --git a/tests/commands/app-emails-publish.test.ts b/tests/commands/app-emails-publish.test.ts index a6ee301f..532c4c37 100644 --- a/tests/commands/app-emails-publish.test.ts +++ b/tests/commands/app-emails-publish.test.ts @@ -9,8 +9,9 @@ import { type RunCliOptions, runCli } from '../helpers/run-cli' /** * `app-emails upsert` and an email unpublished in Brew: a re-run leaves it * off (`APP_EMAIL_NOT_PUBLISHED`), and only the same setup with - * `publish: true` turns it back on. A bare `publish: true` is no remedy: the - * API puts a kit key back on its starter design and refuses any other key. + * `publish: true` turns it back on. A bare `publish: true` repeats only a kit + * key's starter setup: the API replaces a kit key's design built from source + * with the starter, and refuses any other key. */ function cli(argv: readonly string[], options: RunCliOptions = {}) { From f1d019ac510867a283476a0cce2fd6c645e10cda Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 05:24:39 -0500 Subject: [PATCH 25/26] chore: sync API spec to brew-v2 #2169 6e4accea4c Vendors openapi/public-api-v1.yaml byte for byte from brew-v2 6e4accea4c (sha256 b86fb228...178bd3), the final #2169 head. main's spec is unchanged since 373e900a9a. Types regenerated. The delta is description text only, on the app-emails publish remedy: an email unpublished in Brew turns back on when it is published in Brew or when upsertAppEmail repeats its setup with the same starter or source and publish: true. publish: true alone rebuilds a starter-kit key from its starter and is refused for any other key. Touches the AppEmailPutRequest publish field, PUT /v1/app-emails/{triggerKey}, POST /v1/app-emails/kit and PUT /v1/integrations/{provider}/connection. The 0.20.0 notes and RELEASING name the new spec head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CHANGELOG.md | 2 +- RELEASING.md | 2 +- openapi/public-api-v1.yaml | 10 ++++++---- src/generated/openapi-types.ts | 10 ++++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ee51b2..0146ca3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,7 @@ the team sandbox, app emails and browser sign-in. the payload at compile time. A workspace with no keyed triggers gets the same bytes as before (no `--check` drift); `--no-registry` leaves the block out. -- The vendored spec is the GetBrew/brew-v2#2169 head `02a3946b18` (main's +- The vendored spec is the GetBrew/brew-v2#2169 head `6e4accea4c` (main's spec, brew-v2 `373e900a9a`, plus the DX delta): the by-key fire and the app-emails routes, `key` / `appEmail` on trigger rows, the sandbox error codes (`DOMAIN_REQUIRED`, `SANDBOX_DAILY_LIMIT`), the app email refusals diff --git a/RELEASING.md b/RELEASING.md index 592b5d66..1830c0bf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -47,7 +47,7 @@ The API side must be live first: GetBrew/brew-v2#2169 deployed (the by-key fire, `/v1/app-emails`, the team sandbox), and the live spec (`https://brew.new/openapi/public-api-v1.yaml`, what the spec-drift sentinel reads) matching `openapi/public-api-v1.yaml`, -which is the #2169 head `02a3946b18` (main's spec, brew-v2 `373e900a9a`, +which is the #2169 head `6e4accea4c` (main's spec, brew-v2 `373e900a9a`, plus the DX delta). ```bash diff --git a/openapi/public-api-v1.yaml b/openapi/public-api-v1.yaml index dfc37ce1..a4f03b7c 100644 --- a/openapi/public-api-v1.yaml +++ b/openapi/public-api-v1.yaml @@ -13532,7 +13532,7 @@ components: additionalProperties: false publish: type: boolean - description: Publish the automation. Defaults to true, except that an email unpublished in Brew after a setup took it live stays unpublished (warning `APP_EMAIL_NOT_PUBLISHED`) until a call sends `true`. + description: "Publish the automation. Defaults to true, except that an email unpublished in Brew after a setup took it live stays unpublished (warning `APP_EMAIL_NOT_PUBLISHED`) until a call with the same `starter` or `source` sends `publish: true`. Sent alone, `publish: true` rebuilds a starter-kit key from its starter and is refused for any other key." overwrite: type: boolean description: Replace a design edited in Brew since setup. Defaults to false. @@ -47742,7 +47742,9 @@ paths: **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger.<field> }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A kit key never sends from a domain of the other purpose: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. - **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. An email unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until `publish: true`. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + + **Unpublished in Brew** an email unpublished in Brew after setup stays unpublished on a re-run, the kit or a hosted-hook connect (`APP_EMAIL_NOT_PUBLISHED`). To turn it back on, publish it in Brew or repeat this request with the same `starter` or `source` and `publish: true`. `publish: true` alone is not the same request: on a starter-kit key it rebuilds the design from the kit’s starter (replacing a design built from `source`), and on any other key it is `400 INVALID_REQUEST`. **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`): the setup imports its design the way `importEmail` does. @@ -48473,7 +48475,7 @@ paths: **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a `domainId` sets up the keys of its purpose and answers each key of the other class `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. - **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. A key unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until `upsertAppEmail` sends `publish: true`. + **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. A key unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until it is published in Brew or `upsertAppEmail` repeats its setup with the same `starter` or `source` and `publish: true` (`publish: true` alone rebuilds the key from the kit’s starter, replacing a design built from `source`). **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`), as for `upsertAppEmail`. @@ -70629,7 +70631,7 @@ paths: **Input** `signingSecret`, the secret the provider signs deliveries with (Supabase: `v1,whsec_…` from Authentication > Hooks; Clerk: `whsec_…` from the endpoint). Supabase only: `settings.projectUrl`, the URL confirmation links point at (default: the one Supabase sends with each email). - **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). An auth app email unpublished in Brew stays off, so that auth email is refused until it is published again (in Brew, or `upsertAppEmail` with `publish: true`); this response carries no warning for it, so check `published` with `listAppEmails`. Until the first signed delivery the connection is `pending_verification`. + **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). An auth app email unpublished in Brew stays off, so that auth email is refused until it is published again: in Brew, or by `upsertAppEmail` repeating its setup with the same `starter` or `source` and `publish: true` (`publish: true` alone rebuilds the key from the kit’s starter, replacing a design built from `source`). This response carries no warning for it, so check `published` with `listAppEmails`. Until the first signed delivery the connection is `pending_verification`. **Scopes** an API key needs `emails` and `automations` (`x-brew-scopes`): the call also runs the app email kit. diff --git a/src/generated/openapi-types.ts b/src/generated/openapi-types.ts index 6ff66b46..c766b629 100644 --- a/src/generated/openapi-types.ts +++ b/src/generated/openapi-types.ts @@ -1428,7 +1428,9 @@ export interface paths { * * **Input** at most one of `starter` and `source`; a starter-kit key defaults to its own starter. `payloadSchema` declares flat payload fields (on a starter, added to the kit’s); without it the starter’s contract is used, or for a `source` the fields its `{{ trigger.<field> }}` merge tags read (a nested path such as `trigger.user.name` or a loop over `trigger.items` declares its object or array). A field the trigger already declares keeps its stored type and markers, so a re-run without `payloadSchema` never drops a `secret`. Only `email` (the recipient) reaches the contact; every other field is template data. Mark a one-time code or sign-in link `secret: true`: it is used only to render the email and redacted wherever the fire is stored, logged or counted (never on `email`). An omitted marker counts `pii: high` as secret, and a field the starter kit marks secret (`otp`, `confirmationUrl`, `newEmail`) stays secret. `domainId` picks the sender; by default a verified domain whose purpose matches the email (transactional for auth emails, trial ending and payment failed; marketing for welcome and win-back), else the team-only sandbox (members of your organization, under a daily cap) until one verifies. A kit key never sends from a domain of the other purpose: naming one is `422 APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`. `publish` defaults to true. * - * **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. An email unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until `publish: true`. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + * **Re-runs** change only what differs. The same starter and version, or the same source, keeps the design. A newer starter version, another starter or another source replaces it, unless it was edited in Brew since setup: then `409 APP_EMAIL_EDITED_IN_BREW` until `overwrite: true`. A new `subject` or `previewText` alone changes the send step and keeps the design. An HTML or MJML replacement is a new design, and the unedited one it replaced is deleted unless a run that started before may still send it. A deleted design or automation is created again. A sandbox sender moves to a matching domain once one verifies, and a domain of the other purpose picked on a kit key’s send step is not kept. A setup publishes only the automation version it wrote: a draft saved in Brew since takes the setup’s changes but is left unpublished with a `DRAFT_SAVED_NOT_LIVE` warning, and a change made with `publish: false` while the email is live answers the same warning. A key whose trigger already starts a published automation that no setup made is `409 APP_EMAIL_TRIGGER_IN_USE` (`details.automationIds`), since every fire would send both. A call that fails part way leaves nothing a re-run would duplicate, and two calls for one key at once end with one design and one automation. + * + * **Unpublished in Brew** an email unpublished in Brew after setup stays unpublished on a re-run, the kit or a hosted-hook connect (`APP_EMAIL_NOT_PUBLISHED`). To turn it back on, publish it in Brew or repeat this request with the same `starter` or `source` and `publish: true`. `publish: true` alone is not the same request: on a starter-kit key it rebuilds the design from the kit’s starter (replacing a design built from `source`), and on any other key it is `400 INVALID_REQUEST`. * * **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`): the setup imports its design the way `importEmail` does. * @@ -1461,7 +1463,7 @@ export interface paths { * * **Input** `sets` (default both), `stack` (narrows the auth keys to the ones that auth library sends and picks the code snippets; default `custom`, every auth key) or exact `keys`, plus `domainId` and `overwrite` for every key (a `domainId` sets up the keys of its purpose and answers each key of the other class `APP_EMAIL_DOMAIN_PURPOSE_MISMATCH`). `supabase` sets up every auth email Supabase hands to Brew through its Send Email Hook (`authDelivery: hosted-webhook`; connect with `connectIntegrationHook`, or deploy the returned self-hosted hook from `@brew.new/sdk/supabase` as a Supabase Edge Function instead); `clerk` sets up the auth emails Clerk hands to Brew in its `email.created` webhook (`authDelivery: hosted-webhook`; connect Clerk in Brew, then turn off Clerk's own delivery for the templates `stack.note` lists) plus the lifecycle emails. * - * **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. A key unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until `upsertAppEmail` sends `publish: true`. + * **Returns** `200` with `results[]` in kit order, each `ok: true` with the setup result or `ok: false` with its `error` (`code`, `message`, `details`), and `stack` (`provider`, `authDelivery`, `note`, `keys`, `snippets`). One key failing does not stop the others; running the kit again finishes them. A key unpublished in Brew after setup stays unpublished (`APP_EMAIL_NOT_PUBLISHED`) until it is published in Brew or `upsertAppEmail` repeats its setup with the same `starter` or `source` and `publish: true` (`publish: true` alone rebuilds the key from the kit’s starter, replacing a design built from `source`). * * **Scopes** an API key needs both `automations` and `emails` (`x-brew-scopes`), as for `upsertAppEmail`. * @@ -2548,7 +2550,7 @@ export interface paths { * * **Input** `signingSecret`, the secret the provider signs deliveries with (Supabase: `v1,whsec_…` from Authentication > Hooks; Clerk: `whsec_…` from the endpoint). Supabase only: `settings.projectUrl`, the URL confirmation links point at (default: the one Supabase sends with each email). * - * **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). An auth app email unpublished in Brew stays off, so that auth email is refused until it is published again (in Brew, or `upsertAppEmail` with `publish: true`); this response carries no warning for it, so check `published` with `listAppEmails`. Until the first signed delivery the connection is `pending_verification`. + * **Re-runs** are safe: the `connectionId` and `webhookUrl` never change for a brand and provider, a new secret replaces the old one, and the auth app emails already set up are kept (a design edited in Brew is never replaced). An auth app email unpublished in Brew stays off, so that auth email is refused until it is published again: in Brew, or by `upsertAppEmail` repeating its setup with the same `starter` or `source` and `publish: true` (`publish: true` alone rebuilds the key from the kit’s starter, replacing a design built from `source`). This response carries no warning for it, so check `published` with `listAppEmails`. Until the first signed delivery the connection is `pending_verification`. * * **Scopes** an API key needs `emails` and `automations` (`x-brew-scopes`): the call also runs the app email kit. * @@ -7557,7 +7559,7 @@ export interface components { localPart?: string; name?: string; }; - /** @description Publish the automation. Defaults to true, except that an email unpublished in Brew after a setup took it live stays unpublished (warning `APP_EMAIL_NOT_PUBLISHED`) until a call sends `true`. */ + /** @description Publish the automation. Defaults to true, except that an email unpublished in Brew after a setup took it live stays unpublished (warning `APP_EMAIL_NOT_PUBLISHED`) until a call with the same `starter` or `source` sends `publish: true`. Sent alone, `publish: true` rebuilds a starter-kit key from its starter and is refused for any other key. */ publish?: boolean; /** @description Replace a design edited in Brew since setup. Defaults to false. */ overwrite?: boolean; From 536a9b86c7b3d7f9ed752637c37c552e07de2a9c Mon Sep 17 00:00:00 2001 From: thedogwiththedataonit <thomas0806.park@gmail.com> Date: Sun, 1 Nov 2026 06:15:09 -0500 Subject: [PATCH 26/26] test(app-emails): the APP_EMAIL_NOT_PUBLISHED fixture quotes the current server text brew-v2 #2169's saga now asks for the same starter or source plus publish: true, and says a bare publish: true rebuilds a kit key from its starter. The fixture quoted the old bare-remedy wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tests/commands/app-emails-publish.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/app-emails-publish.test.ts b/tests/commands/app-emails-publish.test.ts index 532c4c37..0c3545e7 100644 --- a/tests/commands/app-emails-publish.test.ts +++ b/tests/commands/app-emails-publish.test.ts @@ -29,7 +29,7 @@ const URL = 'https://brew.new/api/v1/app-emails/order.paid' const NOT_PUBLISHED = { code: 'APP_EMAIL_NOT_PUBLISHED', message: - '"order.paid" was unpublished in Brew after setup, so the setup left it unpublished. Publish it in Brew, or run PUT /v1/app-emails/order.paid with `publish: true`.', + '"order.paid" was unpublished in Brew after setup, so the setup left it unpublished. Publish it in Brew, or send PUT /v1/app-emails/order.paid again with the same `starter` or `source` and `publish: true`: `publish: true` alone rebuilds a starter-kit key from its starter (replacing a design built from `source`) and is refused for any other key.', } function setup(published: boolean, warnings: Array<typeof NOT_PUBLISHED>) {