NodaroAccount
Nodaro Docs
DocumentationNode ReferenceModelsTemplatesAI Agents (MCP)DevelopersSelf-hostingResearchCompare
REST API

Webhooks

Get signed job.completed and job.failed events on your server, start workflows from a Webhook Trigger URL, create schedules and send results out.

Webhooks connect Nodaro to your other systems in both directions. A webhook endpoint that you register receives a signed event each time one of your jobs ends, so your server does not need to poll. A Webhook Trigger node gives a workflow a URL that any system can call to start a run, a Schedule Trigger runs it on a timetable, and a Webhook Output node sends a run's results to your server.

promptWebhook Triggerprompt, imageUrlGenerate ImageNano Banana ProWebhook OutputYour server
An outside system calls the Webhook Trigger URL, Generate Image runs, and Webhook Output posts the image URL to your server.

Endpoints

These routes manage the endpoints that Nodaro sends job events to. See Get job events on your server.

MethodPathWhat it does
POST/v1/webhook-endpointsRegister an endpoint. The answer holds its signing secret, once.
GET/v1/webhook-endpointsYour endpoints, newest first. A secret is never listed.
GET/v1/webhook-endpoints/:idOne endpoint.
PATCH/v1/webhook-endpoints/:idChange url, events, description or isActive.
DELETE/v1/webhook-endpoints/:idDelete the endpoint and its delivery log.
POST/v1/webhook-endpoints/:id/rotate-secretReplace the signing secret, with a grace period for the old one.
GET/v1/webhook-endpoints/:id/deliveriesThe delivery log, newest first.
GET/v1/webhook-endpoints/:id/deliveries/:deliveryIdOne delivery.
POST/v1/webhook-endpoints/:id/testSend a test ping.

These routes start workflows and manage their triggers. They are not under /v1/webhook-endpoints.

MethodPathWhat it does
POST/v1/webhooks/:tokenStart a run. Public: the token in the path is the credential.
GET/v1/workflows/:id/triggersA workflow's triggers, with each webhook's URL and token.
PATCH/v1/workflow-triggers/:idPause or resume a trigger with isActive, or change a schedule's config.
POST/v1/workflow-triggersCreate a trigger by hand, not tied to any node.
POST/v1/workflows/:id/sync-triggersRegister a workflow's trigger nodes again from its saved version.

Get job events on your server

Register an https endpoint, and Nodaro sends it a POST each time one of your jobs ends. Your server learns about a finished generation without polling GET /v1/jobs/:id. Each request is signed with the endpoint's own secret, retried with backoff until your server answers, and recorded in the endpoint's delivery log.

Register an endpoint

curl -s -X POST "https://app.nodaro.ai/v1/webhook-endpoints" \
  -H "Authorization: Bearer $NODARO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/nodaro-webhook", "description": "order fulfilment"}'
const { data: endpoint, secret } = await client.webhooks.create({
  url: "https://example.com/nodaro-webhook",
  description: "order fulfilment",
})
// Store `secret` (whsec_...) with your server's config: it is shown only once.

await client.webhooks.test(endpoint.id) // a ping, sent within seconds
nodaro webhooks create --url https://example.com/nodaro-webhook
nodaro webhooks test <id> --wait

The answer is 201 with { data, secret }. The secret starts with whsec_ and appears in this answer only, so store it at once. events is optional: an endpoint receives both job events unless events names one of them.

An endpoint reads back like this:

{
  "id": "3f6c2a1e-8b4d-4c7a-9e1f-2d5b6a7c8e90",
  "url": "https://example.com/nodaro-webhook",
  "description": "order fulfilment",
  "events": ["job.completed", "job.failed"],
  "isActive": true,
  "disabledReason": null,
  "disabledAt": null,
  "consecutiveFailures": 0,
  "appId": null,
  "secretCreatedAt": "2026-10-15T10:00:00Z",
  "previousSecretExpiresAt": null,
  "createdAt": "2026-10-15T10:00:00Z",
  "updatedAt": "2026-10-15T10:00:00Z"
}

Events

EventSent when
job.completedA job of yours reached completed. A job held for review (pending_review) sends its event when it is approved.
job.failedA job of yours reached failed.
pingOnly from POST /v1/webhook-endpoints/:id/test. An endpoint never subscribes to it.

