# Sending email

> Everything about POST /emails - recipients, HTML and text, batch sends, scheduling with natural language, attachments (base64 or remote path), tags, headers and streams.

Source: https://docs.pidgely.com/guides/sending/

[`POST /emails`](https://docs.pidgely.com/api-reference/emails/send/) accepts one email; [`POST /emails/batch`](https://docs.pidgely.com/api-reference/emails/send-batch/) accepts up to 100. Both return as soon as the email is safely queued, with its id. Delivery, retries and bounce handling happen in the background and are reported through [webhooks](https://docs.pidgely.com/guides/webhooks/).

```json
{
  "from": "Acme <onboarding@acme.com>",
  "to": ["steve@example.com"],
  "cc": ["billing@acme.com"],
  "reply_to": "support@acme.com",
  "subject": "Your invoice for October",
  "html": "<p>Hi Steve, your invoice is attached.</p>",
  "attachments": [{ "path": "https://files.acme.com/invoices/2026-10.pdf" }],
  "tags": [{ "name": "category", "value": "invoice" }]
}
```

## Sender and recipients

- **`from`** is `addr@domain` or `Name <addr@domain>`. The domain must be [verified](https://docs.pidgely.com/guides/domains/) in your account (exact match) and allowed for the API key.
- **`to`, `cc`, `bcc`** take one address or a list. Up to **50 unique recipients** across the three; addresses are de-duplicated (case-insensitive local part, IDN domains normalized).
- **`reply_to`** takes one address or a list and doesn't count toward the 50.
- Addresses with an IP literal as domain, local parts over 64 characters or addresses over 254 characters are rejected.

Each recipient is delivered separately and gets its own status. Recipients on your [suppression list](https://docs.pidgely.com/guides/suppressions/) are accepted but not delivered: they end as `suppressed`, don't count toward your quota, and trigger `email.suppressed`.

## Content

- **`subject`** is required (up to 998 bytes) unless a [template](https://docs.pidgely.com/guides/templates/) provides it.
- **`html`** and/or **`text`**: at least one is required. When you send only `html`, Pidgely generates the plain-text part for you.
- The `Message-ID` uses your domain; lines always end in CRLF.

## Batch sending

```js tab="Node.js"
import { Pidgely } from 'pidgely';

const pidgely = new Pidgely(process.env.PIDGELY_API_KEY);

const { data, error } = await pidgely.batch.send([
  {
    from: 'Acme <hi@acme.com>',
    to: ['alice@example.com'],
    subject: 'Hi Alice',
    html: '<p>Hi!</p>',
  },
  {
    from: 'Acme <hi@acme.com>',
    to: ['bob@example'],
    subject: 'Hi Bob',
    html: '<p>Hi!</p>',
  },
], {
  batchValidation: 'permissive',
});
console.log(data ?? error);
```

```python tab="Python"
import os
import pidgely

pidgely.api_key = os.environ["PIDGELY_API_KEY"]

result = pidgely.Batch.send([
    {
        "from": "Acme <hi@acme.com>",
        "to": ["alice@example.com"],
        "subject": "Hi Alice",
        "html": "<p>Hi!</p>",
    },
    {
        "from": "Acme <hi@acme.com>",
        "to": ["bob@example"],
        "subject": "Hi Bob",
        "html": "<p>Hi!</p>",
    },
], {
    "batch_validation": "permissive",
})
print(result)
```

```php tab="PHP"
<?php

require __DIR__ . '/vendor/autoload.php';

$pidgely = Pidgely::client(getenv('PIDGELY_API_KEY'));

$result = $pidgely->batch->send([
    [
        'from' => 'Acme <hi@acme.com>',
        'to' => ['alice@example.com'],
        'subject' => 'Hi Alice',
        'html' => '<p>Hi!</p>',
    ],
    [
        'from' => 'Acme <hi@acme.com>',
        'to' => ['bob@example'],
        'subject' => 'Hi Bob',
        'html' => '<p>Hi!</p>',
    ],
], [
    'batch_validation' => 'permissive',
]);
var_dump($result);
```

```go tab="Go"
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/pidgely/pidgely-go"
)

func main() {
	ctx := context.Background()
	client := pidgely.NewClient(os.Getenv("PIDGELY_API_KEY"))

	result, err := client.Batch.Send(ctx, []pidgely.SendEmailRequest{
		{
			From:    "Acme <hi@acme.com>",
			To:      []string{"alice@example.com"},
			Subject: "Hi Alice",
			Html:    "<p>Hi!</p>",
		},
		{
			From:    "Acme <hi@acme.com>",
			To:      []string{"bob@example"},
			Subject: "Hi Bob",
			Html:    "<p>Hi!</p>",
		},
	}, pidgely.WithBatchValidation(pidgely.BatchPermissive))
	if err != nil {
		panic(err)
	}
	fmt.Println(result)
}
```

```ruby tab="Ruby"
require "pidgely"

Pidgely.api_key = ENV["PIDGELY_API_KEY"]

result = Pidgely::Batch.send([
  {
    from: "Acme <hi@acme.com>",
    to: ["alice@example.com"],
    subject: "Hi Alice",
    html: "<p>Hi!</p>"
  },
  {
    from: "Acme <hi@acme.com>",
    to: ["bob@example"],
    subject: "Hi Bob",
    html: "<p>Hi!</p>"
  }
], {
  batch_validation: "permissive"
})
puts result
```

```bash tab="cURL"
curl -X POST 'https://api.pidgely.com/emails/batch' \
  -H "Authorization: Bearer $PIDGELY_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'x-batch-validation: permissive' \
  -d '[
  {
    "from": "Acme <hi@acme.com>",
    "to": [
      "alice@example.com"
    ],
    "subject": "Hi Alice",
    "html": "<p>Hi!</p>"
  },
  {
    "from": "Acme <hi@acme.com>",
    "to": [
      "bob@example"
    ],
    "subject": "Hi Bob",
    "html": "<p>Hi!</p>"
  }
]'
```

- 1 to 100 emails per request, each with the full single-send shape (attachments, `scheduled_at` and `template` included).
- **`strict`** (default): all or nothing. The first invalid email fails the request, with its index in the message (`[1].to: …`).
- **`permissive`**: valid emails are queued and invalid ones are listed in `errors`:

```json
{
  "data": [{ "id": "0199d8a2-6f3c-7c51-9e2a-3b1f0c6d4e21" }],
  "errors": [{ "index": 1, "message": "to: invalid address \"bob@example\": invalid domain \"example\"" }]
}
```

A batch that would exceed the daily quota is rejected as a whole. Batches support [idempotency keys](https://docs.pidgely.com/idempotency/) like single sends.

## Scheduling

Set `scheduled_at` to send later, up to **30 days** ahead. It accepts ISO 8601:

```json
{ "scheduled_at": "2026-10-20T09:00:00-03:00" }
```

or natural language, evaluated in **UTC**:

| You write | Means |
|---|---|
| `in 10 minutes`, `in 2 hours`, `in 3 days`, `in 1 week` | Relative to now. Units: `s`/`sec`/`seconds`, `m`/`min`/`minutes`, `h`/`hr`/`hours`, `d`/`days`, `w`/`weeks` |
| `in an hour`, `in a day`, `one week from now`, `3 days from now` | `a`, `an` and `one` work as numbers; `in` and `from now` are optional |
| `tomorrow`, `tomorrow at 9am`, `tomorrow 18:30`, `today at 5:45pm` | A time of day in UTC (12- or 24-hour) |
| `now`, `immediately`, `right now` | Send now |

Matching ignores case and extra spaces. ISO dates without an offset are treated as UTC. A time in the past (or now) sends immediately; anything unrecognised fails with `unrecognised date "…": use ISO 8601 or phrases like "in 1 hour"`.

While the email is still scheduled you can:

- **reschedule** it with [`PATCH /emails/{id}`](https://docs.pidgely.com/api-reference/emails/update/) and a new `scheduled_at` (in the future, within 30 days);
- **cancel** it with [`POST /emails/{id}/cancel`](https://docs.pidgely.com/api-reference/emails/cancel/).

Both return `409` (`not_reschedulable` / `not_cancelable`) once the email has been sent or canceled. A scheduled email shows `last_event: "scheduled"`.

## Attachments

Each attachment has **either** `content` (standard base64) **or** `path` (an `http`/`https` URL that Pidgely downloads when you send):

```json
{
  "attachments": [
    { "filename": "invoice.pdf", "content": "JVBERi0xLjcKJcfsj6IKNSAwIG9iago8PC9MZW5ndGggNiAwIFIvRmlsdGVyIC9GbGF0ZURlY29kZT4+CnN0cmVhbQp4nO1d…" },
    { "path": "https://files.acme.com/reports/october.csv", "content_type": "text/csv" },
    { "filename": "logo.png", "content": "iVBORw0KGgoAAAANSUhEUgAA…", "content_id": "logo" }
  ],
  "html": "<img src=\"cid:logo\" alt=\"Acme\"> <p>Your files are attached.</p>"
}
```

- **Size.** All attachments of an email share a budget of **40 MiB** (decoded), and the whole message must stay under 40 MiB after encoding. There is no separate limit on the number of attachments.
- **Remote `path`.** Fetched once at send time with a 30-second timeout. Only a `200` response is accepted, redirects are not followed, URLs with credentials and private or internal addresses are refused. The filename defaults to the last segment of the URL path, and the response `Content-Type` is used unless it is `text/html`.
- **Inline images.** Set `content_id` and reference it as `cid:<content_id>` in the HTML.
- **Content type** is guessed from the extension when you omit `content_type` (fallback `application/octet-stream`).
- **Blocked types.** Executables and scripts are rejected: `.exe .bat .cmd .com .cpl .dll .js .jse .msi .msp .pif .scr .vbs .vbe .wsf .wsh .hta .jar .ps1 .lnk .iso .img .vhd .reg .app .apk`.
- File names can't contain `/`, `\` or line breaks.

Attachments of a sent email can be listed and downloaded with [signed links](https://docs.pidgely.com/api-reference/emails/list-attachments/) valid for one hour.

## Tags

Tags are name/value pairs (`^[A-Za-z0-9_-]{1,256}$` for both) stored with the email and included in its webhook events. Use them to tie an email back to your own data:

```json
{ "tags": [{ "name": "user_id", "value": "42" }, { "name": "category", "value": "password_reset" }] }
```

## Custom headers

`headers` adds headers to the message:

```json
{ "headers": { "X-Entity-Ref-ID": "order-1234", "List-Id": "Acme receipts <receipts.acme.com>" } }
```

Header names are 1 to 76 printable ASCII characters without `:`; values can't contain line breaks. Headers the platform manages are rejected: `From`, `To`, `Cc`, `Bcc`, `Subject`, `Date`, `Message-ID`, `MIME-Version`, `Content-Type`, `Content-Transfer-Encoding`, `DKIM-Signature`, `Return-Path`, `Received`, `Sender`, `Reply-To` (use `reply_to`), `Feedback-ID`, `X-Pidgely-Ref`, `Authentication-Results` and the `ARC-*` headers.

## Streams

Pidgely keeps **transactional** and **broadcast** mail apart, on separate IP pools, so a marketing campaign never hurts the delivery of password resets. `POST /emails` uses the transactional stream by default. Set `"stream": "broadcast"` for bulk or marketing mail sent through the emails endpoint: those messages get `List-Unsubscribe` and `List-Unsubscribe-Post` headers ([RFC 8058](https://pidgely.com/blog/one-click-unsubscribe-rfc-8058/) one-click unsubscribe). [Broadcasts](https://docs.pidgely.com/guides/broadcasts/) always use the broadcast stream.

## Templates

Instead of `html`/`text`, reference a published [template](https://docs.pidgely.com/guides/templates/) and pass its variables:

```json
{
  "to": ["steve@example.com"],
  "template": { "id": "order-confirmation", "variables": { "ORDER_ID": "1234", "TOTAL": 59.9 } }
}
```

## Status of an email

[`GET /emails/{id}`](https://docs.pidgely.com/api-reference/emails/retrieve/) returns `last_event`, the most advanced status across the recipients:

| `last_event` | Meaning |
|---|---|
| `queued` | Accepted, waiting for delivery |
| `scheduled` | Waiting for its `scheduled_at` |
| `delivery_delayed` | A temporary failure; Pidgely retries for up to 72 hours |
| `delivered` | The receiving server accepted the message |
| `bounced` | Permanently rejected; the address is now [suppressed](https://docs.pidgely.com/guides/suppressions/) |
| `complained` | The recipient marked it as spam |
| `suppressed` | Not sent: the address is on the suppression list |
| `failed` | Gave up (retry window exhausted, account suspended, content expired) |
| `canceled` | Canceled before sending |

The bodies of sent emails are kept for 30 days.

## Retries and timing

Temporary failures are retried after 1 minute, 5, 15 and 30 minutes, 1 and 2 hours, then every 4 hours, for up to **72 hours**; each one emits `email.delivery_delayed`. Permanent failures bounce immediately. If the primary route is blocked or unreachable, Pidgely can deliver through a second own node.
