Jobs and executions
Poll, list, rate, cancel and delete Nodaro runs from TypeScript, track workflow runs, and get signed webhook calls when a job ends.
A job is one generation in Nodaro, such as one image, one video render or one voiceover, and an execution is one run of a whole workflow. client.jobs reads, lists, cancels and deletes jobs, and client.executions reads, lists and cancels workflow runs. client.webhooks registers endpoints that Nodaro calls when a job ends. client.videoPro stops or continues a long Generate Video Pro run. The methods call the same endpoints as the Jobs and Executions REST APIs.
Methods
| Method | What it does |
|---|---|
executions.get(id) | Read a workflow run, with the state of every node |
executions.listForWorkflow(workflowId, params?) | List the runs of a workflow |
executions.cancel(id, params?) | Stop a workflow run |
jobs.get(id) | Read a job with its input and output |
jobs.list(params?) | List your jobs, newest first |
jobs.rate(id, rating) | Rate one of your jobs good or bad, or clear the rating |
jobs.rename(id, title) | Name one of your jobs, or clear its name |
jobs.fromUpload(params) | Record an image edited in the browser as a job |
jobs.getStatus(id) | Read the status of a job, for polling |
jobs.cancel(id) | Stop a job and refund its reserved credits |
jobs.delete(id, options?) and jobs.restore(id) | Send a job to the Trash, bring it back, or delete it for good |
webhooks.create(input) and more | Register, change, test and audit the endpoints Nodaro calls when a job ends |
verifyWebhookSignature(options) | Check the signature of a call your endpoint received |
videoPro.stop(jobId) | Stop a Generate Video Pro run and keep what is done |
videoPro.continueRun(jobId, opts?) | Continue a Generate Video Pro run as a new job |
Statuses
A job moves through these statuses:
| Job status | Meaning |
|---|---|
pending, queued | Waiting for a worker. |
processing | Running. progress goes from 0 to 100 when the model reports it. |
pending_review | Held for human review by a content policy of this deployment. Not an end state. |
completed | Done. output_data holds the result. |
failed | Failed. error_message explains why, and error_hint may classify it. |
cancelled | Stopped by a cancel. |
An execution has its own statuses: pending, running, completed, failed, cancelled, stopping, timed_out and discarded. Each node inside it is pending, running, completed, failed or skipped.
client.executions
An execution is one run of a workflow. It groups one job per AI node and the state of every node.
executions.get(id)
Reads a workflow run, including a map of every node's state. When the id belongs to a single-node job instead of a workflow run, the server answers with the same shape for that one node.
get(id: string): Promise<{ data: WorkflowExecution }>Prop
Type
const { data } = await client.executions.get(executionId)
console.log(data.status, `${data.completedNodes}/${data.totalNodes}`, data.totalCreditsUsed)A WorkflowExecution has id, kind, workflowId, status, outcome, triggerType (manual, webhook, schedule, app_run, single-node, telegram, telegram_account, api or mcp), nodeStates, totalNodes, completedNodes, failedNodes, totalCreditsUsed, errorMessage, startedAt, completedAt, createdAt and updatedAt. kind is execution for a workflow run and job for a single-node job. A single-node job started by an MCP client has the triggerType mcp.
Runs that find nothing new. A run that found nothing new ends completed, not failed. For example, a feed found no new posts, so the writer after it had nothing to write. A node that needs text, and whose connected text came from a node that produced nothing in this run, is skipped with nodeStates[id].skipReason set to "empty_input". The completed run then has outcome: "nothing_new". Any other completed run has outcome: "succeeded". outcome is absent until the run completes.
import { executionOutcome, countEmptyInputSkips } from "@nodaro/sdk"
const { data } = await client.executions.get(executionId)
if (data.outcome === "nothing_new") {
console.log(`Nothing new: ${countEmptyInputSkips(data.nodeStates)} node(s) had nothing to work on`)
}
executionOutcome(data.status, data.nodeStates) // the same answer, worked out in your codeReading a node's output. nodeStates[nodeId].output is present when the node completed. It can also be present when the node failed but the run kept a usable result, as the 3D scene nodes do. Check the field, not the status, and never treat a present output as success:
import { nodeStateMayCarryOutput } from "@nodaro/sdk"
const node = data.nodeStates["scene-1"]
if (node.status === "failed") {
console.error(node.error) // the failure stands
if (node.output?.plan) {
// the draft the run kept is still here, and it was billed
}
}
nodeStateMayCarryOutput(node.status) // true for "completed" and "failed"OUTPUT_BEARING_NODE_STATUSES, the set of those two statuses, is exported too.
executions.listForWorkflow(workflowId, params?)
Lists the runs of one workflow, newest first, a page at a time. The list includes single-node runs started on that workflow.
listForWorkflow(workflowId: string, params?: ListExecutionsForWorkflowParams): Promise<{
data: WorkflowExecutionSummary[]
nextCursor?: string
}>Prop
Type
const { data: runs, nextCursor } = await client.executions.listForWorkflow(workflowId, {
limit: 20,
status: "completed",
})Each WorkflowExecutionSummary carries the same kind and outcome as get().
executions.cancel(id, params?)
Stops a workflow run. There are three ways to stop it:
- Immediately (the default): running jobs are cancelled, their reserved credits are refunded, and the status becomes
cancelled. A running metered LLM job is cancelled too, but it is charged what the provider already billed for it, and the rest of its hold is refunded. One stopped before its call starts is refunded, except for what an earlier attempt of the same job already used. mode: "after_current": the status becomesstopping. Running nodes finish and land on the canvas and in your library, then the run stops.mode: "discard": no new nodes start, but running jobs are not cancelled, because an external model call cannot be stopped halfway. They finish and are saved to your library, but their results do not return to the canvas. The status becomesdiscarded, and there is no refund, because the jobs completed.
cancel(id: string, params?: { mode?: "after_current" | "discard" }): Promise<{ success: true }>Prop
Type
await client.executions.cancel(executionId, { mode: "after_current" })client.jobs
A job is one generation: one image, one video render, one voiceover. A workflow run creates one job per AI node, and every single-node run creates one job. Job fields use snake_case, as the API sends them.
jobs.get(id)
Reads a job, including its input and output.
get(id: string): Promise<{ data: Job }>Prop
Type
const { data: job } = await client.jobs.get(jobId)
if (job.status === "completed") console.log(job.output_data)A Job has these fields:
| Field | Description |
|---|---|
id, status, progress | The job id, its status and its progress from 0 to 100. |
input_data, output_data | The request and the result. Server-only values are removed from both. |
error_message | Why the job failed, or null. |
error_detail | The provider's own words for a failure, with links cut to their host and secrets removed. null when the failure had none. It is also null on a job of a workflow run whose workflow you cannot open, such as someone else's share link or published app that you ran. A deployment can keep these words for its administrators, and then it is null too. Show it beside error_message, and branch on error_hint. |
error_hint | A structured reason for some failures. See below. |
credits | The credits reserved for the job, or null. |
credit_status | reserved, committed or refunded: where the credits stand. null for a job without a charge. |
credits_charged | What the job cost once its hold settled, the steps it ran included. A job that runs other jobs for you, such as a Nodaro Motion run's reference preparation, mask and video, is charged for each step once the step settles. It can be lower than the hold. When the run's own hold was refunded, it is 0 plus any settled steps. It is null while the job's own hold is open, even when some steps have settled, when nothing in the run was reserved, or when its steps could not be read for a moment. Add up this field to total your spend. |
credits_held | The run's hold: credits plus the steps' holds. |
credits_released | What went back from credits_held once every hold in the run settled, or null until then. |
job_type | The kind of job. |
created_at, started_at, completed_at | Timestamps. |
user_id | The owner. |
source, source_detail | Where the job came from: sdk, cli, mcp, app, web, api and more, with a detail such as sdk/2.17.0. |
recovering | true while the platform recovers a job whose worker stopped after the model delivered. |
favorited | Whether the job's owner favorited it. See client.gallery. |
rating | "good", "bad" or null. See jobs.rate(). |
title | Your own name for the job, or null until you name it. See jobs.rename(). |
session_id | The generation session the job was created in, or null. |
The server leaves out favorited and rating when it cannot read them for a moment. Treat a missing field as unknown, never as false.
An image job's output_data carries the image's pixel size: width and height of imageUrl, and images ({ url, width, height } per entry) beside imageUrls. Older jobs do not have these fields. Job.output_data is typed unknown, so cast it to NodeJobOutput to read them typed. See runAndWait().
error_hint has five kinds. Narrow on kind before you read the rest:
safety-block: the model's own safety filter refused the output.classiscopyright,likenessorsafety.retriedsays whether Nodaro already retried once, andsuggestedProvider, when present, names a model you can retry the same request on.subject: "input_video", when present, means the provider refused the job's input video itself: send a different clip.subject: "input_image"on acopyrightblock means the provider refused one of the input images as possibly copyrighted: send a different picture.subject: "prompt"means the provider's own words named the prompt: change the prompt.subject: "audio"means they blamed the generated audio, not the picture. See When a model blocks the prompt.policy-block: a content policy of this deployment rejected the request or the result.reasonis written for users, so show it as it is.credits: the job ran out of credits partway, because a step that reserves its own credits was refused, for example the video that a Nodaro Motion or trend run starts.codeis the refusal's code:insufficient_credits, or a plan or workspace limit such asfree_video_limitorbudget_exceeded. Treat an unknown code likeinsufficient_credits. The job's credits are refunded. Ask the person to top up, or raise the limit, and run it again.input-refused: the provider refused the job's input video without naming a content-policy class.subjectis"input_video". No other model is suggested: send a different clip.request-refused: the provider refused the request as it was sent, naming a fault of it, such as a setting, a file's format or size, or the prompt's length. The same request is refused again: change the input images, the video, the prompt or the settings. Withsubject: "input_video", the provider's words named the input video as the fault, such as its length or size, or no character found in it: send a different clip. It is not a content-policy block. On a Nodaro Motion or trend run, it is the refusal of one step.
jobs.list(params?)
Lists your jobs, newest first, a page at a time (GET /v1/jobs).
list(params?: ListJobsParams): Promise<{
data: Job[]
next: string | null
}>Prop
Type
let cursor: string | undefined
do {
const page = await client.jobs.list({ type: "llm-structured", origin: "my-app", cursor })
for (const job of page.data) console.log(job.id, job.status)
cursor = page.next ?? undefined
} while (cursor)Every filter combines with the others. A page can hold fewer rows than limit, even none, and still have a next. Page on next, never on the number of rows.
// Your favorited images and videos that came out well
const { data: keepers } = await client.jobs.list({
type: ["generate-image", "generate-video"],
status: "completed",
favorited: true,
rating: "good",
})jobs.rate(id, rating)
Rates one of your jobs "good" or "bad", or clears its rating with null (PUT /v1/jobs/:id/rating). A job has one rating, and only its owner can rate it. Another person's job throws NotFoundError.
rate(id: string, rating: "good" | "bad" | null): Promise<{ data: { jobId: string; rating: "good" | "bad" | null } }>Prop
Type
await client.jobs.rate(jobId, "good")
await client.jobs.rate(jobId, null) // clear itThe rating reads back as Job.rating and filters jobs.list(). OAuth apps need the assets:write scope. A server without ratings throws a NodaroError with code not_available and status 503.
jobs.rename(id, title)
Names one of your jobs, or clears its name with null (PATCH /v1/jobs/:id). Use it for a name such as "Wedding toast, final" on a picture, a clip, a track or a speech take. Only the job's owner can name it. Another person's job throws NotFoundError.
rename(id: string, title: string | null): Promise<{ data: { jobId: string; title: string | null } }>Prop
Type
await client.jobs.rename(jobId, "Hero shot v2")
await client.jobs.rename(jobId, null) // clear it- The name is trimmed. A longer or multi-line name throws a
NodaroErrorwith codevalidation_error. - Naming is free and works on any job type and status, the Trash included. Nothing else about the job changes, and the results keep their order.
- The name reads back as
Job.titlefromget(),list(), a session's items andclient.trash.list(). It is alsotitleon a gallery card, public beside the prompt, and on a library row, where it is the name of the job that made the file, not itsfilename. - OAuth apps need the
assets:writescope.
jobs.fromUpload(params)
Records an image edited in the browser as a job (POST /v1/jobs/from-upload). Store the edited image first with uploads.upload. The result is a completed, private and free image-edit job whose output_data.imageUrl is the upload. You can then rate it, favorite it, list it with list({ type: "image-edit" }) and file it into a session like any image job.
fromUpload(params: JobFromUploadParams): Promise<{ data: Job }>Prop
Type
const upload = await client.uploads.upload(editedPng)
const { url: editStateUrl } = await client.uploads.uploadJson(designState)
const { data: job } = await client.jobs.fromUpload({
assetId: upload.assetId!,
sourceJobId,
editStateUrl,
sessionId,
})One upload is one job: the same assetId again returns the same job, so a retry never makes two. An edit of a character, object, creature or location image keeps that link. OAuth apps need the assets:write scope.
NotFoundError: the asset, the source or the session is not yours.NodaroErrorwith codevalidation_error: the asset is not an image you stored, or the source has no finished image.JobBlockedError: a job policy of the deployment refuses the image. See Record an image edited in the browser.
jobs.getStatus(id)
Reads only the status of a job: id, status, progress, output_data, error_message, error_detail, error_hint, credit_status and completed_at, when the job finished on the server's clock or null until it has (GET /v1/jobs/:id/status). It skips the request data and the cost fields, so it is much lighter than get(). Use it in polling loops.
getStatus(id: string): Promise<{ data: JobStatusResult }>Prop
Type
async function waitForJob(jobId: string) {
for (;;) {
const { data } = await client.jobs.getStatus(jobId)
if (data.status === "completed") return data.output_data
if (data.status === "failed" || data.status === "cancelled") {
throw new Error(data.error_message ?? data.status)
}
await new Promise((resolve) => setTimeout(resolve, 2_000))
}
}client.nodes.runAndWait() runs this loop for you, with progress, cancellation and typed errors.
jobs.cancel(id)
Cancels a job and refunds the credits it had reserved. A job held for review can be cancelled too.
cancel(id: string): Promise<{ success: true; cancelled: number; inFlight?: boolean }>A running metered LLM job is the exception. If its call has already started, the cancel answers { success: true, cancelled: 0, inFlight: true }: the job finishes and is charged only what it used. A job stopped before its call starts is refunded, except for what an earlier attempt of the same job already used, for example a retry after a billed attempt.
Prop
Type
const { cancelled } = await client.jobs.cancel(jobId)jobs.delete(id, options?) and jobs.restore(id)
delete sends a job to the Trash (DELETE /v1/jobs/:id). From then on, every read leaves the job out. It stays restorable for 30 days, and is then deleted for good with its private media. A copy saved to the Library stays. restore (POST /v1/jobs/:id/restore) brings the job back, into every session it was in. { forever: true } deletes a job that is already in the Trash for good, now. On a live job it throws a NodaroError with code not_in_trash (status 409).
Only the job's owner may delete or restore it. A job that is still running keeps running, so cancel it first when its worker should stop. Nothing is refunded. OAuth apps need the assets:write scope. Both methods return { success: true }.
delete(id: string, options?: { forever?: boolean }): Promise<{ success: true }>
restore(id: string): Promise<{ success: true }>Prop
Type
await client.jobs.delete(jobId) // to the Trash
await client.jobs.restore(jobId) // undo
await client.jobs.delete(jobId, { forever: true }) // after a delete: gone for goodA deleted job is never listed by jobs.list(): it is in client.trash.
client.webhooks
Outbound webhooks tell your server when a job ends, so your app does not have to poll. Register an https endpoint, and Nodaro sends it a POST for every job.completed and job.failed of your account. Each call is signed with the endpoint's own secret. Failed calls are retried with backoff for about 43 hours, and every attempt is kept in the endpoint's delivery log. See Webhooks for the events, the body, the headers and the retry schedule.
OAuth app tokens need the jobs:read scope. An endpoint that a connected app registers receives only the jobs that app submitted itself.
create(input: CreateWebhookEndpointInput): Promise<CreatedWebhookEndpoint> // { data, secret }
list(): Promise<{ data: WebhookEndpoint[] }>
get(id: string): Promise<{ data: WebhookEndpoint }>
update(id: string, input: UpdateWebhookEndpointInput): Promise<{ data: WebhookEndpoint }>
delete(id: string): Promise<{ success: true }>
rotateSecret(id: string, input?: RotateWebhookSecretInput): Promise<RotatedWebhookSecret> // { secret, previousSecretExpiresAt }
deliveries(id: string, params?: ListWebhookDeliveriesParams): Promise<WebhookDeliveryPage>
getDelivery(id: string, deliveryId: string): Promise<{ data: WebhookDelivery }>
test(id: string): Promise<{ data: WebhookDelivery }>webhooks.create(input)
Registers an endpoint and returns it with its secret (whsec_…). The secret is shown only once, so store it with your server's configuration.
Prop
Type
const { data: endpoint, secret } = await client.webhooks.create({
url: "https://example.com/nodaro-webhook",
events: ["job.completed", "job.failed"],
})
await client.webhooks.test(endpoint.id) // sends a pingOne address can have one endpoint (409 url_taken). An account can have at most 10 endpoints (400 limit_reached).
webhooks.update(id, input)
Changes any of url, events, description (null clears it) and isActive. After 50 deliveries in a row fail for good, Nodaro switches the endpoint off and sets disabledReason to "consecutive_failures". isActive: true switches it back on and resets consecutiveFailures.
webhooks.rotateSecret(id, input?)
Creates a new secret and returns it once. The new secret signs every delivery from now on, retries included. The old secret keeps signing beside it for gracePeriodSeconds, from 0 to 604800 (default one day), so you can update your receiver without a gap. During that time, Nodaro-Signature carries a second v1=, and verifyWebhookSignature accepts either. Pass { gracePeriodSeconds: 0 } to stop a leaked secret at once.
const { secret, previousSecretExpiresAt } = await client.webhooks.rotateSecret(endpoint.id)previousSecretExpiresAt is null when the old secret stopped at once.
webhooks.deliveries(id, params?) and getDelivery(id, deliveryId)
deliveries() reads the delivery log, newest first. The log keeps 30 days. Filter with status ("pending", "delivered" or "failed"), and page with limit (1 to 100, default 20) and cursor. getDelivery() reads one delivery by its id.
Each WebhookDelivery carries event, jobId, status, attempts, responseStatus, responseBody (the first 2 KB), durationMs, error, requestBody, nextAttemptAt and deliveredAt. requestBody is the exact body sent, and becomes null once the delivery succeeds.
webhooks.test(id)
Queues a ping and returns its delivery. The ping is sent within seconds and tried once, even to an endpoint that is switched off. Follow it with getDelivery(id, delivery.id).
verifyWebhookSignature(options)
Checks a call your endpoint received and returns the parsed event. It is a top-level export of @nodaro/sdk, not a method on client. It uses Web Crypto only, so it runs in Node 20+, Deno, Bun, Cloudflare Workers, Vercel Edge and browsers.
verifyWebhookSignature(options: {
payload: string | Uint8Array | ArrayBuffer // the raw body, as received
header: string | readonly string[] | null | undefined // Nodaro-Signature
secret: string // whsec_…
toleranceSeconds?: number // default 300; 0 turns the check off
}): Promise<WebhookEvent>Pass the raw body exactly as received, never JSON that you parsed and serialized again. The event is a JobWebhookEvent, whose data.job is a Job, or a PingWebhookEvent. A failed check throws WebhookSignatureError with one of these codes: missing_header, malformed_header, signature_mismatch, timestamp_outside_tolerance, invalid_payload, missing_secret or crypto_unavailable. The comparison takes constant time.
import { verifyWebhookSignature, WebhookSignatureError } from "@nodaro/sdk"
export async function POST(request: Request) {
try {
const event = await verifyWebhookSignature({
payload: await request.text(),
header: request.headers.get("nodaro-signature"),
secret: process.env.NODARO_WEBHOOK_SECRET!,
})
if (event.type === "job.completed") await saveResult(event.data.job)
return new Response(null, { status: 204 })
} catch (err) {
if (err instanceof WebhookSignatureError) return new Response(err.code, { status: 400 })
throw err
}
}Answer with any 2xx status within 10 seconds, and the delivery counts as delivered. The body's id is the delivery id and stays the same on every retry, so use it to skip an event you already handled.
client.videoPro
Run control for Generate Video Pro, the node that renders long videos in segments. It works on Nodaro Cloud. Start the run like any node run, then use these methods on its job.
videoPro.stop(jobId)
Stops a running Generate Video Pro job gracefully. The segment in progress is abandoned and still billed, and the remaining segments are skipped. The finished segments are joined into the job's final video, and the unused reserve is refunded. A job that has not started yet is cancelled with a full refund.
stop(jobId: string): Promise<StopVideoProResult>Prop
Type
await client.videoPro.stop(jobId)
const { data } = await client.jobs.getStatus(jobId) // becomes completed, with the partial videoKeep polling the job. It completes with output_data.pro.stopped set to true.
videoPro.continueRun(jobId, opts?)
Continues a stopped, failed or completed run as a new job. Segments before fromSegment are reused, and every segment from it on is generated again. You pay only for the regenerated segments plus the flat Pro fee.
continueRun(jobId: string, opts?: { fromSegment?: number }): Promise<ContinueVideoProResult>Prop
Type
const { jobId: newJobId, fromSegment } = await client.videoPro.continueRun(jobId, { fromSegment: 4 })The result carries the new jobId to poll, and may carry continuedFromJobId, fromSegment, segmentCount and deduped.
Frequently asked questions
What is the difference between a job and an execution?
A job is one generation, such as one image or one video. An execution is one run of a whole workflow, and it groups one job per AI node plus the state of every node.
Which method should a polling loop call?
client.jobs.getStatus(jobId). It returns only the status, progress, output and error, so it is much lighter than client.jobs.get.
Does cancelling a job refund its credits?
Yes. client.jobs.cancel stops the job and refunds the credits it had reserved. Cancelling an execution immediately does the same for its running jobs. A metered LLM job whose call has started is the exception: it is charged what it used.
Can Nodaro call my server when a job finishes instead of my app polling?
Yes. Register an https endpoint with client.webhooks.create. Nodaro then sends a signed POST for every job.completed and job.failed of your account. Check each call with verifyWebhookSignature.
What does the status pending_review mean?
A content policy of this deployment is holding the result for a person to review. It is not an end state: the job becomes completed, failed or cancelled later. Keep waiting and do not run the request again.
Related
Run nodes
Workflows and projects
Errors
Jobs
Webhooks
Last updated on
Workflows and projects
Create, update, share, export, import and run Nodaro workflows from TypeScript with client.workflows, and organize them in projects with client.projects.
Run nodes
Run any Nodaro node from TypeScript without a workflow. Discover node types, start runs, wait for results, and pass references and camera direction.