Noddocs

Webhooks

Receive events via outbound webhooks and trigger tasks via inbound webhooks

Overview

Nod supports two webhook flows:

DirectionPurposeAuth
OutboundNod delivers events (messages, decisions) to your agent's URLAgent secret
InboundExternal services trigger tasks via a public URLtrigger_id (URL token)

Outbound webhooks (event delivery)

When your agent is not connected via WebSocket, Nod delivers events to the agent's configured webhook_url. This is the fallback delivery mechanism.

Event
user message, decision resolved
WebSocket?
Try first
Webhook
Fallback if offline

Setting the webhook URL

Set the webhook_url on your agent when creating it in the Nod app, or update it from the agent settings.

Delivery format

Events are delivered as POST requests to your webhook_url with a JSON body and a set of X-Nod-* headers:

User message delivery
POST https://my-server.com/nod-events
Content-Type: application/json
X-Nod-Event-Id: evt_a1b2c3d4
X-Nod-Delivery-Id: dlv_e5f6a7b8
X-Nod-Timestamp: 1711234567
X-Nod-Retry-Num: 0
X-Nod-Retry-Reason: first_attempt
X-Nod-Signature: sha256=abc123...

{
  "type": "user_message",
  "text": "Can you deploy to staging?",
  "sender_name": "Alice",
  "conversation_id": "conv_abc123",
  "message_id": "msg_xyz789"
}
Decision resolved delivery
POST https://my-server.com/nod-events
Content-Type: application/json
X-Nod-Event-Id: evt_c3d4e5f6
X-Nod-Delivery-Id: dlv_a1b2c3d4
X-Nod-Timestamp: 1711234890
X-Nod-Retry-Num: 0
X-Nod-Retry-Reason: first_attempt

{
  "type": "decision_resolved",
  "decision_id": "dec_xyz",
  "status": "approved",
  "note": "Go ahead",
  "conversation_id": "conv_abc123",
  "always_allow": false,
  "always_allow_pattern": null
}

Delivery headers

Every outbound delivery includes these HTTP headers. Use them to verify authenticity, detect retries, and deduplicate events on your end.

HeaderDescription
X-Nod-Event-IdStable event identifier. Stays the same across all retry attempts for the same event (format: evt_<hex>). Use this to deduplicate.
X-Nod-Delivery-IdUnique identifier for this specific delivery attempt. Changes on every retry.
X-Nod-TimestampUnix timestamp (seconds) of when the delivery was attempted. Use this when verifying the signature.
X-Nod-Retry-Num0-indexed retry counter. 0 = first attempt, 1 = first retry, 2 = second retry.
X-Nod-Retry-ReasonWhy this delivery is happening: first_attempt, http_timeout, http_error, or unknown.
X-Nod-SignatureHMAC-SHA256 signature of the payload. Only present when an agent secret is configured.

Verifying signatures

When you configure an agent secret, Nod signs every delivery. Verify the signature to confirm the request came from Nod and was not tampered with:

Signature verification (Python)
import hashlib, hmac, time

