Webhooks

NORYX pushes events to your HTTPS endpoint as they happen. An Admin subscribes an endpoint in Settings → Webhooks, picks the events and copies the signing secret, which is shown once.

Delivery

Each delivery is a POST with a JSON body, the headers NORYX-Event, NORYX-Delivery and NORYX-Signature. Answer 2xx within 10 seconds; anything else (redirects included) is a failed attempt, retried with exponential backoff: 1 minute, then ×3, at most 12 hours apart, 8 attempts in all. After 20 deliveries in a row fail every attempt, the subscription is disabled; an Admin enables it again in Settings.

Delivery is at least once: deduplicate on the event id, which is the same on a retry and on a replay from the delivery log. Payloads carry identifiers, status and routing fields, never conversation text; read more with an API key. Endpoints must use https and a public address.

Verify the signature

NORYX-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. After a secret rotation the previous secret also signs for 24 hours, so the header may carry two v1 values; either one verifying is enough. Refuse signatures older than five minutes, and always verify the raw body as received.

With @noryx/sdk: examples/webhook-receiver.ts
/**
 * A webhook receiver that checks every delivery's signature before trusting it. Run it, then
 * subscribe http://localhost:4900/noryx in Settings → Webhooks (local profile) with its secret:
 *   NORYX_WEBHOOK_SECRET=whsec_... pnpm --filter @noryx/sdk example:webhook-receiver
 */
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { constructWebhookEvent, NoryxWebhookSignatureError, type WebhookEvent } from '@noryx/sdk';

/** Handles one delivery: verify, deduplicate on the event id, then act. */
export function webhookHandler(secret: string, onEvent: (event: WebhookEvent) => void) {
  const seen = new Set<string>();
  return (req: IncomingMessage, res: ServerResponse) => {
    let body = '';
    req.on('data', (chunk: Buffer) => (body += chunk.toString('utf8')));
    req.on('end', () => {
      const header = req.headers['noryx-signature'];
      constructWebhookEvent(body, Array.isArray(header) ? header[0] : header, secret)
        .then((event) => {
          // At-least-once delivery: a retry or a replay carries the same event id.
          if (!seen.has(event.id)) {
            seen.add(event.id);
            onEvent(event);
          }
          res.writeHead(200).end('ok');
        })
        .catch((error: unknown) => {
          res.writeHead(error instanceof NoryxWebhookSignatureError ? 400 : 500).end();
        });
    });
  };
}

