POST /api/v1/emails
Compose an email to one contact. The email is saved as a DRAFT and grouped under a campaign. Requires the write:emails scope (or *).
Draft-first
This call never sends. Review the draft at its reviewUrl, or release it with POST /api/v1/emails/:id/send.
Request
POST /api/v1/emails HTTP/1.1
Host: agentlyleads.com
Authorization: Bearer alk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"contactExternalId": "hs_contact_123",
"subject": "Pickleball sets, 15,830 sets, in Los Angeles",
"bodyHtml": "<p>{{firstName}},</p><p>Branded pickleball sets already in the US...</p>",
"campaign": "DEAL-1995 Pickleball outreach",
"from": "kevin@example.com",
"contactContext": "Pitched pickleball 2026-09-08 at $5.00. Next: his volume."
}Body
| Field | Description |
|---|---|
contactId or contactExternalId | The recipient. One is required. The To address is always the contact's own email on file, never a caller-supplied address. |
subject | Required. {{firstName}} and {{company}} are replaced at send time. |
bodyHtml | Required. Email-client-safe HTML: inline styles only, no <style> blocks or classes. Same tokens as subject. |
campaign | A short title to group related emails under. Reused if it exists. Omit to file under the standing "Direct Outreach" campaign. |
from | A sending identity from GET /api/v1/emails/identities. Any other address is rejected with 400. |
offeringId | Link the email to an offering so it shows in that offering's communication view. |
contactContext | A compact rolling summary saved on the contact (aiContext, 4,000 characters max). Merge the existing context with what this email did and the next step. |
attachments | Up to 10. Each has fileName and exactly one of contentBase64 (10 MB decoded max) or a public https url the server downloads. |
asUserEmail | Act as this workspace member. Defaults to the workspace owner. |
Response — 201
{
"email": {
"id": "cmtsv6ts404hzii01dnmu4k5t",
"status": "DRAFT",
"direction": "OUTBOUND",
"subject": "Pickleball sets, 15,830 sets, in Los Angeles",
"toEmail": "jane@buyer.test",
"fromEmail": "kevin@example.com",
"contactId": "cmr8dlczc00xkic01prvioybz",
"campaignId": "cmtsv6tr704hxii01xyz",
"campaign": { "id": "cmtsv6tr704hxii01xyz", "name": "DEAL-1995 Pickleball outreach" },
"attachmentCount": 0,
"reviewUrl": "https://agentlyleads.com/emails/cmtsv6ts404hzii01dnmu4k5t",
"createdAt": "2026-09-08T16:16:05.256Z"
},
"contact": { "id": "cmr8dlczc00xkic01prvioybz", "name": "Jane Buyer", "email": "jane@buyer.test" },
"contextSaved": true,
"otherPendingDrafts": []
}otherPendingDrafts lists up to five other drafts already waiting on the same contact, so a superseded draft is not left behind to be sent by mistake. Delete one with DELETE /api/v1/emails/:id.
Reply in a thread
To continue an existing conversation instead of starting a new one, use POST /api/v1/emails/:id/reply with the id of the email being answered. The reply is drafted to that email's contact, with a Re: subject, threaded onto the original. The sender does not have to be in your CRM: with no contact, the reply goes to the email's lead, and with neither, to the address the email came from. That reply is not filed on any record, so contact and lead come back null.
POST /api/v1/emails/cmtsy2o2v05peii01g2ebysb0/reply HTTP/1.1
Authorization: Bearer alk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{ "bodyHtml": "<p>Fair enough. What does the take-all need to be a pack?</p>" }Returns 201 with the same email object plus inReplyTo.