Postbox docs

Sending

Send mail, make retries safe with idempotency keys, and read the delivery lifecycle correctly.

Two ways to send. Pick by who the sender is.

A person's mailbox (support@, a team inbox, anything that should receive replies): messages.send, below. The sender must be a mailbox you have created; the message is filed in its Sent folder, threads with the conversation, and replies land in that inbox.

Your application (invites, receipts, notifications, password resets): emails.send. Any address on a verified domain works, no mailbox needed. Replies to that address are not delivered anywhere, which is usually what you want for noreply@.

Using messages.send from an address that is not a mailbox returns 404 mailbox_not_found.

import { Postbox } from "@postbox/sdk";

const pb = new Postbox({
  apiKey: process.env.PB_KEY!,
  projectId: process.env.PROJECT_ID!,
});

await pb.messages.send(
  {
    from: "[email protected]",
    to: [{ address: "[email protected]", name: "Jess" }],
    subject: "Welcome to Acme",
    bodyHtml: "<p>Glad you are here.</p>",
    bodyText: "Glad you are here.",
  },
  { idempotencyKey: "welcome-user-1042" },
);
import os
from postbox import Postbox

pb = Postbox(api_key=os.environ["PB_KEY"], project_id=os.environ["PROJECT_ID"])

pb.messages.send(
    {
        "from": "[email protected]",
        "to": [{"address": "[email protected]", "name": "Jess"}],
        "subject": "Welcome to Acme",
        "bodyHtml": "<p>Glad you are here.</p>",
        "bodyText": "Glad you are here.",
    },
    idempotency_key="welcome-user-1042",
)
using Postbox;

var pb = new PostboxClient(
    new PostboxOptions
    {
        ApiKey = Environment.GetEnvironmentVariable("PB_KEY")!,
        ProjectId = Environment.GetEnvironmentVariable("PROJECT_ID")!,
    }
);

await pb.Messages.SendAsync(
    new MessagesSendBody
    {
        From = "[email protected]",
        To = new List<Dictionary<string, object?>>
        {
            new() { ["address"] = "[email protected]", ["name"] = "Jess" },
        },
        Subject = "Welcome to Acme",
        BodyHtml = "<p>Glad you are here.</p>",
        BodyText = "Glad you are here.",
    },
    idempotencyKey: "welcome-user-1042"
);
import cloud.postboxapp.*;
import cloud.postboxapp.gen.*;
import java.util.*;

Postbox pb = new Postbox(PostboxOptions.builder().apiKey(System.getenv("PB_KEY")).projectId(System.getenv("PROJECT_ID")).build());

pb.messages.send(MessagesSendBody.builder()
    .from("[email protected]")
    .to(List.of(Map.of("address", "[email protected]", "name", "Jess")))
    .subject("Welcome to Acme")
    .bodyHtml("<p>Glad you are here.</p>")
    .bodyText("Glad you are here.")
    .build());
import postbox "github.com/postboxapp/postbox-go"

pb, err := postbox.NewWithKey(os.Getenv("PB_KEY"))
if err != nil {
    log.Fatal(err)
}
ctx := context.Background()

pb.Messages.Send(ctx, &postbox.MessagesSendBody{
    From:     "[email protected]",
    To:       []map[string]any{{"address": "[email protected]", "name": "Jess"}},
    Subject:  "Welcome to Acme",
    BodyHtml: "<p>Glad you are here.</p>",
    BodyText: "Glad you are here.",
})
curl -X POST https://api.postboxapp.cloud/v1/messages/send \
  -H "Authorization: Bearer $PB_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-user-1042" \
  -d '{
        "from": "[email protected]",
        "to": [{ "address": "[email protected]", "name": "Jess" }],
        "subject": "Welcome to Acme",
        "bodyHtml": "<p>Glad you are here.</p>",
        "bodyText": "Glad you are here."
      }'

Requires the send permission and an active domain.

Request body

FieldRequiredNotes
fromyesA bare address that is a mailbox on a domain you have verified. No display name here (Name <addr> is rejected).
toyesAt least one recipient. [{ address, name? }].
cc, bccnoSame recipient shape.
subjectyesCannot be empty.
bodyTextnoPlain-text part.
bodyHtmlnoHTML part.
attachmentsnoSee below.
threadIdnoUUID. Files the message into an existing thread.
replyTonoReply-To header.

