openapi: 3.1.0
info:
  title: Sendara API
  version: "1.0.0"
  description: |
    Sendara is a multi-channel messaging API — send email, SMS, push, voice, and
    webhooks through one API, one key, and one bill.

    Authenticate every request with a Bearer API key (`Authorization: Bearer sk_live_...`).
    Keys are scoped: `send` keys can send, `read` keys can read, `admin` keys can
    manage keys and domains. Test-mode keys (`sk_test_...`) simulate delivery
    without sending real messages and are exempt from billing.
  contact:
    name: Sendara
    url: https://sendara.dev
servers:
  - url: https://api.sendara.dev
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Send
    description: Send messages across channels.
  - name: Messages
    description: Read sent messages and their event timeline.
  - name: Usage
    description: Account usage and cost.
  - name: API Keys
    description: Create and manage API keys (admin scope).
  - name: Domains
    description: Add and verify sending domains (admin scope).
  - name: Suppressions
    description: Manage suppressed/unsubscribed recipients.
  - name: Billing
    description: Subscription plan, checkout, and customer portal.
  - name: Broadcasts
    description: Bulk email campaigns to an audience or an inline recipient list.
  - name: Contacts
    description: Store and manage contacts (the people you send to).
  - name: Lists
    description: Static and dynamic (segmented) contact lists.
  - name: Templates
    description: Reusable mustache-style content templates with typed variables.
  - name: Webhooks
    description: |
      Subscribe to delivery events and receive signed event callbacks.

      Each callback is an HTTP POST to your `endpoint_url` carrying a
      `WebhookEventPayload` body and these headers:

      - `Sendara-Event-Id` — unique event id (stable across retries; use it to dedupe).
      - `Sendara-Event-Type` — the canonical event type.
      - `Sendara-Timestamp` — Unix seconds when the signature was computed.
      - `Sendara-Signature` — lowercase hex `HMAC-SHA256(signing_secret, "<Sendara-Timestamp>.<rawBody>")`.

      To verify: recompute the HMAC over `"<Sendara-Timestamp>.<rawBody>"` using your
      subscription's `signing_secret` and compare in constant time against
      `Sendara-Signature`. During a secret rotation, also accept the
      `previous_signing_secret` for the grace window. Failed deliveries are
      retried with exponential backoff (~30s base, ×2, ±25% jitter) for up to 24h.
  - name: Test Recipients
    description: Register and verify your own addresses for free real-email test sends.
  - name: Uploads
    description: Upload images for use in email content.
  - name: Spend Caps
    description: Cap spend per account or per key (admin scope).

