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.

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
      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 } }
      responses:
        "200":
          description: A list of message summaries.
          content:
            application/json:
              schema:
                type: object
                required: [messages]
                properties:
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/MessageSummary" }
        "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" }

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

    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 }

    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 }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, status]
          properties:
            code: { type: string, example: "from_not_verified" }
            message: { type: string }
            status: { type: integer, example: 422 }
