Skip to content

TypeScript SDK

@notificme/sdk is the official TypeScript SDK. It sends events, keeps live cards up to date, asks people for decisions and waits for the answer, and verifies the signed webhooks the server sends you.

  • Works on Node.js 18+, Bun, Deno, Cloudflare Workers and browsers.
  • Ships ESM and CommonJS with full type definitions.
  • Has no runtime dependencies: it uses the native fetch and WebCrypto.
  • Retries 429, 5xx and network errors (honouring Retry-After), with a timeout on every request.
Terminal window
npm install @notificme/sdk

You write fields in camelCase, and the SDK converts them to the protocol’s snake_case. It never touches the keys inside payload.

import { NotificMe } from "@notificme/sdk";
const notific = new NotificMe({ token: process.env.NOTIFICME_TOKEN, source: "billing" });
Option Default Description
token NOTIFICME_TOKEN env var Service token (nm_svc_…). Keep it on the server.
baseUrl NOTIFICME_URL or https://api.notific.me API address (your own server, if self-hosted).
source none Default source of your events.
timeoutMs 30000 Per-request timeout.
maxRetries 3 Retries on 429, 5xx and network errors.
retryBaseDelayMs / retryMaxDelayMs 500 / 10000 Exponential backoff with jitter.
fetch global fetch Custom fetch implementation.
userAgent none Extra text for the User-Agent header (Node only).

notific.keysUrl is the URL of the server’s public keys, used to verify webhooks. Every method also accepts { signal, timeoutMs } as its last argument.

Sends an event (POST /v0/events) and resolves with 202 Accepted data.

const result = await notific.send({
type: "container.die", // dotted lowercase
severity: "high", // info | low | medium | high | critical (default: info)
summary: "api-v2 died on vps-01",
to: "jimmy", // handle, member e-mail or group; omit to let the rules decide
dedupKey: "vps-01/api-v2", // merges repeats in the dedup window
expiresWhen: "container.healthy",
payload: { host: "vps-01", exitCode: 137 }, // sent as is
});
// result: { id, status: "queued" | "duplicate", duplicateOf?, senderId, receivedAt, cardId?, replayed }
Field Description
type Required. Dotted lowercase type.
source Defaults to the client’s source.
severity Defaults to info.
occurredAt Date or string; defaults to now.
id Your idempotency id. Generated when omitted, so retries never duplicate.
to One recipient or a list.
summary, card Shortcuts for content.summary and content.card.
content { summary, card?, rich? }.
payload, dedupKey, ttl, expiresWhen, retention See Events.
cardId, live Live cards: live: true generates a cardId, returned in the result.

This is how a send() with a card renders, and the code behind it:

Push / watchapi-v2 died on vps-01 (exit 137)

Container died

api-v2 on vps-01 was killed by the OOM killer and restarted.

Crashes in 24h3+2
  • Exit code 137 (out of memory)
  • Memory limit: 512 MiB
  • Restarted automatically
Open dashboard ↗Restart
import { NotificMe } from "@notificme/sdk";
const notific = new NotificMe({ source: "my-service" });
await notific.send({
type: "container.die",
severity: "high",
summary: "api-v2 died on vps-01 (exit 137)",
card: {
title: "Container died",
theme: { accent: "#E5484D", icon: "🐳" },
blocks: [
{
type: "text",
text: "api-v2 on vps-01 was killed by the OOM killer and restarted.",
},
{ type: "metric", label: "Crashes in 24h", value: 3, delta: "+2" },
{
type: "list",
items: [
{ text: "Exit code 137 (out of memory)", icon: "⚠️" },
{ text: "Memory limit: 512 MiB", icon: "📦" },
{ text: "Restarted automatically", icon: "🔁" },
],
},
{
type: "buttons",
buttons: [
{ label: "Open dashboard", url: "https://dokploy.example.com/project/api" },
{ label: "Restart", action: "docker.restart" },
],
},
],
},
});

Updates a live card by sending a new event with the same cardId. type, source, severity and summary are taken from the last event this client sent for that card; you can override any of them.

const deploy = await notific.send({
type: "deploy.progress",
summary: "Deploying api-v2",
live: true,
card: { blocks: [{ type: "progress", value: 0.1, steps: ["Build", "Tests", "Push image", "Rollout"], current: 0 }] },
});
await notific.update(deploy.cardId!, {
card: { blocks: [{ type: "progress", value: 0.6, steps: ["Build", "Tests", "Push image", "Rollout"], current: 2 }] },
});
Push / watchDeploying api-v2