paths:
  /v1/send:
    post:
      tags: [Send]
      summary: Send a message
      operationId: send
      description: |
        Send a single message on any channel. An `idempotency_key` is required —
        retrying the same key returns the original result instead of sending twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendRequest"
            examples:
              email:
                summary: Transactional email
                value:
                  channel: email
                  idempotency_key: "evt_welcome_8f3a"
                  message_type: transactional
                  destination: { email: "user@example.com" }
                  payload:
                    subject: "Welcome to Acme"
                    body_html: "<h1>Welcome 🎉</h1>"
                  metadata: { from_email: "hello@acme.com" }
              sms:
                summary: SMS OTP
                value:
                  channel: sms
                  idempotency_key: "otp_2291_5f"
                  message_type: transactional
                  destination: { phone_number: "+254712345678" }
                  payload: { body: "Your Acme code is 481920" }
      responses:
        "201":
          description: Message accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendResponse"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: |
            The request conflicts with current state — either the idempotency
            key was reused with a different payload (`idempotency_key_reused`)
            or the recipient is on the suppression list (`recipient_suppressed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/Unprocessable" }

  /v1/send/batch:
    post:
      tags: [Send]
      summary: Send a batch of messages
      operationId: sendBatch
      description: |
        Send up to many messages in one call. Each item is processed
        independently; the response preserves request order, with per-item
        success or error (partial success is normal).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              items: { $ref: "#/components/schemas/SendRequest" }
      responses:
        "200":
          description: Per-item results in request order.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BatchItemResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/send/bulk:
    post:
      tags: [Broadcasts]
      summary: Send a bulk email
      operationId: sendBulk
      description: |
        Send one email to many recipients in a single call. Provide an
        `audience_list_id` (a contact list) or an inline `recipients` array, and
        either inline content (`subject` + `body_html`/`body_text`) or a
        `template_id` rendered per recipient. The send fans out asynchronously
        and returns a broadcast to poll. Suppressed and unsubscribed recipients
        are skipped automatically; each recipient is idempotent.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkSendRequest" }
      responses:
        "202":
          description: Bulk send accepted; fan-out is in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Broadcast" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/broadcasts:
    get:
      tags: [Broadcasts]
      summary: List broadcasts
      operationId: listBroadcasts
      responses:
        "200":
          description: The account's broadcasts.
          content:
            application/json:
              schema:
                type: object
                required: [broadcasts]
                properties:
                  broadcasts:
                    type: array
                    items: { $ref: "#/components/schemas/Broadcast" }
    post:
      tags: [Broadcasts]
      summary: Create a broadcast
      operationId: createBroadcast
      description: |
        Create a broadcast in draft. Pass `send_now: true` to fan out
        immediately, or `scheduled_at` to schedule it for later.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkSendRequest" }
      responses:
        "201":
          description: The created broadcast.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Broadcast" }

  /v1/broadcasts/{id}:
    get:
      tags: [Broadcasts]
      summary: Get a broadcast
      operationId: getBroadcast
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The broadcast with aggregate delivery stats.
          content:
            application/json:
              schema:
                type: object
                properties:
                  broadcast: { $ref: "#/components/schemas/Broadcast" }
                  stats: { $ref: "#/components/schemas/BroadcastStats" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/broadcasts/{id}/send:
    post:
      tags: [Broadcasts]
      summary: Send a broadcast now
      operationId: sendBroadcast
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "202":
          description: Fan-out started.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Broadcast" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/broadcasts/{id}/cancel:
    post:
      tags: [Broadcasts]
      summary: Cancel a broadcast
      operationId: cancelBroadcast
      description: Cancels a draft or scheduled broadcast before it sends.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The cancelled broadcast.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Broadcast" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/messages:
    get:
      tags: [Messages]
      summary: List messages
      operationId: listMessages
      description: |
        Returns messages newest-first with keyset pagination. Pass `limit`
        (default 50, max 100) and, to page, the opaque `next_cursor` from the
        previous response back as `cursor`. When `next_cursor` is `null` there
        are no more pages.
      parameters:
        - { name: channel, in: query, schema: { $ref: "#/components/schemas/Channel" } }
        - { name: status, in: query, schema: { type: string } }
        - { name: from, in: query, description: "RFC3339 lower bound (inclusive).", schema: { type: string, format: date-time } }
        - { name: to, in: query, description: "RFC3339 upper bound (inclusive).", schema: { type: string, format: date-time } }
        - name: limit
          in: query
          description: "Max messages to return. Default 50, max 100."
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          description: "Opaque keyset cursor from a previous response's `next_cursor`."
          schema: { type: string }
      responses:
        "200":
          description: A page of message summaries plus the cursor for the next page.
          content:
            application/json:
              schema:
                type: object
                required: [messages, next_cursor]
                properties:
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/MessageSummary" }
                  next_cursor:
                    type: [string, "null"]
                    description: "Opaque cursor for the next page, or `null` on the last page."
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/messages/{id}:
    get:
      tags: [Messages]
      summary: Get a message
      operationId: getMessage
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The message with its event timeline.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/usage:
    get:
      tags: [Usage]
      summary: Get usage
      operationId: getUsage
      parameters:
        - { name: period, in: query, description: "Billing period YYYY-MM (defaults to current).", schema: { type: string } }
      responses:
        "200":
          description: Usage summary for the period.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/suppressions:
    get:
      tags: [Suppressions]
      summary: List suppressed recipients
      operationId: listSuppressions
      parameters:
        - { name: channel, in: query, schema: { $ref: "#/components/schemas/Channel" } }
      responses:
        "200":
          description: Suppressed/unsubscribed recipients.
          content:
            application/json:
              schema:
                type: object
                required: [suppressions]
                properties:
                  suppressions:
                    type: array
                    items: { $ref: "#/components/schemas/Suppression" }
    post:
      tags: [Suppressions]
      summary: Suppress a recipient
      operationId: addSuppression
      description: Adds a recipient to the suppression list, blocking all sends to it on the channel.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel, recipient]
              properties:
                channel: { $ref: "#/components/schemas/Channel" }
                recipient: { type: string }
                reason: { type: string }
      responses:
        "201":
          description: The created suppression.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Suppression" }
    delete:
      tags: [Suppressions]
      summary: Remove a suppression (un-suppress)
      operationId: removeSuppression
      parameters:
        - { name: channel, in: query, required: true, schema: { $ref: "#/components/schemas/Channel" } }
        - { name: recipient, in: query, required: true, schema: { type: string } }
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/billing:
    get:
      tags: [Billing]
      summary: Get billing state
      operationId: getBilling
      responses:
        "200":
          description: The account's plan and subscription status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan: { type: string, example: "pro" }
                  subscription_status: { type: string, example: "active" }

  /v1/billing/checkout:
    post:
      tags: [Billing]
      summary: Start a checkout
      operationId: createCheckout
      description: Returns a hosted Polar checkout URL to subscribe to Pro.
      responses:
        "200":
          description: Checkout URL.
          content:
            application/json:
              schema: { type: object, properties: { url: { type: string } } }
        "503":
          description: Billing is not configured.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/billing/portal:
    post:
      tags: [Billing]
      summary: Open the customer portal
      operationId: createPortal
      responses:
        "200":
          description: Customer portal URL.
          content:
            application/json:
              schema: { type: object, properties: { url: { type: string } } }

  /v1/keys:
    get:
      tags: [API Keys]
      summary: List API keys
      operationId: listApiKeys
      responses:
        "200":
          description: The account's API keys (no secrets).
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [API Keys]
      summary: Create an API key
      operationId: createApiKey
      description: The plaintext key is returned exactly once — store it securely.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scope: { $ref: "#/components/schemas/Scope" }
                test_mode: { type: boolean, default: false }
      responses:
        "201":
          description: The created key, including the one-time plaintext secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatedApiKey" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/keys/{id}/rotate:
    post:
      tags: [API Keys]
      summary: Rotate an API key
      operationId: rotateApiKey
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The new plaintext key.
          content:
            application/json:
              schema:
                type: object
                required: [key]
                properties: { key: { type: string } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/keys/{id}:
    delete:
      tags: [API Keys]
      summary: Revoke an API key
      operationId: revokeApiKey
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Revoked. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains:
    get:
      tags: [Domains]
      summary: List domains
      operationId: listDomains
      responses:
        "200":
          description: The account's sending domains.
          content:
            application/json:
              schema:
                type: object
                required: [domains]
                properties:
                  domains:
                    type: array
                    items: { $ref: "#/components/schemas/Domain" }
    post:
      tags: [Domains]
      summary: Add a sending domain
      operationId: createDomain
      description: |
        Registers the domain with the email provider and returns the DNS records
        to publish (3 DKIM CNAMEs, a custom MAIL FROM MX + SPF, and DMARC).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, example: "mail.acme.com" }
      responses:
        "201":
          description: The created domain with DNS records to publish.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "409":
          description: Domain already registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/domains/{domain}:
    get:
      tags: [Domains]
      summary: Get a domain
      operationId: getDomain
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The domain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Domain" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains/{domain}/verify:
    post:
      tags: [Domains]
      summary: Verify a domain
      operationId: verifyDomain
      description: Re-checks DNS/SES status and returns the per-record result.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Verification result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DomainVerification" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains/{domain}/bimi:
    get:
      tags: [Domains]
      summary: Get BIMI state
      operationId: getBimi
      description: |
        Returns the domain's BIMI (brand logo) state: the hosted logo URL, the
        TXT record to publish (`null` until a logo is set), whether DMARC is at
        enforcement with a recommended policy, whether the record is live, and a
        note on which mailbox providers require a VMC.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The domain's BIMI state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BimiState" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Domains]
      summary: Set the BIMI logo URL
      operationId: setBimi
      description: |
        Point BIMI at a square SVG Tiny PS logo served over HTTPS. Sendara
        validates the URL and returns the BIMI state, including the generated
        TXT record.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [logo_url]
              properties:
                logo_url:
                  type: string
                  description: Public HTTPS URL of a square SVG Tiny PS logo.
                  example: "https://cdn.acme.com/brand/logo-tiny-ps.svg"
      responses:
        "200":
          description: The updated BIMI state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BimiState" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/domains/{domain}/bimi/logo:
    post:
      tags: [Domains]
      summary: Upload a BIMI logo
      operationId: uploadBimiLogo
      description: |
        Upload a BIMI logo as `multipart/form-data` with a `file` part (SVG,
        max 1 MiB). Sendara validates the SVG Tiny PS profile, hosts the logo on
        a stable HTTPS URL, and returns the BIMI state.
      parameters:
        - { name: domain, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: The updated BIMI state with the hosted logo URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BimiState" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413":
          description: The SVG exceeds the 1 MiB limit (`payload_too_large`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      operationId: listContacts
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: The account's contacts.
          content:
            application/json:
              schema:
                type: object
                required: [contacts]
                properties:
                  contacts:
                    type: array
                    items: { $ref: "#/components/schemas/Contact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Contacts]
      summary: Create a contact
      operationId: createContact
      description: |
        Create a contact. Provide at least one of `email`, `phone_number`, or
        `device_token`. A duplicate email or phone for the account returns
        `duplicate_contact` (409).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactInput" }
      responses:
        "201":
          description: The created contact.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409":
          description: A contact with this email or phone already exists.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/contacts/{id}:
    get:
      tags: [Contacts]
      summary: Get a contact
      operationId: getContact
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The contact.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Contacts]
      summary: Update a contact
      operationId: updateContact
      description: Partial update — only the fields you send are changed.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactInput" }
      responses:
        "200":
          description: The updated contact.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: A contact with this email or phone already exists.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    delete:
      tags: [Contacts]
      summary: Delete a contact
      operationId: deleteContact
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The contact was deleted.
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/contacts/import:
    post:
      tags: [Contacts]
      summary: Bulk-import contacts
      operationId: importContacts
      description: |
        Import contacts from a CSV or JSON file already staged in object storage.
        Provide the `s3_key` returned by your upload flow and the `format`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [s3_key, format]
              properties:
                s3_key: { type: string }
                format: { type: string, enum: [csv, json] }
      responses:
        "200":
          description: Import result.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportResult" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/contacts/lists:
    get:
      tags: [Lists]
      summary: List contact lists
      operationId: listLists
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: The account's contact lists.
          content:
            application/json:
              schema:
                type: object
                required: [lists]
                properties:
                  lists:
                    type: array
                    items: { $ref: "#/components/schemas/ContactList" }
    post:
      tags: [Lists]
      summary: Create a contact list
      operationId: createList
      description: |
        Create a `static` list (members added explicitly) or a `dynamic` list
        (members computed from `segment_rules`). Dynamic lists require
        `segment_rules`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                list_type: { $ref: "#/components/schemas/ListType" }
                segment_rules: { $ref: "#/components/schemas/SegmentRules" }
      responses:
        "201":
          description: The created list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/contacts/lists/{id}:
    get:
      tags: [Lists]
      summary: Get a contact list
      operationId: getList
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Lists]
      summary: Update a contact list
      operationId: updateList
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                segment_rules: { $ref: "#/components/schemas/SegmentRules" }
      responses:
        "200":
          description: The updated list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Lists]
      summary: Delete a contact list
      operationId: deleteList
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The list was deleted.
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/contacts/lists/{id}/members:
    get:
      tags: [Lists]
      summary: List members
      operationId: listMembers
      description: |
        Returns the contacts in the list. For static lists these are the
        explicit members; for dynamic lists the `segment_rules` are evaluated
        against your contacts.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The list's members.
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items: { $ref: "#/components/schemas/Contact" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Lists]
      summary: Add a member
      operationId: addMember
      description: Add a contact to a static list. Dynamic lists reject membership writes.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contact_id]
              properties:
                contact_id: { type: string }
      responses:
        "201":
          description: The created membership.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ListMember" }
        "400":
          description: The list is dynamic; membership cannot be set explicitly.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The contact is already a member (`duplicate_member`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/contacts/lists/{id}/members/{contactId}:
    delete:
      tags: [Lists]
      summary: Remove a member
      operationId: removeMember
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: contactId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The member was removed.
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/templates:
    get:
      tags: [Templates]
      summary: List templates
      operationId: listTemplates
      responses:
        "200":
          description: The account's templates.
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
    post:
      tags: [Templates]
      summary: Create a template
      operationId: createTemplate
      description: |
        Create a reusable template. Content uses mustache `{{ variable }}`
        placeholders; declare each in `variables` with optional `sample`,
        `default`, and `required` metadata.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateInput" }
            examples:
              email:
                summary: Email template
                value:
                  name: "Welcome"
                  channel: email
                  subject: "Welcome, {{ first_name }}"
                  body_html: "<h1>Hi {{ first_name }}</h1>"
                  variables:
                    - { name: first_name, sample: "Ada", required: true }
      responses:
        "201":
          description: The created template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/templates/{id}:
    get:
      tags: [Templates]
      summary: Get a template
      operationId: getTemplate
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Templates]
      summary: Update a template
      operationId: updateTemplate
      description: Updating content bumps the template `version`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateUpdate" }
      responses:
        "200":
          description: The updated template.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Template" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Templates]
      summary: Delete a template
      operationId: deleteTemplate
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The template was deleted.
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/templates/{id}/render:
    post:
      tags: [Templates]
      summary: Render a template
      operationId: renderTemplate
      description: |
        Render the template with the supplied `vars` and return the resulting
        channel payload (e.g. `{subject, body_html, body_text}` for email)
        without sending. Missing required variables return `missing_variable`;
        a malformed template returns `invalid_template`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                vars:
                  type: object
                  additionalProperties: true
                  description: "Variable values keyed by name."
      responses:
        "200":
          description: The rendered channel payload.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: "Channel payload, e.g. `{subject, body_html, body_text}` for email."
        "400":
          description: A required variable was missing or the template is invalid.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      operationId: listWebhooks
      responses:
        "200":
          description: The account's webhook subscriptions.
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookSubscription" }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      operationId: createWebhook
      description: |
        Subscribe an HTTPS endpoint to event callbacks. Omit `event_types` (or
        send an empty array) to receive all event types. The response includes
        the `signing_secret` used to verify the `Sendara-Signature` header on
        every delivery — store it securely.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint_url]
              properties:
                endpoint_url: { type: string, format: uri, example: "https://acme.com/webhooks/sendara" }
                event_types:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                  description: "Event types to subscribe to. Empty = all."
      responses:
        "201":
          description: The created subscription, including its signing secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSubscription" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/webhooks/{id}:
    get:
      tags: [Webhooks]
      summary: Get a webhook subscription
      operationId: getWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSubscription" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Webhooks]
      summary: Update a webhook subscription
      operationId: updateWebhook
      description: Change the endpoint, event-type filter, or pause/resume via `is_active`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                endpoint_url: { type: string, format: uri }
                event_types:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                is_active: { type: boolean }
      responses:
        "200":
          description: The updated subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSubscription" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      operationId: deleteWebhook
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The subscription was deleted.
          content:
            application/json:
              schema: { type: object, properties: { message: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List delivery attempts
      operationId: listWebhookDeliveries
      description: |
        Returns recent delivery attempts for the subscription, newest first —
        useful for debugging failed callbacks.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
      responses:
        "200":
          description: Recent delivery records.
          content:
            application/json:
              schema:
                type: object
                required: [deliveries]
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate the signing secret
      operationId: rotateWebhookSecret
      description: |
        Generates a new `signing_secret`. The previous secret stays valid for a
        grace window (returned as `previous_signing_secret`) so in-flight
        deliveries can still be verified during rotation.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The subscription with its new signing secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSubscription" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/test-recipients:
    get:
      tags: [Test Recipients]
      summary: List test recipients
      operationId: listTestRecipients
      responses:
        "200":
          description: The account's registered test recipients.
          content:
            application/json:
              schema:
                type: object
                required: [recipients]
                properties:
                  recipients:
                    type: array
                    items: { $ref: "#/components/schemas/TestRecipient" }
    post:
      tags: [Test Recipients]
      summary: Register a test recipient
      operationId: createTestRecipient
      description: |
        Register one of your own addresses (up to 3 per account). A verification
        email is sent; once the recipient confirms, you can send free real test
        emails to it with `test_send: true` (capped 10/recipient/day).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "201":
          description: The pending test recipient; a verification email was sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TestRecipient" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "422":
          description: The 3-recipient cap was reached (`too_many_test_recipients`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/test-recipients/{id}/resend:
    post:
      tags: [Test Recipients]
      summary: Resend the verification email
      operationId: resendTestRecipientVerification
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: A new verification email was sent. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/test-recipients/{id}:
    delete:
      tags: [Test Recipients]
      summary: Remove a test recipient
      operationId: deleteTestRecipient
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Removed. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/uploads:
    post:
      tags: [Uploads]
      summary: Upload an image
      operationId: createUpload
      description: |
        Upload an image (PNG, JPEG, GIF, or WebP, max 2 MiB) as
        `multipart/form-data` with a `file` part. Returns a stable public URL
        you can embed in email HTML.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "201":
          description: The stored asset and its public URL.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Upload" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "413":
          description: The file exceeds the 2 MiB limit (`payload_too_large`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/spend-caps:
    put:
      tags: [Spend Caps]
      summary: Set a spend cap
      operationId: setSpendCap
      description: |
        Set or update a spend cap. Omit `key_id` to cap the whole account, or
        provide a `key_id` to cap a single API key. The `soft_limit_micros`
        warns; the `hard_limit_micros` blocks further sends with
        `spend_cap_exceeded` (402). Values are in micro-dollars (1,000,000 = $1).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                key_id:
                  type: string
                  description: "Cap this key only. Omit for the account-wide cap."
                soft_limit_micros: { type: [integer, "null"], minimum: 0 }
                hard_limit_micros: { type: [integer, "null"], minimum: 0 }
      responses:
        "200":
          description: The applied spend cap.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SpendCap" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: The referenced API key was not found.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key as a Bearer token: `Authorization: Bearer sk_live_...`"

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: The key's scope does not permit this operation.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Resource not found.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    BadRequest:
      description: Malformed request.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unprocessable:
      description: The request was understood but cannot be processed (e.g. unverified from address).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Channel:
      type: string
      enum: [email, sms, push, voice, webhook]
    Scope:
      type: string
      enum: [send, read, admin]
    MessageType:
      type: string
      enum: [transactional, marketing]
      default: transactional

    SendRequest:
      type: object
      required: [channel, idempotency_key, destination, payload]
      properties:
        channel: { $ref: "#/components/schemas/Channel" }
        idempotency_key:
          type: string
          description: Unique key per logical send; retries with the same key are deduplicated.
        message_type: { $ref: "#/components/schemas/MessageType" }
        destination:
          type: object
          description: |
            Channel-specific recipient. email→`{email}`, sms/voice→`{phone_number}`,
            push→`{device_token}`, webhook→`{url}`.
          additionalProperties: true
        payload:
          type: object
          description: |
            Channel-specific content. email→`{subject, body_html, body_text}`,
            sms→`{body}`, push→`{title, body}`, voice→`{body}`, webhook→`{data}`.
          additionalProperties: true
        template_id:
          type: string
          description: Optional template to render instead of an inline payload.
        template_vars:
          type: object
          additionalProperties: true
        metadata:
          type: object
          description: "Per-send options, e.g. email `{from_email}`, sms `{sender_id}`."
          additionalProperties: true
        store_payload:
          type: boolean
          default: true
          description: |
            Whether to retain the rendered content after sending. Defaults to
            true (a copy is kept). Set false to redact the stored payload once
            the message is dispatched — the email is still delivered, but the
            content is not retained.
        test_send:
          type: boolean
          default: false
          description: |
            Route the send through the verified-test-recipient path. The
            destination must be one of your verified test recipients (see
            `/v1/test-recipients`); the email is delivered for real, free, and
            capped at 10/recipient/day. Fails with `recipient_not_verified` or
            `test_send_daily_limit` otherwise.

    SendResponse:
      type: object
      required: [id, status, channel, idempotency_key, created_at]
      properties:
        id: { type: string, example: "msg_a1b2c3" }
        status: { type: string, example: "queued" }
        channel: { $ref: "#/components/schemas/Channel" }
        idempotency_key: { type: string }
        created_at: { type: string, format: date-time }

    BatchItemResult:
      type: object
      required: [success]
      properties:
        success: { type: boolean }
        response: { $ref: "#/components/schemas/SendResponse" }
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
            status: { type: integer }

    MessageSummary:
      type: object
      required: [id, channel, status, message_type, created_at]
      properties:
        id: { type: string }
        channel: { $ref: "#/components/schemas/Channel" }
        status: { type: string, example: "delivered" }
        message_type: { type: string }
        created_at: { type: string, format: date-time }

    Message:
      allOf:
        - $ref: "#/components/schemas/MessageSummary"
        - type: object
          properties:
            events:
              type: array
              items: { $ref: "#/components/schemas/MessageEvent" }

    MessageEvent:
      type: object
      properties:
        id: { type: string }
        type: { type: string, example: "delivered" }
        occurred_at: { type: string, format: date-time }

    Usage:
      type: object
      required: [period, total_send_count, total_cost_micros, channels]
      properties:
        period: { type: string, example: "2026-06" }
        total_send_count: { type: integer }
        total_cost_micros:
          type: integer
          description: Total cost in micro-dollars (1,000,000 = $1.00).
        channels:
          type: array
          items:
            type: object
            properties:
              channel: { $ref: "#/components/schemas/Channel" }
              send_count: { type: integer }
              cost_micros: { type: integer }

    Suppression:
      type: object
      properties:
        channel: { $ref: "#/components/schemas/Channel" }
        recipient: { type: string }
        state: { type: string, enum: [suppressed, unsubscribed], example: "suppressed" }
        reason: { type: string }
        updated_at: { type: string, format: date-time }

    ApiKey:
      type: object
      properties:
        id: { type: string }
        key_prefix: { type: string }
        scope: { $ref: "#/components/schemas/Scope" }
        name: { type: string }
        is_revoked: { type: boolean }
        test_mode: { type: boolean }
        last_used_at: { type: [string, "null"], format: date-time }
        request_count: { type: integer }
        created_at: { type: string, format: date-time }

    CreatedApiKey:
      type: object
      required: [id, key, key_prefix, scope, test_mode, created_at]
      properties:
        id: { type: string }
        key:
          type: string
          description: The plaintext secret — shown only once.
        key_prefix: { type: string }
        scope: { $ref: "#/components/schemas/Scope" }
        test_mode: { type: boolean }
        created_at: { type: string, format: date-time }

    VerificationStatus:
      type: string
      enum: [pending, verified, failed]

    DnsRecord:
      type: object
      properties:
        type: { type: string, example: "CNAME" }
        name: { type: string }
        value: { type: string }

    Domain:
      type: object
      properties:
        id: { type: string }
        domain: { type: string }
        dkim_status: { $ref: "#/components/schemas/VerificationStatus" }
        spf_status: { $ref: "#/components/schemas/VerificationStatus" }
        dmarc_status: { $ref: "#/components/schemas/VerificationStatus" }
        txt_status: { $ref: "#/components/schemas/VerificationStatus" }
        dns_records:
          type: array
          items: { $ref: "#/components/schemas/DnsRecord" }
        mail_from_domain: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    DomainVerification:
      type: object
      properties:
        domain: { type: string }
        fully_verified: { type: boolean }
        results:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              type: { type: string }
              name: { type: string }
              status: { $ref: "#/components/schemas/VerificationStatus" }
              detail: { type: string }

    BimiState:
      type: object
      properties:
        logo_url:
          type: string
          nullable: true
          description: The hosted (or supplied) HTTPS URL of the BIMI logo.
        record:
          nullable: true
          description: The TXT record to publish, or null until a logo is set.
          allOf:
            - $ref: "#/components/schemas/DnsRecord"
        dmarc:
          type: object
          properties:
            at_enforcement:
              type: boolean
              description: True when DMARC is at p=quarantine or p=reject.
            recommended:
              type: string
              description: A DMARC policy at enforcement to copy.
        bimi_published:
          type: boolean
          description: True when the BIMI TXT record is live in DNS.
        vmc_note:
          type: string
          description: Which mailbox providers require a paid VMC certificate.

    BulkSendRequest:
      type: object
      required: [from_email]
      properties:
        name: { type: string, description: "Display name for the broadcast." }
        from_email: { type: string, description: "Verified sending address." }
        subject: { type: string }
        body_html: { type: string }
        body_text: { type: string }
        template_id: { type: string, description: "Render this template per recipient instead of inline content." }
        message_type: { $ref: "#/components/schemas/MessageType" }
        audience_list_id: { type: string, description: "Contact list (static or dynamic) to send to." }
        recipients:
          type: array
          description: "Inline recipients; an alternative to audience_list_id."
          items:
            type: object
            required: [email]
            properties:
              email: { type: string }
              data:
                type: object
                additionalProperties: true
                description: "Per-recipient template variables."
        scheduled_at: { type: string, format: date-time, description: "Schedule the send for a future time." }
        send_now: { type: boolean, description: "Fan out immediately when creating via POST /v1/broadcasts." }

    Broadcast:
      type: object
      properties:
        id: { type: string, example: "bc_a1b2c3" }
        name: { type: string }
        status: { type: string, enum: [draft, scheduled, sending, sent, cancelled, failed] }
        from_email: { type: string }
        message_type: { $ref: "#/components/schemas/MessageType" }
        audience_list_id: { type: [string, "null"] }
        scheduled_at: { type: [string, "null"], format: date-time }
        total_recipients: { type: integer }
        sent_count: { type: integer }
        failed_count: { type: integer }
        created_at: { type: string, format: date-time }

    BroadcastStats:
      type: object
      properties:
        total: { type: integer }
        sent: { type: integer }
        delivered: { type: integer }
        bounced: { type: integer }
        complained: { type: integer }
        opened: { type: integer }
        failed: { type: integer }

    ConsentState:
      type: string
      enum: [subscribed, unsubscribed, suppressed, unknown]

    ListType:
      type: string
      enum: [static, dynamic]
      default: static

    EventType:
      type: string
      description: A canonical delivery event type.
      enum: [queued, sent, delivered, failed, bounced, opened, complained]

    ContactInput:
      type: object
      description: |
        Create/update body for a contact. Provide at least one of `email`,
        `phone_number`, or `device_token`.
      properties:
        email: { type: [string, "null"] }
        phone_number: { type: [string, "null"] }
        device_token: { type: [string, "null"] }
        first_name: { type: string }
        last_name: { type: string }
        attributes:
          type: object
          additionalProperties: true
          description: "Arbitrary custom fields, usable in dynamic-list segment rules."
        tags:
          type: array
          items: { type: string }
        email_consent: { $ref: "#/components/schemas/ConsentState" }
        sms_consent: { $ref: "#/components/schemas/ConsentState" }
        push_consent: { $ref: "#/components/schemas/ConsentState" }
        voice_consent: { $ref: "#/components/schemas/ConsentState" }

    Contact:
      type: object
      properties:
        id: { type: string, example: "ct_a1b2c3" }
        account_id: { type: string }
        email: { type: [string, "null"] }
        phone_number: { type: [string, "null"] }
        device_token: { type: [string, "null"] }
        first_name: { type: string }
        last_name: { type: string }
        attributes:
          type: object
          additionalProperties: true
        tags:
          type: array
          items: { type: string }
        email_consent: { $ref: "#/components/schemas/ConsentState" }
        sms_consent: { $ref: "#/components/schemas/ConsentState" }
        push_consent: { $ref: "#/components/schemas/ConsentState" }
        voice_consent: { $ref: "#/components/schemas/ConsentState" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ImportResult:
      type: object
      description: Outcome of a contact import.
      properties:
        imported: { type: integer }
        skipped: { type: integer }
        failed: { type: integer }
        errors:
          type: array
          items: { type: string }

    SegmentRules:
      type: object
      description: |
        Filter criteria for a dynamic list. A contact matches when it has ALL
        listed `tags` AND matches ALL listed `attributes` (AND semantics).
      properties:
        tags:
          type: array
          items: { type: string }
        attributes:
          type: object
          additionalProperties: true

    ContactList:
      type: object
      properties:
        id: { type: string, example: "list_a1b2c3" }
        account_id: { type: string }
        name: { type: string }
        list_type: { $ref: "#/components/schemas/ListType" }
        segment_rules: { $ref: "#/components/schemas/SegmentRules" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ListMember:
      type: object
      properties:
        id: { type: string, example: "clm_a1b2c3" }
        contact_list_id: { type: string }
        contact_id: { type: string }
        added_at: { type: string, format: date-time }

    TemplateChannel:
      type: string
      enum: [email, sms, push, voice]

    TemplateVariable:
      type: object
      description: Declares a `{{ name }}` placeholder used in the template.
      required: [name]
      properties:
        name: { type: string, example: "first_name" }
        sample: { type: string, description: "Example value used in previews." }
        default: { type: string, description: "Fallback when the variable is omitted." }
        required: { type: boolean, description: "If true, rendering fails when the value is missing." }

    TemplateInput:
      type: object
      required: [name, channel]
      properties:
        name: { type: string }
        channel: { $ref: "#/components/schemas/TemplateChannel" }
        subject: { type: [string, "null"], description: "Email subject (email only)." }
        body_text: { type: [string, "null"] }
        body_html: { type: [string, "null"] }
        body_json:
          type: object
          additionalProperties: true
          description: "Structured (block-editor) body, if used."
        variables:
          type: array
          items: { $ref: "#/components/schemas/TemplateVariable" }

    TemplateUpdate:
      type: object
      description: Partial update — only the fields you send are changed.
      properties:
        name: { type: string }
        subject: { type: [string, "null"] }
        body_text: { type: [string, "null"] }
        body_html: { type: [string, "null"] }
        body_json:
          type: object
          additionalProperties: true
        variables:
          type: array
          items: { $ref: "#/components/schemas/TemplateVariable" }
        is_active: { type: boolean }

    Template:
      type: object
      properties:
        id: { type: string, example: "tmpl_a1b2c3" }
        account_id: { type: string }
        name: { type: string }
        channel: { $ref: "#/components/schemas/TemplateChannel" }
        subject: { type: [string, "null"] }
        body_text: { type: [string, "null"] }
        body_html: { type: [string, "null"] }
        body_json:
          type: object
          additionalProperties: true
        variables:
          type: array
          items: { $ref: "#/components/schemas/TemplateVariable" }
        version: { type: integer }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WebhookSubscription:
      type: object
      properties:
        id: { type: string, example: "whsub_a1b2c3" }
        account_id: { type: string }
        endpoint_url: { type: string, format: uri }
        signing_secret:
          type: string
          description: "Secret for verifying the `Sendara-Signature` header."
        previous_signing_secret:
          type: [string, "null"]
          description: "Valid during a rotation grace window; null otherwise."
        event_types:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
          description: "Subscribed event types. Empty means all."
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        subscription_id: { type: string }
        event_id: { type: string }
        event_type: { $ref: "#/components/schemas/EventType" }
        payload:
          allOf:
            - $ref: "#/components/schemas/WebhookEventPayload"
          description: "The event body that was (or will be) POSTed to the endpoint."
        status: { type: string, enum: [pending, succeeded, failed, exhausted] }
        attempt_count: { type: integer }
        next_retry_at: { type: [string, "null"], format: date-time }
        response_status: { type: [integer, "null"], description: "HTTP status returned by the endpoint." }
        created_at: { type: string, format: date-time }

    WebhookEventPayload:
      type: object
      description: |
        The JSON body POSTed to your endpoint for each event. Verify it with the
        `Sendara-Signature` header (see the webhook security section).
      properties:
        event_id: { type: string, example: "evt_a1b2c3" }
        event_type: { $ref: "#/components/schemas/EventType" }
        message_id: { type: string, example: "msg_a1b2c3" }
        account_id: { type: string }
        payload:
          type: object
          additionalProperties: true
          description: "Provider event detail."
        occurred_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    TestRecipient:
      type: object
      properties:
        id: { type: string, example: "tr_a1b2c3" }
        email: { type: string, format: email }
        status: { type: string, enum: [pending, verified] }
        verified_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    Upload:
      type: object
      properties:
        id: { type: string, example: "asset_a1b2c3" }
        url: { type: string, format: uri, example: "https://api.sendara.dev/v1/assets/asset_a1b2c3" }
        content_type: { type: string, example: "image/png" }
        bytes: { type: integer }

    SpendCap:
      type: object
      description: |
        A spend cap. Field names are PascalCase. `KeyID` is null for the
        account-wide cap; limits are micro-dollars (1,000,000 = $1.00) and null
        when unset.
      properties:
        ID: { type: string, example: "cap_a1b2c3" }
        AccountID: { type: string }
        KeyID: { type: [string, "null"] }
        SoftLimitMicros: { type: [integer, "null"] }
        HardLimitMicros: { type: [integer, "null"] }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, status]
          properties:
            code:
              type: string
              description: |
                Stable machine-readable code. The API emits:
                `unauthorized` (401), `forbidden` (403), `invalid_request` (400),
                `not_found` (404), `recipient_suppressed` (409),
                `idempotency_key_reused` (409), `from_not_verified` (403/422),
                `missing_variable` (400), `invalid_template` (400),
                `invalid_token` (400), `invalid_signature` (401),
                `rate_limit_exceeded` (429), `spend_cap_exceeded` (402),
                `billing_not_configured` (400), `duplicate_contact` (409),
                `duplicate_member` (409), `too_many_test_recipients` (422),
                `recipient_not_verified` (403), `test_send_daily_limit` (429),
                `payload_too_large` (413), `internal_error` (500).
              enum:
                - unauthorized
                - forbidden
                - invalid_request
                - not_found
                - recipient_suppressed
                - idempotency_key_reused
                - from_not_verified
                - missing_variable
                - invalid_template
                - invalid_token
                - invalid_signature
                - rate_limit_exceeded
                - spend_cap_exceeded
                - billing_not_configured
                - duplicate_contact
                - duplicate_member
                - too_many_test_recipients
                - recipient_not_verified
                - test_send_daily_limit
                - payload_too_large
                - internal_error
              example: "from_not_verified"
            message: { type: string }
            status: { type: integer, example: 422 }
