Documentation

Emails

Send, schedule, batch, cancel and look up outbound email, with its event timeline and delivery metrics.

An email is one outbound message and the record of what happened to it. Every send goes through the same path whether it is immediate, scheduled, part of a batch or held for approval: the workspace, the key's guardrails, the sending domain, suppressions and topic preferences are all checked before anything leaves, and the outcome is written to the message log you read back with these endpoints.

For the ideas behind sending (recipient names, templates, scheduling phrases, suppression) see Sending. For keys held by agents see Limits for agents and Approvals.

The email object

Returned by the list and retrieve endpoints.

FieldTypeDescription
idstringThe message id. Use it to retrieve or cancel the message, and as reply_to_message_id on a later send.
fromstringThe sender exactly as it was given, including any display name.
toarray of stringBare recipient addresses, as given. Display names are never stored here. Addresses dropped before sending (suppressed, opted out of the topic, or on a domain that cannot receive mail) are not in this list, whether they were dropped when the send was made or when a scheduled or approved message went out. When no to address was left, so the message was rejected or was canceled at its due time, the list holds every address as requested.
subjectstringThe subject as sent, after any template rendering.
statusstringWhere the message is. See the status table below.
risk_class"transactional" | "marketing"Which sending identity the message used.
provider_message_idstring | nullThe id the delivery provider assigned when it accepted the message. null until then.
scheduled_atstring (ISO 8601) | nullWhen a scheduled or held message is due. null for an immediate send.
sent_atstring (ISO 8601) | nullWhen the provider accepted the message.
opened_atstring (ISO 8601) | nullThe first recorded open. Only ever set when the sending domain's tracking hostname is live. Set a few seconds after the open, and never by a security scanner's automated fetch; see Automated clicks and opens.
clicked_atstring (ISO 8601) | nullThe first recorded click. Same conditions as opened_at.
created_atstring (ISO 8601)When the message was recorded.

cc, bcc, reply_to, tags, thread_id and the body are not part of this object. A thread's transcript (GET /v1/threads/{id}) includes outbound bodies.

Statuses

StatusMeaning
queuedRecorded but not handed to the provider. A send held for approval sits here until it is decided. So does an immediate send that failed before hand-off (provider throttling, the warm-up limit, or a suspended workspace). A scheduled message is queued for the moment it is being sent.
scheduledWaiting for its scheduled_at. A due message held back by the plan allowance, provider throttling, a suspension, a missing postal address or the warm-up limit returns to scheduled and is tried again on a later pass.
sentAccepted by the provider.
deliveredThe receiving server accepted it.
bouncedA bounce was reported. This covers transient bounces too; the bounce event's sub_type says Permanent, Transient or Undetermined, and only permanent bounces suppress the address.
complainedThe recipient marked it as spam. The address is now suppressed for all mail.
rejectedNothing was sent: no to address was left once suppressed, opted-out and undeliverable addresses were dropped, either when the send was made or when its approval was released; or the provider rejected the message.
canceledIt will not be sent: its approval was rejected or expired, it was cancelled with Cancel a scheduled email or from the dashboard, everyone left in its to unsubscribed while it was scheduled (an unsubscribe by one of several recipients only removes that address from to, cc or bcc, and the rest still receive it), or it was dropped at its due time because no to address was left after suppressions and topic preferences were checked again.
failedThe provider refused the message, or a scheduled message could not be sent: its sending domain was removed, or it was still being held back (by provider throttling, a suspension, a missing postal address or the warm-up limit) after being picked up five times. Waiting for the plan allowance does not count towards the five, since nothing was attempted.

The status follows the most recent delivery event received, so a message can move from delivered to complained days later.

List emails

GET /v1/emails · requires emails:read

The message log, newest first by created_at, cursor-paged as described in Pagination. Held, scheduled, skipped and failed messages all appear here, not only delivered ones, and so do the messages sent by campaigns and automations.

Query parameters

ParameterTypeDefaultDescription
statusstringnoneOnly messages with exactly this status, one of the values in Statuses. Any other value answers 422 invalid_request.
tostringnoneOnly messages with this address in to. The value is lowercased and compared exactly, so it does not match cc or bcc, and it does not match an address that was sent with capital letters in it.
limitinteger50Page size, at most 100. A fraction is rounded down; a missing, non-numeric value, or one below 1 after rounding, means 50.
cursorstringnoneThe next_cursor from the previous page. Messages created in the same millisecond, as a batch often is, are ordered by id, so a page boundary never skips one.

