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.
Endpoints
These routes manage the endpoints that Nodaro sends job events to. See Get job events on your server.
| Method | Path | What it does |
|---|---|---|
POST | /v1/webhook-endpoints | Register an endpoint. The answer holds its signing secret, once. |
GET | /v1/webhook-endpoints | Your endpoints, newest first. A secret is never listed. |
GET | /v1/webhook-endpoints/:id | One endpoint. |
PATCH | /v1/webhook-endpoints/:id | Change url, events, description or isActive. |
DELETE | /v1/webhook-endpoints/:id | Delete the endpoint and its delivery log. |
POST | /v1/webhook-endpoints/:id/rotate-secret | Replace the signing secret, with a grace period for the old one. |
GET | /v1/webhook-endpoints/:id/deliveries | The delivery log, newest first. |
GET | /v1/webhook-endpoints/:id/deliveries/:deliveryId | One delivery. |
POST | /v1/webhook-endpoints/:id/test | Send a test ping. |
These routes start workflows and manage their triggers. They are not under /v1/webhook-endpoints.
| Method | Path | What it does |
|---|---|---|
POST | /v1/webhooks/:token | Start a run. Public: the token in the path is the credential. |
GET | /v1/workflows/:id/triggers | A workflow's triggers, with each webhook's URL and token. |
PATCH | /v1/workflow-triggers/:id | Pause or resume a trigger with isActive, or change a schedule's config. |
POST | /v1/workflow-triggers | Create a trigger by hand, not tied to any node. |
POST | /v1/workflows/:id/sync-triggers | Register 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 secondsnodaro webhooks create --url https://example.com/nodaro-webhook
nodaro webhooks test <id> --waitThe 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
| Event | Sent when |
|---|---|
job.completed | A job of yours reached completed. A job held for review (pending_review) sends its event when it is approved. |
job.failed | A job of yours reached failed. |
ping | Only 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:
| Header | Value |
|---|---|
Nodaro-Signature | t=<unix seconds>,v1=<hex>. During a secret rotation, one v1= for each secret. |
Nodaro-Event | job.completed, job.failed or ping |
Nodaro-Delivery | The delivery id, the same on every retry of this event |
Nodaro-Webhook | The endpoint id |
Nodaro-Attempt | 1 for the first attempt, then 2, 3 and so on |
User-Agent | Nodaro-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"
}
}
}idis the delivery id. It is unique for each event and endpoint, and the same on every retry, so de-duplicate on it.createdAtis when the job reached this status.data.jobis the job in the shapeGET /v1/jobs/:idreturns, as it was at the first attempt. Retries send the same body, byte for byte.credits_chargedcan still benull, withcredit_status: "reserved", when the charge has not settled. Read the job again for the final figure. As onGET /v1/jobs/:id,credits_chargedincludes the steps the job ran,credits_heldis the run's hold with its steps' holds, andcredits_releasedis what went back once every hold in the run settled (nulluntil then). See Credits of a job.titleis the owner's own name for the job, ornulluntil it is named.- An event is at most 64 KB. For a larger job,
input_datais left out first, thenoutput_data, anddata.omittedlists what was left out.GET /v1/jobs/:idhas the whole job. - A
pingcarries"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:
- The signature matches the exact bytes you received. Never re-serialize the JSON first.
- The timestamp is recent. Five minutes is the usual tolerance. The check stops a captured request from being replayed later.
- You have not handled this
idalready.
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 attempt | Next attempt in |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 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
createdAtand the job's ownstatus, 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_unavailableorjob_unreadablemeanwhile.
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:
error | Meaning |
|---|---|
http_<status> | Your server answered with that status. |
timeout | No answer within 10 seconds. |
blocked_address | The address does not resolve to a public internet address. Nodaro refused it before connecting. |
connection_refused, connection_reset, tls_error, network_error | The connection failed. |
endpoint_disabled | The endpoint was switched off while the delivery waited. |
app_revoked | You revoked the connected app that registered the endpoint. |
job_deleted | The job was deleted before it could be sent. |
attempts_exhausted | The last attempt was cut off on Nodaro's side before its answer was recorded. |
signing_unavailable, job_unreadable | A 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
httpson a public host only. Nohttp://, nolocalhost, no private, loopback or link-local address, and no name that only works inside a private network, such as one ending in.internalor.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 itshttpsaddress.- 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:readscope. A personal token limited to some workflows cannot. - An endpoint a connected app registers (
appIdis set) receives only the jobs that app submitted itself, and only while your grant to the app includesjobs:read. Jobs of a workflow run the app started are not included: follow the run withGET /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 answer503 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>/triggerslists a workflow's triggers.PATCH /v1/workflow-triggers/<id>with{ "isActive": false }pauses a trigger, andtrueresumes 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. Aconfigyou 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-triggersregisters 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-triggerscreates 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 field | Meaning |
|---|---|
rules | One or more rules. The workflow runs when any rule matches. |
timezone | The clock the rules are read in, as a zone name such as Asia/Jerusalem. UTC when omitted. |
maxExecutions | Stop after this many runs. The schedule stays registered: raise or clear the number to continue. |
retry | Optional. 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. |
kind | Fields | Runs |
|---|---|---|
minutes | every from 1 to 59 | At minute 0, N, 2N and so on, every hour |
hours | every from 1 to 23, minute | At hour 0, N, 2N and so on, every day, at that minute |
days | every from 1 to 31, hour, minute | Every Nth calendar day at that time |
weeks | every from 1 to 52, weekdays (0 is Sunday, 6 is Saturday), hour, minute | On those weekdays, every Nth week |
months | every from 1 to 12, dayOfMonth from 1 to 31, hour, minute | On that day, or the month's last day when the month is shorter, every Nth month |
cron | cron, a 5-field expression | Whenever 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
cronrule every field must match, both the day of the month and the day of the week, where some cron tools accept either one.7means Sunday, like0. - Values out of range answer
400, and so does a time zone the server cannot read or aretryvalue out of range. - A retry run is listed by
GET /v1/workflows/<id>/executionswithretry: { "attempt": 1, "maxAttempts": 3, "retryOf": "<failed execution id>" }. - Older forms still work for triggers you create by hand: an
intervalsuch as5m,1hor1d, or acronstring. 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
cronrule 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:
| Choice | Works 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 withGET /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.
Related
Webhook Trigger
Schedule Trigger
Webhook Output
Automations
Executions
Last updated on
Uploads
Upload images, video and audio with POST /v1/upload, copy files from a URL, import videos from links, trim stored media, search your library and link to a file.
Characters
Create, update, archive and restore characters over REST, generate portrait candidates, expressions, angles and motion clips, and approve the anchor portrait.