Skip to main content
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.
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.
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 and email_bounced webhook events.

Request Body

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:
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:
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.
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.

Choosing an Inbox

Look up usable inboxes with:
The inbox must be owned by your team and have overall_status of ACTIVE.
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.

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
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:
The sources are checked in that order, so a contact who is both unsubscribed and blocklisted reports unsubscribed.
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.
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.

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_ 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:
See Replies for the full thread and message surface, including sending a follow-up reply into the same conversation.

Status Codes

Full payload shapes are in Error Codes.

Next Steps

Contacts Overview

Search, import, tag and segment the directory.

Replies

Read and answer the conversation a direct email starts.

Webhook Events

Get delivery, bounce and reply outcomes pushed to you.

Error Codes

402 and 409 payload shapes in full.