Every job in your jobs list counts: a single generation, and the job of each node inside a workflow run. A cancelled job sends nothing, and neither do the statuses on the way, such as pending and processing. A job that fails and is completed later sends one event for each ending. The jobs inside a Component node's run are not in your jobs list, so they send nothing: the node that started the run reports for it.

The request

Nodaro sends a POST with Content-Type: application/json and these headers:

HeaderValue
Nodaro-Signaturet=<unix seconds>,v1=<hex>. During a secret rotation, one v1= for each secret.
Nodaro-Eventjob.completed, job.failed or ping
Nodaro-DeliveryThe delivery id, the same on every retry of this event
Nodaro-WebhookThe endpoint id
Nodaro-Attempt1 for the first attempt, then 2, 3 and so on
User-AgentNodaro-Webhooks/1

Only the timestamp and the body are signed. Route and de-duplicate on the verified body's type and id, never on the Nodaro-Event or Nodaro-Delivery headers.

{
  "id": "6f1d0c0e-1c7e-4d0a-9f2b-0a4e2f7b9c11",
  "type": "job.completed",
  "createdAt": "2026-10-15T10:00:04.512Z",
  "data": {
    "job": {
      "id": "0b9a5c1d-2e3f-4a5b-8c6d-7e8f9a0b1c2d",
      "status": "completed",
      "progress": 100,
      "job_type": "generate-image",
      "input_data": { "type": "generate-image", "prompt": "a lighthouse at dusk" },
      "output_data": { "imageUrl": "https://cdn.nodaro.ai/images/0b9a5c1d.png" },
      "error_message": null,
      "error_hint": null,
      "credit_status": "committed",
      "credits": 40,
      "credits_charged": 40,
      "credits_held": 40,
      "credits_released": 0,
      "source": "sdk",
      "session_id": null,
      "title": null,
      "created_at": "2026-10-15T10:00:00.000Z",
      "completed_at": "2026-10-15T10:00:04.512Z"
    }
  }
}
  • id is the delivery id. It is unique for each event and endpoint, and the same on every retry, so de-duplicate on it.
  • createdAt is when the job reached this status.
  • data.job is the job in the shape GET /v1/jobs/:id returns, as it was at the first attempt. Retries send the same body, byte for byte. credits_charged can still be null, with credit_status: "reserved", when the charge has not settled. Read the job again for the final figure. As on GET /v1/jobs/:id, credits_charged includes the steps the job ran, credits_held is the run's hold with its steps' holds, and credits_released is what went back once every hold in the run settled (null until then). See Credits of a job. title is the owner's own name for the job, or null until it is named.
  • An event is at most 64 KB. For a larger job, input_data is left out first, then output_data, and data.omitted lists what was left out. GET /v1/jobs/:id has the whole job.
  • A ping carries "data": { "webhookId": "<endpoint id>" }.

Verify the signature

v1 is the HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint's whole secret (whsec_..., as UTF-8). Check three things before you trust a request:

  1. The signature matches the exact bytes you received. Never re-serialize the JSON first.
  2. The timestamp is recent. Five minutes is the usual tolerance. The check stops a captured request from being replayed later.
  3. You have not handled this id already.

With the SDK, verifyWebhookSignature from @nodaro/sdk does the checks. It uses Web Crypto only, so it runs in Node 20 and later, Deno, Bun, Cloudflare Workers, Vercel Edge and browsers.

import express from "express"
import { verifyWebhookSignature, WebhookSignatureError } from "@nodaro/sdk"

const app = express()

app.post("/nodaro-webhook", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await verifyWebhookSignature({
      payload: req.body, // the raw bytes
      header: req.headers["nodaro-signature"],
      secret: process.env.NODARO_WEBHOOK_SECRET!,
      toleranceSeconds: 300,
    })
    if (event.type === "job.completed") {
      await saveResult(event.data.job.id, event.data.job.output_data)
    }
    res.sendStatus(204) // any 2xx within 10 seconds counts as delivered
  } catch (err) {
    if (err instanceof WebhookSignatureError) return res.status(400).send(err.code)
    throw err
  }
})

