Skip to content

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:

src/billing.ts
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.

FieldWhat it does
typeThe notification type, which picks the template. Letters, digits, _, . and -.
recipientWho gets it: id is required; email, name, phone, timezone and locale are optional.
payloadThe values the template renders with.
channelsNarrows the send to some of the template’s channels, such as ['email'].
transactionalSends regardless of the contact’s preferences, for receipts and password resets.
emailPer-send email options: fromName, replyTo and up to 10 attachments.
pushPer-send push options: silent: true shows the alert without a sound, and collapseId replaces a push already shown with the same id.
localeThe language of this send alone, ahead of the contact’s recipient.locale, which it doesn’t change; see Languages.
dataUp to 20 string values for your clients, such as your own event id. Every push and the feed item carry them as they came.
idempotencyKeyMakes the send safe to repeat: the same key returns the first send’s result.
inlineDelivers 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:

TypeScript
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

TypeScript
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:

src/auth.ts
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.

TypeScript
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