openapi: 3.1.0
info:
  title: SenderKit Public API
  version: "1.0.0"
  description: |
    Public REST API for SenderKit — send transactional messages (email, SMS,
    push, web push), list message history, and read/render templates.

    ## Authentication
    All endpoints require a Bearer API key:

        Authorization: Bearer sk_live_xxx

    The `sk_live_` / `sk_test_` prefix selects the environment (live vs. test).
    The prefix is only a hint for humans; the secret is the full token. Keys are
    created in the dashboard and shown once at creation.

    ## Sends are asynchronous
    `POST /v1/send` enqueues the message and returns `202` with `status: "queued"`.
    Delivery happens out of band; poll `GET /v1/messages` to observe progress.

    ## Rate limits
    All endpoints are rate limited per API key. Sends and reads count against
    separate budgets, so listing messages never competes with sending them.
    A `429` response includes a `Retry-After` header (seconds).
servers:
  - url: https://api.senderkit.com
    description: Production
  - url: http://localhost:3000/api
    description: Local development

security:
  - apiKey: []

tags:
  - name: Context
    description: Inspect the workspace and environment an API key operates in.
  - name: Send
    description: Dispatch messages.
  - name: Messages
    description: Read message history and cancel pending sends.
  - name: Templates
    description: Read and render stored templates.