verifyWebhookSignature returns the parsed event, or throws a WebhookSignatureError. Its code is missing_header, malformed_header, signature_mismatch, timestamp_outside_tolerance, invalid_payload, missing_secret or crypto_unavailable.

Without the SDK, in Node:

import { createHmac, timingSafeEqual } from "node:crypto"

// rawBody: the request body as received (a Buffer or a string).
function verify(rawBody: Buffer | string, header: string, secret: string, toleranceSeconds = 300) {
  const pairs = header.split(",").map((part) => part.trim().split("=", 2))
  const t = pairs.find(([key]) => key === "t")?.[1]
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest()
  const matches = pairs.some(([key, value]) => {
    if (key !== "v1") return false
    const got = Buffer.from(value, "hex")
    return got.length === expected.length && timingSafeEqual(got, expected)
  })
  return matches && Math.abs(Date.now() / 1000 - Number(t)) <= toleranceSeconds
}

During a rotation's grace period the header carries a v1= for each secret, the new one first. Accept the request when any of them matches. The delivery log keeps the exact body of a delivery that has not succeeded (requestBody), so you can reproduce a failing check byte for byte.

Retries

Any 2xx answer within 10 seconds counts as delivered. Anything else fails the attempt: a 3xx (redirects are not followed), a 4xx, a 5xx, a timeout or an address that does not answer. The delivery is then tried again:

After attemptNext attempt in
130 seconds
22 minutes
310 minutes
41 hour
56 hours
612 hours
724 hours

Each wait varies by up to 10% either way. After the 8th attempt, about 43 hours after the first, the delivery fails for good. A test ping is tried once.

  • Delivery is at least once. A retry can arrive after your server handled the first attempt, for example when it answered too late. De-duplicate on the delivery id.
  • Events can arrive out of order. Up to 5 deliveries go to one endpoint at a time, and 10 for one account. Use createdAt and the job's own status, not the order of arrival.
  • A server that does not answer at all rests the endpoint. After 5 attempts in a row with no HTTP answer, the endpoint rests for a minute, twice as long each time it happens again, up to an hour. Waiting deliveries keep their attempts, and any HTTP answer ends the rest.
  • A problem on Nodaro's side is not counted against you. The delivery waits a few minutes and is tried again without spending an attempt. Its log entry shows signing_unavailable or job_unreadable meanwhile.

Automatic switch-off

When 50 deliveries in a row fail for good, Nodaro switches the endpoint off: isActive becomes false and disabledReason becomes "consecutive_failures". Fix the receiver, check it with a test ping, then switch the endpoint on with PATCH /v1/webhook-endpoints/:id and { "isActive": true }. That also resets the count, and so does any success.

While an endpoint is switched off, by you or by Nodaro, no events are recorded for it. Deliveries already waiting end failed with error: "endpoint_disabled". A test ping is still sent.

The delivery log

GET /v1/webhook-endpoints/:id/deliveries lists an endpoint's deliveries, newest first, 20 to a page by default and up to 100. Filter with status (pending, delivered or failed), and page with limit and cursor. The answer is { data, next }. The log is kept for 30 days.

Each delivery carries its event, jobId, status, attempts, the receiver's last responseStatus, the first 2 KB of its responseBody, durationMs, error and, while pending, nextAttemptAt. requestBody is the exact body sent, kept while the delivery is pending or after it failed, and null once delivered. GET /v1/webhook-endpoints/:id/deliveries/:deliveryId reads one delivery, for example the ping that a test queued.

error is one of these values:

errorMeaning
http_<status>Your server answered with that status.
timeoutNo answer within 10 seconds.
blocked_addressThe address does not resolve to a public internet address. Nodaro refused it before connecting.
connection_refused, connection_reset, tls_error, network_errorThe connection failed.
endpoint_disabledThe endpoint was switched off while the delivery waited.
app_revokedYou revoked the connected app that registered the endpoint.
job_deletedThe job was deleted before it could be sent.
attempts_exhaustedThe last attempt was cut off on Nodaro's side before its answer was recorded.
signing_unavailable, job_unreadableA problem on Nodaro's side. The delivery waits and is tried again, without spending an attempt.

