Skip to main content

Endpoints

GET    /v1/emails
POST   /v1/emails
GET    /v1/emails/{emailId}
PATCH  /v1/emails/{emailId}
DELETE /v1/emails/{emailId}
GET    /v1/emails/{emailId}/content
GET    /v1/emails/{emailId}/plain-text
PUT    /v1/emails/{emailId}/plain-text
DELETE /v1/emails/{emailId}/plain-text
POST   /v1/emails/{emailId}/messages
POST   /v1/emails/{emailId}/save-code
POST   /v1/emails/{emailId}/approve
POST   /v1/emails/{emailId}/unapprove
GET    /v1/emails/{emailId}/generations/{generationId}
GET    /v1/emails/{emailId}/generations/{generationId}/content
GET    /v1/emails/{emailId}/generations/{generationId}/plain-text
Emails are the reusable content assets that blasts and triggered flows send. Each email starts as a draft, accumulates versions as you iterate, and must be approved before a blast or flow can use it.
AI generation is asynchronous. Creating with a prompt or posting a message returns 202 Accepted with a generation_id, and you poll the generation endpoint to track progress. Only one generation can run per email at a time.

Idempotency

POST /v1/emails, POST /v1/emails/{emailId}/messages, and POST /v1/emails/{emailId}/save-code accept an optional Idempotency-Key header. Retrying with the same key replays the stored result instead of creating a duplicate. If a request with the same key is still being processed, the API returns 409 Conflict with error_code: "idempotency_in_progress".

Create an Email

POST /v1/emails

Request Body

FieldTypeRequiredNotes
namestringYesInternal display name, 1-200 characters. Not shown to recipients
promptstringNoAI brief, 1-10000 characters. When present, content is generated asynchronously
subjectstringNoMaximum 998 characters. Must be set before approval
preview_textstring | nullNoInbox preheader, maximum 200 characters
from_namestring | nullNoMaximum 120 characters. Must be set before approval
from_addressstring | nullNoMust be set before approval; sending requires a verified domain
reply_to_emailstring | nullNoMust be set before approval
Omit prompt to create a blank draft scaffolded from your workspace branding (201 Created). Include prompt to queue an AI generation (202 Accepted).

Request Example

curl --request POST \
  --url https://api.tented.ai/v1/emails \
  --header "Authorization: Bearer $TENTED_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Welcome email",
    "prompt": "A warm welcome email for new Acme Analytics signups with a CTA to book a demo",
    "subject": "Welcome to Acme"
  }'

Response Example

202 Accepted
{
  "email_id": "f11ef3cf-8664-4fe5-a261-c5b4d647b7d1",
  "generation_id": "01JZ9GLYFA4L4Y9CBM4H31TT8V",
  "message_id": "01JZ9GLYFA6H2T0N8W1QG64M3E",
  "status": "generating"
}

Poll a Generation

GET /v1/emails/{emailId}/generations/{generationId}
Generation status moves through generating to completed or failed. Completed generations include the version they produced and paths to their content: 200 OK
{
  "generation_id": "01JZ9GLYFA4L4Y9CBM4H31TT8V",
  "status": "completed",
  "type": "iteration",
  "version": 2,
  "content_path": "/v1/emails/f11ef3cf-8664-4fe5-a261-c5b4d647b7d1/generations/01JZ9GLYFA4L4Y9CBM4H31TT8V/content",
  "plain_text_path": "/v1/emails/f11ef3cf-8664-4fe5-a261-c5b4d647b7d1/generations/01JZ9GLYFA4L4Y9CBM4H31TT8V/plain-text",
  "plain_text_source": "auto",
  "created_at": "2026-07-01T12:00:00.000Z",
  "completed_at": "2026-07-01T12:00:41.000Z"
}
Failed generations return error_code: "generation_failed" and an error_message instead.

Retrieve an Email

GET /v1/emails/{emailId}
200 OK
{
  "email_id": "f11ef3cf-8664-4fe5-a261-c5b4d647b7d1",
  "name": "Welcome email",
  "status": "draft",
  "subject": "Welcome to Acme",
  "preview_text": null,
  "from_name": "Acme",
  "from_address": "hello@acme.com",
  "reply_to_email": "support@acme.com",
  "current_version": 2,
  "approved_version": null,
  "number_of_iterations": 2,
  "plain_text_overridden": false,
  "plain_text_overridden_at": null,
  "plain_text_stale_after_html_iteration": false,
  "created_at": "2026-07-01T12:00:00.000Z",
  "updated_at": "2026-07-01T12:00:41.000Z",
  "created_by_name": "tented-api",
  "latest_generation_status": "completed",
  "content_path": "/v1/emails/f11ef3cf-8664-4fe5-a261-c5b4d647b7d1/content",
  "plain_text_path": "/v1/emails/f11ef3cf-8664-4fe5-a261-c5b4d647b7d1/plain-text"
}
status is draft or approved. Creating a blank draft (201 Created) and metadata updates return this same shape.