Send at least one of bodyText or bodyHtml. Send both when you can: a text/html multipart is better for deliverability than HTML alone.

Attachments are inline base64, not uploads:

{
  "attachments": [
    {
      "filename": "invoice.pdf",
      "content": "JVBERi0xLjQKJ...",
      "mimeType": "application/pdf",
      "cid": "invoice-1"
    }
  ]
}

cid is optional and only needed to reference an image from your HTML with <img src="cid:invoice-1">. Total message size is capped at 40MB, and base64 inflates payloads by about a third, so budget accordingly.

Sending from your application

For mail your application sends on its own behalf, use emails.send (POST /v1/emails). It is a separate pipeline from mailbox sending: a verified domain authorizes every address under it, nothing is provisioned, and the send is logged under Sending in the console rather than in a mailbox. It needs a project-scoped API key.

await pb.emails.send(
  {
    from: "[email protected]",
    fromName: "Acme",
    to: [{ address: "[email protected]", name: "Jess" }],
    subject: "You're invited to Acme",
    html: "<p>Jess, your workspace is ready.</p>",
    text: "Jess, your workspace is ready.",
  },
  { idempotencyKey: "invite-1042" },
);
pb.emails.send(
    {
        "from": "[email protected]",
        "fromName": "Acme",
        "to": [{"address": "[email protected]", "name": "Jess"}],
        "subject": "You're invited to Acme",
        "html": "<p>Jess, your workspace is ready.</p>",
        "text": "Jess, your workspace is ready.",
    },
    idempotency_key="invite-1042",
)
curl https://api.postboxapp.cloud/v1/emails \
  -H "Authorization: Bearer $PB_KEY" \
  -H "Idempotency-Key: invite-1042" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "[email protected]",
    "fromName": "Acme",
    "to": [{ "address": "[email protected]", "name": "Jess" }],
    "subject": "You'"'"'re invited to Acme",
    "html": "<p>Jess, your workspace is ready.</p>",
    "text": "Jess, your workspace is ready."
  }'
FieldRequiredNotes
fromyesA bare address on a verified domain. Put the display name in fromName.
fromNamenoDisplay name shown next to the address.
to, cc, bccto yesSame recipient shape as above.
subjectyesCannot be empty.
html, textone ofbodyHtml / bodyText are accepted as aliases.
replyTonoWhere replies should go, since the sender itself has no inbox.
headers, tags, attachmentsnoCustom headers, key-value tags for filtering the log, attachments.

The response is { id, status, recipientCount, suppressed }. Read a send back with emails.get(id), list and filter with emails.list(...), and see aggregate outcomes with emails.stats(). Idempotency works the same way as below.

Idempotency

POST /messages/send accepts an Idempotency-Key header. Reuse the same key and the original result is returned instead of a second message being sent.

Idempotency-Key: welcome-user-1042

Use it for anything triggered by a retryable event. A key derived from your own domain object (welcome-user-1042) is better than a random UUID, because on retry your code must produce the same key for it to protect you. A random UUID regenerated on retry protects nothing.

In the SDK it is a call option, not part of the body:

await pb.messages.send(
  { from, to, subject, bodyHtml },
  {
    idempotencyKey: `welcome-${user.id}`,
  },
);

The delivery lifecycle

A 200 from /messages/send does not mean the mail was delivered. It means Postbox validated, stored, and queued it.

POST /messages/send ──► 200 (queued)
                          │
                          ▼
                    message.sent          accepted for delivery
                          │
             ┌────────────┼────────────┐
             ▼            ▼            ▼
     message.delivered  message.bounced  message.complained
EventMeansTiming
message.sentThe message was accepted for delivery.Seconds.
message.deliveredThe receiving server accepted it.Seconds to minutes.
message.bouncedRejected. bounceType is hard or soft.Seconds to hours.
message.complainedRecipient marked it as spam.Hours to days.
message.failedThe message was rejected before delivery was attempted.Seconds.

Only message.delivered means the message landed. message.sent is acceptance, not delivery, and reporting built on it overstates delivery rates.