Rotate the secret

POST /v1/webhook-endpoints/:id/rotate-secret answers { secret, previousSecretExpiresAt }, and the new secret appears in this answer only. The new secret signs every delivery from then on, retries included. The replaced secret keeps signing beside it for gracePeriodSeconds: a day by default and 7 days at most. That gives you time to update your receiver without a gap. Send { "gracePeriodSeconds": 0 } to stop the old secret at once, for example after a leak.

Rules and limits

  • https on a public host only. No http://, no localhost, no private, loopback or link-local address, and no name that only works inside a private network, such as one ending in .internal or .local. The URL may not carry a user name or a password. The address is resolved again on every attempt. To receive on your own machine, use a tunnel and register its https address.
  • One endpoint per address. Registering the same address twice answers 409 url_taken. Subscribe one endpoint to both events instead.
  • 10 endpoints per account. The 11th answers 400 limit_reached.
  • Writes are rate-limited: 30 a minute for create, update, delete and rotate-secret, and 10 test pings a minute.
  • Who can manage endpoints: a signed-in session, a personal API token, or a connected app's token with the jobs:read scope. A personal token limited to some workflows cannot.
  • An endpoint a connected app registers (appId is set) receives only the jobs that app submitted itself, and only while your grant to the app includes jobs:read. Jobs of a workflow run the app started are not included: follow the run with GET /v1/workflow-executions/:id. The app sees only the endpoints it registered. You see and can delete all of them.
  • Self-hosted installs send from their own server and need NODARO_ENCRYPTION_KEY, which the Docker Compose stack generates. Without it the routes answer 503 encryption_key_missing.
  • Not available over MCP. Registering an endpoint returns a signing secret and sets up account-wide delivery, which is an action for an app or the CLI, not for an AI agent.

Other errors: 400 validation_error for a bad address or body, 403 insufficient_scope, 404 not_found for an id that is not yours, and 409 url_invalid for an endpoint whose stored address the rules no longer accept. Change its url before you switch it on or test it. 409 secret_required means an endpoint registered before signing existed: rotate its secret before you switch it on or test it.

Start a workflow from an HTTP call

Add a Webhook Trigger

Add a Webhook Trigger node to the workflow. Under Output Parameters, add one parameter for each value the caller will send. Each parameter has a Name, which matches a key in the request's JSON body, and a Type: text, imageUrl, videoUrl or audioUrl. Wire the parameters into the nodes that should use them.

Save the workflow

Saving creates the endpoint: Nodaro mints a 32-byte token and registers POST /v1/webhooks/<token>. This happens whichever way the workflow is saved: from the editor, the API, the SDK, an import or MCP. The token is minted once and then kept, so the URL you give an outside system stays valid through every later save. Removing the node retires the URL.

Read the URL

GET /v1/workflows/<id>/triggers returns the workflow's triggers, including each webhook's webhookUrl and webhookToken. webhookUrl is a path, /v1/webhooks/<token>, so put your Nodaro address in front of it. The trigger whose config.nodeId is the node's ID belongs to that Webhook Trigger node. The list shows only the triggers of workflows you own.

In the editor, the node's settings panel shows the same address in full under Webhook URL, with a copy button.

Call it

Send a POST with a JSON body. No Authorization header is needed: the token in the URL is the credential.

curl -s -X POST "https://app.nodaro.ai/v1/webhooks/$WEBHOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "a lighthouse in a storm", "imageUrl": "https://example.com/lighthouse.jpg"}'

The body becomes the trigger's payload. Each parameter reads the key of the same name, and its type routes the value: an imageUrl feeds image inputs, a videoUrl feeds video inputs.

Each webhook trigger accepts 10 requests per minute. The limit is separate from your API tokens' limits.

Typical callers are a payment system that starts an onboarding video when a customer signs up, a code repository that asks for a release-notes summary, a content system that publishes a new article, or a no-code automation tool.

What a triggered run executes

  • A trigger wired to something runs only its branch: the nodes downstream of the trigger, plus every node those nodes need as input.
  • A trigger wired to nothing runs the whole workflow.
  • So one workflow can carry several triggers, each starting its own branch.
  • "Wired" means anything that feeds another node: a drawn connection, a node inside a Group, or a field mapping.
  • Runs from the editor, the API and published apps are not limited by triggers. They run what they always run.

