/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.Request Body
Unknown fields are rejected — the schema is strict.
Bodies
At least one ofbody_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:
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.
Choosing an Inbox
Look up usable inboxes with:overall_status of ACTIVE.
Cost
Each send deducts 1 credit from the team balance, recorded asDirect 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
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.
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 nocampaign_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:
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.

