Send from your Node backend.
One call per event in your app. Notify works out the channels, devices, preferences and timing for each recipient.
npm install @productcraft/notify. The key needs notify.send (the Send notifications key preset has it). Create the client once per process:
1
The client
// lib/notify.ts
import { Notify } from "@productcraft/notify";
// Reads PRODUCTCRAFT_API_KEY and PRODUCTCRAFT_WORKSPACE_ID from the
// environment. Server only: never ship this key to a browser or an app.
export const notify = new Notify();2
Send to many at once
Up to 1,000 recipients per call. Ids Notify does not know never fail the call: they come back in skipped (recipient_not_found, or recipient_erased for 24 hours after an erasure). Pass create_missing: true to create bare recipients instead. The call fails with 422 RECIPIENT_NOT_FOUND only when nobody is left.
const followers: string[] = ["user_1", "user_2", "user_999"]; // up to 1,000 per call
const result = await notify.notifications.send(
{
type: "post.published",
recipients: followers,
data: { title: "Release notes 4.2", url: "https://app.example.com/posts/42" },
actor: { external_id: "user_7" }, // name and avatar in the inbox; user_7 is left out
tenant: "org_42", // inbox filter for multi-team apps
tags: { post: "p_42" }, // searchable in the Message log
},
{ idempotencyKey: "post-published:p_42" },
);
if ("id" in result) {
// An unknown id never fails the call: it is listed here instead.
for (const s of result.skipped) console.log(s.external_id, s.reason); // user_999 recipient_not_found
}3
Profiles inline
A recipient object instead of an id creates or updates that profile in the same call: handy for a first email to someone you have not identified yet.
// A recipient object creates or updates the profile with the send.
await notify.notifications.send(
{
type: "invoice.paid",
recipients: [{ external_id: "user_456", email: "bo@example.com", locale: "pt-PT" }],
data: { amount: "12.00", invoice_url: "https://app.example.com/invoices/inv_123" },
},
{ idempotencyKey: "invoice-paid:inv_123" },
);4
What happened
The send returns as soon as the notification is stored. Each recipient’s channels are planned and delivered in the background, usually within a second or two. Look a notification up, list its deliveries, or read one user’s timeline:
const notificationId = "0193a8b2-7c1d-7e3f-9a40-5b6c7d8e9f01";
const notification = await notify.notifications.retrieve(notificationId);
console.log(notification.status, notification.counts);
// One delivery per recipient, channel and device or address.
for await (const d of notify.deliveries.list({ notification_id: notificationId })) {
console.log(d.recipient_external_id, d.channel, d.state, d.reason, d.explanation);
}
// Everything one user got, across notifications.
const timeline = await notify.recipients.timeline("user_123");
console.log(timeline.data.length);5
Cancel or retract
// Not sent yet (scheduled, held, retrying): cancel it.
await notify.notifications.cancel("0193a8b2-7c1d-7e3f-9a40-5b6c7d8e9f01");
// Already in the inbox and no longer true ("invite accepted"): retract it.
// The item disappears from the bell in real time.
await notify.notifications.retract("0193a8b2-7c1d-7e3f-9a40-5b6c7d8e9f01");To cancel without keeping our id, give the send a cancellation_key: see Scheduling, cancellation and routing.
6
Errors
Every failure is a typed error with a stable code, a requestId for support, and a docUrl. Network errors, 429 and 5xx are retried for you with the same Idempotency-Key. All codes are on the error codes page.
import { APIError, NotFoundError, RateLimitError } from "@productcraft/notify";
try {
await notify.notifications.send(
{ type: "comment.created", recipients: ["user_123"], data: {} },
{ idempotencyKey: "comment-created:c_982" },
);
} catch (err) {
if (err instanceof NotFoundError) console.log(err.code); // NOTIFICATION_TYPE_NOT_FOUND
else if (err instanceof RateLimitError) console.log("retry after", err.retryAfter);
else if (err instanceof APIError) console.log(err.status, err.code, err.requestId, err.docUrl);
else throw err;
}