Scheduled sending
Set scheduled_at to hold a message until a specific time. When that time arrives, the message enters the normal delivery lifecycle and produces the same events as an immediate send. Your application does not need to run its own scheduler.
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><p><a href="{{ bird.unsubscribe_url }}">Unsubscribe</a></p>',
category: "marketing",
scheduled_at: "2027-01-15T09: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><p><a href=\"{{ bird.unsubscribe_url }}\">Unsubscribe</a></p>",
category="marketing",
scheduled_at="2027-01-15T09: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><p><a href=\"{{ bird.unsubscribe_url }}\">Unsubscribe</a></p>",
Category: bird.CategoryMarketing,
ScheduledAt: time.Date(2027, 1, 15, 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><p><a href="{{ bird.unsubscribe_url }}">Unsubscribe</a></p>',
category: 'marketing',
scheduledAt: new \DateTimeImmutable('2027-01-15T09: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><p><a href="{{ bird.unsubscribe_url }}">Unsubscribe</a></p>' \
--category marketing \
--scheduled-at 2027-01-15T09: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><p><a href=\"{{ bird.unsubscribe_url }}\">Unsubscribe</a></p>",
"category": "marketing",
"scheduled_at": "2027-01-15T09:00:00Z"
}'The call returns 202 Accepted with the em_-prefixed message ID and status: accepted straight away. It is the same message object an immediate send returns, plus scheduled_at echoed back in UTC, so you can confirm the send time without a follow-up read. Acceptance is synchronous; delivery is deferred. Omit scheduled_at from the request and the message goes out right away, and its response carries no scheduled_at key.
Reads update asynchronously. A message you just scheduled can initially return 404 from Get a message and be absent from the message list or dashboard. Large content or attachments can extend this wait while we store them. Keep the ID and scheduled_at from the 202 response, and retry reads with backoff using that ID. You can also cancel with this ID before the message appears.
When a message appears while still waiting to send, reads show status: scheduled and its scheduled_at:
{
"id": "em_01ky7q24hafjgvzfg02v3m177p",
"status": "scheduled",
"scheduled_at": "2027-01-15T09: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.
A message can start sending before reads catch up, so you may first see a later status. There is no fixed delay after which a read is guaranteed to show the message.
Scheduling consumes one unit of your organization's scheduled-email allowance for the billing period. Exceeding that allowance is rejected with a 422 (E10003).
Schedule inline content or a template
Use scheduled_at with inline content or a stored template, built as for an immediate template send. We pin the published version, selected language and parameter values when we accept the request, and send that version at the scheduled time. Publishing a newer version does not change the selection. Deleting the template before the scheduled time rejects the message without sending it.
A marketing message gets an unsubscribe link as a small footer at the end of its body. To place the link yourself, put {{ bird.unsubscribe_url }} in each body you supply, or in the template's bodies for a template send.
A batch item takes scheduled_at on the same terms, so one batch can mix scheduled and immediate messages. Each scheduled item draws its own unit of the allowance, and the whole batch is rejected if any item's time is out of range. Each scheduled item carries its own scheduled_at back in the batch response, and an item that sends immediately has no scheduled_at key; the batch reference shows both in one response.
An immediate-send payload can still be too large to schedule. If its body, recipient list, or metadata exceed the scheduling limit, the API returns 422. Reduce those fields or send the message 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. - Provide an exact instant. Include a UTC
Z(2027-01-15T09:00:00Z) or an explicit offset (2026-07-30T09:00:00-04:00, the same instant as13: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 messages that have appeared and are still waiting to send. A newly accepted schedule can be absent while its content is uploading or reads are catching up:
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 with 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('em_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:Code example{ "error": { "type": "conflict_error", "code": "E10005", "name": "EmailNotCancelable", "message": "This message cannot be canceled.", "remediation": "Only a scheduled message that has not started sending can be canceled. Once sending begins there is no way to pull it back." } }As the send time arrives, a cancel can also lose the race to the send itself and come back
409for the same reason. -
You can cancel while content is still uploading. A scheduled send with attachments or a large body may still be storing content after the
202. A successful cancel keeps it canceled even if that upload finishes later. -
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 only when a message is released. Its construction and governance stay the same. Attachments, category, tags, and metadata all behave exactly as on an immediate send, and are echoed on the webhook events the same way. Five 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
fromdomain is no longer verified when the scheduled time arrives, the message is not sent. Its recipients return asrejectedwith a reason instead of receiving mail 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. Scheduling leaves the quota unchanged. If the allowance is exhausted at send time, the recipients are rejected.
- 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.
- A stored template must still exist at send time. If you delete the template after scheduling, the message is not sent. Its recipients return as
rejectedwithgeneration_failure.
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 | The payload is too large to park; reduce the body, recipients, or metadata, or send now | |
409 | E10005 | The message can no longer be canceled: it already started sending, sent, or was canceled |
404 | A message read has not caught up with acceptance, or no message with that ID exists in this workspace |
Webhooks
Two events are specific to scheduling, on top of the usual delivery events:
email.scheduledreports a message waiting for its futurescheduled_at. For template sends, the selected version is loaded again and its content is prepared at send time. This event can arrive before that work completes.email.canceledfires when a scheduled message is canceled before it sends.
When the message fires, the normal email.accepted chain follows unchanged.
Scheduling events are published asynchronously. A message can leave the scheduled state before email.scheduled is published. Receiving a webhook does not mean read endpoints already show that state.
Next steps
- Sending email: the full send payload and the async 202 model
- Suppressions: who we do 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
- Schedule an email to send later: a video that shows a scheduled send and how to cancel one
Related resources
Continue with the documentation, guides and examples for this topic.