# Sending and receiving

> Send from an address you own: recipients, replies that stay in the thread, HTML and plain text, attachments, and why a send is refused.

`from` has to be an address you own, and `in_reply_to` keeps the conversation
together. Sends go through the same abuse checks and the same daily cap as the
web composer — there is one set of rules, and the API is not a way around it.

## Attachments

Upload the file, then name its id in the send. Two calls, because a
ten-megabyte PDF is thirteen megabytes of base64 and putting it in the JSON
body would make every proxy between you and us carry it that way.

```bash
curl https://pidgeon.ai/api/v1/attachments \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-F identity_id=0c939bd7-2d69-4150-a2ac-de994f0e4887 \
-F file=@invoice.pdf
```

```ts
// The File's name is the filename the recipient sees.
const { data: file } = await attachmentsUpload({
body: {
  identity_id: '0c939bd7-2d69-4150-a2ac-de994f0e4887',
  file: new File([pdfBytes], 'invoice.pdf', { type: 'application/pdf' }),
},
throwOnError: true,
});

await messagesSend({
body: {
  from: 'receipts@acme.com',
  to: 'customer@example.com',
  subject: 'Your invoice',
  text: 'Attached.',
  attachment_ids: [file.data.id],
},
});
```

```python
files = {"file": open("invoice.pdf", "rb")}
httpx.post(
  "https://pidgeon.ai/api/v1/attachments",
  data={"identity_id": "0c939bd7-2d69-4150-a2ac-de994f0e4887"},
  files=files,
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
)
```

**An upload expires in a day.** It is a file no message refers to yet, so the
window is long enough for a retry after a failed send and short enough that a
crashed script does not leave its files with us.

**An id belongs to the address that uploaded it.** Naming somebody else's is
a `404`, which is also what a `404` means everywhere else here: not yours, or
not there, and we do not say which.

**A send takes all the ids or none.** If one has expired the whole call is
refused and nothing is consumed, so the ones that are still good can be used
in the retry. Half a message is worse than a refusal.

**Two sizes, both from your plan:** one file, and all the files on one
message. Going over either is refused before the message is built rather than
truncated after, because a silently shortened attachment is worse than a send
that did not happen.

## An HTML body

Pass `html` beside `text`, not instead of it.

```bash
curl https://pidgeon.ai/api/v1/messages \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "receipts@acme.com", "to": "customer@example.com",
      "subject": "Your receipt", "text": "Thanks.",
      "html": "<p>Thanks.</p>" }'
```

```ts
await messagesSend({
body: {
  from: 'receipts@acme.com',
  to: 'customer@example.com',
  subject: 'Your receipt',
  text: 'Thanks. Your receipt is below.',
  html: '<p>Thanks. Your receipt is below.</p>',
},
});
```

```python
httpx.post(
  "https://pidgeon.ai/api/v1/messages",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={"from": "receipts@acme.com", "to": "customer@example.com",
        "subject": "Your receipt", "text": "Thanks.",
        "html": "<p>Thanks.</p>"},
)
```

`text` stays required. A message with no plain-text part renders as nothing
in a client that refuses HTML, and sending one is a deliverability decision
nobody should make by leaving a field out.

## Send a reply

```bash
curl https://pidgeon.ai/api/v1/messages -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{
  "from": "hello@yourdomain.com",
  "to": "billing@supplier.com",
  "subject": "Re: Invoice 4417",
  "text": "Received, thank you.",
  "in_reply_to": "af08b0a1-284f-464a-a36e-8e15bb7c2eb8"
}'
```

```ts
await messagesSend({
body: {
  from: 'hello@yourdomain.com',
  to: 'billing@supplier.com',
  subject: 'Re: Invoice 4417',
  text: 'Received, thank you.',
  in_reply_to: event.data.message_id,
},
});
```

```python
httpx.post(
  "https://pidgeon.ai/api/v1/messages",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={
      "from": "hello@yourdomain.com",
      "to": "billing@supplier.com",
      "subject": "Re: Invoice 4417",
      "text": "Received, thank you.",
      "in_reply_to": event["data"]["message_id"],
  },
)
```

`in_reply_to` sets the `In-Reply-To` and `References` headers, which is what
makes the reply land in the same conversation in the recipient's client as
well as in yours.

## Rejected, not raised

Recipients that fail a check come back in `rejected` with a reason, and the
rest are sent. A suppressed address, a domain that does not resolve, or a
recipient over a limit does not lose the other four.

```bash
{
"data": {
  "id": "2a41309b-eefd-447c-a844-7c10a2469d6a",
  "thread_id": "463ec73a-699f-4ea8-a8fa-bb736ae1601e",
  "rejected": [{ "address": "gone@example.com", "reason": "suppressed" }]
}
}
```

An address that hard-bounced or reported spam is suppressed automatically and
stays suppressed until it is cleared. Sending to it again is refused rather
than attempted, because the reputation cost falls on the domain rather than on
the message.

**A recipient your own rules forbid is not this.** That refuses the whole
send — `403` with `blocked` when a rule on the address says no, and
`not_permitted` when its send policy does — and nothing goes to anybody. The
two are separated on purpose: `rejected` is the provider declining an address
at hand-off, which can leave a send partly done and is worth reading for.
Your own rules are checked before anything leaves, and dropping one recipient
silently is how *send the invoice to accounts and to Marcus* reaches one of
them.

## What limits a send

Three things, and the effective limit is the lowest of them: your plan's daily
allowance, the account's trust tier, and the per-minute API rate limit. A new
domain starts with a small allowance and earns its way up as its mail is
delivered and read — nobody can make a new domain trustworthy overnight, and
a large limit on day one is a reputation burnt on day two.