if (import.meta.url === `file://${process.argv[1] ?? ''}`) {
  const secret = process.env.NORYX_WEBHOOK_SECRET ?? '';
  createServer(
    webhookHandler(secret, (event) => {
      console.log(`${event.type} ${event.data.object.id ?? ''} (event ${event.id})`);
    }),
  ).listen(4900, () => {
    console.log('Listening on http://localhost:4900/noryx');
  });
}
Without the SDK: examples/verify-without-sdk.ts
/**
 * Verifying a NORYX webhook without the SDK, with node:crypto: the signed content is
 * "<t>.<raw body>", HMAC-SHA256 with your secret, hex; the header may carry two v1 values
 * while a secret rotation overlaps.
 */
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyNoryxSignature(
  rawBody: string,
  header: string | undefined,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  if (!header) return false;
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v ?? '');
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret)
    .update(`${String(t)}.${rawBody}`)
    .digest();
  return signatures.some((s) => {
    const given = Buffer.from(s, 'hex');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

Events (API version 2026-10-16)

signal.created

Something changed at an account or deal: funding, hiring, a leadership change, a stalled deal.

Example payload
{
  "id": "0199f0a1-0000-7000-8000-00000000e0e0",
  "type": "signal.created",
  "apiVersion": "2026-10-16",
  "createdAt": "2026-10-16T10:00:00.000Z",
  "organizationId": "0199f0a1-0000-7000-8000-0000000000aa",
  "data": {
    "object": {
      "object": "signal",
      "id": "0199f0a1-0000-7000-8000-000000000001",
      "category": "leadership_change",
      "title": "Orion Telecom appointed a new Chief Financial Officer",
      "status": "new",
      "strength": 0.8,
      "accountId": "0199f0a1-0000-7000-8000-0000000000a1",
      "dealId": null,
      "personId": null,
      "occurredAt": "2026-10-16T08:00:00.000Z",
      "sourceType": "news",
      "sourceUrl": "https://news.example.com/orion-cfo",
      "url": "https://app.noryx.example/accounts/0199f0a1-0000-7000-8000-0000000000a1"
    }
  }
}

decision.created

NORYX reached a conclusion: a next best action, a deal diagnosis, a brief.

Example payload
{
  "id": "0199f0a1-0000-7000-8000-00000000e0e0",
  "type": "decision.created",
  "apiVersion": "2026-10-16",
  "createdAt": "2026-10-16T10:00:00.000Z",
  "organizationId": "0199f0a1-0000-7000-8000-0000000000aa",
  "data": {
    "object": {
      "object": "decision",
      "id": "0199f0a1-0000-7000-8000-000000000002",
      "kind": "next_best_action",
      "title": "ACME: book a call with the CFO before the pricing review",
      "status": "proposed",
      "subjectType": "deal",
      "subjectId": "0199f0a1-0000-7000-8000-0000000000d1",
      "confidence": "high",
      "producedBy": "next_best_action",
      "decidedByUserId": null,
      "decidedAt": null,
      "createdAt": "2026-10-16T08:05:00.000Z",
      "url": "https://app.noryx.example/decisions/0199f0a1-0000-7000-8000-000000000002"
    }
  }
}

decision.approved

A person approved a Decision Card; its actions go to execution or the outbox.

Example payload
{
  "id": "0199f0a1-0000-7000-8000-00000000e0e0",
  "type": "decision.approved",
  "apiVersion": "2026-10-16",
  "createdAt": "2026-10-16T10:00:00.000Z",
  "organizationId": "0199f0a1-0000-7000-8000-0000000000aa",
  "data": {
    "object": {
      "object": "decision",
      "id": "0199f0a1-0000-7000-8000-000000000002",
      "kind": "next_best_action",
      "title": "ACME: book a call with the CFO before the pricing review",
      "status": "accepted",
      "subjectType": "deal",
      "subjectId": "0199f0a1-0000-7000-8000-0000000000d1",
      "confidence": "high",
      "producedBy": "next_best_action",
      "decidedByUserId": "0199f0a1-0000-7000-8000-0000000000u1",
      "decidedAt": "2026-10-16T09:00:00.000Z",
      "createdAt": "2026-10-16T08:05:00.000Z",
      "url": "https://app.noryx.example/decisions/0199f0a1-0000-7000-8000-000000000002"
    }
  }
}

action.executed

An approved action ran: sent, or captured in the sandbox outbox.

Example payload
{
  "id": "0199f0a1-0000-7000-8000-00000000e0e0",
  "type": "action.executed",
  "apiVersion": "2026-10-16",
  "createdAt": "2026-10-16T10:00:00.000Z",
  "organizationId": "0199f0a1-0000-7000-8000-0000000000aa",
  "data": {
    "object": {
      "object": "action",
      "id": "0199f0a1-0000-7000-8000-000000000003",
      "type": "send_email",
      "title": "Follow up with Dana Whitfield",
      "status": "executed",
      "subjectType": "deal",
      "subjectId": "0199f0a1-0000-7000-8000-0000000000d1",
      "decisionId": "0199f0a1-0000-7000-8000-000000000002",
      "agent": "next_best_action",
      "autonomyLevel": 3,
      "approvedVia": "person",
      "executedAt": "2026-10-16T09:00:05.000Z",
      "delivery": "captured",
      "url": "https://app.noryx.example/outbox"
    }
  }
}

outcome.recorded

What happened after an action: a reply, a meeting booked, a deal won or lost.

Example payload
{
  "id": "0199f0a1-0000-7000-8000-00000000e0e0",
  "type": "outcome.recorded",
  "apiVersion": "2026-10-16",
  "createdAt": "2026-10-16T10:00:00.000Z",
  "organizationId": "0199f0a1-0000-7000-8000-0000000000aa",
  "data": {
    "object": {
      "object": "outcome",
      "id": "0199f0a1-0000-7000-8000-000000000004",
      "type": "meeting_booked",
      "result": "positive",
      "actionId": "0199f0a1-0000-7000-8000-000000000003",
      "decisionId": "0199f0a1-0000-7000-8000-000000000002",
      "subjectType": "deal",
      "subjectId": "0199f0a1-0000-7000-8000-0000000000d1",
      "value": null,
      "occurredAt": "2026-10-17T14:00:00.000Z",
      "source": "detected"
    }
  }
}

deal.updated

A deal’s stage, status, amount, close date, forecast category, owner, name or next step changed. `previous` holds the old values; next-step text is never sent.

Example payload
{
  "id": "0199f0a1-0000-7000-8000-00000000e0e0",
  "type": "deal.updated",
  "apiVersion": "2026-10-16",
  "createdAt": "2026-10-16T10:00:00.000Z",
  "organizationId": "0199f0a1-0000-7000-8000-0000000000aa",
  "data": {
    "object": {
      "object": "deal",
      "id": "0199f0a1-0000-7000-8000-0000000000d1",
      "name": "ACME: platform expansion",
      "accountId": "0199f0a1-0000-7000-8000-0000000000a1",
      "stage": "negotiation",
      "status": "open",
      "amount": 180000,
      "currency": "USD",
      "closeDate": "2026-12-15",
      "forecastCategory": "commit",
      "ownerUserId": "0199f0a1-0000-7000-8000-0000000000u1",
      "updatedAt": "2026-10-16T10:00:00.000Z",
      "url": "https://app.noryx.example/deals/0199f0a1-0000-7000-8000-0000000000d1"
    }
  }
}

ping

A test delivery sent from Settings → Webhooks.

Example payload
{
  "id": "0199f0a1-0000-7000-8000-00000000e0e0",
  "type": "ping",
  "apiVersion": "2026-10-16",
  "createdAt": "2026-10-16T10:00:00.000Z",
  "organizationId": "0199f0a1-0000-7000-8000-0000000000aa",
  "data": {
    "object": {
      "object": "ping",
      "message": "NORYX webhook test"
    }
  }
}