> ## Documentation Index
> Fetch the complete documentation index at: https://docs.12m.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Send a message

> Queue a new conversation or a reply. Idempotent on Idempotency-Key.

Every send is **queued** — we accept the request, classify the recipient,
pin (or race) a backing mailbox, and the dispatcher delivers from there.
The response carries a `pendingId` you can correlate with the downstream
`email.sent` webhook (or `email.send_failed_permanently` on a hard fail).

The body has two shapes — **new conversation** or **reply** — that are
mutually exclusive. New conversations need `to` and `subject`; replies
need `convId` and we resolve recipient, subject (with `Re:`), threading
headers, and the previously-pinned mailbox from the existing thread.

`text` and `html` can both be set; recipients see the multipart message
their client prefers. Most production senders set both.

## Send classification

The response includes `sendClass`, which tells you how we routed:

* **`cold_first_contact`** — first message ever from this identity to
  this recipient. We pace these with the identity's drip schedule and
  honor the working-hours window. The first available mailbox to claim
  the row owns the recipient from then on; subsequent sends use the
  same mailbox.

* **`cold_followup`** — second-or-later cold message to a recipient
  who hasn't replied in the last 3 outbounds. Same drip + working
  hours as a first contact, but pinned to a known mailbox.

* **`warm`** — replying to a recipient who responded within their last
  3 outbounds. Bypasses drip pacing and the working-hours window —
  responsive recipients get answered as fast as the dispatcher fires.

`pinnedAccountId` is the backing mailbox id (only meaningful for
`cold_followup` and `warm`; null on `cold_first_contact` until a
mailbox claims the row).

## Labels

Label the conversation at queue time by passing `labels: [...]`.
Idempotent on the conv (re-labeling an already-labeled conv is a
no-op), so sequencer flows can pass the same campaign label on every
step.

```json theme={null}
{
  "to": "morgan@northwindrobotics.com",
  "subject": "Quick intro",
  "text": "...",
  "labels": ["campaign:Q4-launch", "priority:hot"]
}
```

The per-label rollup analytics (sent / replied / positive / bounced)
pivot off these labels — see `GET /identities/:id/labels/stats`.
Free-form strings; we recommend a `key:value` convention so labels
filter cleanly on prefix.

## No-reply nudges

Pass `noReplyEventAfter` (duration string `"1d"` / `"4h"` / `"30m"` or a
raw number of milliseconds) to schedule an `email.no_reply` webhook +
event when no inbound lands on the conversation within that window.
Default: 1 day. Floor: 60 seconds.

## Idempotency

Pass `Idempotency-Key` to make retries safe. Same key with the same
body returns the original response (header `Idempotent-Replayed:
true`); same key with a different body returns `409`. Keys live for 24
hours, scoped per-org.

For sequencers the right key is `<leadId>:<step>` — if your worker
crashes after we accepted the send but before your DB recorded it, the
retry returns the same response and you don't double-send.

## Stitching to external threads

If the conversation started outside 12m (an imported CRM history,
forwarded mail), pass `inReplyTo` and `references` on the new-conv
shape. We thread the new send onto the external chain.

```json theme={null}
{
  "to":         "morgan@northwindrobotics.com",
  "subject":    "Re: Fleet rotation",
  "text":       "Following up...",
  "inReplyTo":  "<original-message-id@northwindrobotics.com>",
  "references": ["<original-message-id@northwindrobotics.com>"]
}
```

## Status codes

* **`202 Accepted`** — at least one row was queued. The body's
  `results[]` array tells you per-recipient outcome.
* **`429 Too Many Requests`** — every recipient on the request was
  rejected (`do_not_contact`, `no_eligible_pool`, `inactive`,
  `suspended`). Inspect `results[i].reason` to discriminate.
* **`409 Conflict`** — `Idempotency-Key` reused with a different
  request body.

## What we don't support yet

* **Scheduled sends.** No `sendAt`. We pace cold sends via the identity
  scheduling config (working hours, drip interval); for one-shot future
  sends, run a scheduler in your own infra and call at fire time.
* **Attachments.** Not supported.
* **Custom headers.** No way to set `List-Unsubscribe` /
  `List-Unsubscribe-Post` today. High-priority because of bulk-sender
  requirements; we'll ship it.


## OpenAPI

````yaml POST /v1/identities/{handle}/send
openapi: 3.1.0
info:
  title: inboxbase.ai API
  version: 1.0.0
  description: >-
    Managed email infrastructure for outbound. Send, reply, and read threads
    through one API; we run the mailbox pool, rotation, warmup, and
    deliverability under the hood.
  contact:
    name: inboxbase.ai support
    email: support@inboxbase.ai
    url: https://inboxbase.ai
servers:
  - url: https://api.inboxbase.ai
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Send
    description: Sending email.
  - name: Conversations
    description: Reading threads.
  - name: Events
    description: Pull-mode event stream.
