Norano API

Webhooks

Signed event delivery, the signature scheme, and copy-paste verifiers.

Instead of polling, subscribe to signed outbound webhooks for job completion.

Endpoints & events

POST /api/v1/webhook_endpoints
Authorization: Bearer norano_sk_live_…
{ "url": "https://your-app.example/hooks/norano",
  "enabled_events": ["job.succeeded", "job.failed"] }

The response includes a signing secret whsec_…, shown once. Events are job-centric: job.queued, job.running, job.succeeded, job.failed, job.canceled. Most agents subscribe only to the two terminal events. Each event has a stable id, type, created, and a data.object that is the full Job (see async jobs).

Signature scheme

Norano-Signature: t=<unix>,v1=<hex HMAC-SHA256>
Norano-Webhook-Id: <event id>

The signed payload is the exact string "{t}.{raw_request_body}", keyed by your endpoint secret. Verify against the raw body (before any JSON parse), with a constant-time compare. Reject if now - t > 300s (5-minute tolerance) and dedupe on the event id to defeat replays.

Verify the signature

import crypto from 'node:crypto';

// rawBody: the exact bytes of the request body (Buffer or string)
// header: the value of the "Norano-Signature" header
export function verifyNoranoWebhook(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
  const t = Number(parts.t);
  const sig = parts.v1;
  if (!t || !sig) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

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

  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
import time

def verify_norano_webhook(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
    # Parse defensively: a malformed header (a part without "=") must REJECT, not raise.
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        sig = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance_sec:
        return False

    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

Delivery & replay

Deliveries retry with exponential backoff (~6 attempts over ~24h); a 2xx marks the event delivered. Each attempt is recorded in a delivery log; you can replay a delivery with POST /api/v1/webhook_deliveries/{id}/retry.

SSRF note (callback URLs are agent-supplied)

Because an agent supplies the callback URL, Norano accepts https:// only and rejects loopback, private, and link-local targets (including the cloud metadata IP), re-resolves the host at delivery time, and does not follow redirects to disallowed targets.