Notify guides
01 · Send from Node

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
// 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.

publish.ts
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.

invoice.ts
// 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:

lookup.ts
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

undo.ts
// 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.

errors.ts
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;
}