Messages & notifications
Read the delivery log, manage suppressions and providers, and build an in-app notification inbox from the SDK.
#Messages & notifications
sdk.messages covers two very different audiences, and it is worth knowing which half you are using.
The inbox is end-user surface. It is scoped to whoever holds the session and needs no special grant, so it is what you build a notification bell out of. There is no parameter for reading somebody else's inbox — the gateway derives the owner from the session, deliberately.
Everything else is admin surface, gated on the system/messages permission: read to look at the log or suppressions, send to send, manage to change providers or block an address.
send is its own grant, and if you are wiring up an integration it is probably the only one you want — a key that mails your members should not also be able to rewrite your provider credentials.
#Sending
const result = await sdk.messages.send({
to: ['ada@example.org', 'grace@example.org'],
subject: 'Working bee this Saturday',
html: '<p>See you at 9am.</p>',
idempotencyKey: 'working-bee-2026-08-15'
});
console.log(`${result.queued} queued, ${result.refused} refused`);
Three things to internalise:
It queues. send() resolving means the messages were accepted, not delivered. Watch list() or take a delivery webhook for the real outcome. Large batches go out over the following minutes rather than inside your call, because a thousand sends will not fit in one request.
One message per recipient. There is no shared row addressed to many people — suppression, bounces and unsubscribes are per-person, and a single row could not express any of them. Mailing 200 people costs 200 units of your daily allowance.
The batch is all-or-nothing on quota, per-recipient on everything else. If the send would exceed the project's daily allowance, nothing is queued and you get email_quota_exceeded with the numbers on it. But a single suppressed address just comes back queued: false with a reason while the others go:
const blocked = result.recipients.filter((r) => !r.queued);
for (const r of blocked) console.warn(`${r.to}: ${r.detail}`);
Pass idempotencyKey whenever a retry is possible — a webhook handler, a job runner, anything with an at-least-once delivery guarantee. Repeating a key within 24 hours returns the original result with replayed: true and the same message ids, rather than mailing everybody twice.
#An in-app notification inbox
const { unread } = await sdk.messages.unreadCount();
const { data: notifications } = await sdk.messages.inbox();
await sdk.messages.markRead(notifications[0].id);
Marking somebody else's notification read returns 404 rather than a permission error — it is simply not your row.
#The delivery log
const { data, total, hasMore } = await sdk.messages.list({
search: 'ann@example.com',
status: 'bounced',
page: 1
});
const detail = await sdk.messages.get(data[0].id);
console.log(detail.events); // the full delivery timeline
search matches the recipient address or the subject. Filtering and paging happen server-side, so a search sees every message rather than only the page you already hold.
#Reading a status honestly
sent means the provider accepted the message, not that anyone received it. On a provider without delivery webhooks configured, it is the last state you will ever see — the message is not stuck, there is simply nothing more to learn.
Treat delivered as the only positive confirmation, and bounced / complained / failed as the negative ones.
if (detail.status === 'sent' && !detail.events.some((e) => e.event === 'delivered')) {
// Accepted, unconfirmed. Not a failure — and not a success either.
}
#Timestamps
Every timestamp in this API is epoch seconds, not milliseconds:
new Date(message.created_at * 1000);
#Suppressions
Addresses on the suppression list are skipped by every send. Hard bounces and spam complaints are added automatically, because continuing to mail an address that rejected you is the fastest way to get an entire domain filtered.
const { data } = await sdk.messages.suppressions({ search: 'ann', reason: 'hard_bounce' });
await sdk.messages.suppress('someone@example.com'); // reason: 'manual'
await sdk.messages.unsuppress('someone@example.com');
Releasing a hard bounce that is still invalid will simply bounce again and cost you sending reputation — check why it was suppressed before undoing it.
#Providers
const { data: providers } = await sdk.messages.providers();
await sdk.messages.configureProvider('resend', {
apiKey: 're_...',
fromAddress: 'noreply@yourdomain.com'
});
// Mailgun carries its region and sending domain in the same call. EU keys
// MUST set the region — against the US endpoint they fail like a bad key.
await sdk.messages.configureProvider('mailgun', {
apiKey: 'key-...',
region: 'eu',
sendingDomain: 'mg.yourdomain.com'
});
// A personal mailbox: the app password goes in apiKey, and the mailbox is
// both the username and the sending address.
await sdk.messages.configureProvider('smtp', {
apiKey: 'your-app-password',
preset: 'gmail',
username: 'you@gmail.com',
fromAddress: 'you@gmail.com'
});
Credentials are never returned by the API — hasApiKey and hasWebhookSecret tell you whether one is stored, and nothing more.
An omitted or empty secret means "keep what is stored", so re-submitting a form whose fields were masked does not wipe the key. To clear a non-secret setting, pass an explicit empty string.
#Prove it works before you depend on it
A typo'd API key produces a provider that looks configured and fails on the first real password reset. testProvider pushes a real message through the whole pipeline:
const result = await sdk.messages.testProvider('resend', 'you@yourdomain.com');
if (!result.sent) {
console.error(result.detail);
}
// Either way, result.messageId points at the log row with the full story.
A refused or failed test resolves with sent: false rather than throwing — a failure is an answer with evidence attached, and throwing would discard the useful half.
The send is pinned to the provider you name, so it cannot pass by quietly falling through to a healthy one behind it. Any configured provider can be tested this way, not just the first in the order. To check that account recovery works — which uses whatever the order chooses — use the test send under Settings → Email instead.
await sdk.messages.removeProvider('resend'); // deletes the config AND the credential
#Related
The admin UI over the same API is documented in Messages, including provider setup and DNS.