Container died
api-v2 on vps-01 was killed by the OOM killer and restarted.
- Exit code 137 (out of memory)
- Memory limit: 512 MiB
- Restarted automatically
@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.
fetch and WebCrypto.429, 5xx and network errors (honouring Retry-After), with a timeout on every request.npm install @notificme/sdkYou write fields in camelCase, and the SDK converts them to the protocol’s snake_case. It never touches the keys inside payload.
new NotificMe(options)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.
send(event)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:
api-v2 on vps-01 was killed by the OOM killer and restarted.
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" }, ], }, ], },});{ "source": "my-service", "type": "container.die", "severity": "high", "occurred_at": "2026-11-05T12:00:00Z", "content": { "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" } ] } ] } }}update(cardId, event)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 }] },});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.", }, ], },});{ "source": "my-service", "type": "deploy.progress", "severity": "info", "occurred_at": "2026-11-05T12:00:00Z", "card_id": "deploy/api-v2", "content": { "summary": "Deploying api-v2", "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." } ] } }}ask(input, options?)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 agentThe CRM cleanup agent found 340 contacts duplicated by e-mail.
| Copies | |
|---|---|
| [email protected] | 4 |
| [email protected] | 3 |
| [email protected] | 2 |
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: [ ], }, { type: "options", questionId: "action" }, { type: "options", questionId: "backup" }, { type: "input", questionId: "note" }, ], },});
console.log(reply.status, reply.answers, reply.asText);{ "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" ], "allow_other": true }, { "id": "backup", "type": "yesno", "text": "Back up first?" }, { "id": "note", "type": "text", "text": "Instructions for the agent", "optional": true } ], "level": 2, "timeout": "30m", "on_timeout": { "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": [ [ 4 ], [ 3 ], [ 2 ] ] }, { "type": "options", "question_id": "action" }, { "type": "options", "question_id": "backup" }, { "type": "input", "question_id": "note" } ] }}| 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:
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);{ "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", "question_id": "regions" }, { "type": "input", "question_id": "until" }, { "type": "input", "question_id": "batch" }, { "type": "input", "question_id": "voice" } ] }}createAsk(input, options?)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?)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.
UntrustedTextFree 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.