Deploy api-v2

  1. Build
  2. Tests
  3. Push image
  4. Rollout

Commit 4c69fac by Jimmy: verification with the authenticator app.

import { NotificMe } from "@notificme/sdk";
const notific = new NotificMe({ source: "my-service" });
const deploy = await notific.send({
type: "deploy.progress",
severity: "info",
summary: "Deploying api-v2",
live: true,
card: {
title: "Deploy api-v2",
theme: { accent: "#FF6A00", icon: "🚀" },
blocks: [
{
type: "progress",
value: 0.6,
steps: ["Build", "Tests", "Push image", "Rollout"],
current: 2,
},
{
type: "text",
text: "Commit 4c69fac by Jimmy: verification with the authenticator app.",
},
],
},
});

Creates a question set and waits (long-poll) until a person answers or the question set times out. The result is typed by question id.

const r = await notific.ask({
to: "jimmy",
title: "Agent wants to delete 340 duplicate records",
context: "The CRM cleanup agent found 340 contacts duplicated by e-mail.",
questions: [
{ id: "action", type: "single", text: "What should we do?",
options: ["Approve", "Only the first 10", "Deny"], allowOther: true },
{ id: "backup", type: "yesno", text: "Back up first?" },
{ id: "note", type: "text", text: "Instructions for the agent", optional: true },
],
level: 2,
timeout: "30m",
onTimeout: { action: "Deny" },
});
if (r.status === "answered" && r.answers.action === "Approve" && r.answers.backup) {
// r.answers.action: "Approve" | "Only the first 10" | "Deny" | UntrustedText (because allowOther)
// r.answers.backup: boolean
// r.answers.note: UntrustedText | undefined
}
console.log(r.asText); // natural-language summary, ready for an AI agent
Push / watchAgent wants to delete 340 duplicate records

CRM cleanup

The CRM cleanup agent found 340 contacts duplicated by e-mail.

E-mailCopies
ana@example.com4
bruno@example.com3
carla@example.com2
What should we do?
Back up first?
Instructions for the agent (optional)
import { NotificMe } from "@notificme/sdk";
const notific = new NotificMe({ source: "my-service" });
const reply = await notific.ask({
title: "Agent wants to delete 340 duplicate records",
questions: [
{
id: "action",
type: "single",
text: "What should we do?",
options: ["Approve", "Only the first 10", "Deny"],
allowOther: true,
},
{ id: "backup", type: "yesno", text: "Back up first?" },
{
id: "note",
type: "text",
text: "Instructions for the agent",
optional: true,
},
],
level: 2,
timeout: "30m",
onTimeout: { action: "Deny" },
card: {
title: "CRM cleanup",
theme: { icon: "🤖" },
blocks: [
{
type: "text",
text: "The CRM cleanup agent found 340 contacts duplicated by e-mail.",
},
{
type: "table",
columns: ["E-mail", "Copies"],
rows: [
["ana@example.com", 4],
["bruno@example.com", 3],
["carla@example.com", 2],
],
},
{ type: "options", questionId: "action" },
{ type: "options", questionId: "backup" },
{ type: "input", questionId: "note" },
],
},
});
console.log(reply.status, reply.answers, reply.asText);
Field Description
title Required, up to 160 characters.
questions Required, 1 to 20. Types: yesno, single, multi, text, voice, number, date.
to Recipients.
context Why you are asking (up to 4000 characters).
level 0–4, default 1. The workspace may require more.
quorum Number of people that must answer (2 or more).
offline "allowed" or "denied".
timeout "30s", "30m", "2h", "3d" or a number of seconds.
onTimeout Answers applied when nobody answers in time, by question id.
escalate Who is notified when the timeout is near.
callbackUrl Webhook that receives the signed reply.
card A card with context. Place questions with { type: "options", questionId } and { type: "input", questionId }.
Option Description
idempotencyKey For POST /v0/asks; generated when omitted, so a retry never creates the question set twice.
maxWaitMs Maximum client-side wait. When it runs out, AskPendingError is thrown.
signal, timeoutMs Cancel the call and the wait; per-request timeout.
Question type Answer type
yesno boolean
single one of the options (| UntrustedText with allowOther: true)
multi array of options (extra free text in r.details[id].freeText)
text UntrustedText
voice { text?: UntrustedText; audioRef?: string }
number number
date string (YYYY-MM-DD or RFC 3339)
  • status: "answered", "timeout" (the onTimeout answers were applied) or "rejected". Once you check for "answered", every required answer is present.
  • answers: typed by question id.
  • asText: natural-language summary for an AI agent.
  • answeredBy (identity, deviceId, levelUsed, authMethod), answeredAt, signature and contentHash.
  • details[id]: the question text, the raw value and any free text.
  • raw: the reply exactly as the server sent it.