Read Content

GET /v1/emails/{emailId}/content
GET /v1/emails/{emailId}/generations/{generationId}/content
Returns the HTML of the latest completed version (or of one specific generation) with a text/html content type — not JSON.

Plain Text

GET    /v1/emails/{emailId}/plain-text
PUT    /v1/emails/{emailId}/plain-text
DELETE /v1/emails/{emailId}/plain-text
Every completed generation carries a plain-text alternative derived from its HTML. GET returns it; PUT overrides it with your own text; DELETE reverts to the auto-derived version. All three return the same shape: 200 OK
{
  "email_id": "f11ef3cf-8664-4fe5-a261-c5b4d647b7d1",
  "generation_id": "01JZ9GLYFA4L4Y9CBM4H31TT8V",
  "version": 2,
  "content": "Welcome to Acme...",
  "overridden": true,
  "stale_after_html_iteration": false,
  "plain_text_source": "override"
}

PUT Request Body

FieldTypeRequiredNotes
contentstringYesPlain-text body, up to 1 MB. An empty string clears it
PUT and DELETE require a completed generation to exist, otherwise they return 400 Bad Request. stale_after_html_iteration flips to true when the HTML is iterated after an override — a signal to review or revert your custom plain text. The generation-scoped GET /v1/emails/{emailId}/generations/{generationId}/plain-text returns the same shape without the override flags.

Iterate With AI

POST /v1/emails/{emailId}/messages
FieldTypeRequiredNotes
promptstringYesEdit instruction against the current HTML, 1-10000 characters
asset_idsstring[]NoEmail asset IDs to make available to the generation
Returns 202 Accepted with a generation_id to poll. Starting a second generation while one is running returns 409 Conflict with error_code: "generation_in_progress".

Save Code Directly

POST /v1/emails/{emailId}/save-code
FieldTypeRequiredNotes
codestringYesFull replacement HTML body, up to 2 MB
Creates a new version synchronously and returns 200 OK with the new generation_id and version. Unlike an AI iteration, saving code does not unapprove an approved email. Blocked while a generation is running.

Update Metadata

PATCH /v1/emails/{emailId}
Accepts the same optional fields as create except prompt: name, subject, preview_text, from_name, from_address, reply_to_email. Omit a field to leave it unchanged; pass null to clear nullable fields. Updating preview_text rewrites the preheader in the current HTML in place without creating a new version.

Approve and Unapprove

POST /v1/emails/{emailId}/approve
POST /v1/emails/{emailId}/unapprove
Approving marks the email’s current version usable by blasts and flows. It requires subject, from_name, from_address, and reply_to_email to be populated; otherwise the API returns 400 Bad Request with error_code: "email_missing_required_headers". Optionally send {"version": <n>} as an optimistic-concurrency precondition — a mismatch with the current version returns 409 Conflict with error_code: "approval_version_stale". Unapproving is rejected while the email is scheduled in a blast (email_in_scheduled_blast) or used by an active flow (email_in_active_flow).

List Emails

GET /v1/emails

Query Parameters

ParameterTypeDefaultNotes
statusstringanyany, draft, or approved
sort_bystringupdated_atupdated_at, created_at, or name. Only applies when status=any
sort_orderstringdescasc or desc
limitnumber25Page size, 1-100
cursorstringnext_cursor from a previous response
Responses contain emails and next_cursor. An absent next_cursor means the list is exhausted. List items are the email object minus latest_generation_status and the content_path / plain_text_path fields — lists never inline HTML; fetch it per email via GET /v1/emails/{emailId}/content.

Delete an Email

DELETE /v1/emails/{emailId}
Returns 200 OK with {"email_id": "...", "deleted": true}. Deletion is blocked with 409 Conflict while the email is scheduled in a blast (email_in_scheduled_blast) or used by an active flow (email_in_active_flow).

Common Errors

StatusCause
400 Bad RequestInvalid JSON body or field validation failure
400 Bad RequestApproval attempted with missing sender headers (email_missing_required_headers)
401 UnauthorizedMissing or invalid bearer token
404 Not FoundEmail or generation does not exist in the workspace
409 ConflictA generation is already running (generation_in_progress)
409 ConflictApproval version precondition failed (approval_version_stale)
409 ConflictEmail is in use by a scheduled blast or active flow
409 ConflictSame Idempotency-Key still processing (idempotency_in_progress)

Next: Manage Email Blasts

Send an approved email to an audience as a one-time blast.