NOUKAI

Receiving Webhooks

Build a webhook receiver — verify signatures, dedupe deliveries, and handle retries.

Use webhooks to react to async flow runs without polling. This guide walks through a minimal Node and Python receiver and explains the must-handle cases.

1. Get your signing secret

In the Noukai dashboard, open Settings → Webhooks and click Rotate to issue a secret. The dashboard shows the full plaintext once — copy it into your secrets store immediately. After dismissal you can only see a masked preview.

You can also rotate via the API — see POST /organizations/{org_id}/webhooks/secret/rotate (admin-only).

2. Subscribe a job

Pass callbackUrl on the async /jobs endpoint:

curl -X POST "https://api.noukai.xyz/api/v1/seq/$ORG/$PROJECT/$FLOW/jobs" \
  -H "Authorization: Bearer $NOUKAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "summarize this report",
    "callbackUrl": "https://example.com/hooks/noukai"
  }'

Optionally, restrict the events you receive:

{
  "message": "...",
  "callbackUrl": "https://example.com/hooks/noukai",
  "callbackEvents": ["flow.completed"]
}

Webhooks fire only on /jobs. Sync /execute and step-through /step ignore callbackUrl.

3. Stand up the receiver

The receiver must:

  1. Read the raw request bytes before any JSON parsing.
  2. Verify X-Noukai-Signature using your signing secret.
  3. Dedupe on X-Noukai-Delivery.
  4. Return 2xx quickly (under 20 s).

Node (Express)

import express from "express";
import crypto from "node:crypto";
 
const SECRET = process.env.NOUKAI_WEBHOOK_SECRET!;
const seen = new Set<string>(); // replace with Redis / DB in prod
 
const app = express();
app.use("/hooks/noukai", express.raw({ type: "application/json" }));
 
app.post("/hooks/noukai", (req, res) => {
  const raw = (req.body as Buffer).toString("utf8");
  const header = req.header("x-noukai-signature") ?? "";
  const deliveryId = req.header("x-noukai-delivery") ?? "";
 
  if (!verify(raw, header, SECRET)) {
    return res.status(401).end();
  }
  if (seen.has(deliveryId)) {
    return res.status(200).end(); // already processed
  }
  seen.add(deliveryId);
 
  const payload = JSON.parse(raw);
  // payload.event is "flow.completed" or "flow.failed"
  // payload.executionId, payload.flowId, payload.result, ...
  handleEvent(payload);
 
  res.status(200).end();
});
 
function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=", 2))
  );
  if (!parts.t || !parts.v1) return false;
 
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
 
  const match = (sig: string) =>
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
 
  return match(parts.v1) || (parts.v2 != null && match(parts.v2));
}
 
function handleEvent(payload: any) {
  // your business logic
}

Python (FastAPI)

import hmac, hashlib, json
from fastapi import FastAPI, Header, HTTPException, Request
 
SECRET = os.environ["NOUKAI_WEBHOOK_SECRET"].encode()
seen: set[str] = set()  # replace with Redis / DB in prod
 
app = FastAPI()
 
 
@app.post("/hooks/noukai")
async def receive(
    request: Request,
    x_noukai_signature: str = Header(...),
    x_noukai_delivery: str = Header(...),
):
    raw = await request.body()
 
    if not verify(raw, x_noukai_signature, SECRET):
        raise HTTPException(status_code=401)
    if x_noukai_delivery in seen:
        return {"ok": True}
    seen.add(x_noukai_delivery)
 
    payload = json.loads(raw)
    handle_event(payload)
    return {"ok": True}
 
 
def verify(raw: bytes, header: str, secret: bytes) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    if "t" not in parts or "v1" not in parts:
        return False
 
    expected = hmac.new(
        secret, f"{parts['t']}.".encode() + raw, hashlib.sha256
    ).hexdigest()
 
    if hmac.compare_digest(expected, parts["v1"]):
        return True
    if "v2" in parts and hmac.compare_digest(expected, parts["v2"]):
        return True
    return False
 
 
def handle_event(payload: dict):
    # your business logic
    ...

4. Rotate the secret without downtime

When you rotate, Noukai signs every delivery with both the new and previous secret for 24 hours (the v2= slot in X-Noukai-Signature). The verifier code above accepts either match, so you can:

  1. Rotate via dashboard or API → save the new plaintext.
  2. Deploy the new secret to your receiver.
  3. Old and new pods both keep verifying — old pods match on v2, new pods match on v1.
  4. After the grace window ends, only v1 is sent and the rollover is complete.

5. Inspect deliveries

The dashboard's Settings → Webhooks → Recent deliveries table mirrors GET /organizations/{org_id}/webhooks/deliveries. Use it to:

  • See which deliveries succeeded vs. retried vs. dead-lettered.
  • Read the response status and error excerpt Noukai recorded.
  • Find a X-Noukai-Delivery UUID to correlate with your receiver logs.

Checklist

  • HTTPS URL that resolves to a public IP (private IPs are blocked).
  • Verify against raw body bytes — not re-serialized JSON.
  • Dedupe on X-Noukai-Delivery before doing any side effect.
  • Return 2xx quickly. Defer heavy work to a queue.
  • Treat 3xx as misconfiguration — Noukai disables redirects and records 3xx as permanent failure.
  • On payload.truncated === true, fetch full output from GET /seq/.../jobs/{executionId}.

On this page