Scheduled sending
Set scheduled_at on a send and we hold the message until that time, then send it exactly as if you had called the API at that moment, with the same events as any other send. You hand the message over once, and no scheduler of your own has to stay up until 9am.
Scheduling a send
Add a scheduled_at timestamp to a normal POST /v1/email/messages send. Nothing else about the payload changes.
const msg = await bird.email.send({
from: "news@yourdomain.com",
to: ["delivered@messagebird.dev"],
subject: "Your weekly digest",
html: "<p>Here is what happened this week...</p>",
category: "marketing",
scheduled_at: "2026-07-30T09:00:00Z",
});
console.log(msg.id, msg.status); // "em_…", "accepted"msg = client.email.send(
from_="news@yourdomain.com",
to=["delivered@messagebird.dev"],
subject="Your weekly digest",
html="<p>Here is what happened this week...</p>",
category="marketing",
scheduled_at="2026-07-30T09:00:00Z",
)
print(msg.id, msg.status)package main
import (
"context"
"fmt"
"log"
"os"
"time"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(option.WithAPIKey(os.Getenv("BIRD_API_KEY")))
if err != nil {
log.Fatal(err)
}
msg, err := client.Email.Send(context.Background(), bird.EmailSendParams{
From: "news@yourdomain.com",
To: []string{"delivered@messagebird.dev"},
Subject: "Your weekly digest",
HTML: "<p>Here is what happened this week...</p>",
Category: bird.CategoryMarketing,
ScheduledAt: time.Date(2026, 7, 30, 9, 0, 0, 0, time.UTC),
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)
}$message = $bird->email->send(
from: 'news@yourdomain.com',
to: ['delivered@messagebird.dev'],
subject: 'Your weekly digest',
html: '<p>Here is what happened this week...</p>',
category: 'marketing',
scheduledAt: new \DateTimeImmutable('2026-07-30T09:00:00Z'),
);
echo $message->getId(), ' ', $message->getStatus();bird email send \
--from news@yourdomain.com \
--to delivered@messagebird.dev \
--subject 'Your weekly digest' \
--html '<p>Here is what happened this week...</p>' \
--category marketing \
--scheduled-at 2026-07-30T09:00:00Zcurl -X POST https://us1.platform.bird.com/v1/email/messages \
-H "Authorization: Bearer bk_us1_..." \
-H "Content-Type: application/json" \
-d '{
"from": "news@yourdomain.com",
"to": ["delivered@messagebird.dev"],
"subject": "Your weekly digest",
"html": "<p>Here is what happened this week...</p>",
"category": "marketing",
"scheduled_at": "2026-07-30T09:00:00Z"
}'The call returns 202 Accepted with the em_-prefixed message ID and status: accepted straight away, the same response shape as an immediate send. Acceptance is synchronous; delivery is deferred. Omit scheduled_at and the message goes out right away.
On read endpoints the message shows status: scheduled with its scheduled_at until the send time arrives:
कोड उदाहरण
{
"id": "em_01ky7q24hafjgvzfg02v3m177p",
"status": "scheduled",
"scheduled_at": "2026-07-30T09:00:00Z",
"category": "marketing"
}When the time comes, we release the message and its status advances through the usual states (accepted, then processed, then delivered, and so on). scheduled_at stays set afterwards, so you can always see what a message was scheduled for.
Scheduling consumes one unit of your organization's scheduled-email allowance for the billing period. Exceeding that allowance is rejected with a 422 (E10003).
A scheduled send uses inline content
scheduled_at and template are mutually exclusive, and a send that sets both is rejected with a 422. That is the contract: a scheduled send has its own subject and body, and a template send goes out immediately. If the content you want to schedule lives in a template, take the rendered subject and body from it (the dashboard and the bird CLI both preview a template into the exact subject, HTML, and text a send would deliver) and schedule those as inline content.
A batch item cannot have scheduled_at either, so schedule through the single-send endpoint.
One more thing that catches people out: a payload that sends fine immediately can still be too large to park. A send whose recipient list, tags, and metadata together are very large comes back with a 422 asking you to reduce them or send immediately.
Choosing the send time
scheduled_at is an absolute RFC 3339 timestamp. Two rules govern it:
- It has to be between 30 seconds and 30 days in the future. Nearer than 30 seconds, or further out than 30 days, is rejected with a 422. The floor keeps a schedule from racing an immediate send. Thirty days is the furthest horizon we hold a message for.
- It is an exact instant, not a wall-clock time in some timezone. Include a UTC Z (2026-07-30T09:00:00Z) or an explicit offset (2026-07-30T09:00:00-04:00, the same instant as 13:00:00Z). We compare the instant against the current time and never interpret a bare local time or apply a recipient's timezone. To send at 9am in each recipient's local time, work out those instants yourself and schedule one send per timezone.
Relative expressions like "in 2 hours" are not accepted. Send a resolved timestamp.
Listing scheduled messages
Filter the message list by status to see the messages that have not fired yet:
for await (const message of bird.email.list({ status: "scheduled" })) {
console.log(message.id, message.scheduled_at);
}for message in client.email.list(status="scheduled"):
print(message.id, message.scheduled_at)for msg, err := range client.Email.List(context.Background(), bird.EmailListParams{Status: bird.EmailStatusScheduled}) {
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id)
}foreach ($bird->email->list(['status' => 'scheduled']) as $message) {
echo $message->getId(), "\n";
}bird email list --status scheduledcurl "https://us1.platform.bird.com/v1/email/messages?status=scheduled" \
-H "Authorization: Bearer bk_us1_..."status=canceled lists the ones you canceled before they sent. Once a scheduled message fires it moves into the pipeline and appears under the delivery statuses, the same as any other send. The dashboard's email log offers the same Scheduled and Canceled filters.
Canceling a scheduled send
Cancel a message any time before it starts sending with POST /v1/email/messages/{message_id}/cancel:
await bird.email.cancel("em_abc123");client.email.cancel("em_abc123")if err := client.Email.Cancel(context.Background(), "em_abc123"); err != nil {
log.Fatal(err)
}$bird->email->cancel('eml_01krdgeqcxet5s7t44vh8rt9mg');bird email cancel <message-id> --yescurl -X POST "https://{region}.platform.bird.com/v1/email/messages/{message_id}/cancel" \
-H "Authorization: Bearer $TOKEN"A successful cancel returns 204 No Content. The message's status becomes canceled, it never sends, and an email.canceled webhook fires. Four things to know:
-
Only a still-scheduled message can be canceled. A message that has already started sending, already sent, or was already canceled comes back 409:कोड उदाहरण
{ "error": { "type": "conflict_error", "code": "E10005", "name": "EmailNotCancelable", "message": "This message cannot be canceled. Only scheduled messages that have not started sending can be canceled." } }As the send time arrives, a cancel can also lose the race to the send itself and come back 409 for the same reason. -
A large send takes a few seconds to become cancelable. A scheduled send with attachments or a large body is still being stored for a few seconds after the 202, and a cancel in that window returns 409 while the message stays scheduled. So if a cancel straight after scheduling returns 409, read the message back: if it still shows status: scheduled, retry the cancel.
-
Canceling does not give the scheduled-email allowance back. The unit you consumed at schedule time stays consumed, which is what stops a schedule-then-cancel loop from working around the allowance. Your regular send allowance is untouched, because that is only charged when a message actually sends.
-
Cancel is safe to retry with an Idempotency-Key, like any other write.
To move a scheduled send to a different time, cancel it and submit a new send with the new scheduled_at. You get a fresh em_ ID.
What happens at send time
Scheduling changes when a message is released, not how it is built or governed. Attachments, category, tags, and metadata all behave exactly as on an immediate send, and are echoed on the webhook events the same way. Four checks split across the two moments:
- Payload and domain validation run up front. A malformed scheduled send fails on the API call with a 422, so you find out now rather than at 9am.
- The sender domain is re-checked at send time. If your from domain is no longer verified when the scheduled time arrives, the message is not sent: its recipients come back rejected with a reason instead of going out from an unverified domain. Keep the domain verified for the whole window.
- Your send allowance is charged at send time. The regular send quota is consumed when the message fires, not when you schedule it, and an allowance exhausted at that moment rejects the recipients rather than sending.
- Suppression is evaluated at send time, against your suppression list as it stands then, so someone who unsubscribes between scheduling and sending is still honored.
Errors
| Status | Code | When |
|---|---|---|
| 422 | E10003 | Your organization's scheduled-email allowance for the billing period is used up |
| 422 | scheduled_at is under 30 seconds or over 30 days away | |
| 422 | scheduled_at was combined with template, or set on a batch item | |
| 422 | The payload is too large to park; reduce the recipients, tags, or metadata, or send now | |
| 409 | E10005 | The message can no longer be canceled: it already started sending, sent, or was canceled |
| 404 | No message with that ID in this workspace |
Webhooks
Two events are specific to scheduling, on top of the usual delivery events:
- email.scheduled fires when a message is accepted with a future scheduled_at, and reports that time.
- email.canceled fires when a scheduled message is canceled before it sends.
When the message fires, the normal email.accepted chain follows unchanged.
Next steps
- Sending email: the full send payload and the async 202 model
- Suppressions: who we will not deliver to, and why, evaluated at send time
- Events and webhooks: the events a scheduled message produces once it fires
- Idempotency: safe retries for the schedule and cancel calls
- API reference: the cancel endpoint's full contract