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

# Direct Email

> Send a personal 1:1 email to a contact with no campaign behind it.

**POST** `/api/public/v1/contacts/{contact_id}/send-email`

Compose and send a personal email straight to a directory contact. No campaign, no sequence, no follow-ups — just one message from one of your inboxes.

<Info>
  Requires the `contacts:send_email` scope. This is the **only public endpoint that spends credits**, and therefore the only one that can return `402 Payment Required`. See [API Scopes](/authentication/scopes).
</Info>

```python theme={null}
response = requests.post(
    f"{base_url}/api/public/v1/contacts/{contact_id}/send-email",
    headers={"X-API-Key": api_key},
    json={
        "inbox_id": "650e8400-e29b-41d4-a716-446655440000",
        "subject": "Following up on our call",
        "body_text": "Hi Alice -- sending over the notes we discussed.\n\nBest,\nJohn"
    }
)

print(response.json())
```

```json theme={null}
{
  "success": true,
  "message": "Email queued successfully",
  "inbox_id": "650e8400-e29b-41d4-a716-446655440000",
  "inbox_email": "john@yourdomain.com",
  "recipient_email": "alice@prospect.com",
  "message_id": "<direct_1a2b3c4d@yourdomain.com>",
  "thread_id": "7a0e8400-e29b-41d4-a716-446655440000",
  "credits_remaining": 1499
}
```

<Warning>
  The send is **queued**, not awaited. A `200` means the message was accepted, charged and handed to the sending worker — it does not mean it was delivered. Delivery and bounce outcomes arrive later as [`email_delivered`](/webhooks/event-types) and `email_bounced` webhook events.
</Warning>

## Request Body

| Field | Type | Required | Description |
| - | - | - | - |
| `inbox_id` | UUID | Yes | Team inbox to send from. Must be `ACTIVE` and have a sender address. |
| `subject` | string | Yes | Max 500 characters. Whitespace-only is rejected with a 422. |
| `body_text` | string | No | Plain-text body. |
| `body_html` | string | No | HTML body. |
| `cc` | array | No | CC recipients. |
| `bcc` | array | No | BCC recipients. |
| `attachments` | array | No | Base64-encoded files, at most 5. |

Unknown fields are rejected — the schema is strict.

### Bodies

At least one of `body_text` or `body_html` must be present. Neither is required *individually*, so the rule is enforced by the send service rather than the schema and surfaces as a **400**, not a 422:

```json theme={null}
{"detail": "At least one of body_text or body_html must be provided"}
```

If you send only `body_html`, a plain-text version is derived from it automatically. Empty HTML paragraphs are normalised before sending.

A subject that is empty after stripping is rejected at the schema level, but the service substitutes `(no subject)` for a subject that is present-and-blank in any other path — so always send a real subject.

### Attachments

Each attachment is an object:

```json theme={null}
{
  "attachments": [
    {
      "filename": "proposal.pdf",
      "content_base64": "JVBERi0xLjQK...",
      "content_type": "application/pdf"
    }
  ]
}
```

`content_type` defaults to `application/octet-stream`. Files are uploaded to object storage during the request and referenced by URL downstream, so they never sit in the send queue as base64.

<Warning>
  The **5-file cap is enforced** and returns a 422. Total payload size is **not** validated by the API — the practical ceiling is whatever your sending provider accepts for a single message. Base64 inflates a file by roughly a third, so a 10 MB attachment arrives as about 13 MB of request body.
</Warning>

## Choosing an Inbox

Look up usable inboxes with:

```python theme={null}
response = requests.get(
    f"{base_url}/api/public/v1/sender-accounts",
    headers={"X-API-Key": api_key},
    params={"status": "ACTIVE"}
)

inbox_id = response.json()["data"][0]["inbox_id"]
```

The inbox must be owned by your team and have `overall_status` of `ACTIVE`.

<Warning>
  **The two endpoints scope differently.** `GET /sender-accounts` filters to your API key's *workspace*; inbox resolution on the send endpoint checks *team* ownership. An API key pinned to one workspace can therefore send from an inbox that the sender-accounts list does not show, and conversely may not see every inbox it is allowed to use. Prefer an inbox that appears in both.
</Warning>

| Failure | Status | `detail` |
| - | - | - |
| Inbox does not exist, or belongs to another team | `404` | `Inbox not found or access denied` |
| Inbox has no sender address | `400` | `Inbox is missing a sender email address` |
| Inbox is not `ACTIVE` | `400` | `Inbox is not active` |