paths:
  /v1/identities/{handle}/send:
    post:
      tags:
        - Send
      summary: Send a message
      description: >-
        Send a new conversation by passing `to` + `subject`, or reply to an
        existing conversation by passing `convId`. The two modes are mutually
        exclusive. Pass `Idempotency-Key` to make retries safe.
      operationId: sendMessage
      parameters:
        - $ref: '#/components/parameters/Handle'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/SendNewBody'
                - $ref: '#/components/schemas/SendReplyBody'
            examples:
              newConversation:
                summary: New conversation
                value:
                  to: morgan@northwindrobotics.com
                  subject: Quick intro — fleet rotation
                  text: Hi Morgan, ...
                  noReplyEventAfter: 4h
              reply:
                summary: Reply to existing thread
                value:
                  convId: conv_dd6e7130674645d3
                  text: Friday at 10 works.
      responses:
        '202':
          description: At least one recipient accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  parameters:
    Handle:
      name: handle
      in: path
      required: true
      description: Identity handle, URL-encoded.
      schema:
        type: string
        example: alice.acme@inboxbase.ai
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: >-
        Up to 255 characters. Same key with same body returns the original
        response (with `Idempotent-Replayed: true`); same key with a different
        body returns `409`. Keys live 24 hours, scoped per-org.
      schema:
        type: string
        maxLength: 255
  schemas:
    SendNewBody:
      type: object
      required:
        - to
        - subject
      properties:
        to:
          description: >-
            Recipient address. Pass an array to send to multiple recipients;
            each becomes its own conversation.
          oneOf:
            - type: string
              format: email
            - type: array
              items:
                type: string
                format: email
        subject:
          type: string
          minLength: 1
          description: Subject line.
        text:
          type: string
          description: Plain-text body. At least one of `text` / `html` is required.
        html:
          type: string
          description: HTML body.
        inReplyTo:
          type: string
          description: '`Message-ID` of an external message you''re stitching to.'
        references:
          type: array
          items:
            type: string
          description: Cumulative References chain for stitching to an external thread.
        noReplyEventAfter:
          $ref: '#/components/schemas/DurationOrMs'
          default: 1d
        labels:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 120
          maxItems: 24
          description: >-
            Conversation tags applied at queue time (e.g.
            `["campaign:Q4-launch", "priority:hot"]`). Idempotent — a tag
            already on the conv is a no-op. Pivot label-rollup analytics off
            these via `/identities/:id/labels/stats`.
    SendReplyBody:
      type: object
      required:
        - convId
      properties:
        convId:
          type: string
          pattern: ^conv_[0-9a-f]+$
          description: Conversation id from a prior send response or webhook.
        text:
          type: string
        html:
          type: string
        noReplyEventAfter:
          $ref: '#/components/schemas/DurationOrMs'
          default: 1d
        labels:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 120
          maxItems: 24
          description: >-
            Conversation tags applied at queue time. Idempotent on labels
            already present on this conv.
    SendResponse:
      type: object
      required:
        - status
        - identity
        - queued
        - rejected
        - results
      description: >-
        Queue-first send response. The dispatcher delivers each row
        asynchronously; correlate `pendingId` with the downstream `email.sent`
        webhook to know when delivery actually fired.
      properties:
        status:
          type: string
          enum:
            - queued
            - rejected
          description: >-
            `queued` if at least one recipient queued; `rejected` if the whole
            batch was rejected.
        identity:
          type: string
          description: Identity handle echoed back.
        queued:
          type: integer
          description: Count of recipients successfully queued.
        rejected:
          type: integer
          description: Count of recipients rejected.
        results:
          type: array
          items:
            $ref: '#/components/schemas/SendResultRow'
    DurationOrMs:
      description: >-
        A duration string (`"4h"`, `"3 days"`, `"90 minutes"`, `"1.5h"`) or a
        raw number interpreted as milliseconds. Floor 1 minute.
      oneOf:
        - type: string
          examples:
            - 4h
            - 1d
            - 3 days
            - 90 minutes
        - type: integer
          format: int64
          minimum: 60000
    SendResultRow:
      type: object
      required:
        - to
        - status
      properties:
        to:
          type: string
          format: email
        status:
          type: string
          enum:
            - queued
            - rejected
        pendingId:
          type: string
          pattern: ^psn_[0-9a-f]+$
          description: >-
            Stable id for this queued send. Carried on the downstream
            `email.sent` event.
        sendClass:
          type: string
          enum:
            - cold_first_contact
            - cold_followup
            - warm
          description: >-
            How we routed this send. Cold rows respect drip pacing + working
            hours; warm bypasses both.
        pinnedAccountId:
          type: string
          nullable: true
          description: >-
            Backing mailbox id. Null on `cold_first_contact` until a mailbox
            claims the row.
        dispatchAt:
          type: integer
          format: int64
          description: >-
            Best-estimate dispatch time (Unix ms). Cold rows respect the
            working-hours window; warm dispatches now.
        dispatchAtIso:
          type: string
          format: date-time
        convId:
          type: string
          pattern: ^conv_[0-9a-f]+$
          description: >-
            Pre-minted at queue time. Always present on a queued row — persist
            alongside your sequencer state immediately, no waiting for
            `email.sent`. Two scheduleSends to the same (recipient, subject)
            before either dispatches share this id.
        reason:
          type: string
          enum:
            - do_not_contact
            - no_eligible_pool
            - inactive
            - suspended
            - invalid_recipient
          description: Present when `status = "rejected"`.
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: string
          description: Short machine-friendly error code.
        details:
          type: object
          additionalProperties: true
          description: Optional structured context.
  responses:
    ValidationError:
      description: >-
        Validation error: bad body shape, mutually-exclusive fields, malformed
        Idempotency-Key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Identity or conversation not in this org.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    IdempotencyConflict:
      description: '`Idempotency-Key` reused with a different body.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Daily cap exhausted, or all backing mailboxes capped/unhealthy.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sk_live_...

````