openapi: 3.1.0 info: title: Email-Fast API version: "1.0.0" description: > Email-Fast platform API. Two planes: the DATA API (machine, api-key → project, :3000) for sending + the people graph, and the CONTROL API (human, session → user, :3300) for tenant self-management. Generated SDKs (python/php/go/ruby) target this contract. servers: - url: http://localhost:3000 description: DATA API (api-key auth) - url: http://localhost:3300 description: CONTROL API (session auth) security: - apiKeyAuth: [] tags: - name: emails - name: messages - name: suppressions - name: contacts - name: validate - name: auth - name: projects - name: keys - name: usage - name: domains - name: team - name: billing - name: compliance - name: integrations paths: /v1/emails: post: tags: [emails] operationId: sendEmail summary: Admit an email to the durable outbox (202; never sent inline) security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SendEmailRequest" } responses: "202": description: Accepted for delivery content: application/json: schema: { $ref: "#/components/schemas/SendEmailResponse" } "422": description: Blocked at admission (unverified sending domain or content scan) content: application/json: schema: { $ref: "#/components/schemas/BlockedResponse" } "429": { description: Quota exceeded } "401": { description: Unauthorized } "403": { description: Forbidden (project suspended, or non-secret key) } /v1/emails/batch: post: tags: [emails] operationId: sendBatch summary: Admit up to 500 messages (207; each funnels through the same admission path) security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/BatchSendRequest" } responses: "207": description: Multi-status — per-item results (a per-item failure never aborts the rest) content: application/json: schema: { $ref: "#/components/schemas/BatchSendResponse" } "400": { description: Invalid request body } "401": { description: Unauthorized } "403": { description: Forbidden (non-secret key) } /v1/messages/{id}: get: tags: [messages] operationId: getMessage summary: Message status + event history security: - apiKeyAuth: [] parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Message" } "404": { description: Not found } /v1/messages/{id}/cancel: post: tags: [messages] operationId: cancelMessage summary: Cancel a still-queued (e.g. scheduled) message before it sends security: - apiKeyAuth: [] parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": description: Canceled content: application/json: schema: type: object properties: ok: { type: boolean } id: { type: string, format: uuid } status: { type: string, examples: [canceled] } "401": { description: Unauthorized } "409": { description: Not cancelable (already sending/sent or unknown) } /v1/suppressions: post: tags: [suppressions] operationId: addSuppression security: [{ apiKeyAuth: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/AddSuppressionRequest" } responses: "201": { description: Created } get: tags: [suppressions] operationId: listSuppressions security: [{ apiKeyAuth: [] }] responses: "200": { description: OK } /v1/contacts: post: tags: [contacts] operationId: upsertContact security: [{ apiKeyAuth: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ContactRequest" } responses: "201": { description: Created } "200": { description: Updated } /v1/contacts/{email}: get: tags: [contacts] operationId: getContact summary: Read a contact by email security: - apiKeyAuth: [] parameters: - { name: email, in: path, required: true, schema: { type: string, format: email } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Contact" } "404": { description: Not found } /v1/events: post: tags: [contacts] operationId: ingestEvent security: [{ apiKeyAuth: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/EventRequest" } responses: "202": { description: Accepted } /v1/validate: post: tags: [validate] operationId: validateEmail summary: Validate a single address (syntax, MX, disposable, role, typo) before you send security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [email] properties: { email: { type: string } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/ValidationResult" } "400": { description: Invalid request body } "401": { description: Unauthorized } /v1/validate/batch: post: tags: [validate] operationId: validateBatch summary: Validate up to the batch limit of addresses in one call security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [emails] properties: emails: { type: array, items: { type: string } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/BatchValidationResponse" } "400": { description: Invalid request body } "401": { description: Unauthorized } /v1/integrations/catalog: get: tags: [integrations] operationId: integrationsCatalog summary: The integrations marketplace directory (Segment, Zapier, Make, generic) security: - apiKeyAuth: [] responses: "200": description: OK content: application/json: schema: type: object properties: integrations: { type: array, items: { type: object, additionalProperties: true } } "401": { description: Unauthorized } /v1/integrations/auth-test: get: tags: [integrations] operationId: integrationsAuthTest summary: Validate the API key (Zapier/Make connection test) security: - apiKeyAuth: [] responses: "200": description: OK content: application/json: schema: type: object properties: ok: { type: boolean } project_id: { type: string, format: uuid } "401": { description: Unauthorized } /v1/integrations/segment: post: tags: [integrations] operationId: segmentInbound summary: Segment inbound source — identify→contact, track/page→event description: > The API key identifies the project. If a Segment integration with a signing secret is connected, a valid X-Signature (hex HMAC-SHA1 of the raw body) is also required. security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: { type: object, additionalProperties: true, description: "A Segment identify/track/page/screen/group event" } responses: "202": description: Accepted (contact upserted, event ingested, or ignored) content: application/json: schema: type: object properties: applied: { type: string, enum: [contact, event, ignored] } reason: { type: string } contact_id: { type: string, format: uuid } event_id: { type: string, format: uuid } created: { type: boolean } "400": { description: Invalid JSON body } "401": { description: Unauthorized or bad signature } /v1/hooks: post: tags: [integrations] operationId: subscribeHook summary: REST-hook subscribe — create an instant trigger for a partner catch URL (Zapier/Make) security: - apiKeyAuth: [] requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [target_url, event] properties: target_url: { type: string, format: uri } event: { type: string, description: "A public event type (e.g. email.bounced) or *" } platform: { type: string, enum: [zapier, make, generic] } responses: "201": description: Subscribed. The signing secret is returned ONCE — deliveries are HMAC-signed with it. content: application/json: schema: type: object properties: id: { type: string, format: uuid } event: { type: string } target_url: { type: string } secret: { type: string, description: "whsec_… signing secret, returned only at create" } "400": { description: Invalid request / event / url } "401": { description: Unauthorized } get: tags: [integrations] operationId: listHooks summary: List this project's REST-hook subscriptions security: - apiKeyAuth: [] responses: "200": { description: OK } "401": { description: Unauthorized } /v1/hooks/{id}: delete: tags: [integrations] operationId: unsubscribeHook summary: REST-hook unsubscribe (only hook-created endpoints) security: - apiKeyAuth: [] parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": { description: Unsubscribed } "401": { description: Unauthorized } "404": { description: Not found } /auth/request-link: post: tags: [auth] operationId: requestLoginLink summary: Request a magic-link (always 200, no user enumeration) security: [] requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string, format: email } redirect_to: { type: string } responses: "200": { description: OK } /auth/verify: post: tags: [auth] operationId: verifyLoginLink summary: Consume a magic-link token, set a session, return a one-time secret key on signup security: [] requestBody: required: true content: application/json: schema: type: object required: [token] properties: token: { type: string } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/VerifyResponse" } "401": { description: Invalid or expired token } /me: get: tags: [auth] operationId: getMe security: [{ sessionAuth: [] }] responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Me" } "401": { description: Unauthorized } /auth/logout: post: tags: [auth] operationId: logout security: [{ sessionAuth: [] }] responses: "200": { description: OK } /auth/mfa/totp: post: tags: [auth] operationId: mfaTotp summary: Complete a parked (mfa_pending) session with an authenticator code description: The only endpoints that accept a pending session. The matched TOTP step is persisted so a captured code can never be replayed. Attempts are rate-limited PER USER. On success the session token is ROTATED — clients MUST replace their bearer/cookie with the returned session_token. security: [{ sessionAuth: [] }] requestBody: required: true content: application/json: schema: { type: object, additionalProperties: false, required: [code], properties: { code: { type: string, minLength: 6, maxLength: 64 } } } responses: "200": description: Second factor cleared; a rotated session token is issued (old token invalidated) content: application/json: schema: { $ref: "#/components/schemas/MfaCleared" } "400": { description: Invalid request / TOTP not enrolled / session not pending } "401": { description: No session, wrong code, or replayed code } "429": { description: Too many attempts } /auth/mfa/recovery: post: tags: [auth] operationId: mfaRecovery summary: Complete a parked session by burning a one-time recovery code description: On success the session token is ROTATED — clients MUST replace their bearer/cookie with the returned session_token. Rate-limited PER USER. security: [{ sessionAuth: [] }] requestBody: required: true content: application/json: schema: { type: object, additionalProperties: false, required: [code], properties: { code: { type: string, minLength: 6, maxLength: 64 } } } responses: "200": description: Second factor cleared; the code is burned and a rotated session token is issued content: application/json: schema: { $ref: "#/components/schemas/MfaCleared" } "400": { description: Invalid request / session not pending } "401": { description: No session or unknown/used code } "429": { description: Too many attempts } /me/security: get: tags: [auth] operationId: getSecurity summary: Second-factor status + remaining recovery codes security: [{ sessionAuth: [] }] responses: "200": { description: OK } "401": { description: Not signed in } /me/totp/enroll: post: tags: [auth] operationId: enrollTotp summary: Mint a TOTP secret (returned ONCE as base32 + otpauth URI); inactive until proven security: [{ sessionAuth: [] }] responses: "200": { description: Secret + otpauth:// URI (render as a QR) } "401": { description: Not signed in } "409": { description: An active authenticator exists — disable it first } /me/totp/activate: post: tags: [auth] operationId: activateTotp summary: Prove the authenticator with a live code — arms enforcement, returns recovery codes once security: [{ sessionAuth: [] }] requestBody: required: true content: application/json: schema: { type: object, additionalProperties: false, required: [code], properties: { code: { type: string, minLength: 6, maxLength: 64 } } } responses: "200": { description: 2FA armed; one-time recovery codes returned exactly once } "400": { description: Not enrolled } "401": { description: Wrong code } "409": { description: Already active } /me/totp: delete: tags: [auth] operationId: disableTotp summary: Disable 2FA (requires a current code or an unused recovery code) security: [{ sessionAuth: [] }] requestBody: required: true content: application/json: schema: { type: object, additionalProperties: false, required: [code], properties: { code: { type: string, minLength: 6, maxLength: 64 } } } responses: "200": { description: Disabled; recovery codes cleared } "400": { description: Not enabled } "401": { description: Proof rejected } /auth/mfa/passkey/options: post: tags: [auth] operationId: mfaPasskeyOptions summary: Issue a WebAuthn authentication challenge for a parked (mfa_pending) session security: [{ sessionAuth: [] }] responses: "200": { description: PublicKeyCredentialRequestOptions (contains the challenge) } "400": { description: Session not pending / no passkey enrolled } "401": { description: No session } /auth/mfa/passkey: post: tags: [auth] operationId: mfaPasskey summary: Verify a WebAuthn assertion; clears mfa_pending + rotates the session token description: Delegated to @simplewebauthn/server. On success the session token is ROTATED — adopt the returned session_token. Rate-limited PER USER. security: [{ sessionAuth: [] }] responses: "200": description: Second factor cleared; rotated session token issued content: application/json: schema: { $ref: "#/components/schemas/MfaCleared" } "400": { description: Session not pending / no challenge } "401": { description: Invalid assertion } "429": { description: Too many attempts } /me/passkeys: get: tags: [auth] operationId: listPasskeys summary: List the caller's registered passkeys security: [{ sessionAuth: [] }] responses: "200": { description: OK } "401": { description: Not signed in } /me/passkeys/register/options: post: tags: [auth] operationId: passkeyRegisterOptions summary: Issue a WebAuthn registration challenge security: [{ sessionAuth: [] }] responses: "200": { description: PublicKeyCredentialCreationOptions (contains the challenge) } "401": { description: Not signed in } /me/passkeys/register/verify: post: tags: [auth] operationId: passkeyRegisterVerify summary: Verify the attestation and store the passkey security: [{ sessionAuth: [] }] responses: "201": { description: Passkey registered } "400": { description: No / expired challenge } "401": { description: Invalid attestation } "409": { description: Credential already registered } /me/passkeys/{id}: delete: tags: [auth] operationId: deletePasskey summary: Remove a passkey security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: Removed } "401": { description: Not signed in } "404": { description: Not found } /me/auth-events: get: tags: [auth] operationId: listMyAuthEvents summary: The caller's own authentication audit trail (most recent first) security: [{ sessionAuth: [] }] responses: "200": { description: OK } "401": { description: Not signed in } /orgs/{orgId}/auth-events: get: tags: [auth] operationId: listOrgAuthEvents summary: Org-wide authentication audit trail (admin role) security: [{ sessionAuth: [] }] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: OK } "401": { description: Not signed in } "403": { description: Requires the admin role } /sso/metadata: get: tags: [sso] operationId: ssoMetadata summary: SP SAML metadata XML (import into the IdP) security: [] responses: "200": { description: SP metadata XML } /sso/{orgId}/login: get: tags: [sso] operationId: ssoLogin summary: SP-initiated SAML login — 302 redirect to the org's IdP security: [] parameters: - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } } - { name: redirect_to, in: query, required: false, schema: { type: string } } responses: "302": { description: Redirect to the IdP SSO URL } "404": { description: SSO not configured / disabled } /sso/{orgId}/acs: post: tags: [sso] operationId: ssoAcs summary: SAML Assertion Consumer Service — validate the signed assertion + mint a session description: Accepts application/x-www-form-urlencoded (SAMLResponse, RelayState). The signed assertion is the authentication (delegated to node-saml); JIT-provisions the user with the connection's default role and returns a session token. security: [] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: Authenticated; session token + redirect target returned } "400": { description: Missing SAMLResponse } "401": { description: Invalid / tampered / unsigned assertion } "404": { description: SSO not configured / disabled } "429": { description: Too many ACS attempts } /orgs/{orgId}/sso: get: tags: [sso] operationId: getSsoConnection summary: Get the org's SAML connection (owner) security: [{ sessionAuth: [] }] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: OK (null when unconfigured) } "401": { description: Not signed in } "403": { description: Requires the owner role } put: tags: [sso] operationId: putSsoConnection summary: Configure the org's SAML IdP connection (owner) security: [{ sessionAuth: [] }] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [entity_id, sso_url, idp_cert] properties: entity_id: { type: string, minLength: 1, maxLength: 1024 } sso_url: { type: string, format: uri } idp_cert: { type: string, minLength: 1, maxLength: 20000 } default_role: { type: string, enum: [admin, member, viewer] } enabled: { type: boolean } responses: "200": { description: Saved } "400": { description: Invalid request } "401": { description: Not signed in } "403": { description: Requires the owner role } delete: tags: [sso] operationId: deleteSsoConnection summary: Remove the org's SAML connection (owner) security: [{ sessionAuth: [] }] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: Deleted } "401": { description: Not signed in } "403": { description: Requires the owner role } "404": { description: Not found } /orgs/{orgId}/scim-tokens: get: tags: [sso] operationId: listScimTokens summary: List the org's SCIM tokens (hints only; owner) security: [{ sessionAuth: [] }] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: OK } "403": { description: Requires the owner role } post: tags: [sso] operationId: createScimToken summary: Mint a SCIM bearer token (returned once; owner) security: [{ sessionAuth: [] }] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: false content: application/json: schema: { type: object, additionalProperties: false, properties: { name: { type: string, maxLength: 120 } } } responses: "201": { description: Token created (plaintext returned exactly once) } "403": { description: Requires the owner role } /orgs/{orgId}/scim-tokens/{tid}: delete: tags: [sso] operationId: revokeScimToken summary: Revoke a SCIM token (owner) security: [{ sessionAuth: [] }] parameters: - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } } - { name: tid, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": { description: Revoked } "403": { description: Requires the owner role } "404": { description: Not found } /scim/v2/Users: get: tags: [scim] operationId: scimListUsers summary: SCIM 2.0 — list/filter provisioned users (bearer scim_token) security: [{ scimAuth: [] }] parameters: - { name: filter, in: query, required: false, schema: { type: string } } - { name: startIndex, in: query, required: false, schema: { type: integer } } - { name: count, in: query, required: false, schema: { type: integer } } responses: "200": { description: SCIM ListResponse } "401": { description: Invalid / missing bearer token } post: tags: [scim] operationId: scimCreateUser summary: SCIM 2.0 — provision a user (creates the org membership) security: [{ scimAuth: [] }] responses: "201": { description: SCIM User created } "400": { description: Missing userName/email } "401": { description: Invalid / missing bearer token } "409": { description: Already provisioned } /scim/v2/Users/{id}: get: tags: [scim] operationId: scimGetUser summary: SCIM 2.0 — fetch a user security: [{ scimAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: SCIM User } "401": { description: Invalid / missing bearer token } "404": { description: Not found } put: tags: [scim] operationId: scimReplaceUser summary: SCIM 2.0 — replace a user (toggle active / update name) security: [{ scimAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: SCIM User } "401": { description: Invalid / missing bearer token } "404": { description: Not found } patch: tags: [scim] operationId: scimPatchUser summary: SCIM 2.0 — patch a user (deprovision via active=false drops membership + revokes sessions) security: [{ scimAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: SCIM User } "401": { description: Invalid / missing bearer token } "404": { description: Not found } delete: tags: [scim] operationId: scimDeleteUser summary: SCIM 2.0 — deprovision a user (drops membership + revokes sessions) security: [{ scimAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "204": { description: Deprovisioned } "401": { description: Invalid / missing bearer token } "404": { description: Not found } /scim/v2/ServiceProviderConfig: get: tags: [scim] operationId: scimServiceProviderConfig summary: SCIM 2.0 — service provider capabilities security: [{ scimAuth: [] }] responses: "200": { description: ServiceProviderConfig } /orgs/{orgId}/projects: get: tags: [projects] operationId: listProjects security: [{ sessionAuth: [] }] parameters: - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": { description: OK } post: tags: [projects] operationId: createProject security: [{ sessionAuth: [] }] parameters: - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } } requestBody: required: true content: application/json: schema: { type: object, required: [name], properties: { name: { type: string } } } responses: "201": { description: Created } "403": { description: Forbidden (requires admin) } /projects/{id}/keys: get: tags: [keys] operationId: listKeys security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: OK } post: tags: [keys] operationId: createKey security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: type: object properties: kind: { type: string, enum: [secret, public, restricted] } name: { type: string } responses: "201": description: Created (plaintext key returned once) content: application/json: schema: { $ref: "#/components/schemas/CreatedKey" } "403": { description: Forbidden } /keys/{keyId}/rotate: post: tags: [keys] operationId: rotateKey security: [{ sessionAuth: [] }] parameters: [{ name: keyId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "201": { description: Rotated } "409": { description: Already revoked } /keys/{keyId}: delete: tags: [keys] operationId: revokeKey security: [{ sessionAuth: [] }] parameters: [{ name: keyId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: Revoked } /projects/{id}/usage: get: tags: [usage] operationId: getUsage security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/Usage" } /projects/{id}/domains: get: tags: [domains] operationId: listDomains security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: OK } post: tags: [domains] operationId: addDomain security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: { type: object, required: [domain], properties: { domain: { type: string } } } responses: "201": { description: Created with DNS records } "409": { description: Domain already exists } /domains/{domainId}/verify: post: tags: [domains] operationId: verifyDomain security: [{ sessionAuth: [] }] parameters: [{ name: domainId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: Verification result } "409": { description: Domain owned by another project } /orgs/{orgId}/members: get: tags: [team] operationId: listMembers security: [{ sessionAuth: [] }] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: OK } post: tags: [team] operationId: inviteMember security: [{ sessionAuth: [] }] parameters: [{ name: orgId, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string, format: email } role: { type: string, enum: [admin, member, viewer] } responses: "202": { description: Invited } "403": { description: Forbidden (no privilege escalation) } /orgs/{orgId}/members/{userId}: patch: tags: [team] operationId: changeMemberRole security: [{ sessionAuth: [] }] parameters: - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } } - { name: userId, in: path, required: true, schema: { type: string, format: uuid } } requestBody: required: true content: application/json: schema: { type: object, required: [role], properties: { role: { type: string, enum: [owner, admin, member, viewer] } } } responses: "200": { description: OK } "409": { description: Last-owner guard } delete: tags: [team] operationId: removeMember security: [{ sessionAuth: [] }] parameters: - { name: orgId, in: path, required: true, schema: { type: string, format: uuid } } - { name: userId, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": { description: OK } "409": { description: Last-owner guard } /projects/{id}/billing/checkout: post: tags: [billing] operationId: billingCheckout security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: { type: object, required: [plan_id], properties: { plan_id: { type: string } } } responses: "200": { description: Provider checkout URL } /projects/{id}/billing: get: tags: [billing] operationId: getBilling security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: OK } /projects/{id}/compliance/erasure: delete: tags: [compliance] operationId: eraseSubject summary: GDPR crypto-shred erasure (synchronous, irreversible) security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: { type: object, required: [email], properties: { email: { type: string, format: email } } } responses: "200": { description: Erased } /legal/subprocessors: get: tags: [compliance] operationId: listSubprocessors security: [] responses: "200": { description: OK } /projects/{id}/integrations: get: tags: [integrations] operationId: listIntegrations summary: List a project's connected integrations security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: OK (secrets are never returned — only has_secret + a short hint) } "401": { description: Not signed in } "403": { description: Requires the viewer role } "404": { description: Project not found } post: tags: [integrations] operationId: connectIntegration summary: Connect an integration (a signing_secret is stored write-only, never returned) security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [platform, name] properties: platform: { type: string, enum: [segment, zapier, make, generic] } name: { type: string, minLength: 1, maxLength: 120 } direction: { type: string, enum: [inbound, outbound, both] } signing_secret: { type: string, minLength: 8, maxLength: 400 } config: type: object additionalProperties: true description: "Platform options. NOT for credentials — it is visible to viewer-role members; a signing_secret is stored write-only." responses: "201": { description: Connected } "400": { description: Invalid request (unknown field, bad platform/direction, signing_secret length) } "401": { description: Not signed in } "403": { description: Requires the admin role } "404": { description: Project not found } "409": { description: Name already used for this platform } /projects/{id}/integrations/{iid}: delete: tags: [integrations] operationId: disconnectIntegration summary: Disconnect an integration security: [{ sessionAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: iid, in: path, required: true, schema: { type: string, format: uuid } } responses: "200": { description: Disconnected } "401": { description: Not signed in } "403": { description: Requires the admin role } "404": { description: Not found (including a malformed integration id) } /projects/{id}/oauth-mailbox/start: post: tags: [oauth-mailbox] operationId: startOauthMailbox summary: Start the BYO Gmail/Outlook OAuth connect flow (returns the provider authorize URL) security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [provider] properties: provider: { type: string, enum: [gmail, outlook] } responses: "200": { description: "authorize_url + provider. The single-use state (10 min TTL) is embedded in the URL." } "400": { description: Invalid request } "401": { description: Not signed in } "403": { description: Requires the admin role } "404": { description: Project not found } "409": { description: BYO SMTP is configured — a project has one outbound identity } "503": { description: The provider OAuth app is not configured on this deployment (lane disarmed) } /projects/{id}/oauth-mailbox/callback: post: tags: [oauth-mailbox] operationId: finishOauthMailbox summary: Finish the connect flow (the dashboard relays the provider's ?code&state here) security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] requestBody: required: true content: application/json: schema: type: object additionalProperties: false required: [code, state] properties: code: { type: string, maxLength: 2048 } state: { type: string, maxLength: 256 } responses: "201": { description: "Mailbox connected (provider, address, status). Tokens are stored encrypted and never returned." } "400": { description: Invalid/expired/used state, rejected code, or the grant lacked a refresh token / mailbox address } "401": { description: Not signed in } "403": { description: Requires the admin role (and the same user who started the flow) } "404": { description: Project not found } "502": { description: The provider token endpoint is unavailable } "503": { description: The provider OAuth app is not configured on this deployment } /projects/{id}/oauth-mailbox: get: tags: [oauth-mailbox] operationId: getOauthMailbox summary: Connection status (provider, address, health — tokens never leave the server) security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: "configured:false, or provider/address/status (active|error|revoked) + last_error" } "401": { description: Not signed in } "403": { description: Requires the viewer role } "404": { description: Project not found } delete: tags: [oauth-mailbox] operationId: disconnectOauthMailbox summary: Disconnect the mailbox (best-effort provider-side token revoke first) security: [{ sessionAuth: [] }] parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }] responses: "200": { description: Disconnected — the project's mail routes via the managed MTA again } "401": { description: Not signed in } "403": { description: Requires the admin role } "404": { description: No mailbox connected } components: securitySchemes: apiKeyAuth: type: http scheme: bearer description: "Secret API key: Authorization: Bearer ef_live_..." sessionAuth: type: http scheme: bearer description: "Control session token (issued by /auth/verify)." scimAuth: type: http scheme: bearer description: "SCIM provisioning token (efscim_…), minted per org via /orgs/{orgId}/scim-tokens." schemas: SendEmailRequest: type: object required: [to, subject, html] properties: to: { type: string, format: email } to_name: { type: string } from: { type: string, format: email } from_name: { type: string } subject: { type: string } html: { type: string } data: { type: object, additionalProperties: true } message_stream: { type: string, enum: [transactional, marketing, journey] } idempotency_key: { type: string } track_opens: { type: boolean } track_clicks: { type: boolean } topic: { type: string } send_at: { type: string, format: date-time, description: "ISO-8601 instant to defer the send until" } sto: { type: boolean, description: "Send-time optimization — resolve the best hour from the contact's profile (ignored when send_at is set)" } SendEmailResponse: type: object properties: id: { type: string, format: uuid } status: { type: string } idempotent_replay: { type: boolean } reason: { type: string } BatchSendRequest: type: object additionalProperties: false required: [messages] properties: messages: type: array minItems: 1 maxItems: 500 items: { $ref: "#/components/schemas/SendEmailRequest" } BatchSendResponse: type: object properties: accepted: { type: integer } total: { type: integer } results: type: array items: type: object properties: index: { type: integer } status: { type: string } id: { type: string, format: uuid } reason: { type: string } idempotent_replay: { type: boolean } error: { type: string } scope: { type: string } ValidationResult: type: object properties: email: { type: string } verdict: { type: string, enum: [deliverable, risky, undeliverable, unknown] } score: { type: integer, description: "0–100 confidence the address is safely sendable" } checks: type: object properties: syntax: { type: boolean } mx: { type: boolean } disposable: { type: boolean } role: { type: boolean } free: { type: boolean } mailbox_verified: { type: boolean } did_you_mean: { type: [string, "null"] } reasons: { type: array, items: { type: string } } BatchValidationResponse: type: object properties: total: { type: integer } summary: { type: object, additionalProperties: { type: integer } } results: type: array items: { $ref: "#/components/schemas/ValidationResult" } Contact: type: object properties: id: { type: string, format: uuid } email: { type: string } name: { type: [string, "null"] } external_id: { type: [string, "null"] } attributes: { type: object, additionalProperties: true } timezone: { type: [string, "null"] } last_sent_at: { type: [string, "null"], format: date-time } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } BlockedResponse: type: object properties: error: { type: string, examples: [blocked] } reason: { type: string, examples: [domain_unverified, content] } id: { type: string, format: uuid } Message: type: object properties: id: { type: string, format: uuid } status: { type: string } to_email: { type: string } subject: { type: string } message_stream: { type: string } attempts: { type: integer } last_error: { type: [string, "null"] } provider_message_id: { type: string } created_at: { type: string, format: date-time } sent_at: { type: string, format: date-time } events: type: array items: { type: object, additionalProperties: true } AddSuppressionRequest: type: object required: [email] properties: email: { type: string, format: email } message_stream: { type: string, enum: [transactional, marketing, journey] } reason: { type: string, enum: [hard_bounce, complaint, unsubscribe, manual] } ContactRequest: type: object required: [email] properties: email: { type: string, format: email } name: { type: string } external_id: { type: string } attributes: { type: object, additionalProperties: true } timezone: { type: string } EventRequest: type: object required: [name] properties: email: { type: string, format: email } contact_id: { type: string, format: uuid } name: { type: string } properties: { type: object, additionalProperties: true } VerifyResponse: type: object properties: user: { type: object, properties: { id: { type: string }, email: { type: string } } } org: { type: object, properties: { id: { type: string } } } project: { type: object, properties: { id: { type: string } } } role: { type: string } session_token: { type: string } mfa_required: type: boolean description: "Present + true when the account has 2FA — the session is PARKED; complete it via /auth/mfa/totp, /auth/mfa/passkey, or /auth/mfa/recovery before the session is usable." redirect_to: { type: string } secret_key: { type: string, description: "Returned once, on signup only" } MfaCleared: type: object properties: ok: { type: boolean } session_token: type: string description: "The ROTATED session token. The pre-clear token is now invalid; clients must adopt this one." Me: type: object properties: user: { type: object, properties: { id: { type: string }, email: { type: string } } } memberships: type: array items: { type: object, properties: { org_id: { type: string }, org_name: { type: string }, role: { type: string } } } active_org: { type: string } active_project: { type: string } role: { type: string } CreatedKey: type: object properties: id: { type: string, format: uuid } kind: { type: string } key_prefix: { type: string } key: { type: string, description: "Plaintext, shown once" } Usage: type: object properties: period: { type: string } counters: { type: object, properties: { accepted: { type: integer }, sent: { type: integer }, suppressed: { type: integer } } } quota: { type: object, properties: { monthly_quota: { type: integer }, daily_quota: { type: integer } } } daily: type: array items: { type: object, properties: { day: { type: string }, accepted: { type: integer } } }