> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brudcast.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Email send API

> Send email over HTTPS with POST /api/v1/send: every field, attachments, custom headers, the 202 response, errors and limits.

export const Screenshot = ({id, src, srcDark, alt, caption}) => {
  if (!src) {
    return <Placeholder id={id} kind="screenshot" label="Screenshot coming soon" description={alt} icon={<ImageIcon />} />;
  }
  return <figure className="brd-media" data-media-id={id}>
      <img className="brd-media-frame block dark:hidden" src={src} alt={alt} />
      <img className="brd-media-frame hidden dark:block" src={srcDark || src} alt={alt} />
      {caption && <figcaption className="brd-media-caption">{caption}</figcaption>}
    </figure>;
};

Send one email per request, as JSON, from any sending domain you've verified. Brudcast queues it
and returns a job ID straight away.

<Screenshot id="ss-developers-http-api-hero" alt="The Sending Keys tab on a verified domain, listing one active sending key" />

<Info>
  **Before you start**, you need:

  * your own verified sending domain. The **Brudcast trial address** has no sending keys. See
    [Email overview](/channels/email/overview).
  * a sending key from that domain's **Sending Keys** tab, or an API key with the **Send Messages**
    permission. See [Authentication](/developers/authentication#sending-keys).
</Info>

## Endpoint

```
POST https://mailing-service.prod.brudcast.com/api/v1/send
```

<ParamField header="Authorization" type="string" required>
  `Bearer ` followed by your sending key. The send API ignores `X-API-Key`.
</ParamField>

<ParamField header="Content-Type" type="string" required>
  `application/json`
</ParamField>

## Example

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -X POST https://mailing-service.prod.brudcast.com/api/v1/send \
    -H "Authorization: Bearer your_sending_key" \
    -H "Content-Type: application/json" \
    -d '{
      "from": { "name": "Example Billing", "address": "billing@mail.example.com" },
      "to": [{ "name": "Jane", "address": "jane@example.com" }],
      "replyTo": { "address": "support@example.com" },
      "subject": "Your receipt",
      "text": "Thanks for your order.",
      "html": "<p>Thanks for your order.</p>",
      "headers": { "X-Order-Id": "10482" }
    }'
  ```

  ```javascript Node.js theme={"system"}
  const response = await fetch("https://mailing-service.prod.brudcast.com/api/v1/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.BRUDCAST_SENDING_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: { name: "Example Billing", address: "billing@mail.example.com" },
      to: [{ name: "Jane", address: "jane@example.com" }],
      replyTo: { address: "support@example.com" },
      subject: "Your receipt",
      text: "Thanks for your order.",
      html: "<p>Thanks for your order.</p>",
      headers: { "X-Order-Id": "10482" },
    }),
  });

  const body = await response.json();
  if (response.status !== 202) throw new Error(body.message);
  console.log(body.data.jobId);
  ```

  ```python Python theme={"system"}
  import os
  import requests

  response = requests.post(
      "https://mailing-service.prod.brudcast.com/api/v1/send",
      headers={"Authorization": f"Bearer {os.environ['BRUDCAST_SENDING_KEY']}"},
      json={
          "from": {"name": "Example Billing", "address": "billing@mail.example.com"},
          "to": [{"name": "Jane", "address": "jane@example.com"}],
          "replyTo": {"address": "support@example.com"},
          "subject": "Your receipt",
          "text": "Thanks for your order.",
          "html": "<p>Thanks for your order.</p>",
          "headers": {"X-Order-Id": "10482"},
      },
  )

  body = response.json()
  if response.status_code != 202:
      raise RuntimeError(body["message"])
  print(body["data"]["jobId"])
  ```

  ```php PHP theme={"system"}
  <?php
  $payload = [
      "from" => ["name" => "Example Billing", "address" => "billing@mail.example.com"],
      "to" => [["name" => "Jane", "address" => "jane@example.com"]],
      "replyTo" => ["address" => "support@example.com"],
      "subject" => "Your receipt",
      "text" => "Thanks for your order.",
      "html" => "<p>Thanks for your order.</p>",
      "headers" => ["X-Order-Id" => "10482"],
  ];

  $ch = curl_init("https://mailing-service.prod.brudcast.com/api/v1/send");
  curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_HTTPHEADER => [
          "Authorization: Bearer " . getenv("BRUDCAST_SENDING_KEY"),
          "Content-Type: application/json",
      ],
      CURLOPT_POSTFIELDS => json_encode($payload),
      CURLOPT_RETURNTRANSFER => true,
  ]);

  $body = json_decode(curl_exec($ch), true);
  $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  if ($status !== 202) {
      throw new RuntimeException($body["message"]);
  }
  echo $body["data"]["jobId"];
  ```
</CodeGroup>

## Request body

<ParamField body="from" type="object" required>
  The sender. The address must be on a domain the key can send from.

  <Expandable title="properties">
    <ParamField body="address" type="string" required>
      A valid email address, up to 254 characters.
    </ParamField>

    <ParamField body="name" type="string">
      The display name, such as `Example Billing`.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="to" type="object[]" required>
  At least one recipient, each with an `address` (up to 254 characters) and an optional `name`.
</ParamField>

<ParamField body="cc" type="object[]">
  Copy recipients, in the same shape as `to`.
</ParamField>

<ParamField body="bcc" type="object[]">
  Blind-copy recipients, in the same shape as `to`. They receive the message but aren't listed in
  its headers.
</ParamField>

<ParamField body="replyTo" type="object">
  Where replies go: `{ "address": "…" }`, up to 254 characters.
</ParamField>

<ParamField body="subject" type="string" required>
  The subject line. Can't be empty.
</ParamField>

<ParamField body="text" type="string">
  The plain-text body. Send `text`, `html` or both. At least one is required.
</ParamField>

<ParamField body="html" type="string">
  The HTML body. Open and click tracking only work on the HTML part.
</ParamField>

<ParamField body="attachments" type="object[]">
  Files to attach. See [Attachments](#attachments).

  <Expandable title="properties">
    <ParamField body="filename" type="string" required>
      The file name the recipient sees, such as `invoice.pdf`.
    </ParamField>

    <ParamField body="content" type="string" required>
      The file's bytes, base64-encoded.
    </ParamField>

    <ParamField body="encoding" type="string" required>
      Always `base64`.
    </ParamField>

    <ParamField body="contentType" type="string" required>
      The MIME type, such as `application/pdf`.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="headers" type="object">
  Extra headers as name–value pairs. Names up to 78 characters, values up to 998. See
  [Custom headers](#custom-headers).
</ParamField>

Everyone in `to` and `cc` can see everyone else in those fields. To send the same email privately to
many people, make one request per recipient, or send a [campaign](/campaigns/overview).

## Attachments

Base64-encode each file and put it in `attachments`:

```javascript Node.js theme={"system"}
import { readFile } from "node:fs/promises";