Waiting can end in two errors: AskClosedError when the question set expires or is cancelled, and AskPendingError when maxWaitMs runs out first. Resume later with waitForReply().

A form with several question types:

Push / watchRelease window for api-v2

Release window

Which regions?
Postpone until
How many servers per batch?
Anything else? (voice) (optional)🎙 Hold to record
import { NotificMe } from "@notificme/sdk";
const notific = new NotificMe({ source: "my-service" });
const reply = await notific.ask({
title: "Release window for api-v2",
questions: [
{
id: "regions",
type: "multi",
text: "Which regions?",
options: ["BR", "US", "EU"],
},
{ id: "until", type: "date", text: "Postpone until" },
{ id: "batch", type: "number", text: "How many servers per batch?" },
{ id: "voice", type: "voice", text: "Anything else? (voice)", optional: true },
],
level: 1,
card: {
title: "Release window",
blocks: [
{ type: "options", questionId: "regions" },
{ type: "input", questionId: "until" },
{ type: "input", questionId: "batch" },
{ type: "input", questionId: "voice" },
],
},
});
console.log(reply.status, reply.answers, reply.asText);

Creates the question set without waiting. Use it with callbackUrl to receive the reply on a webhook, or call handle.wait() later.

const handle = await notific.createAsk({
title: "Approve refund #881?",
questions: [{ id: "ok", type: "yesno", text: "Approve?" }],
callbackUrl: "https://crm.example.com/hooks/notificme",
});
// handle: { id, status: "open", version, level, title, createdAt, timeoutAt?, recipients?, pollUrl?, wait() }

waitForReply(id, options?) and getAsk(id, options?)

Section titled “waitForReply(id, options?) and getAsk(id, options?)”
import { AskPendingError } from "@notificme/sdk";
try {
const r = await notific.ask(input, { maxWaitMs: 60_000 });
} catch (err) {
if (err instanceof AskPendingError) {
// later, maybe in another process:
const r = await notific.waitForReply(err.askId, { questions: input.questions });
}
}
// One read, optionally waiting up to 60 s for a change:
const view = await notific.getAsk(id, { waitSeconds: 30 });

Pass the same questions to waitForReply() to get typed answers.

import { verifyWebhook, receiveWebhook, receiveWebhookRequest } from "@notificme/sdk";
import { expressWebhook, expressWebhookOf, fastifyWebhookPlugin, fastifyWebhook, honoWebhook } from "@notificme/sdk";
Function Use
verifyWebhook(rawBody, signatureHeader, key, options?) Verifies the Ed25519 signature; throws WebhookVerificationError.
receiveWebhook(rawBody, headers, key, options?) Verifies and parses; for the reply topic, returns a typed reply.
receiveWebhookRequest(request, key, options?) The same for a fetch Request (Next.js, Workers, Deno, Bun).
expressWebhook({ key }), expressWebhookOf(req) Express middleware.
fastifyWebhookPlugin, fastifyWebhook Fastify plugin and preHandler hook.
honoWebhook({ key }) Hono middleware (c.get("notificme")).
notific.verifyWebhook(), notific.receiveWebhook() The same, using this client’s keysUrl.

See Webhooks for full examples.

Free text typed by a person arrives as an UntrustedText, not a string. .text returns the raw string; .quoted() returns a JSON string literal to embed in a prompt as data. Never execute it as a command, SQL, HTML or instructions.

Error When
NotificMeApiError Non-2xx response. Has status, code (stable, such as validation_failed or rate_limited), details and requestId.
NotificMeError Client-side failure. code is timeout, network_error, aborted, invalid_response or missing_token.
AskClosedError The question set expired or was cancelled without a reply.
AskPendingError maxWaitMs ran out while the question set was still open (askId).
WebhookVerificationError Invalid webhook signature (reason).

All of them extend NotificMeError.