Example

curl "https://api.sendraven.ai/v1/emails?status=bounced&limit=2" \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

Response

200 OK

{
  "object": "list",
  "data": [
    {
      "id": "3f6c1a52-8d0e-4b7a-9c21-5e4f7a9b2d10",
      "from": "Acme <billing@mail.example.com>",
      "to": ["ana@example.com"],
      "subject": "Your invoice for September",
      "status": "bounced",
      "risk_class": "transactional",
      "provider_message_id": "0100019a1b2c3d4e-5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c-000000",
      "scheduled_at": null,
      "sent_at": "2026-09-15T12:00:01.000Z",
      "opened_at": null,
      "clicked_at": null,
      "created_at": "2026-09-15T12:00:00.000Z"
    }
  ],
  "has_more": true,
  "next_cursor": "MjAyNi0wOS0xNVQxMjowMDowMC4wMDBafDNmNmMxYTUyLThkMGUtNGI3YS05YzIxLTVlNGY3YTliMmQxMA"
}

Errors

StatusTypeWhen
422invalid_requeststatus is not one of the values in Statuses.

Send an email

POST /v1/emails · requires emails:send

Sends one message now, schedules it, or, from a key that requires approval, holds it for a person. A request that is accepted in any of those ways answers 202, and so does a send whose recipients were all suppressed: read status and skipped to tell them apart, as described in Outcomes that are not errors.

Always send an Idempotency-Key. A retried send that is not deduplicated is a second copy in someone's inbox. A replay of the same key with the same body within 24 hours returns the stored 202 without sending again. The key belongs to that one request: sending it again with a different body answers 422 idempotency_key_reused and sends nothing, so use a new key for a new message. The body is compared as JSON, so the order of its fields does not matter. Error responses are not stored, so a failed request can be fixed and retried under the same key.

Body

Only the fields below are accepted. Any other field is refused with 422 invalid_request and nothing is sent: the message names the unknown field, and a guessed name for the reply field, such as in_reply_to, points to reply_to_message_id. Until 23 September 2026 unknown fields were silently dropped, so a reply with a misspelled field started a new conversation instead of joining the thread.

