Nurama Developers
Guides

Webhooks

Receive signed HTTP callbacks when tasks, messages and assets change in a workspace.

A webhook subscription tells Nurama to POST a signed JSON payload to a URL you control whenever a matching event happens in a workspace. Use webhooks when a server you run needs to react to changes without holding a WebSocket connection open.

Create a subscription

Workspace admins create subscriptions from the workspace's Webhooks tab in the Nurama web app, or over the API with a personal access token that belongs to an admin and carries the webhooks:manage scope:

curl -s https://api.nurama.com/v1/workspaces/$WORKSPACE_ID/webhooks \
  -H "Authorization: Bearer $NURAMA_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Task sync",
    "url": "https://example.com/hooks/nurama",
    "events": ["task.created", "task.updated"]
  }'

The response includes signingSecret. It is shown once, so store it with your other secrets before you do anything else. Rules:

  • The URL must be HTTPS and publicly reachable. Private and loopback addresses are refused.
  • Choose events from the event catalogue. Unknown names are rejected.
  • A workspace can hold 25 subscriptions that are not failed out.
  • Only the app or a personal access token with webhooks:manage can manage subscriptions. Bot API keys and MCP connections cannot, so a compromised bot or a misled assistant can never register a receiver for your events.
  • Set expiresAt for a temporary integration and the subscription stops delivering after that time.

The same operations are available in the SDK under client.webhook: createWebhook, listWebhooks, updateWebhook, rotateWebhookSecret, testWebhook, listWebhookDeliveries and replayWebhookDelivery.

What a delivery looks like

Each delivery is an HTTP POST with a JSON body and these headers:

HeaderMeaning
X-Nurama-Event-TypeThe event name, for example task.created
X-Nurama-Event-IdIdentifies the event. Retries and replays of the same event carry the same id.
X-Nurama-Delivery-IdIdentifies this attempt.
X-Nurama-Subscription-IdThe subscription being delivered to.
X-Nurama-TimestampWhen the request was signed, as Unix seconds.
X-Nurama-Signature-256sha256=<hex>, the signature described below.
User-AgentNurama-Webhook/1.0

The body is an envelope around the same notification the WebSocket API emits:

{
  "id": "0192…",
  "type": "task.created",
  "createdAt": "2026-09-23T10:15:04.512Z",
  "workspace": { "id": "…", "slug": "acme" },
  "initiatorId": "…",
  "initiatorType": "user",
  "resourceId": "…",
  "resourceType": "task",
  "tokens": { "…": "…" },
  "changes": { "create": [{ "resourceType": "task", "resource": { "…": "…" } }] }
}

type is the webhook event name. tokens and changes are shaped exactly as documented for the underlying notification; the event catalogue links each event to those pages. Payloads are at most 256 KB and additive-only: fields may be added over time, never removed or renamed, so ignore fields you do not recognise.

Respond quickly

Return any 2xx status within 10 seconds. Redirects are not followed and count as failures. Do the real work after responding, for example by putting the payload on a queue, so a slow downstream never causes retries.

Verify the signature

Every request is signed with the subscription's secret using HMAC-SHA256 over the timestamp header, a dot, and the raw request body:

signature = HMAC_SHA256(secret, "<X-Nurama-Timestamp>.<raw body bytes>")

Verify against the raw bytes you received, before parsing JSON, and compare in constant time. Reject requests whose timestamp is more than a few minutes old to defeat replays of captured requests.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyNuramaWebhook(rawBody: Buffer, headers: Record<string, string>, secret: string) {
  const timestamp = headers['x-nurama-timestamp'];
  const received = headers['x-nurama-signature-256'] ?? '';
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');

  return expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

In Express, mount the handler with express.raw({ type: 'application/json' }) so the body arrives unparsed.

Retries, dead letters and pausing

A delivery that gets a non-2xx response, a redirect or a timeout is retried with increasing delays: 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, then 24 hours, for up to 8 attempts in total. After the last failure the delivery is dead-lettered and can be inspected and replayed from the deliveries endpoint.

If 50 consecutive deliveries to one subscription dead-letter, the subscription is automatically paused with status failedOut. Fix the receiver, then set the status back to active with a PATCH; deliveries that were skipped while paused are not sent. You can also pause a subscription yourself by setting status paused.

Handle duplicates: a retry or replay carries the same X-Nurama-Event-Id, so keep the ids you have processed and skip repeats. Deliveries can arrive out of order.

Testing and debugging

  • POST /workspaces/{workspaceId}/webhooks/{webhookId}/test queues a webhook.test delivery to that subscription, regardless of its event list.
  • GET /workspaces/{workspaceId}/webhooks/{webhookId}/deliveries lists attempts with their status (pending, inflight, succeeded, failed, dlq), the response code and any error.
  • POST /workspaces/{workspaceId}/webhooks/{webhookId}/deliveries/{attemptId}/replay queues a fresh attempt for a past delivery.

Rotating the secret

POST /workspaces/{workspaceId}/webhooks/{webhookId}/rotate-secret returns a new secret once. Deliveries signed from that moment use the new secret, including retries of earlier failures, so update the receiver immediately after rotating. Anything that fails verification during the switch is retried on the normal schedule.

Reference

On this page