const pdf = await readFile("invoice.pdf");

const attachments = [
  {
    filename: "invoice.pdf",
    content: pdf.toString("base64"),
    encoding: "base64",
    contentType: "application/pdf",
  },
];
```

The whole JSON body must be under 10 MB. Base64 makes a file about a third larger, so that leaves
room for roughly 7.5 MB of original files in one message.

## Custom headers

Anything in `headers` is added to the message, with these exceptions. Brudcast silently removes
these headers because it sets them itself, or because they'd break signing or delivery:

* `DKIM-Signature`, `Return-Path`, `Received`
* `Content-Type`, `MIME-Version`, `Content-Transfer-Encoding`
* `traceparent`, `tracestate`
* `X-Job-Id`

Don't use names that start with `X-Brudcast-`. Brudcast reserves them, and its own values replace
yours.

Brudcast adds these headers to every message:

| Header       | Value                                                                                            |
| ------------ | ------------------------------------------------------------------------------------------------ |
| `Message-ID` | Built from the `jobId`, so you can match a message in a recipient's mailbox back to your request |
| `X-Job-Id`   | Starts with the `jobId`                                                                          |

For replies, use the `replyTo` field rather than a `Reply-To` header.

## Response

A successful request returns `202 Accepted`:

```json theme={"system"}
{
  "status": true,
  "message": "Email queued for delivery",
  "data": { "jobId": "3f1c2a9e-8b7d-4c6e-9a51-2d0e7f4b6c10" }
}
```

<ResponseField name="status" type="boolean">
  `true` when the message was queued.
</ResponseField>

<ResponseField name="message" type="string">
  `Email queued for delivery`.
</ResponseField>

<ResponseField name="data.jobId" type="string">
  A UUID that identifies this message. Use it to look the message up in the
  [message log](/developers/sending/message-status). It also appears in webhook payloads.
</ResponseField>

`202` means queued, not delivered. Delivery to each recipient's mail server happens next, and you
learn the outcome from the message log or from [webhooks](/developers/webhooks/overview).

## Which from addresses are allowed

| Key                            | Allowed `from` domains                    |
| ------------------------------ | ----------------------------------------- |
| Domain sending key             | Only the domain the key was created on    |
| API key with **Send Messages** | Any sending domain your organization owns |

In both cases the domain must be set up in Brudcast. Verify it before sending, or receiving servers
can't check the DKIM signature and SPF record, and your mail is likely to be rejected or filtered.

## What happens after you send

* **Suppressed recipients are dropped.** Addresses on your organization's
  [suppression list](/deliverability/bounces-complaints-and-suppression) are skipped silently. If
  every recipient is suppressed, nothing is sent, even though the request returned `202`.
* **Temporary failures are retried.** A message deferred by the receiving server is retried up to
  3 times.
* **Your domain's tracking settings apply.** Open tracking, click tracking and unsubscribe links
  follow the settings on the domain's **Settings** tab in **Channels > Email**. They're off on a new
  domain. When a message has several `to` recipients, every open and click is credited to the first
  one.

## Errors

| Status | Message                                                                  | Cause                                                    |
| ------ | ------------------------------------------------------------------------ | -------------------------------------------------------- |
| 400    | `From address domain 'x' does not match sending key domain 'y'`          | A domain sending key used a `from` on another domain     |
| 400    | `Domain x is not registered`                                             | The `from` domain isn't set up in Brudcast               |
| 400    | `From address domain 'x' is not owned by the sending key's organization` | The `from` domain belongs to another organization        |
| 401    | `Unauthorized`                                                           | The key is missing, unknown or not yet active            |
| 403    | `Sending is disabled for this account: <reason>`                         | The account or the domain is suspended                   |
| 422    | `Validation error`                                                       | See `errors` for each field                              |
| 429    | Several messages                                                         | A rate limit. See [Rate limits](/developers/rate-limits) |
| 500    | `An unexpected error occurred`                                           | Also returned for invalid JSON or a body over 10 MB      |