paths:
  /v1/context:
    get:
      tags: [Context]
      summary: Get workspace context
      operationId: getContext
      description: |
        Returns the workspace the API key belongs to and the environment the key
        operates in. Useful to confirm which workspace and mode subsequent calls
        will affect before sending.
      responses:
        '200':
          description: Workspace context.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Context'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The API key's workspace no longer exists (`workspace_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/send:
    post:
      tags: [Send]
      summary: Send a message
      operationId: sendMessage
      description: |
        Enqueue a message for delivery. Provide **either** `template` (send a
        stored template) **or** `content` (inline raw content) — never both.
        Returns `202` immediately; the message is dispatched asynchronously.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Optional caller-supplied key to make a send safely retryable. A
            repeat request with the same key (within the same workspace and
            environment) returns the original send instead of creating a
            duplicate. Recommended for all sends so client retries never
            double-send.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/TemplateSend'
                - $ref: '#/components/schemas/RawEmailSend'
                - $ref: '#/components/schemas/RawSmsSend'
                - $ref: '#/components/schemas/RawPushSend'
                - $ref: '#/components/schemas/RawWebPushSend'
            examples:
              template:
                summary: Template-based send
                value:
                  template: welcome
                  to: user@example.com
                  vars:
                    name: Ada
                    dashboardUrl: https://app.example.com/dashboard
                  metadata:
                    userId: usr_123
              rawEmail:
                summary: Raw email send
                value:
                  channel: email
                  to: user@example.com
                  content:
                    subject: Your receipt
                    html: <p>Thanks for your order, {{name}}.</p>
                  interpolate: true
                  vars:
                    name: Ada
      responses:
        '202':
          description: Message accepted and queued for delivery.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResult'
        '400':
          description: |
            Invalid request — malformed JSON or failed validation
            (`invalid_request`), a channel/template mismatch
            (`channel_mismatch`), an envelope field (cc/bcc/replyTo/attachments)
            used on a non-email channel (`envelope_not_supported`), or (in live
            mode) the provider for the channel is not configured.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: |
            Monthly message quota exhausted for the workspace's plan
            (`message_limit_reached`). Enforcement is soft: sends keep working
            past the advertised plan limit up to a grace buffer (e.g. Free =
            3,000 + 20%); only past that buffer are sends rejected. Live sends
            only — test-mode sends never consume quota. Upgrade the plan to
            keep sending. Workspaces sending through SenderKit's managed
            provider can also hit a separate managed-send cap
            (`managed_send_limit_reached`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Template not found, or has no published version in live mode.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: |
            The template exists but is archived (`template_archived`) and can no
            longer be sent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/messages:
    get:
      tags: [Messages]
      summary: List messages
      operationId: listMessages
      description: |
        Returns messages for the authenticated workspace and environment,
        newest first, with cursor pagination.

        Setting `tail=1` switches the endpoint to a Server-Sent Events stream
        (`text/event-stream`) that backfills the most recent 50 messages and
        then pushes new ones live. Streams are closed by the server after 30
        minutes; EventSource clients reconnect automatically and receive a
        fresh backfill. The schemas below describe the non-streaming JSON
        response.
      parameters:
        - name: limit
          in: query
          description: Page size (1–200).
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          description: Opaque pagination cursor from a previous response's `nextCursor`.
          schema:
            type: string
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/MessageStatus'
        - name: channel
          in: query
          schema:
            $ref: '#/components/schemas/Channel'
        - name: template
          in: query
          description: Filter by template slug.
          schema:
            type: string
        - name: metadata[key]
          in: query
          description: |
            Filter by metadata containment, Stripe-style. E.g.
            `?metadata[userId]=usr_123`. Multiple keys are AND-ed. Values that
            parse as numbers/booleans are coerced.
          schema:
            type: string
        - name: tail
          in: query
          description: Set to `1` to receive a Server-Sent Events stream instead of JSON.
          schema:
            type: string
            enum: ["1"]
      responses:
        '200':
          description: A page of messages.
          content:
            application/json:
              schema:
                type: object
                required: [data, nextCursor]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Message'
                  nextCursor:
                    type: [string, "null"]
                    description: Cursor for the next page, or null if no more results.
            text/event-stream:
              schema:
                type: string
                description: |
                  SSE stream (when `tail=1`). Events: `message` (a Message
                  object), `ready` (backfill complete), `error`
                  (`{ code, message }`; `poll_failed` errors are transient and
                  the stream stays open).
        '400':
          description: Invalid `status` or `channel` filter value (`invalid_request`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/messages/{id}:
    get:
      tags: [Messages]
      summary: Get a message
      operationId: getMessage
      description: |
        Retrieve a single message by its public ID. The message must belong to
        the authenticated workspace and match the API key's environment (live
        or test).
      parameters:
        - $ref: '#/components/parameters/MessageId'
      responses:
        '200':
          description: The message.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No message with that id in this workspace and environment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      tags: [Messages]
      summary: Cancel a message
      operationId: cancelMessage
      description: |
        Cancel a still-pending message before it is dispatched. Only messages
        whose status is `scheduled` or `queued` can be canceled; anything past
        that (rendered, dispatched, sent, delivered, failed, opted_out,
        canceled) is already committed to a provider or terminal.

        Cancellation is race-safe: if the dispatcher advances the message while
        the cancel request is in flight, the request fails with `409` and the
        message's freshly observed status.
      parameters:
        - $ref: '#/components/parameters/MessageId'
      responses:
        '200':
          description: The message was canceled.
          content:
            application/json:
              schema:
                type: object
                required: [id, status]
                properties:
                  id:
                    type: string
                    description: Public message ID (e.g. `msg_...`).
                    example: msg_0a1b2c3d4e5f6g7h
                  status:
                    type: string
                    const: canceled
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No message with that id in this workspace and environment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: |
            Not cancelable (`not_cancelable`) — the message has already been
            dispatched or is in a terminal state. The error message includes
            the message's current status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/templates:
    get:
      tags: [Templates]
      summary: List templates
      operationId: listTemplates
      responses:
        '200':
          description: All templates in the workspace.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TemplateSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/templates/{slug}:
    get:
      tags: [Templates]
      summary: Get a template
      operationId: getTemplate
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Template detail, including its current published version.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No template with that slug.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/templates/{slug}/render:
    post:
      tags: [Templates]
      summary: Render a template
      operationId: renderTemplate
      description: |
        Render the template's current published version with the supplied
        variables, without sending. Returns the rendered output for the
        template's channel plus any variable paths that were referenced but not
        provided.
      parameters:
        - $ref: '#/components/parameters/Slug'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                vars:
                  type: object
                  additionalProperties: true
                  description: Variable values to interpolate into the template.
            examples:
              default:
                value:
                  vars:
                    name: Ada
      responses:
        '200':
          description: Rendered template output.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RenderResult'
        '400':
          description: Malformed JSON or failed validation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No published version for the template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: API key with an `sk_live_` or `sk_test_` prefix.

  parameters:
    Slug:
      name: slug
      in: path
      required: true
      description: Template slug.
      schema:
        type: string
    MessageId:
      name: id
      in: path
      required: true
      description: Public message ID (e.g. `msg_...`).
      schema:
        type: string

  responses:
    Unauthorized:
      description: Missing, malformed, invalid, or revoked API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Channel:
      type: string
      enum: [email, sms, push, web-push]

    MessageStatus:
      type: string
      description: |
        Lifecycle status of a message. `scheduled` is the initial status for a
        send with a future `scheduledAt`; `queued` is the initial status for an
        immediate send. `canceled` is terminal and reached when a `scheduled` or
        `queued` message is canceled before dispatch.
      enum: [scheduled, queued, rendered, dispatched, sent, delivered, failed, opted_out, canceled]

    Metadata:
      type: object
      description: Caller-supplied tags/IDs. Values must be string, number, or boolean.
      additionalProperties:
        oneOf:
          - type: string
          - type: number
          - type: boolean

    Attachment:
      type: object
      required: [filename, contentType, content]
      properties:
        filename:
          type: string
          maxLength: 255
        contentType:
          type: string
          example: application/pdf
        content:
          type: string
          format: byte
          description: Base64-encoded bytes.
        inline:
          type: boolean
          default: false
          description: When true, attachment is referenced from HTML via `cid:<contentId>`.
        contentId:
          type: string
          description: Required when `inline=true`.
      description: |
        Up to 50 attachments per message, 10 MB total decoded size across all
        attachments. Larger messages are rejected with `attachment_too_large`.

    TemplateSend:
      type: object
      required: [template, to]
      properties:
        template:
          type: string
          description: Template slug to send.
        version:
          type: integer
          minimum: 1
          description: |
            Optional explicit version pin. Defaults to the latest version in
            test mode and the current published version in live mode.
        channel:
          $ref: '#/components/schemas/Channel'
        to:
          type: string
          description: Recipient (email address, phone number, or device token).
        vars:
          type: object
          additionalProperties: true
          description: Variables interpolated into the template.
        metadata:
          $ref: '#/components/schemas/Metadata'
        scheduledAt:
          type: string
          format: date-time
          description: |
            Optional ISO-8601 timestamp to schedule the send for later. Must be
            in the future and within 30 days. When provided, the send is
            accepted with `status: "scheduled"` and dispatched at that time.
        cc:
          type: array
          items:
            type: string
            format: email
          description: Email-only. Cc recipients applied at send time.
        bcc:
          type: array
          items:
            type: string
            format: email
          description: Email-only. Bcc recipients applied at send time.
        replyTo:
          type: string
          format: email
          description: Email-only. Reply-To address applied at send time.
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/Attachment'
          description: Email-only. Per-send attachments — not stored on the template.

    RawEmailSend:
      type: object
      required: [channel, content, to]
      properties:
        channel:
          type: string
          const: email
        to:
          type: string
        from:
          type: string
          format: email
          description: Optional From override. Defaults to the provider connection's address.
        interpolate:
          type: boolean
          default: false
          description: When true, `vars` are interpolated into the inline content.
        content:
          type: object
          required: [subject, html]
          properties:
            subject:
              type: string
            preheader:
              type: string
            html:
              type: string
            text:
              type: string
            cc:
              type: array
              items:
                type: string
                format: email
            bcc:
              type: array
              items:
                type: string
                format: email
            replyTo:
              type: string
              format: email
            attachments:
              type: array
              items:
                $ref: '#/components/schemas/Attachment'
        vars:
          type: object
          additionalProperties: true
        metadata:
          $ref: '#/components/schemas/Metadata'
        scheduledAt:
          type: string
          format: date-time
          description: |
            Optional ISO-8601 timestamp to schedule the send for later. Must be
            in the future and within 30 days. When provided, the send is
            accepted with `status: "scheduled"` and dispatched at that time.

    RawSmsSend:
      type: object
      required: [channel, content, to]
      properties:
        channel:
          type: string
          const: sms
        to:
          type: string
        interpolate:
          type: boolean
          default: false
        content:
          type: object
          required: [body]
          properties:
            body:
              type: string
        vars:
          type: object
          additionalProperties: true
        metadata:
          $ref: '#/components/schemas/Metadata'
        scheduledAt:
          type: string
          format: date-time
          description: |
            Optional ISO-8601 timestamp to schedule the send for later. Must be
            in the future and within 30 days. When provided, the send is
            accepted with `status: "scheduled"` and dispatched at that time.

    RawPushSend:
      type: object
      required: [channel, content, to]
      properties:
        channel:
          type: string
          const: push
        to:
          type: string
        interpolate:
          type: boolean
          default: false
        content:
          type: object
          required: [title, body]
          properties:
            title:
              type: string
            body:
              type: string
            data:
              type: object
              additionalProperties:
                type: string
            badge:
              type: integer
              minimum: 0
            sound:
              type: string
        vars:
          type: object
          additionalProperties: true
        metadata:
          $ref: '#/components/schemas/Metadata'
        scheduledAt:
          type: string
          format: date-time
          description: |
            Optional ISO-8601 timestamp to schedule the send for later. Must be
            in the future and within 30 days. When provided, the send is
            accepted with `status: "scheduled"` and dispatched at that time.

    RawWebPushSend:
      type: object
      required: [channel, content, to]
      properties:
        channel:
          type: string
          const: web-push
        to:
          type: string
          description: |
            JSON-serialized browser `PushSubscription` (the object returned by
            `pushManager.subscribe()`), including `endpoint` and `keys`.
        interpolate:
          type: boolean
          default: false
        content:
          type: object
          required: [title, body]
          properties:
            title:
              type: string
            body:
              type: string
            icon:
              type: string
              description: URL of the notification icon.
            clickUrl:
              type: string
              description: URL opened when the notification is clicked.
            data:
              type: object
              additionalProperties:
                type: string
            badge:
              type: integer
              minimum: 0
        vars:
          type: object
          additionalProperties: true
        metadata:
          $ref: '#/components/schemas/Metadata'
        scheduledAt:
          type: string
          format: date-time
          description: |
            Optional ISO-8601 timestamp to schedule the send for later. Must be
            in the future and within 30 days. When provided, the send is
            accepted with `status: "scheduled"` and dispatched at that time.

    SendResult:
      type: object
      required: [id, status, livemode]
      properties:
        id:
          type: string
          description: Public message ID (e.g. `msg_...`).
          example: msg_0a1b2c3d4e5f6g7h
        status:
          type: string
          enum: [queued, scheduled]
          description: |
            `queued` for an immediate send, or `scheduled` when `scheduledAt`
            was provided on the request.
        livemode:
          type: boolean

    Message:
      type: object
      description: A message row. `vars`, `metadata`, and `timeline` are JSON.
      properties:
        id:
          type: string
          format: uuid
        workspaceId:
          type: string
          format: uuid
        publicId:
          type: string
          example: msg_0a1b2c3d4e5f6g7h
        templateSlug:
          type: [string, "null"]
          description: Null for raw sends.
        channel:
          $ref: '#/components/schemas/Channel'
        status:
          $ref: '#/components/schemas/MessageStatus'
        livemode:
          type: boolean
        recipient:
          type: string
        vars:
          type: object
          additionalProperties: true
        metadata:
          $ref: '#/components/schemas/Metadata'
        fromOverride:
          type: [string, "null"]
        interpolate:
          type: boolean
          description: |
            Raw sends only — whether Mustache interpolation was run over the
            inline content. Always false for template sends.
        pinnedVersion:
          type: [integer, "null"]
          description: |
            Template sends only — the explicit template version pinned by the
            caller, or null when resolved by environment.
        provider:
          type: [string, "null"]
        providerConnectionId:
          type: [string, "null"]
          format: uuid
        providerMessageId:
          type: [string, "null"]
        latencyMs:
          type: [integer, "null"]
        error:
          type: [string, "null"]
        timeline:
          type: array
          items:
            type: object
            properties:
              t:
                type: string
                format: date-time
              e:
                type: string
            additionalProperties: true
        createdAt:
          type: string
          format: date-time
        scheduledAt:
          type: [string, "null"]
          format: date-time
          description: |
            When the send was scheduled for, or null for an immediate send.
        idempotencyKey:
          type: [string, "null"]
          description: |
            The Idempotency-Key supplied on the send request, or null if none
            was provided.

    Context:
      type: object
      required: [workspace, mode]
      properties:
        workspace:
          type: object
          required: [id, slug, name]
          properties:
            id:
              type: string
              format: uuid
            slug:
              type: string
            name:
              type: string
        mode:
          type: string
          enum: [live, test]
          description: |
            The environment the API key operates in. Note this is the string
            `live`/`test`, distinct from the boolean `livemode` field on other
            responses.

    TemplateSummary:
      type: object
      properties:
        slug:
          type: string
        channel:
          $ref: '#/components/schemas/Channel'
        description:
          type: [string, "null"]
        status:
          type: string
          enum: [active, draft, archived]
        updatedAt:
          type: string
          format: date-time

    TemplateDetail:
      type: object
      properties:
        slug:
          type: string
        channel:
          $ref: '#/components/schemas/Channel'
        description:
          type: [string, "null"]
        status:
          type: string
          enum: [active, draft, archived]
        updatedAt:
          type: string
          format: date-time
        currentVersion:
          type: [object, "null"]
          properties:
            versionNumber:
              type: integer
            variables:
              type: array
              items:
                type: object
                required: [name, type]
                properties:
                  name:
                    type: string
                  type:
                    type: string
                    enum: [string, array, object, boolean]
                  description:
                    type: [string, "null"]
                  required:
                    type: boolean
                additionalProperties: true
            publishedAt:
              type: [string, "null"]
              format: date-time

    RenderResult:
      type: object
      required: [channel, output, missing]
      properties:
        channel:
          $ref: '#/components/schemas/Channel'
        output:
          type: object
          description: |
            Rendered fields for the channel. Email -> {subject, preheader, html,
            text}; SMS -> {body}; push -> {title, body, data, badge, sound};
            web push -> {title, body, icon, clickUrl, data, badge}.
          additionalProperties: true
        missing:
          type: array
          description: Variable paths referenced by the template but not supplied.
          items:
            type: string

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine-readable error code.
              example: invalid_request
            message:
              type: string
            issues:
              type: array
              description: Present on validation failures (Zod issues).
              items:
                type: object
                additionalProperties: true
            limit:
              type: integer
              description: Present on rate-limit errors.