## Cost

Each send deducts **1 credit** from the team balance, recorded as `Direct email to <recipient>`. `credits_remaining` in the response is the balance after the charge.

The deduction and the send are committed together, so a failure during orchestration rolls the credit back with everything else. If the request fails *after* the commit — the queue publish is outside the transaction — the credit is refunded in a separate transaction.

When the team is out of credits:

**Status:** `402 Payment Required`

```json theme={null}
{
  "detail": {
    "message": "Insufficient credits to send this email",
    "required": 1,
    "available": 0
  }
}
```

Nothing is sent and nothing is charged.

## Suppression

Before any credit is touched, the contact is checked against three suppression sources. A suppressed contact returns **409 Conflict** and costs nothing:

```json theme={null}
{
  "detail": {
    "message": "This contact cannot be emailed",
    "reason": "blocklist",
    "block_type": "DOMAIN",
    "blocked_value": "prospect.com",
    "email": "alice@prospect.com"
  }
}
```

| `reason` | Means | `block_type` / `blocked_value` |
| - | - | - |
| `unsubscribed` | The contact unsubscribed | both `null` |
| `bounced` | A previous send hard-bounced | both `null` |
| `blocklist` | The address or its domain is on your team blocklist | `EMAIL` or `DOMAIN`, and the matching entry |

The sources are checked in that order, so a contact who is both unsubscribed *and* blocklisted reports `unsubscribed`.

<Tip>
  Check before you send with `GET /contacts/{contact_id}` — the detail response carries `contact.is_suppressed` and `contact.suppression_reason`, computed on read from exactly the same three sources the send gate uses.
</Tip>

<Info>
  Suppression is **email-keyed and team-wide**, not per-campaign. Deleting a contact does not clear an unsubscribe, and re-importing the address will not make it sendable.
</Info>

## How Replies Thread

A direct email has no campaign to hang a conversation on, so the first send to a contact creates a **campaign-less external anchor**: a lead row with no `campaign_id`, flagged external, plus one conversation thread. Every subsequent direct email to that contact — and every reply — consolidates into the same conversation.

Because the anchor is external and campaign-less, an inbound reply:

* appears in your unibox as an ordinary conversation;
* does **not** change any campaign's status, counts or follow-ups;
* does **not** flip any campaign lead to `REPLIED`, even if the same address is also a lead in a live campaign.

### `message_id`

```
<direct_1a2b3c4d@yourdomain.com>
```

`direct_` followed by the first 8 hex characters of the anchor's ID, at your inbox's domain — the inbox's managed domain if it has one, otherwise the mailbox address's own domain. Reply ingestion resolves that identifier on its own, with no campaign filter, which is precisely what keeps a direct conversation separate from a campaign conversation with the same person.

### `thread_id`

A UUID. Pass it straight to the conversation endpoints:

```python theme={null}
messages = requests.get(
    f"{base_url}/api/public/v1/replies/threads/{thread_id}/messages",
    headers={"X-API-Key": api_key}
).json()
```

See [Replies](/replies/overview) for the full thread and message surface, including sending a follow-up reply into the same conversation.

## Status Codes

| Status | Cause |
| - | - |
| `200` | Accepted, charged and queued |
| `400` | Inbox unusable, or neither body field provided |
| `401` | Missing or invalid API key |
| `402` | Team out of credits |
| `403` | Key lacks `contacts:send_email` |
| `404` | Contact not in your team, or inbox not found |
| `409` | Contact is suppressed |
| `422` | Blank subject, more than 5 attachments, or an unknown field |
| `500` | Unexpected server error |

Full payload shapes are in [Error Codes](/reference/errors#direct-email-errors).

## Next Steps

<CardGroup cols={2}>
  <Card title="Contacts Overview" icon="users" href="/contacts/overview">
    Search, import, tag and segment the directory.
  </Card>

  <Card title="Replies" icon="message-circle" href="/replies/overview">
    Read and answer the conversation a direct email starts.
  </Card>

  <Card title="Webhook Events" icon="zap" href="/webhooks/event-types">
    Get delivery, bounce and reply outcomes pushed to you.
  </Card>

  <Card title="Error Codes" icon="alert-triangle" href="/reference/errors">
    402 and 409 payload shapes in full.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.