A trigger created by hand through the API names no node. Nodaro then uses the only Webhook Trigger on the canvas; with two of them, the whole workflow runs.

Keep the URL secret

The token is the only credential, so anyone who holds the URL can start runs and spend your credits. For a workflow in an organization's workspace, the runs are paid by the workspace's budget: anyone you share the URL with can spend the class's or the team's credits.

Every automated run, whether from a webhook, a schedule or Telegram, first checks that the trigger's creator may still run the workflow. When they no longer can, because a grant was revoked, a membership was suspended or the workspace was archived, the automation stops. The workflow's run history then shows one failed entry with the code run_requires_authenticated_member.

Manage triggers through the API

Trigger nodes register themselves when the workflow is saved. Four routes manage them directly:

  • GET /v1/workflows/<id>/triggers lists a workflow's triggers.
  • PATCH /v1/workflow-triggers/<id> with { "isActive": false } pauses a trigger, and true resumes it. For a trigger that belongs to a node, the next save applies the node's own switch again, so use the node's switch for anything that should stick. A config you send is merged into the stored one: send only what changes. The trigger's link to its node and its run count are kept.
  • POST /v1/workflows/<id>/sync-triggers registers the workflow's trigger nodes again from its saved version. The editor calls it after every save, and anyone with edit access may call it. It answers { "data": { "synced": …, "created": …, "updated": …, "removed": … } }.
  • POST /v1/workflow-triggers creates a trigger that belongs to no node, so saving the workflow never changes or removes it. Creating one needs the same permission as running the workflow, because a trigger is a run that nobody attends.

Schedules

A Schedule Trigger runs a workflow on a timetable. A schedule is a list of rules, and the workflow runs whenever any rule matches the current minute.

Turn a schedule on

A Schedule Trigger node fires only while its data says "active": true, the node's switch. A node written through the API, the SDK or MCP without active is registered paused. The editor's switch and the schedule button in its top bar (Schedule on or Schedule off) set the same field. A template export never carries it, so an imported schedule always starts paused.

Create a schedule by hand

{
  "workflowId": "8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
  "type": "schedule",
  "config": {
    "rules": [
      { "kind": "days", "every": 1, "hour": 9, "minute": 0 },
      { "kind": "weeks", "every": 2, "weekdays": [1, 3], "hour": 18, "minute": 30 }
    ],
    "timezone": "Asia/Jerusalem"
  }
}

Send it with POST /v1/workflow-triggers. The same config shape is what a Schedule Trigger node stores.

config fieldMeaning
rulesOne or more rules. The workflow runs when any rule matches.
timezoneThe clock the rules are read in, as a zone name such as Asia/Jerusalem. UTC when omitted.
maxExecutionsStop after this many runs. The schedule stays registered: raise or clear the number to continue.
retryOptional. The node's Retry on failure: { afterMinutes, maxAttempts, cheapLanesOnly }. A scheduled run that ends failed or timed_out runs again afterMinutes after it ended (5 to 1,440, default 30), up to maxAttempts times (1 to 5, default 3). With cheapLanesOnly (default true), the model calls of the run and its retries do not fall back to the model maker's direct service. A field left out takes its default. Omit retry for no retry; in a PATCH, "retry": null turns it off. See Schedule Trigger.
kindFieldsRuns
minutesevery from 1 to 59At minute 0, N, 2N and so on, every hour
hoursevery from 1 to 23, minuteAt hour 0, N, 2N and so on, every day, at that minute
daysevery from 1 to 31, hour, minuteEvery Nth calendar day at that time
weeksevery from 1 to 52, weekdays (0 is Sunday, 6 is Saturday), hour, minuteOn those weekdays, every Nth week
monthsevery from 1 to 12, dayOfMonth from 1 to 31, hour, minuteOn that day, or the month's last day when the month is shorter, every Nth month
croncron, a 5-field expressionWhenever the expression matches
  • Every Nth day, week or month counts from a fixed start, and weeks start on Monday, so saving again never shifts the days a schedule runs.
  • In a cron rule every field must match, both the day of the month and the day of the week, where some cron tools accept either one. 7 means Sunday, like 0.
  • Values out of range answer 400, and so does a time zone the server cannot read or a retry value out of range.
  • A retry run is listed by GET /v1/workflows/<id>/executions with retry: { "attempt": 1, "maxAttempts": 3, "retryOf": "<failed execution id>" }.
  • Older forms still work for triggers you create by hand: an interval such as 5m, 1h or 1d, or a cron string. A Schedule Trigger node saved with them is converted to rules.