A soft bounce is transient (a full mailbox). A hard bounce is permanent (no such address) and adds the recipient to your suppression list.

Suppression

Hard bounces and complaints suppress the recipient automatically. Later sends to a suppressed address are rejected at send time.

Suppression is enforced, not advisory. Continuing to send to addresses that have already hard bounced is among the fastest ways to get a sending domain blocked, and sending reputation is partly shared. See Deliverability for how suppression, bounce rates, and reputation fit together.

Rate limits and the plan gate

Two independent limits apply, both keyed off the sending mailbox and its org, not a flat per-key request rate.

Mailbox send caps. If the mailbox belongs to a group, the group's hourly, daily, and burst (token bucket) limits apply. Exceeding any of them rejects the send until the window rolls over or the bucket refills.

Plan allowance. Sends and receives are metered together per calendar month at the org level, across every project. On the free plan, running out stops sends outright. On a paid plan, metered overage is allowed past the allowance only while billing is active. If billing is not active, sends hard-stop with a message telling you to add a payment method.

Hitting a limit

Exceeding either limit returns a 429. Back off and retry with exponential backoff and jitter rather than hammering the endpoint. See Errors.

Polling a message's status

Webhooks and the event stream are the best way to learn a message's outcome, but if you can't receive them, poll the status directly. Every send returns a messageId; use it to check where the message is in its lifecycle:

await pb.messages.getStatus(messageId);
pb.messages.get_status(message_id)
await pb.Messages.GetStatusAsync(messageId);
pb.messages.getStatus(messageId);
pb.Messages.GetStatus(ctx, messageID)
curl https://api.postboxapp.cloud/v1/messages/$MESSAGE_ID/status \
  -H "Authorization: Bearer $PB_KEY"

Or poll by the Idempotency-Key you sent with, for when the returned id was not retained:

await pb.messages.getStatusByKey({ idempotencyKey: "welcome-user-1042" });
pb.messages.get_status_by_key(query={"idempotencyKey": "welcome-user-1042"})
await pb.Messages.GetStatusByKeyAsync(
    new MessagesGetStatusByKeyQuery { IdempotencyKey = "welcome-user-1042" }
);
pb.messages.getStatusByKey(
    MessagesGetStatusByKeyQuery.builder().idempotencyKey("welcome-user-1042").build()
);
pb.Messages.GetStatusByKey(ctx, &postbox.MessagesGetStatusByKeyQuery{IdempotencyKey: "welcome-user-1042"})
curl "https://api.postboxapp.cloud/v1/messages/status?idempotencyKey=welcome-user-1042" \
  -H "Authorization: Bearer $PB_KEY"

Both return the same object:

{
  "success": true,
  "data": {
    "messageId": "…",
    "status": "delivered",
    "terminal": true,
    "providerMessageId": "…",
    "attempts": 1,
    "bounceType": null,
    "bounceCode": null,
    "queuedAt": "…",
    "sentAt": "…",
    "deliveredAt": "…"
  }
}

status follows the same delivery lifecycle as the events: queued → sent → delivered, or bounced / failed. Poll until terminal is true, then stop. A terminal status does not change. Space polls out by a few seconds and back off, since outcomes arrive asynchronously, and prefer webhooks or the event stream wherever they can be received. Requires the send permission.

Checking what happened

Delivery outcomes are asynchronous, so poll the log or subscribe to the events:

await pb.logs.outbound();

await pb.stats.bounces();
pb.logs.outbound()

pb.stats.bounces()
await pb.Logs.OutboundAsync();

await pb.Stats.BouncesAsync();
pb.logs.outbound();

pb.stats.bounces();
pb.Logs.Outbound(ctx)

pb.Stats.Bounces(ctx)
curl https://api.postboxapp.cloud/v1/logs/outbound \
  -H "Authorization: Bearer $PB_KEY" -H "X-Project-Id: $PROJECT_ID"

curl https://api.postboxapp.cloud/v1/stats/bounces \
  -H "Authorization: Bearer $PB_KEY" -H "X-Project-Id: $PROJECT_ID"

Subscribing beats polling. See Webhooks and Events.