Webhooks (replies)
Instead of keeping a process waiting on ask(), you can create the question set with a callback_url. When it is answered (or times out), notific.me sends the signed reply to that URL. These outgoing calls are the webhooks. (Calls that other systems send to notific.me are hooks.)
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, handle.status === "open"What arrives
Section titled “What arrives”POST to your callback_url with the reply as the JSON body and these headers:
Notificme-Signature: t=<unix>,kid=<key id>,sig=<base64url(Ed25519(t + "." + body))>Notificme-Webhook-Id: <id> (the same on every retry: use it to deduplicate)Notificme-Topic: replyNotificme-Attempt: 1Delivery and retries
Section titled “Delivery and retries”- The reply and the webhook are written in the same transaction (transactional outbox), so a decision never gets lost.
- Any
2xxcounts as delivered. Errors and timeouts (15 s) are retried with exponential backoff for up to 24 hours. - Webhooks in app.notific.me lists every delivery with its status, attempts and last error, and lets you resend one.
Verifying the signature
Section titled “Verifying the signature”The server signs with Ed25519. The public keys are published at <api>/.well-known/notificme-keys.json (JWKS). Signatures more than 5 minutes away from your clock are rejected.
import express from "express";import { NotificMe, expressWebhook, expressWebhookOf } from "@notificme/sdk";
const notific = new NotificMe();const app = express();
// Put the middleware before any JSON parser (it reads the raw body).app.post("/hooks/notificme", expressWebhook({ key: notific.keysUrl }), (req, res) => { const webhook = expressWebhookOf(req)!; // verified console.log(webhook.reply?.status, webhook.reply?.asText); res.sendStatus(204);});import Fastify from "fastify";import { NotificMe, fastifyWebhookPlugin } from "@notificme/sdk";
const notific = new NotificMe();const app = Fastify();
await app.register(fastifyWebhookPlugin, { prefix: "/hooks/notificme", key: notific.keysUrl, handler: async (webhook) => { console.log(webhook.reply?.asText); }, // answers 204 when the handler returns nothing});import { Hono } from "hono";import { NotificMe, honoWebhook, type NotificMeWebhook } from "@notificme/sdk";
const notific = new NotificMe();const app = new Hono<{ Variables: { notificme: NotificMeWebhook } }>();
app.post("/hooks/notificme", honoWebhook({ key: notific.keysUrl }), (c) => { const webhook = c.get("notificme"); console.log(webhook.reply?.asText); return c.body(null, 204);});import { receiveWebhookRequest } from "@notificme/sdk";
const keys = "https://api.notific.me/.well-known/notificme-keys.json";
export async function POST(request: Request) { try { const webhook = await receiveWebhookRequest(request, keys); console.log(webhook.reply?.asText); return new Response(null, { status: 204 }); } catch { return new Response(null, { status: 401 }); }}Every helper answers 401 {"error":{"code":"invalid_signature"}} when a signature is invalid, so your handler never sees that request.
Low level
Section titled “Low level”import { receiveWebhook, verifyWebhook } from "@notificme/sdk";
// Throws WebhookVerificationError (error.reason says why).await verifyWebhook(rawBody, headers["notificme-signature"], keys);
// Verify and parse: headers, JSON payload and, for the "reply" topic, a typed result.const webhook = await receiveWebhook(rawBody, headers, keys, { questions });webhook.reply?.answers.ok;The key can be the keys URL (downloaded and cached), a base64url string, raw bytes, a JWK or a JWKS. WebhookVerificationError.reason is missing_header, malformed_header, timestamp_out_of_tolerance, unknown_key, invalid_key or invalid_signature.