openapi: 3.1.0
info:
  title: Hivsy API
  version: 1.0.0
  summary: Send email, trigger automations, and manage recipient consent.
  description: |
    The public Hivsy integration API uses API keys created in the dashboard.
    Pass the key as a Bearer token on every integration request:

        Authorization: Bearer hsl_live_...

    Test keys (`hsl_test_...`) validate and record sends without delivering
    messages or consuming credits. Dashboard-only endpoints use a browser
    session and are intentionally outside this public contract.
  contact:
    name: Hivsy support
    url: https://www.hivsy.com/docs
externalDocs:
  description: Human-readable API documentation
  url: https://www.hivsy.com/docs
servers:
  - url: https://api.hivsy.com
    description: Production
tags:
  - name: Documentation
  - name: System
  - name: Email
  - name: Automations
  - name: Audiences
security:
  - bearerAuth: []
paths:
  /api/docs:
    get:
      tags: [Documentation]
      operationId: openAPIDocumentation
      summary: Open the human-readable API reference
      security: []
      responses:
        '307':
          description: Redirects to the public documentation page.
  /api/openapi.yaml:
    get:
      tags: [Documentation]
      operationId: getOpenAPISpec
      summary: Download this OpenAPI document
      security: []
      responses:
        '200':
          description: OpenAPI 3.1 document.
          headers:
            Cache-Control:
              schema: { type: string, example: 'public, max-age=300' }
          content:
            application/yaml:
              schema: { type: string }
  /api/status:
    get:
      tags: [System]
      operationId: getSystemStatus
      summary: Check public service health
      description: Returns the current API, database, and outbound-queue storage health.
      security: []
      responses:
        '200':
          description: Current service health. A degraded dependency is represented in the response body.
          content:
            application/json:
              schema:
                type: object
                required: [status, components]
                properties:
                  status:
                    type: string
                    enum: [operational, degraded]
                  components:
                    type: array
                    items:
                      type: object
                      required: [name, status]
                      properties:
                        name: { type: string }
                        status:
                          type: string
                          enum: [operational, degraded]
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/emails:
    post:
      tags: [Email]
      operationId: sendEmail
      summary: Send an email
      description: |
        Queues an email from a verified domain. A request can contain at most
        50 recipients across `to`, `cc`, and `bcc`. One credit is consumed for
        each recipient. Suppressed recipients are rejected before any credit
        is charged.

        Pass `Idempotency-Key` to make retries safe. Reusing a key with the
        same request returns the original email with HTTP 200. Reusing it with
        a different request, or while its credit reservation is still being
        finalized, returns HTTP 409.

        Transient SMTP replies and connection failures are retried for up to
        five days. Permanent SMTP failures stop immediately; only a permanent
        recipient rejection is treated as a hard bounce and suppressed. When
        direct delivery spans multiple recipient domains, a retry excludes
        recipients whose destination already accepted the message.

        The complete JSON request body, including base64 attachment expansion,
        must be no larger than 10 MiB.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        $ref: '#/components/requestBodies/SendEmail'
      responses:
        '200':
          description: Idempotent replay of an earlier accepted request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendEmailReplay'
        '202':
          description: Accepted for delivery, or recorded immediately by a test key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendEmailAccepted'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '423': { $ref: '#/components/responses/SendingLocked' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /emails:
    post:
      tags: [Email]
      operationId: sendEmailResendCompatible
      summary: Send an email using the Resend-compatible alias
      description: |
        Alias of `POST /api/emails`. It accepts the same authentication,
        request body, idempotency header, and returns the same response.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        $ref: '#/components/requestBodies/SendEmail'
      responses:
        '200':
          description: Idempotent replay of an earlier accepted request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendEmailReplay'
        '202':
          description: Accepted for delivery, or recorded immediately by a test key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendEmailAccepted'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '423': { $ref: '#/components/responses/SendingLocked' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /api/events:
    post:
      tags: [Automations]
      operationId: ingestAutomationEvent
      summary: Trigger matching automations
      description: |
        Records an application event and synchronously creates runs for every
        active automation whose trigger matches `name`. An automation can only
        send when `contact_email` is present; otherwise its run is recorded as
        failed with a diagnostic. Use `Idempotency-Key` when retrying event
        ingestion so the original event and automation runs are reused. The
        complete request body is limited to 1 MiB.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AutomationEventRequest'
            examples:
              signup:
                value:
                  name: user.signed_up
                  contact_email: ada@example.com
                  data:
                    first_name: Ada
                    plan: starter
      responses:
        '202':
          description: Event accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AutomationEventAccepted'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /api/unsubscribe:
    post:
      tags: [Audiences]
      operationId: unsubscribeContact
      summary: Unsubscribe a contact
      description: |
        Persists an opt-out even when no matching contact exists yet, so later
        imports cannot silently resubscribe the address. Omit `audience_id` for
        an account-wide suppression, or provide it for a list-specific opt-out.
        The complete request body is limited to 16 KiB.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UnsubscribeRequest'
      responses:
        '200':
          description: Contact subscription state updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnsubscribeResult'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Hivsy API key
      description: API key from the dashboard (`hsl_live_...` or `hsl_test_...`).
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      description: A unique key for one logical send or event ingestion. Maximum 256 characters.
      schema:
        type: string
        maxLength: 256
  requestBodies:
    SendEmail:
      required: true
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/SendEmailRequest'
          examples:
            direct:
              value:
                from: Hivsy <hello@example.com>
                to: [ada@example.net]
                subject: Welcome
                html: '<p>Hello, Ada!</p>'
                text: Hello, Ada!
            template:
              value:
                from: hello@example.com
                to: [ada@example.net]
                template_id: 0d10440d-8b87-4f3f-bc39-53aa45e6609f
                variables:
                  first_name: Ada
                scheduled_at: '2027-01-15T15:00:00Z'
  schemas:
    SendEmailRequest:
      type: object
      additionalProperties: false
      required: [from, to]
      properties:
        from:
          type: string
          description: Sender on a verified domain. A display name is allowed.
          example: Hivsy <hello@example.com>
        to:
          type: array
          minItems: 1
          maxItems: 50
          uniqueItems: true
          items: { type: string, format: email }
        cc:
          type: array
          uniqueItems: true
          items: { type: string, format: email }
        bcc:
          type: array
          uniqueItems: true
          items: { type: string, format: email }
        reply_to:
          type: string
          format: email
        subject:
          type: string
          maxLength: 998
          description: Required unless supplied by `template_id`.
        html:
          type: string
          maxLength: 1048576
          description: HTML body. Required unless text or a template body is supplied.
        text:
          type: string
          maxLength: 102400
          description: Plain-text body. Required unless HTML or a template body is supplied.
        headers:
          type: object
          maxProperties: 20
          propertyNames:
            pattern: '^[Xx]-[A-Za-z0-9-]+$'
          additionalProperties:
            type: string
          description: Custom X-* headers. Each name/value pair is limited to 1 KiB combined.
        attachments:
          type: array
          maxItems: 10
          description: The complete JSON request, including base64 expansion, is limited to 10 MiB.
          items:
            $ref: '#/components/schemas/Attachment'
        template_id:
          type: string
          format: uuid
          description: Saved template owned by the API-key account.
        variables:
          type: object
          additionalProperties: { type: string }
          description: Values for exact `{{key}}` placeholders. HTML values are escaped.
        scheduled_at:
          type: string
          format: date-time
          description: Future RFC 3339 time, no more than one year ahead.
    Attachment:
      type: object
      additionalProperties: false
      required: [filename, content]
      properties:
        filename:
          type: string
          maxLength: 255
          description: File name of at most 255 UTF-8 bytes, without path separators or control characters.
        content_type:
          type: string
          description: Optional valid MIME media type.
          example: application/pdf
        content:
          type: string
          format: byte
          description: Base64-encoded content, up to 5 MiB decoded.
    SendEmailAccepted:
      type: object
      additionalProperties: false
      required: [id, status]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, sent]
          description: Test keys return `sent`; live keys normally return `queued`.
    SendEmailReplay:
      type: object
      additionalProperties: false
      required: [id, status, idempotent_replay]
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [pending_credits, queued, sending, sent, failed]
        idempotent_replay: { type: boolean, const: true }
    AutomationEventRequest:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 128
          example: user.signed_up
        contact_email:
          type: string
          format: email
          description: Recipient for automations triggered by this event.
        data:
          type: object
          additionalProperties: true
          description: Application-defined event metadata. The complete request is limited to 1 MiB.
    AutomationEventAccepted:
      type: object
      additionalProperties: false
      required: [event_id, automations_triggered]
      properties:
        event_id: { type: string, format: uuid }
        automations_triggered: { type: integer, minimum: 0 }
    UnsubscribeRequest:
      type: object
      additionalProperties: false
      required: [email]
      properties:
        email: { type: string, format: email }
        audience_id:
          type: string
          format: uuid
          description: Restrict the update to one owned audience.
    UnsubscribeResult:
      type: object
      additionalProperties: false
      required: [unsubscribed]
      properties:
        unsubscribed:
          type: integer
          format: int64
          minimum: 0
          description: Number of currently matching contacts marked unsubscribed; the durable opt-out is stored even when this is zero.
    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error: { type: string }
    WebhookEnvelope:
      type: object
      additionalProperties: false
      required: [type, created_at, data]
      properties:
        type:
          type: string
          enum:
            - email.received
            - email.sent
            - email.failed
            - email.delivery_delayed
            - email.opened
            - email.clicked
            - email.suppressed
        created_at: { type: string, format: date-time }
        data:
          type: object
          additionalProperties: true
  responses:
    BadRequest:
      description: Invalid JSON, parameters, recipients, or suppressed recipient.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key.
      headers:
        WWW-Authenticate:
          schema: { type: string, example: 'Bearer realm="hivsy"' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PaymentRequired:
      description: Insufficient credits.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: A domain-scoped key cannot send from the requested domain.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Referenced template or audience not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    IdempotencyConflict:
      description: The key belongs to a different request, or its original request is still being finalized.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PayloadTooLarge:
      description: The complete JSON request body exceeds that endpoint's documented size limit.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    SendingLocked:
      description: Sending is paused for this account.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded. Public API-key endpoints allow 300 requests per minute per key.
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
webhooks:
  emailEvent:
    post:
      summary: Email lifecycle event
      description: |
        Hivsy posts email lifecycle events to endpoints configured in the
        dashboard. Currently emitted types are `email.received`, `email.sent`,
        `email.failed`, `email.delivery_delayed`, `email.opened`,
        `email.clicked`, and `email.suppressed`.

        Hivsy retries non-2xx responses and connection failures up to ten times.
      parameters:
        - in: header
          name: Webhook-Signature
          required: true
          description: '`sha256=` followed by the lowercase hex HMAC-SHA256 of the raw request body.'
          schema:
            type: string
            pattern: '^sha256=[a-f0-9]{64}$'
        - in: header
          name: X-Webhook-Signature
          required: false
          deprecated: true
          description: Backward-compatible alias of `Webhook-Signature`.
          schema:
            type: string
            pattern: '^sha256=[a-f0-9]{64}$'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEnvelope'
      responses:
        '200':
          description: Return any 2xx response to acknowledge delivery.