{
  "error": {
    "type": "invalid_request",
    "message": "Unknown field `in_reply_to`; nothing was sent. To reply in an existing conversation, pass the id of the message you are answering as `reply_to_message_id`. The accepted fields are listed at https://sendraven.ai/docs/api/emails.",
    "details": [{ "code": "unrecognized_keys", "keys": ["in_reply_to"], "path": [], "message": "Unrecognized key(s) in object: 'in_reply_to'" }]
  }
}
FieldTypeRequiredDefaultDescription
fromstringYesnoneThe sender, as billing@mail.example.com or Acme <billing@mail.example.com>. The part after @ must match (ignoring case) a sending domain in the workspace that is verified for this message's risk_class; otherwise the send fails with 422 no_verified_identity.
tostring or array of stringYesnoneOne mailbox, or an array of at least one. Each is a bare address or Name <address> (see Mailboxes). to, cc and bcc together may name at most 50 addresses; see the recipient limit.
ccarray of stringNononeMailboxes, in the same forms as to. The name, if any, appears in the Cc header. Counts towards the 50-recipient limit.
bccarray of stringNononeMailboxes. A Name <address> form is accepted and the name is discarded, since Bcc never appears in a header. Counts towards the 50-recipient limit.
reply_tostringNononeOne mailbox. A display name is kept in the Reply-To header.
subjectstringUnless a template supplies onenone1 to 998 characters, with no line breaks. When template is also given, this replaces the template's subject verbatim: variables are not substituted into it.
htmlstringOne of html, text or templatenoneThe HTML body. Ignored when template is given.
textstringOne of html, text or templatenoneThe plain-text body. Ignored when template is given. When only html is sent, a plain-text part is derived from it at send time, so a message with HTML always carries a plain-text alternative.
templatestringNononeThe slug of a template in this workspace. Its rendered HTML and text replace html and text. See Templates.
variablesobjectNo{}Values for the template's placeholders. Every value must be a string. Values substituted into the HTML body are HTML-escaped. Every placeholder the template declares must have a value, or the request fails with 422 missing_variables and a missing list. A placeholder the template does not declare, and that has no value, renders as an empty string.
risk_class"transactional" | "marketing"No"transactional"Which sending identity and reputation the message uses. marketing changes what the send requires and adds; see Marketing sends.
scheduled_atstringNononeSend later. An ISO 8601 timestamp such as 2026-09-16T09:00:00.000Z, or a relative phrase: in followed by a number and one of min, mins, minute, minutes, hour, hours, day, days, week, weeks (case-insensitive, for example in 3 days). Other strings are handed to JavaScript's date parser; include a Z or an offset so the time zone is not guessed. A time in the past is accepted and the message goes out on the scheduler's next pass. A value that does not parse, including an empty string, fails with 422 invalid_schedule.
sequence_keystringNononeUp to 200 characters. Stored on the schedule entry of a scheduled send, and ignored for an immediate send or one held for approval. No endpoint reads it.
tagsarray of objectNononeUp to 10 { "name": string, "value": string } pairs, passed to the delivery provider as message tags. Before they are sent, every character outside A-Z, a-z, 0-9, _ and - is replaced with _, each is cut to 256 characters, and an empty result becomes unset. Tags are not returned by this API.
attachmentsarray of objectNononeUp to 20 files. See Attachments.
reply_to_message_idstringNononeThe id of a message in this workspace that this one answers: one you sent, or one you received (an inbound entry in a thread transcript, or the id of an inbound webhook). Sets In-Reply-To and References and puts the new message on that message's thread. An id that matches no message in the workspace fails with 422 invalid_request. See Replying and threads.
thread_idstringNononePut the message on this thread without replying to a particular message. Must be a thread in this workspace, or the send fails with 422 invalid_request. When reply_to_message_id is also given and that message is on a thread, its thread wins.
topicstringNononeA topic key in this workspace. Recipients opted out of the topic are dropped. On a transactional send to exactly one address across to, cc and bcc, the message is treated as a notification: it gets a one-click List-Unsubscribe header and a short footer ("Stop these emails") that opt the recipient out of this topic only, and no postal address is needed. Leave topic off mail nobody should be able to stop, such as sign-in codes and password resets. A key that does not exist fails with 422 unknown_topic. See Topics.

Mailboxes

to, cc, bcc and reply_to accept a bare address (ana@example.com) or a mailbox with a display name (Ana Lima <ana@example.com>). The name may be wrapped in double quotes ("Lima, Ana" <ana@example.com>); the quotes are removed, and the name is quoted again when the header is written, so a comma in a name is safe either way. A name outside ASCII is encoded for you.

A display name may be at most 100 characters and may not contain a line break, < or >; such a name is refused with 422 rather than cleaned. The address inside the angle brackets must be a valid email address. If the same address appears twice with different names, the first name is used.

The name only ever reaches the headers. Suppressions, topic preferences, the key's allowlist and daily cap, threading and the message log all use the bare address, so adding a name never changes who is reached, and the email object's to holds bare addresses.

Attachments

FieldTypeRequiredDefaultDescription
filenamestringYesnone1 to 255 characters, with no line breaks or other control characters. Quotes and characters outside ASCII are allowed and encoded for you.
contentstringYesnoneThe file, base64-encoded. The limit is 10 MB decoded, measured per attachment.
content_typestringNoapplication/octet-streamThe media type, as type/subtype with optional ; name=value parameters, for example application/pdf or text/plain; charset=utf-8. Up to 255 characters. Whitespace around the value is ignored; anything else, including a line break inside it, fails with 422 invalid_request.
content_idstringNononeMakes the attachment inline, for an image referenced from the HTML as cid: followed by this value. 1 to 255 characters, with no spaces, <, > or control characters. Without it the file is a regular attachment.

The JSON body of POST /v1/emails may be up to 15 MB. Base64 adds a third to a file's size, so that fits one attachment at the full 10 MB with room for the rest of the message; several large files belong in separate sends. A body over the limit answers 413 payload_too_large, and one that is not valid JSON answers 400 invalid_json.

The recipient limit

A message can carry at most 50 recipients across to, cc and bcc together, the most the delivery provider accepts on one message. More is refused with 422 invalid_request before the body is validated and before anything is stored, and the message is the same wherever the addresses are, naming the limit and the count:

{
  "error": {
    "type": "invalid_request",
    "message": "A message can have at most 50 recipients across to, cc and bcc; this one has 51. Send it as separate messages, or use a campaign."
  }
}

The count is of the addresses as sent, before duplicates, suppressed or opted-out addresses are dropped. In a batch, an entry over the limit fails only that entry, with the same error.

What a send goes through

The checks run in this order, and the first that fails decides the response.

  1. The recipient limit: to, cc and bcc together must name at most 50 addresses, which is the most one message can carry (422 invalid_request). Then validation of the body (422 invalid_request), then the template: an unknown slug (422 invalid_request, message starting template:) or missing variables (422 missing_variables); then the topic, which must exist (422 unknown_topic, message starting topic:); then a subject and a body must be present (422 invalid_request). A marketing send must name exactly one address across to, cc and bcc (422 invalid_request; see Marketing sends).
  2. Suspension. A suspended workspace cannot send (422 workspace_suspended).
  3. Schedule. An unparseable scheduled_at fails here (422 invalid_schedule).
  4. Recipient allowlist, for an API key that has one: every address in to, cc and bcc must be on it (403 recipient_not_allowed). OAuth access tokens carry no guardrails.
  5. References. reply_to_message_id and thread_id, when given, must name a message and a thread in this workspace (422 invalid_request).
  6. Recipients. Suppressed addresses, addresses on a domain that cannot receive mail, and, when topic is given, addresses opted out of the topic are dropped from to, cc and bcc. Nothing is stored yet.
  7. Billing. One gate, four refusals, each 402 and each needing something different. No payment method verified for the workspace: payment_method_required, and no outbound email leaves at all, on Free or paid. On Free, a send that would take the month past its 3,000 included emails: plan_limit_reached, refused whole rather than partly delivered. A paid subscription Stripe has given up collecting on: billing_past_due. The workspace's own monthly budget reached: budget_exceeded, which by default stops marketing and lets transactional through. None is fixed by retrying; GET /v1/usage says which applies before you send.
  8. Daily cap, for an API key that has one (429 daily_limit).
  9. Sending domain. The from domain must be verified for the risk_class (422 no_verified_identity).
  10. Postal address, for marketing sends only (422 no_postal_address).
  11. Rejection. If no to address is left, the message is recorded as rejected and the call answers 202, even when cc or bcc addresses remain.
  12. Approval hold, for a key that requires approval: the message is stored and held, and the call answers 202.
  13. Scheduled send: the message is stored as scheduled and the call answers 202.
  14. Immediate send: the suspension and the postal address again, the warm-up allowance for marketing mail (422 warmup_limit), then hand-off to the provider (502 ses_error on failure).

The plan allowance and the daily cap count every recipient left after step 6, in to, cc and bcc together: a message to one address with fifty in bcc is fifty-one emails.

Outcomes that are not errors

Four responses come back as 202. Only the first two mean mail is on its way. Every one of them has the same seven fields, id, status, thread_id, scheduled_at, skipped, reason and approval_id; a field that does not apply to the outcome is false or null, never left out.

statusFields with a valueMeaning
sentnone beyond id and thread_idHanded to the provider. skipped is false, and reason and approval_id are null.
scheduledscheduled_atStored and waiting. Cancel it with Cancel a scheduled email.
pending_approvalapproval_id, and scheduled_at when one was givenHeld for a person. Nothing is sent until someone approves it with POST /v1/approvals/{id}. It is not an error and there is nothing to retry: sending again holds a second draft. The message shows as queued in the log.
rejectedskipped: true, reasonNothing was sent, because every address in to was suppressed, opted out of the topic, or is on a domain that cannot receive mail. Do not retry.

reason is suppressed: followed by the suppression reason of the first address (hard_bounce, complaint, unsubscribe, manual or list_hygiene), unsubscribed_from_topic: followed by the topic key, or undeliverable_domain: followed by the domain of the first address. A domain cannot receive mail when it does not exist or publishes no mail server, which is what a mistyped or made-up address usually looks like; such an address would bounce, so it is refused before it reaches the provider. A marketing unsubscribe suppresses only marketing mail, so the same address can still receive a transactional send; hard bounces and complaints suppress both.

When only some addresses are dropped, the send goes ahead to the rest and the response does not say which were removed. Compare the email object's to with what you asked for.

Addresses in cc and bcc are dropped for the same reasons as addresses in to, but a message is rejected only when no to address is left.

