Appearance
Send an email
Send a transactional email. The message is validated, queued, and handed to AWS SES for delivery.
http
POST /v1/emailsAuthentication
Requires an API key with Sending access or Full access, passed as a bearer token:
http
Authorization: Bearer sg_xxx
Content-Type: application/jsonParameters
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string | Yes | Sender address — email@domain or Name <email@domain>. The domain must be verified on your account, and within your key's domain scope. |
to | string | string[] | Yes | One recipient address, or an array of them. Counts toward the combined 50-recipient cap with cc and bcc. |
cc | string | string[] | No | Carbon-copy recipients, same format as to. |
bcc | string | string[] | No | Blind-carbon-copy recipients, same format as to. |
subject | string | Yes* | Subject line, up to 998 characters. *Supplied by the template when you send with one — pass it only to override. |
html | string | One of html / text | HTML body. Supplied by the template when you send with one. |
text | string | One of html / text | Plain-text body. Sending both html and text is recommended. Supplied by the template when you send with one. |
template | object | No | Send with a published template: { "id": "...", "variables": { … } }. id is a template UUID or its team-scoped slug. See Sending with a template. |
template_id | string | No | Flat alternative to template.id — a published template by its UUID. Provide template_id or template_slug, not both. |
template_slug | string | No | Flat alternative — a published template by its team-scoped slug. |
variables | object | No | Values for a template's {{{placeholders}}}, e.g. { "first_name": "Ada" }. Only meaningful when sending with a template. |
reply_to | string | No | A single Reply-To address. |
headers | object | No | Up to 25 custom email headers. See Custom headers. |
tags | object[] | No | Up to 25 { name, value } labels for analytics and filtering. Both fields are ASCII letters, digits, underscores and dashes, 1–256 characters each. See Tags. |
attachments | object[] | No | Files to attach — up to 10. See Attachments. |
scheduled_at | string | No | When to send the message later — an ISO 8601 timestamp or a natural-language phrase like in 45 minutes. See Scheduled sends. |
You can also pass an optional Idempotency-Key request header to make a retried POST /v1/emails return the original result instead of sending a second copy — see Idempotency.
Request
bash
curl https://api.sendgrail.com/v1/emails \
-H "Authorization: Bearer $SENDGRAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@example.com>",
"to": ["recipient@example.org"],
"reply_to": "support@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your purchase.</p>",
"text": "Thanks for your purchase."
}'js
const res = await fetch('https://api.sendgrail.com/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SENDGRAIL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <hello@example.com>',
to: ['recipient@example.org'],
reply_to: 'support@example.com',
subject: 'Your receipt',
html: '<p>Thanks for your purchase.</p>',
text: 'Thanks for your purchase.',
}),
});
const email = await res.json();python
import os, requests
res = requests.post(
"https://api.sendgrail.com/v1/emails",
headers={"Authorization": f"Bearer {os.environ['SENDGRAIL_API_KEY']}"},
json={
"from": "Acme <hello@example.com>",
"to": ["recipient@example.org"],
"reply_to": "support@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your purchase.</p>",
"text": "Thanks for your purchase.",
},
)
email = res.json()php
$response = $http->post('https://api.sendgrail.com/v1/emails', [
'headers' => [
'Authorization' => 'Bearer '.getenv('SENDGRAIL_API_KEY'),
],
'json' => [
'from' => 'Acme <hello@example.com>',
'to' => ['recipient@example.org'],
'reply_to' => 'support@example.com',
'subject' => 'Your receipt',
'html' => '<p>Thanks for your purchase.</p>',
'text' => 'Thanks for your purchase.',
],
]);
$email = json_decode((string) $response->getBody(), true);Response
202 Accepted — the message passed validation and is queued.
json
{
"object": "email",
"id": "3f5c9a1e-8b2d-4e6f-9a1c-7d4b2e8f0a63"
}Just the id. Echoing the whole message back would hand you a copy of what you just sent — and write it into your request log a second time. When you want the full object, ask for it: Retrieve an email.
The id identifies the message everywhere else: the dashboard, every webhook event about it, and the endpoints that reschedule or cancel it.
A message that was blocked by your suppression list says so, rather than making you find out later:
json
{
"object": "email",
"id": "3f5c9a1e-8b2d-4e6f-9a1c-7d4b2e8f0a63",
"last_event": "suppressed",
"suppressed_reason": "bounce"
}Attachments
Pass an attachments array. Each item has:
| Field | Required | Description |
|---|---|---|
filename | Yes | Name shown in the recipient's mail client. |
content | Yes | The file's bytes, base64-encoded. |
content_type | No | MIME type, e.g. application/pdf. Inferred from filename if omitted. |
Limits: up to 10 attachments, with a combined decoded size of 10 MB. Scheduled sends may carry attachments. The batch endpoint may not — send those messages individually.
Attachments are kept for 30 days and can be downloaded back from the API; see List attachments.
bash
curl https://api.sendgrail.com/v1/emails \
-H "Authorization: Bearer $SENDGRAIL_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"from\": \"Acme <hello@example.com>\",
\"to\": \"recipient@example.org\",
\"subject\": \"Your invoice\",
\"html\": \"<p>Invoice attached.</p>\",
\"attachments\": [{
\"filename\": \"invoice.pdf\",
\"content\": \"$(base64 < invoice.pdf | tr -d '\\n')\",
\"content_type\": \"application/pdf\"
}]
}"js
import { readFile } from 'node:fs/promises';
const pdf = await readFile('invoice.pdf');
await fetch('https://api.sendgrail.com/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SENDGRAIL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Acme <hello@example.com>',
to: 'recipient@example.org',
subject: 'Your invoice',
html: '<p>Invoice attached.</p>',
attachments: [{
filename: 'invoice.pdf',
content: pdf.toString('base64'),
content_type: 'application/pdf',
}],
}),
});python
import base64, os, requests
with open("invoice.pdf", "rb") as f:
content = base64.b64encode(f.read()).decode()
requests.post(
"https://api.sendgrail.com/v1/emails",
headers={"Authorization": f"Bearer {os.environ['SENDGRAIL_API_KEY']}"},
json={
"from": "Acme <hello@example.com>",
"to": "recipient@example.org",
"subject": "Your invoice",
"html": "<p>Invoice attached.</p>",
"attachments": [{
"filename": "invoice.pdf",
"content": content,
"content_type": "application/pdf",
}],
},
)php
$content = base64_encode(file_get_contents('invoice.pdf'));
$http->post('https://api.sendgrail.com/v1/emails', [
'headers' => ['Authorization' => 'Bearer '.getenv('SENDGRAIL_API_KEY')],
'json' => [
'from' => 'Acme <hello@example.com>',
'to' => 'recipient@example.org',
'subject' => 'Your invoice',
'html' => '<p>Invoice attached.</p>',
'attachments' => [[
'filename' => 'invoice.pdf',
'content' => $content,
'content_type' => 'application/pdf',
]],
],
]);SendGrail does not store attachment bytes — they're streamed into the message and then discarded.
Scheduled sends
Pass scheduled_at to hold a message until later. It accepts either an ISO 8601 timestamp or a natural-language phrase:
json
{
"from": "Acme <hello@example.com>",
"to": "recipient@example.org",
"subject": "Your weekly report",
"html": "<p>This week's numbers.</p>",
"scheduled_at": "in 45 minutes"
}Both of these are valid scheduled_at values:
"2026-05-22T18:00:00Z"— an absolute ISO 8601 timestamp."in 45 minutes","tomorrow 9am"— a relative phrase. A leadinginis read as an offset from now.
Then:
- If the resolved time is more than ~30 seconds in the future, the response
last_eventisscheduledand the message is held until the due time. - If it resolves to the past or within ~30 seconds of now, the message sends immediately and
scheduled_atcomes backnull. - A value that can't be parsed as either a timestamp or a phrase is rejected with
422.
WARNING
A message can't have both scheduled_at and attachments — attachment bytes aren't stored, so they wouldn't exist when the message comes due. Combining them returns a 422.
Sending with a template
Send with a published template instead of an inline body. The template supplies the subject and the html/text, and its {{{placeholders}}} are filled from variables.
The idiomatic form is the nested template object:
json
{
"from": "Acme <hello@example.com>",
"to": "recipient@example.org",
"template": {
"id": "welcome-email",
"variables": { "first_name": "Ada" }
}
}template.id is either a template's UUID or its team-scoped slug (an alias) — both resolve to the same template. The flat template_id / template_slug / variables fields still work as an alternative to the nested object.
A few rules:
- The template is the body. Sending
htmlortextalongside a template is a contradiction, not a fallback, and is rejected with422(atemplateerror). - Published templates only. A template still in draft is rejected with
422— publish it first. subjectis optional. The template provides one; passsubjectonly to override it for this send. It runs through the same{{{variables}}}, so"Welcome {{{first_name}}}"works.- A
template_id/template_slugthat doesn't resolve on your team, orvariableswith no template, returns422.
Custom headers
Pass headers as an object of name/value strings to inject extra headers into the message — for example List-Unsubscribe, In-Reply-To for threading, or any X-* header your downstream tooling consumes.
json
{
"from": "Acme <hello@example.com>",
"to": "recipient@example.org",
"subject": "Your weekly report",
"html": "<p>…</p>",
"headers": {
"X-Campaign-Id": "welcome-7",
"List-Unsubscribe": "<https://app.example.com/unsubscribe/abc>"
}
}Reserved names are rejected. SendGrail manages these on your behalf, so they can't be overridden: From, Sender, Reply-To, To, Cc, Bcc, Subject, Date, Message-ID, Return-Path, Received, DKIM-Signature, MIME-Version and the Content-* family. Attempting to set one returns a 422.
Limits: up to 25 headers per message; each name must match [A-Za-z0-9][A-Za-z0-9-]*; each value ≤998 characters.
Tags
Pass tags as an array of { name, value } objects to attach labels to the message — useful for filtering in the dashboard and routing webhook events.
json
{
"from": "Acme <hello@example.com>",
"to": "recipient@example.org",
"subject": "Your receipt",
"html": "<p>…</p>",
"tags": [
{ "name": "category", "value": "confirm_email" },
{ "name": "tier", "value": "free" }
]
}Both name and value are ASCII letters, digits, underscores and dashes only, 1–256 characters each — so a tag is always safe to carry in an outbound header or a metrics label without escaping. A value outside that set (say an accented character) is rejected with 422 at tags.N.value, where N is the tag's index.
Limits: up to 25 tags per message. Tags are operational metadata — they aren't echoed in the send response, but they're returned on the email object and carried in every email.* webhook event so your downstream handlers can route on them.
Idempotency
To make POST /v1/emails safe to retry, pass an Idempotency-Key request header (≤255 characters). A repeated request with the same key returns the original send's result instead of creating a second message.
http
POST /v1/emails
Authorization: Bearer sg_xxx
Idempotency-Key: order-4815162342
Content-Type: application/jsonKeys are scoped per account — use any value that is unique to one logical send (an order id, a UUID, a hash of the payload). The original key is recorded on the message and visible in the dashboard, so duplicates are auditable.
The batch endpoint does not honour Idempotency-Key. If you need dedupe protection while sending in bulk, use single sends with their own per-message keys.
Batch sending
Send up to 100 messages in one HTTP call with POST /v1/emails/batch. The body is a JSON array at the root; each element is the same shape as a single send.
http
POST /v1/emails/batchbash
curl https://api.sendgrail.com/v1/emails/batch \
-H "Authorization: Bearer $SENDGRAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{
"from": "Acme <hello@example.com>",
"to": "alice@example.org",
"subject": "Hi Alice",
"html": "<p>Hello!</p>"
},
{
"from": "Acme <hello@example.com>",
"to": "bob@example.org",
"subject": "Hi Bob",
"html": "<p>Hello!</p>"
}
]'The response is 202 Accepted with a data array of results, in the same order as the input:
json
{
"data": [
{ "id": "c1d8f42b-9e07-4a35-8f6c-2b5e9d1a3c80", "status": "queued" },
{ "id": "e9b3a260-7c14-4d9f-a3e8-6f2b1c5d4079", "status": "queued" }
]
}A row whose status is suppressed also carries suppressed_reason.
Batches are all-or-nothing
If any item fails validation, the whole batch is rejected with 422 and nothing is queued. Errors are keyed by the item's index:
json
{
"message": "Domain notyours.example isn't attached to your account.",
"errors": {
"emails.1.from": ["Domain notyours.example isn't attached to your account."]
}
}Fix the flagged item and resubmit. This prevents the "29 sent, then a hard failure" problem when sweeping a list.
A batch counts as a single request against your rate limit — prefer it over many single sends.
Errors
This endpoint can return 401, 422, 429 and 503. Every validation message and how to resolve it is documented in Errors & rate limits.