def verify_nod_signature(body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    # Reject requests older than 5 minutes to prevent replay attacks
    if abs(time.time() - int(timestamp)) > 300:
        return False

    message = f"{timestamp}.".encode() + body
    expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

# In your handler — read raw bytes before JSON parsing:
body = await request.body()
timestamp = request.headers["X-Nod-Timestamp"]
signature = request.headers.get("X-Nod-Signature", "")

if not verify_nod_signature(body, timestamp, signature, YOUR_AGENT_SECRET):
    return Response(status_code=400)
Always use hmac.compare_digest (or your language's constant-time equivalent) for signature comparison — plain string equality is vulnerable to timing attacks.

Idempotency and deduplication

Nod retries deliveries when your endpoint does not respond with a 2xx status within the timeout window. This means your handler may receive the same logical event more than once. Two practices eliminate the problems that causes.

Return 200 immediately

Acknowledge the delivery as fast as possible and do the actual processing asynchronously. If your handler takes longer than the 30-second timeout, Nod marks the delivery as failed and schedules a retry — even if your code eventually completes successfully. That produces duplicate deliveries that are difficult to debug.

Correct pattern (Python / FastAPI)
@app.post("/nod-events")
async def nod_webhook(request: Request, background_tasks: BackgroundTasks):
    body = await request.body()
    event_id = request.headers.get("X-Nod-Event-Id")
    payload = json.loads(body)

    # Enqueue work and return 200 immediately — Nod will not retry
    background_tasks.add_task(process_event, event_id, payload)
    return {"ok": True}

Deduplicate using X-Nod-Event-Id

The X-Nod-Event-Id header is stable across all retry attempts for a given event. Store processed event IDs and skip any event you have already handled:

Deduplication example
async def process_event(event_id: str, payload: dict):
    # Skip if we already processed this event (retries share the same event_id)
    if await redis.exists(f"nod:processed:{event_id}"):
        return

    await handle_payload(payload)

    # Mark as processed — keep the key long enough to cover the retry window
    await redis.setex(f"nod:processed:{event_id}", 86400, "1")

X-Nod-Retry-Num tells you if this is a retry

When X-Nod-Retry-Num is 0 this is the first attempt. A value of 1 or higher means a previous attempt timed out or returned a non-2xx status. Combined with X-Nod-Event-Id deduplication, retries are handled transparently and safely.

Event types delivered

TypeWhenAgent types
user_messageUser sends a chat messageInteractive only
decision_resolvedUser resolves a pending request (approved, rejected, or dismissed)Both
task_dueA scheduled task is due for executionInteractive only
task_triggerWebhook trigger fires for a taskInteractive only

Retry policy

SettingValue
Timeout per attempt30 seconds
Max retry attempts3 (after the initial attempt)
Backoff schedule30s, 60s, 120s (exponential: 2^attempt x 30s)
Final statusDelivery marked failed after all retries are exhausted

A retry is triggered when your endpoint returns a non-2xx HTTP status or when the 30-second timeout elapses without a response. The X-Nod-Retry-Reason header on each delivery indicates why it was sent: http_timeout means the previous attempt timed out, http_error means it returned a non-2xx status.

Consecutive delivery failures are tracked on the agent. If deliveries keep failing, the agent status may change to error.

Returning a non-2xx status or letting a request time out triggers a retry, even if your handler already processed the event. Always return 200 immediately and handle work asynchronously to prevent unintended duplicate processing.

Inbound webhooks (task triggers)

Event-triggered tasks expose a public URL that external services (GitHub, Stripe, monitoring tools) can call to trigger the task.

Trigger URL

POST https://api.asknod.ai/webhook/{trigger_id}

The trigger_id is a 64-character hex string that acts as both the URL path and the authentication token. It is generated when you create an event-triggered task.

Keep the trigger_id secret — anyone with it can trigger the task. Rotate it by deleting and recreating the task if compromised.

Request

Send any JSON body as the trigger payload:

Example: GitHub webhook
POST https://api.asknod.ai/webhook/a1b2c3d4...64chars
Content-Type: application/json

{
  "action": "push",
  "ref": "refs/heads/main",
  "commits": [
    { "message": "Fix login bug", "author": "alice" }
  ]
}

Response

StatusBodyMeaning
200{"status": "triggered", "run_id": "run_..."}Agent is online — task triggered immediately
200{"status": "queued"}Agent is offline — event queued for later
200{"status": "duplicate", "run_id": "run_..."}Same payload already triggered within 24h
403{"detail": "Task is disabled"}The task exists but is disabled
404{"detail": "Not found"}Invalid trigger_id

Deduplication

Payloads are deduplicated using SHA-256 hashing within a 24-hour window. If the exact same payload is sent again for the same task within 24 hours, the response is duplicate and no new run is created.

Delivery flow

Webhook POST
External service
Dedup check
SHA-256 hash
Agent online?
Check session
Push / Queue
Instant or pending

If the agent is online, the event is pushed immediately via WebSocket as a task_trigger message. If offline, it is queued as pending and picked up when the agent polls GET /api/agent/tasks/triggers/pending.

Creating event-triggered tasks

To create a task that listens for webhook triggers, set schedule to "event":

Create from your agent
POST /api/agent/tasks
{
  "name": "Process GitHub push",
  "prompt": "Analyze the push event and summarize changes",
  "schedule": "event"
}

The response includes a trigger_id field — use this to construct the webhook URL. Configure this URL in your external service (e.g., GitHub webhook settings).