The key's daily cap counts recipients in to, cc and bcc, not messages, and always the ones left after suppressed, opted-out and undeliverable addresses are dropped. A held or scheduled send spends it when it is accepted, not when it goes out, so a rejected draft still counts. An immediate send spends it once it is handed to the provider. A send skipped entirely, or refused with an error, spends nothing.

Marketing sends

With risk_class: "marketing":

  • The from domain must be a sending domain verified for marketing. A domain verified only for transactional mail fails with 422 no_verified_identity.
  • The workspace must have a postal address set, or the send fails with 422 no_postal_address. It is checked again when a scheduled, approved or automated marketing message actually goes out, so a workspace that removes its address stops those too rather than mailing them without it.
  • The message goes to exactly one recipient: to, cc and bcc together must name one address, or the send fails with 422 invalid_request before anything is stored. The unsubscribe link and header belong to that one person, so a message with several recipients would hand every other recipient someone else's opt-out. To mail several people, send one message each with Send a batch (a batch fails only the entries that break this rule), or use a campaign.
  • An unsubscribe link and the postal address are added to the body, and one-click List-Unsubscribe headers are set. With topic, the unsubscribe applies to that topic only.
  • The message is not threaded: thread_id in the response is null. reply_to_message_id and thread_id must still name a message and a thread in this workspace, but are otherwise ignored.
  • A sending domain in its warm-up period has a daily allowance of marketing messages. Once it is spent, an immediate send fails with 422 warmup_limit until 00:00 UTC. The allowance is also checked when a scheduled or approved marketing message goes out. Transactional mail is never limited this way.

Replying and threads

Every transactional send that is not rejected lands on a thread, whether it is sent now, scheduled or held for approval, and the response's thread_id names it:

  • With reply_to_message_id, the message joins that message's thread and carries its Message-ID in In-Reply-To, so the recipient's client shows one conversation. The id can be a message you sent or one you received. Replying to a received message, which is the usual case for an agent answering mail, uses that message's own Message-ID and joins its thread. The reply's References is the parent's own References chain followed by the parent's Message-ID, whether it is sent immediately, scheduled, or held for approval and released later. If the message you reply to is on no thread (it was sent as marketing, or before threading existed), the reply still carries the headers, and joins thread_id if given or starts a new thread.
  • With thread_id alone, the message joins that thread without reply headers.
  • With neither, a new thread starts.

A message on a thread updates it when the provider accepts it, however it was sent: immediately, released from approval, or at its scheduled time. That clears the thread's awaiting_reply and adds the message to message_count. Until then, a reply held or scheduled after the latest received message sets the thread's pending_reply.

Example

curl -X POST https://api.sendraven.ai/v1/emails \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from": "Acme <billing@mail.example.com>",
    "to": ["Ana Lima <ana@example.com>"],
    "reply_to": "support@example.com",
    "subject": "Your invoice for September",
    "text": "Hi Ana, your invoice is attached.",
    "tags": [{ "name": "category", "value": "invoice" }],
    "attachments": [
      {
        "filename": "invoice-2026-09.pdf",
        "content": "JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSPj4Kc3RyZWFtCg==",
        "content_type": "application/pdf"
      }
    ]
  }'

With a template, scheduled for later:

{
  "from": "Acme <hello@mail.example.com>",
  "to": "ana@example.com",
  "template": "welcome",
  "variables": { "name": "Ana", "plan": "Pro" },
  "scheduled_at": "in 3 days"
}

Response

202 Accepted

Sent now:

{
  "id": "3f6c1a52-8d0e-4b7a-9c21-5e4f7a9b2d10",
  "status": "sent",
  "thread_id": "a7d2e9b4-1c3f-4e5a-8b6d-0f9e8d7c6b5a",
  "scheduled_at": null,
  "skipped": false,
  "reason": null,
  "approval_id": null
}

Scheduled:

{
  "id": "5b8e2f14-6a7c-4d9e-b0f1-2c3d4e5f6a7b",
  "status": "scheduled",
  "thread_id": "c3b4a5d6-e7f8-4a9b-8c0d-1e2f3a4b5c6d",
  "scheduled_at": "2026-09-18T12:00:00.000Z",
  "skipped": false,
  "reason": null,
  "approval_id": null
}

Held for approval:

{
  "id": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
  "status": "pending_approval",
  "thread_id": "a7d2e9b4-1c3f-4e5a-8b6d-0f9e8d7c6b5a",
  "scheduled_at": null,
  "skipped": false,
  "reason": null,
  "approval_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b"
}

Skipped because the recipient is suppressed:

{
  "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
  "status": "rejected",
  "thread_id": null,
  "scheduled_at": null,
  "skipped": true,
  "reason": "suppressed:hard_bounce",
  "approval_id": null
}

Errors

StatusTypeWhen
400invalid_jsonThe request body is not valid JSON.
402plan_limit_reachedThe workspace is on Free and this send would exceed its 3,000 monthly emails.
402payment_method_requiredThe workspace has no verified payment method, so no outbound email may leave it. A person must add one in the dashboard.
402billing_past_dueThe paid subscription is unpaid or canceled after Stripe stopped retrying.
402budget_exceededThe workspace's own monthly spend ceiling is reached. By default this stops marketing sends only.
403recipient_not_allowedThe key's allowlist does not include an address in to, cc or bcc. The message names it. Do not retry with another spelling.
413payload_too_largeThe request body is larger than 15 MB.
422idempotency_key_reusedThe Idempotency-Key was already used with a different request body in the last 24 hours. Nothing is sent. Use a new key.
422invalid_requestto, cc and bcc together name more than 50 addresses (the message gives the count; see the recipient limit); the body failed validation (with a details array), including an attachment filename, content_type or content_id that could break the message headers; the template slug does not exist (template: No template with slug "…"); there is no subject; there is no html, text or template; reply_to_message_id matches no message in the workspace (reply_to_message_id: No such message); thread_id is not a thread in the workspace (thread_id: No such thread); or a marketing send names more than one address across to, cc and bcc (the message says to send one message per recipient or use a campaign).
422missing_variablesThe template declares variables that variables gives no value for. The error carries a missing array of their names.
422no_verified_identityNo verified sending domain matches the from domain for this risk_class.
422unknown_topictopic is not a topic key in this workspace. The message starts topic:.
422invalid_schedulescheduled_at could not be parsed, or is an empty string.
422workspace_suspendedSending is suspended for this workspace. The message gives the reason.
422no_postal_addressA marketing send from a workspace with no postal address.
422warmup_limitA marketing send from a domain that has used today's warm-up allowance. It resets at 00:00 UTC.
429daily_limitThe key's daily cap would be exceeded by the recipients in to, cc and bcc. Stop; it resets at 00:00 UTC.
502ses_errorThe provider refused or throttled the message. The attempt stays in the log as failed, or queued if it was throttled. A retry creates a new message.

A missing_variables error looks like this:

{
  "error": {
    "type": "missing_variables",
    "message": "Template is missing values for: name, plan",
    "missing": ["name", "plan"]
  }
}

Retrieve an email

GET /v1/emails/{id} · requires emails:read

