Skip to content

Send an email

Send a transactional email. The message is validated, queued, and handed to AWS SES for delivery.

http
POST /v1/emails

Authentication

Requires an API key with Sending access or Full access, passed as a bearer token:

http
Authorization: Bearer sg_xxx
Content-Type: application/json

Parameters

ParameterTypeRequiredDescription
fromstringYesSender address — email@domain or Name <email@domain>. The domain must be verified on your account, and within your key's domain scope.
tostring | string[]YesOne recipient address, or an array of them. Counts toward the combined 50-recipient cap with cc and bcc.
ccstring | string[]NoCarbon-copy recipients, same format as to.
bccstring | string[]NoBlind-carbon-copy recipients, same format as to.
subjectstringYes*Subject line, up to 998 characters. *Supplied by the template when you send with one — pass it only to override.
htmlstringOne of html / textHTML body. Supplied by the template when you send with one.
textstringOne of html / textPlain-text body. Sending both html and text is recommended. Supplied by the template when you send with one.
templateobjectNoSend with a published template: { "id": "...", "variables": { … } }. id is a template UUID or its team-scoped slug. See Sending with a template.
template_idstringNoFlat alternative to template.id — a published template by its UUID. Provide template_id or template_slug, not both.
template_slugstringNoFlat alternative — a published template by its team-scoped slug.
variablesobjectNoValues for a template's {{{placeholders}}}, e.g. { "first_name": "Ada" }. Only meaningful when sending with a template.
reply_tostringNoA single Reply-To address.
headersobjectNoUp to 25 custom email headers. See Custom headers.
tagsobject[]NoUp to 25 { name, value } labels for analytics and filtering. Both fields are ASCII letters, digits, underscores and dashes, 1–256 characters each. See Tags.
attachmentsobject[]NoFiles to attach — up to 10. See Attachments.
scheduled_atstringNoWhen 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:

FieldRequiredDescription
filenameYesName shown in the recipient's mail client.
contentYesThe file's bytes, base64-encoded.
content_typeNoMIME 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 leading in is read as an offset from now.

Then:

  • If the resolved time is more than ~30 seconds in the future, the response last_event is scheduled and 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_at comes back null.
  • 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 html or text alongside a template is a contradiction, not a fallback, and is rejected with 422 (a template error).
  • Published templates only. A template still in draft is rejected with 422 — publish it first.
  • subject is optional. The template provides one; pass subject only to override it for this send. It runs through the same {{{variables}}}, so "Welcome {{{first_name}}}" works.
  • A template_id/template_slug that doesn't resolve on your team, or variables with no template, returns 422.

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/json

Keys 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/batch
bash
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.

Transactional email on your own domain.