How schedules fire

  • The server checks the schedules once a minute and runs a workflow when one of its rules matches that minute.
  • A minute never fires twice, even across a restart or a clock change. A minute the clock jumps over is skipped that day.
  • If the previous run of the workflow is still going, that minute is skipped.
  • A node that cannot run stays paused even when its switch is on, and the node shows Won't run. Examples are a cron rule with no expression and an unreadable time zone. Nothing is guessed: fix the node and save again.

Send results to your server

A Webhook Output node sends the result that reaches it, with the parameters you configure, to your URL as a POST request. Its parameters have the same types as the trigger's: text, imageUrl, videoUrl and audioUrl. Media values are URLs of files stored by Nodaro, which your server can download.

If your endpoint answers with an error, the node fails, and the error appears in the run's history. Use an https URL.

Send a key with the request

Many endpoints accept a request only with a key in a header. Save the header name and the key once as a credential in the web app, under Integrations › HTTP credentials, and choose it in the node. The key never enters the workflow, an export or a template.

When you save a key, you choose who can use it:

ChoiceWorks on
Any address (a plain credential)Runs you start yourself: a run from the editor, and a schedule you set up in the editor.
Only one address (a locked credential)Every run, but only to that address, or to paths under it when you allow that.

Runs started with an API token or an OAuth token, by a webhook trigger, by a schedule created through the API, or from a published app are not your own, even though they run as you. For those runs, lock the credential: a plain one makes the node fail with a clear message instead of sending. A locked credential is sent only when the node's URL matches the address, a redirect to another address is not followed, and the key is never sent over plain http.

With a credential attached, the node does not keep or show your endpoint's response body, only its status code. Credentials are managed only in the web app: the credential routes answer 403 in_app_only to tokens. Publishing an app or sharing a workflow for others to run answers 409 credential_unbound until every credential it sends with, including inside sub-workflows, is locked to an address the node sends to.

Follow a run without an endpoint

You have two other ways to learn the outcome of a run:

  • Poll it. Follow the run with GET /v1/workflow-executions/:id, or a single node's job with GET /v1/jobs/:id/status. See Executions and Jobs.
  • Let the workflow tell you. End the workflow with a Webhook Output node that posts the results to your server.

Frequently asked questions

How do I start a Nodaro workflow with a webhook?

Add a Webhook Trigger node, define its parameters, and save the workflow. Saving creates a URL of the form POST /v1/webhooks/ followed by a token. Send a JSON body whose keys match the parameter names, with no other authentication.

Does Nodaro call my server when a job finishes?

Yes, once you register a webhook endpoint with POST /v1/webhook-endpoints. Nodaro then sends a signed job.completed or job.failed event to your https URL each time one of your jobs ends, and retries for about 43 hours.

How do I verify a Nodaro webhook event?

Compute the HMAC-SHA256 of the timestamp, a dot and the raw body with your endpoint's secret, and compare it with the v1 value of the Nodaro-Signature header. Also check that the timestamp is recent. The SDK's verifyWebhookSignature does both.

Is a webhook URL safe to share?

Treat it like a password. The token in the URL is the only credential, so anyone who has it can start runs and spend your credits, or the workspace's budget. Each trigger accepts 10 requests per minute.

How do I schedule a workflow through the API?

Save a Schedule Trigger node with "active" set to true in its data, or create a schedule with POST /v1/workflow-triggers and a config of rules and a timezone. Nodaro checks every schedule once a minute.

Why does a Schedule Trigger created through the API not run?

A Schedule Trigger runs only while its data says "active" is true. A node written through the API, the SDK or MCP without that field is registered paused. Set the field and save again.

Last updated on

On this page