One message with its event timeline. This is the call to make when asked why an email did not arrive: the events show whether it was delivered, bounced (with the receiving server's diagnostic), complained about, opened or clicked.

Path parameters

ParameterDescription
idThe message id.

The event object

Each entry in events is one thing that happened to the message, oldest first by occurred_at. Every field is always present; a field an event type has no value for is null.

FieldTypeDescription
idstringThe event id.
message_idstringThe message id.
provider_message_idstring | nullThe provider's id for the message.
typestringsend, delivery, bounce, complaint, reject, open, click, rendering_failure or delivery_delay.
recipientstringThe lowercased address the event concerns. Only delivery, bounce and complaint events name the recipient the provider reported; every other type uses the first address in to.
sub_typestring | nullOn bounce: Permanent, Transient or Undetermined. On complaint: the feedback type, when the mailbox provider sent one. On delivery_delay: why delivery is being retried, as SES classifies it, such as MailboxFull, SpamDetected or TransientCommunicationFailure. null on other types.
detailstring | nullOn bounce and delivery_delay: the receiving server's SMTP reply, when there was one. On click: the URL. On reject: the reason. null otherwise.
broadcast_idstring | nullThe campaign the message belonged to, or null.
variantstring | nullThe campaign's A/B variant, or null.
occurred_atstring (ISO 8601)When it happened.
created_atstring (ISO 8601)When it was recorded.

A delivery or bounce event is recorded per recipient, and delivery notices can arrive more than once, so do not count events to count recipients.

Example

curl https://api.sendraven.ai/v1/emails/3f6c1a52-8d0e-4b7a-9c21-5e4f7a9b2d10 \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

Response

200 OK

An email object with an events array.

{
  "id": "3f6c1a52-8d0e-4b7a-9c21-5e4f7a9b2d10",
  "from": "Acme <billing@mail.example.com>",
  "to": ["ana@example.com"],
  "subject": "Your invoice for September",
  "status": "bounced",
  "risk_class": "transactional",
  "provider_message_id": "0100019a1b2c3d4e-5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c-000000",
  "scheduled_at": null,
  "sent_at": "2026-09-15T12:00:01.000Z",
  "opened_at": null,
  "clicked_at": null,
  "created_at": "2026-09-15T12:00:00.000Z",
  "events": [
    {
      "id": "b4c5d6e7-f8a9-4b0c-9d1e-2f3a4b5c6d7e",
      "message_id": "3f6c1a52-8d0e-4b7a-9c21-5e4f7a9b2d10",
      "provider_message_id": "0100019a1b2c3d4e-5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c-000000",
      "type": "bounce",
      "recipient": "ana@example.com",
      "sub_type": "Permanent",
      "detail": "smtp; 550 5.1.1 user unknown",
      "broadcast_id": null,
      "variant": null,
      "occurred_at": "2026-09-15T12:00:03.000Z",
      "created_at": "2026-09-15T12:00:04.000Z"
    }
  ]
}

Errors

StatusTypeWhen
404not_foundNo message with this id in the workspace.

Cancel a scheduled email

DELETE /v1/emails/{id} · requires emails:send

Stops a scheduled message before it goes out. The message becomes canceled and its place on the schedule is withdrawn. Only a message whose status is scheduled is accepted; anything else, including a draft held for approval (queued), answers 409 invalid_state. To withdraw a held draft, reject its approval instead.

The status change is what the scheduler checks before it sends, so a 200 means the message will not go out, even if its time had already come. A message that is being sent at the moment you call answers 409 invalid_state, naming its status.

Path parameters

ParameterDescription
idThe message id.

Example

curl -X DELETE https://api.sendraven.ai/v1/emails/5b8e2f14-6a7c-4d9e-b0f1-2c3d4e5f6a7b \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

Response

200 OK

{
  "id": "5b8e2f14-6a7c-4d9e-b0f1-2c3d4e5f6a7b",
  "status": "canceled"
}

Errors

StatusTypeWhen
404not_foundNo message with this id in the workspace.
409invalid_stateThe message is not scheduled, or stopped being scheduled while the call ran because it was being sent. The message names its current status.

Send a batch

POST /v1/emails/batch · requires emails:send

Up to 100 messages in one call. The body is a JSON array, and each entry is exactly the body of Send an email, with the same fields, forms and limits. Entries are sent one after another in the order given, each through the full send path, and the results come back in the same order.

Once the array is valid, each entry succeeds or fails on its own: more than 50 recipients across to, cc and bcc, a marketing entry with more than one recipient, a missing template variable, a suppressed recipient or a guardrail refusal affects only that entry. Validation is not per entry, though. If any entry fails schema validation (a malformed address, a subject over 998 characters, an unknown risk_class, a field the API does not accept), the whole batch is refused with 422 and nothing is sent; each issue's path in details starts with the entry's index. When an entry named an unknown field, the message names the first such entry and its fields. An entry over the recipient limit is not schema-checked, so it never causes that; it fails alone with the recipient limit error.

The JSON body may be up to 25 MB. That is a ceiling on one request, not 100 times the single-send limit: it still fits one attachment at the full 10 MB, and more than that belongs in separate calls.

An Idempotency-Key covers the batch as a whole. A replay of the same array returns the stored response, 207 included, without sending anything, so to retry only the failed entries, send them as a new batch under a new key: the same key with a different array answers 422 idempotency_key_reused.

Body

An array of 1 to 100 send bodies. See Send an email.

Example

curl -X POST https://api.sendraven.ai/v1/emails/batch \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '[
    {
      "from": "Acme <hello@mail.example.com>",
      "to": "ana@example.com",
      "template": "welcome",
      "variables": { "name": "Ana", "plan": "Pro" }
    },
    {
      "from": "Acme <hello@mail.example.com>",
      "to": "bo@example.com",
      "template": "welcome",
      "variables": {}
    }
  ]'