See [Errors](/developers/errors#email-send-api-errors) for every message and the fix for each.

## Retries and duplicate sends

The send API has no idempotency key. Every accepted request queues a new message, so a request you
send twice delivers twice and charges twice.

That matters because a `202` can be lost on the way back to you — a dropped connection or a client
timeout leaves you unsure whether the message was queued.

| Situation                             | Safe to retry?                                 |
| ------------------------------------- | ---------------------------------------------- |
| `4xx` other than `429`                | No. The request is wrong; fix it first         |
| `429`                                 | Yes, after the `Retry-After` delay             |
| `5xx`                                 | Yes, with backoff                              |
| No response, or your client timed out | Not blindly. The message may already be queued |

For the last case, key the send on something of your own — an order ID, a notification row — and
record the returned `jobId` against it before you retry:

1. Write your own identifier and a `pending` state to your database.
2. Call the send API.
3. Store the `jobId` from the `202` against that identifier.

If step 2 or 3 fails, you still have the row. Before retrying, look the message up with
[Message status](/developers/sending/message-status) using the `jobId` if you captured one; retry
only when you didn't. This is the same pattern as the contact upsert in
[Sync contacts from your app](/developers/guides/sync-contacts-from-your-app).

<Warning>
  Don't retry a `202` you already received. The message is queued, and delivery has not failed —
  it simply hasn't finished. Watch its status instead.
</Warning>

## Limits

| Limit                       | Value                                                                                                           |
| --------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Request body                | 10 MB of JSON                                                                                                   |
| Email address               | 254 characters                                                                                                  |
| Header name / value         | 78 / 998 characters                                                                                             |
| Requests to `/api/v1/send`  | 30 a minute per client IP address                                                                               |
| All requests                | 1,000 a minute per client IP address                                                                            |
| Organization sending limits | Per second, minute, hour, day and month. See [Rate limits](/developers/rate-limits#organization-sending-limits) |

<Columns cols={2}>
  <Card title="Message status" icon="list-checks" href="/developers/sending/message-status">
    Look up what happened to a message by its job ID.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/developers/webhooks/overview">
    Get delivery and bounce events as they happen.
  </Card>

  <Card title="SMTP relay" icon="server" href="/developers/sending/smtp-relay">
    Send from software that only speaks SMTP.
  </Card>
</Columns>
