Sending
One call records a notification and fans it out to every channel its template turns on, through your queue.
Send a notification
Every notification has a type, and each type needs a template before you send it. Then send from anywhere in your app, through its binding to Chaski:
const result = await env.CHASKI.send({
type: 'payment_received',
recipient: { id: user.id, email: user.email, name: user.name },
payload: { amount: '$15.00' },
// Optional: a retried request with the same key sends only once.
idempotencyKey: `payment:${payment.id}`,
});Chaski delivers through every channel the template has turned on, except the ones the contact muted. Each delivery goes through Chaski’s queue, unless the send is inline, and the dashboard shows it as it moves.
| Field | What it does |
|---|---|
type | The notification type, which picks the template. Letters, digits, _, . and -. |
recipient | Who gets it: id is required; email, name, phone, timezone and locale are optional. |
payload | The values the template renders with. |
channels | Narrows the send to some of the template’s channels, such as ['email']. |
transactional | Sends regardless of the contact’s preferences, for receipts and password resets. |
email | Per-send email options: fromName, replyTo and up to 10 attachments. |
push | Per-send push options: silent: true shows the alert without a sound, and collapseId replaces a push already shown with the same id. |
locale | The language of this send alone, ahead of the contact’s recipient.locale, which it doesn’t change; see Languages. |
data | Up to 20 string values for your clients, such as your own event id. Every push and the feed item carry them as they came. |
idempotencyKey | Makes the send safe to repeat: the same key returns the first send’s result. |
inline | Delivers the email before send() returns, for someone waiting on it; see below. |
The recipient’s fields are saved with the notification, so a retry later delivers to the address the send named even if the contact changes in between.
Send in the reader’s language
Give the contact’s language as recipient.locale, and each channel goes out in the closest
language its template has, falling back to the default:
await env.CHASKI.send({
type: 'payment_received',
recipient: { id: user.id, email: user.email, locale: user.locale }, // 'es-PE'
payload: { amount: 1500 },
});
// This send alone in Spanish, whatever the contact's language.
await env.CHASKI.send({ type: 'payment_received', recipient, payload, locale: 'es' });Send the text your app would translate as part of the template, not the payload: the template’s translations hold it per language, and the payload carries the values, which filters format for the reader.
What a send returns
type SendResult = {
notificationId: string;
// true when idempotencyKey matched an earlier send, which is returned instead.
replayed: boolean;
outcome: 'delivered' | 'suppressed' | 'undeliverable';
deliveries: Array<{
id?: string;
channel: 'email' | 'in_app' | 'push';
status: 'pending' | 'sent' | 'skipped';
reason?: string;
}>;
};delivered means at least one channel took the notification. suppressed means the contact’s
own preferences skipped every channel, and undeliverable means the channel settings or the
contact’s data did, such as an email delivery for a recipient without an address.
A channel is skipped, with its reason, when it isn’t configured (channel_not_configured), the
template has no content for it (no_template), the contact muted it (muted_by_preferences) or
the recipient has no address or device for it (missing_email, no_device_tokens). The first
that applies, in that order, is the one recorded.
Deliver while someone waits
A password reset or a verification email has someone on screen waiting for it. With inline: true,
Chaski delivers the email before send() returns instead of through the queue, so the request that
asked for it can tell that person when it didn’t go out:
import { isChaskiError } from 'chaskijs';
try {
await env.CHASKI.send({
type: 'password_reset',
recipient: { id: `email:${address}`, email: address },
payload: { link },
channels: ['email'],
transactional: true,
inline: true,
});
} catch (error) {
if (isChaskiError(error) && ['delivery_failed', 'channel_paused'].includes(error.code)) {
return c.json({ error: 'We couldn’t send the email. Please try again.' }, 502);
}
throw error;
}An inline send is one email that preferences can’t skip, so it needs transactional: true and
channels: ['email']. The delivery is recorded like any other, and the dashboard shows it. When
the provider doesn’t take the email, the delivery is failed with the provider’s error, nothing
retries it in the background, and send() throws a ChaskiError with the code delivery_failed.
With the email channel paused, the email waits as held and
send() throws channel_paused. A template Liquid can’t render throws validation, and a
recipient without an address throws validation before anything is recorded.
With an idempotencyKey, asking again tries a failed or held email once more, and an email that
went out is returned as it was: the key still never sends twice.
Attachments
Email attachments carry either base64 content or a url Chaski fetches. Chaski stores them in
the R2 bucket bound as CHASKI_ATTACHMENTS when the send is made, so retries read the same file.
await env.CHASKI.send({
type: 'invoice_issued',
recipient: { id: user.id, email: user.email },
payload: { invoice: 'INV-1042' },
email: {
attachments: [
{ filename: 'INV-1042.pdf', contentType: 'application/pdf', url: invoice.pdfUrl },
],
},
});Chaski deletes the files under att/ once their email goes out, and when a schedule that carries
them is cancelled or replaced. A delivery that ends failed or dead keeps its files, so a replay
can send them. To bound what those keep, add an
R2 lifecycle rule that deletes
objects under att/ after a number of days. Make it outlast your farthest schedule() with
attachments plus the time you allow for a replay. When a schedule fires, or a replay sends, after
the rule took its files, the delivery fails as attachment_missing.
How a delivery moves
A delivery starts pending on Chaski’s queue, and the channels skipped at send time are recorded
as skipped with their reason. The Chaski Worker consumes the queue: it renders the template and
calls the provider, and the delivery is sent when the provider accepts it. Opening an email marks
it opened, through a pixel served from your app’s userRoutes() mount.
Retries and replay
A failure that might pass, such as a provider answering 503, goes back on the queue with
exponential backoff: a minute, then two, then four, up to an hour between attempts, plus up to 30%
of jitter. A permanent failure, such as a template Liquid can’t render or a misconfigured channel,
stops at once as failed.
A delivery that runs out of attempts is marked dead, and the queue moves it to the dead-letter
queue. Deliveries in the dashboard shows each one with its last error. Replay a failed or dead
delivery once the cause is fixed, or replay every dead one at once.
Updated Oct 30, 2026
MIT License · chaski.sh