Response

202 Accepted when every entry succeeded, 207 Multi-Status when at least one failed. The body has the same shape either way.

FieldTypeDescription
object"list"Always list.
dataarray of objectOne result per entry, in input order.
data[].okbooleanWhether the entry was accepted. A skipped (suppressed) or held entry is ok: true.
data[].id, status, thread_id, scheduled_at, skipped, reason, approval_idOn an accepted entry: the same fields as a single send's response, all always present. A failed entry has only ok and error.
data[].errorobjectOn a failed entry: { "type", "message" } with the same types as a single send. A missing_variables failure also carries missing, the names of the variables with no value, as a single send does.
failedintegerHow many entries failed.
{
  "object": "list",
  "data": [
    {
      "ok": true,
      "id": "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b",
      "status": "sent",
      "thread_id": "0a1b2c3d-4e5f-4a6b-9c7d-8e9f0a1b2c3d",
      "scheduled_at": null,
      "skipped": false,
      "reason": null,
      "approval_id": null
    },
    {
      "ok": false,
      "error": {
        "type": "missing_variables",
        "message": "Template is missing values for: name, plan",
        "missing": ["name", "plan"]
      }
    }
  ],
  "failed": 1
}

Errors

StatusTypeWhen
400invalid_jsonThe request body is not valid JSON. Nothing is sent.
413payload_too_largeThe request body is larger than 25 MB. Nothing is sent.
422idempotency_key_reusedThe Idempotency-Key was already used with a different request body in the last 24 hours. Nothing is sent.
422invalid_requestThe body is not an array of 1 to 100 entries, or any entry fails validation. Nothing is sent.

Get email metrics

GET /v1/emails/metrics · requires emails:read

Delivery and engagement for the whole workspace over the last days UTC days, today included, with totals and a gap-filled daily series: a day with no mail is a row of zeroes rather than missing.

Rates are fractions between 0 and 1, not percentages. delivery_rate, bounce_rate and complaint_rate are over sent. bounce_rate counts permanent bounces only (hard_bounced: the address or domain does not exist), because that is the rate mailbox providers and AWS judge a sender on; bounced also counts transient bounces, such as a full mailbox or a server that never answered. open_rate and click_rate are over delivered, so a deliverability problem does not show up as low engagement; when nothing is recorded as delivered they fall back to sent. Opens and clicks are only recorded for domains whose tracking hostname is live. opened counts a message's first open once; clicked counts every click, so one reader clicking three links counts three.

sent counts recipients handed to the provider, while delivered, bounced and complained count delivery notices, so the rates are approximate for messages with several recipients. sent is rolled up from the send log by a background job about once a minute, so the most recent sends may not show yet. failed counts recipients the provider refused; it appears in each daily row but not in totals, and it is left out of every rate.

Each daily row has date (YYYY-MM-DD, UTC) and the integer counts sent, delivered, bounced, hard_bounced, complained, failed, opened and clicked, spelt as in totals.

Query parameters

ParameterTypeDefaultDescription
daysinteger30The window, clamped to 1 to 365. A fraction is rounded down. A missing, zero or non-numeric value (including anything below 1 after rounding down, such as 0.5) means 30, and a negative one means 1.

Example

curl "https://api.sendraven.ai/v1/emails/metrics?days=2" \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

Response

200 OK

{
  "range_days": 2,
  "totals": {
    "sent": 1200,
    "delivered": 1180,
    "opened": 472,
    "clicked": 59,
    "bounced": 14,
    "hard_bounced": 3,
    "complained": 1,
    "delivery_rate": 0.9833333333333333,
    "open_rate": 0.4,
    "click_rate": 0.05,
    "bounce_rate": 0.0025,
    "complaint_rate": 0.0008333333333333334
  },
  "daily": [
    {
      "date": "2026-09-14",
      "sent": 700,
      "delivered": 690,
      "bounced": 8,
      "hard_bounced": 2,
      "complained": 1,
      "failed": 0,
      "opened": 280,
      "clicked": 35
    },
    {
      "date": "2026-09-15",
      "sent": 500,
      "delivered": 490,
      "bounced": 6,
      "hard_bounced": 1,
      "complained": 0,
      "failed": 2,
      "opened": 192,
      "clicked": 24
    }
  ]
}