Skip to content
Pidgely Docs

Guides

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.

View as Markdown

POST /emails accepts one email; POST /emails/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.

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

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
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
<?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
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
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
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 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:

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 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 one-click unsubscribe). Broadcasts always use the broadcast stream.

Templates

Instead of html/text, reference a published template 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} 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
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.