Webhooks
Receive events via outbound webhooks and trigger tasks via inbound webhooks
Overview
Nod supports two webhook flows:
| Direction | Purpose | Auth |
|---|---|---|
| Outbound | Nod delivers events (messages, decisions) to your agent's URL | Agent secret |
| Inbound | External services trigger tasks via a public URL | trigger_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.
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:
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"
}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.
| Header | Description |
|---|---|
| X-Nod-Event-Id | Stable event identifier. Stays the same across all retry attempts for the same event (format: evt_<hex>). Use this to deduplicate. |
| X-Nod-Delivery-Id | Unique identifier for this specific delivery attempt. Changes on every retry. |
| X-Nod-Timestamp | Unix timestamp (seconds) of when the delivery was attempted. Use this when verifying the signature. |
| X-Nod-Retry-Num | 0-indexed retry counter. 0 = first attempt, 1 = first retry, 2 = second retry. |
| X-Nod-Retry-Reason | Why this delivery is happening: first_attempt, http_timeout, http_error, or unknown. |
| X-Nod-Signature | HMAC-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:
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)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.
@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:
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
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
| Type | When | Agent types |
|---|---|---|
| user_message | User sends a chat message | Interactive only |
| decision_resolved | User resolves a pending request (approved, rejected, or dismissed) | Both |
| task_due | A scheduled task is due for execution | Interactive only |
| task_trigger | Webhook trigger fires for a task | Interactive only |
Retry policy
| Setting | Value |
|---|---|
| Timeout per attempt | 30 seconds |
| Max retry attempts | 3 (after the initial attempt) |
| Backoff schedule | 30s, 60s, 120s (exponential: 2^attempt x 30s) |
| Final status | Delivery 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.
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.
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:
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
| Status | Body | Meaning |
|---|---|---|
| 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
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":
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).