Scheduled Jobs API
The Scheduled Jobs API creates a project that runs a task at defined times, updates its cron schedule, and reads per-run history, logs, and metrics. All requests use the base URL https://api.zenifra.com/v1.
Authentication and permissions
Use an organization API Key or a user token accepted by your integration. Include the organization context in x-organization-id and send Content-Type: application/json on requests with a body.
x-api-key: your-api-key
x-organization-id: your-organization-id
Content-Type: application/jsonThe minimum scopes are:
| Operation | Scope | Resource |
|---|---|---|
| Read plans | API plan access | — |
| Create a Docker/OCI or GitHub Job | project.create | organization:* |
| Update the schedule | project.schedule.update | project:<project-id> |
| List runs | project.read | project:<project-id> |
| Read cycle cost | project.billing.read | project:<project-id> |
| Cancel an active run | project.job-run.cancel | project:<project-id> |
| Read run logs or GitHub builds | project.logs.read | project:<project-id> |
| Read per-run metrics | project.metrics.read | project:<project-id> |
The organization and project must belong to the authorized context. A 403 response means the credential lacks the required scope or resource access. A GitHub source also requires a GitHub account connected to the organization.
Read plans and prices
Read the catalog before creating a Job or showing an estimate:
GET /v1/project/job/plansThe response uses the dedicated Scheduled Jobs product catalog. IDs use the job- prefix, such as job-basic, job-premium, job-premium_plus, and job-business. Always read price_per_minute, currency, payment_mode, features, and permissions from the response. price_per_minute is a number in BRL cents, may be fractional, and payment_mode is per_minute; divide the price by 100 to display BRL.
{
"status": "success",
"data": [
{
"plan": "job-basic",
"price_per_minute": 2,
"currency": "brl",
"payment_mode": "per_minute",
"features": ["Runs of up to 60 minutes"],
"permissions": {
"free_storage_size": "1"
}
}
]
}If Scheduled Jobs is disabled, this endpoint returns 503 with code: SCHEDULED_JOBS_UNAVAILABLE. Treat this as unavailability specific to the Scheduled Jobs catalog; other errors remain request failures.
Create a Job
Create the project with POST /v1/project, using a job-* plan and payment_mode: per_minute. The body must provide exactly one source: image for a ready Docker/OCI image or github for a connected repository.
Docker/OCI source
This example uses a public image, ephemeral storage, and an explicit batch process:
POST /v1/project
Idempotency-Key: unique-key-with-at-least-16-characters{
"name": "nightly-report",
"description": "Generates the daily report",
"plan": "job-basic",
"payment_mode": "per_minute",
"config": {
"type_project": "job",
"image": {
"url": "docker.io/acme/report:1.0",
"is_public": true
},
"envs": [
{ "name": "REPORT_FORMAT", "value": "csv" }
],
"storage": {
"persistent": false,
"capacity": 1
},
"job": {
"cron": "0 3 * * *",
"command": ["/app/report"],
"args": ["--daily"]
}
}
}cron has exactly five fields and is interpreted in UTC. command and args are string arrays; both are optional. When both are omitted, the image uses its own entrypoint and CMD. A Job does not accept a port, domain, exposure, instances, or auto-scaling.
GitHub source
Use a connected GitHub account and provide the repository and branch. The build creates the artifact that the Job runs; start_command, pre_build_command, and build_command belong to the source build flow, while job.command and job.args control the batch process when defined.
{
"name": "github-sync",
"description": "Synchronizes data every hour",
"plan": "job-basic",
"payment_mode": "per_minute",
"config": {
"type_project": "job",
"github": {
"repository_owner": "acme",
"repository_name": "data-jobs",
"branch": "main",
"auto_deploy": false,
"runtime": "nodejs",
"version": "24",
"start_command": "node worker.js",
"pre_build_command": null,
"build_command": "npm ci"
},
"envs": [
{ "name": "MODE", "value": "production" }
],
"storage": {
"persistent": true,
"capacity": 5,
"dir_path_to_persist": "/data"
},
"job": {
"cron": "0 * * * *",
"command": ["node"],
"args": ["worker.js"]
}
}
}Read the build at GET /v1/project/:id/github/builds and its logs at GET /v1/project/:id/github/builds/:buildId/logs. These endpoints use project.logs.read and have their own history; do not confuse a failed build with a failed Job run.
Runtime contract
The image process exit code determines the result:
- code
0producessucceeded; - any non-zero code produces
failed;1is only an example; - without
commandandargs, the image entrypoint andCMDare used; - a process that does not exit remains
runninguntil cancellation or the 60-minute deadline, when it becomesdeadline_exceeded; - an occurrence does not create a parallel run. If another run is active, the schedule may be missed and there is no promise of a queue or catch-up;
- there is no automatic retry. The current failure ends the run and does not create another attempt.
The public statuses are running, succeeded, failed, deadline_exceeded, and cancelled. The raw exit-code number is not part of the public DTO; use status and logs for diagnosis.
Update the schedule
Change only the cron schedule with:
PATCH /v1/project/:id/schedule{
"cron": "30 3 * * *"
}The response confirms the new schedule and reports timezone: UTC:
{
"status": "success",
"data": {
"cron": "30 3 * * *",
"timezone": "UTC"
}
}The required permission is project.schedule.update. An invalid expression returns 400; attempting to update a project that is not a Job is also rejected.
List runs
Read runs from the current billing cycle. Results are ordered from the most recently scheduled run to the oldest:
GET /v1/project/:id/job-runs?page=1&limit=20&status=succeededpage starts at 1, limit accepts up to 50 items, and status can be running, succeeded, failed, deadline_exceeded, or cancelled. The response contains runs and pagination:
{
"status": "success",
"data": {
"runs": [
{
"id": "507f1f77bcf86cd799439011",
"status": "succeeded",
"scheduled_at": "2026-09-01T03:00:00.000Z",
"started_at": "2026-09-01T03:00:02.000Z",
"finished_at": "2026-09-01T03:01:12.000Z",
"duration_seconds": 70,
"billed_minutes": 2,
"plan": "job-basic",
"amount": 4,
"currency": "brl"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"total_pages": 1
},
"cycle_started_at": "2026-09-01T00:00:00.000Z",
"next_reset_at": "2026-10-01T00:00:00.000Z"
}
}duration_seconds is wall-clock time between start and finish, presented as whole seconds. started_at and finished_at are the container's real start and finish: scheduling and image download time are not charged, and a run whose container never started creates no charge. billed_minutes is the billed unit, rounded up with a minimum of 1 and a maximum of 60; the two fields are not equivalent.
On the date reported by next_reset_at, the list resets immediately even if financial processing is delayed. Older runs remain stored internally for audit and billing, but they are no longer exposed through the current-cycle list, logs, or metrics.
Read current-cycle cost
Use project.billing.read to read the total cost of runs that started in the current cycle:
GET /v1/project/:id/job-runs/cost-summary{
"status": "success",
"data": {
"totals": [
{
"currency": "brl",
"total_amount": 120,
"executed_runs": 1,
"billed_minutes": 60,
"billed_runs": 1
}
],
"cycle_started_at": "2026-09-01T00:00:00.000Z",
"next_reset_at": "2026-10-01T00:00:00.000Z"
}
}total_amount uses the currency's minor unit and sums the persisted amounts for each terminal run started in the cycle. Each amount is calculated from billed_minutes and the precise product rate captured for that run, without per-run rounding; the resulting public amount and captured rate are not recalculated when the catalog changes. A run that crosses the billing date remains in the cycle where it started. The total appears once terminal usage is materialized; it does not wait for financial settlement. On the next billing date, totals, executed_runs, billed_minutes, and public history reset. billed_runs is a deprecated alias for executed_runs, kept temporarily for compatibility. Older financial records are not deleted.
Cancel a run
To end an active run, use the project ID and public run ID:
POST /v1/project/:id/job-runs/:runId/cancelThe operation requires project.job-run.cancel on the project. It is idempotent: if the run is already succeeded, failed, deadline_exceeded, or cancelled, the API returns the current terminal state without creating another charge; repeating a request after a successful cancellation returns cancelled, and a repeated request while the same cancellation is still in progress waits for it to finish and returns the same final state. Cancellation affects only the active run identified by :runId; it does not pause the cron schedule or prevent future times. To prevent new occurrences, pause the project.
For an active run that supports safe cancellation, the API responds successfully only after confirming that the run has stopped. Interruption first allows up to 30 seconds for graceful shutdown; only then is forced cleanup requested. The final state of a successful operation is cancelled, and billing uses only the interval between started_at and finished_at, which is the moment cancellation was requested; the graceful shutdown time is not charged. If the run finishes while the request is in progress, the API may return the observed terminal state instead of cancelled. If the run no longer exists in the runtime environment when it is cancelled, it is marked cancelled and billed only up to the last time Zenifra observed it running, never up to the cancellation time. The project can then be deleted. A run that has not started creates no usage.
Older active runs may not support safe cancellation. In that case, the API returns HTTP 409 with JOB_RUN_CANCELLATION_UNAVAILABLE and the message This execution cannot be cancelled safely. Wait for it to finish or reach its time limit.. Wait for the run to finish or reach the 60-minute limit; subsequent runs support safe cancellation. This response is not success and does not confirm that the run stopped. Already materialized billing remains governed by the run rules and the time actually consumed.
{
"status": "success",
"data": {
"run": {
"id": "507f1f77bcf86cd799439011",
"status": "cancelled",
"billed_minutes": 2,
"currency": "brl"
}
}
}The CLI exposes the same operation for one specific run in either of these forms:
zenifra project runs cancel --project <id> --run <id>
zenifra project runs cancel --project <id> --run <id> --jsonThe operation does not provide a --wait option. With --json, the CLI keeps the run's current public fields, such as ID, status, timestamps, duration, billed minutes, plan, currency, and amount, while omitting internal details.
Read logs for a run
Use the project ID and public run ID:
GET /v1/project/:id/job-runs/:runId/logsThe response links the text to the requested run:
{
"status": "success",
"data": {
"run": {
"id": "507f1f77bcf86cd799439011",
"status": "succeeded",
"billed_minutes": 2,
"currency": "brl"
},
"logs": "2026-09-01T03:00:02.000Z report started\\n2026-09-01T03:01:12.000Z report completed"
}
}Logs are limited to 50 KiB per response. To read them, use project.logs.read on the project. An unknown run ID or a run from an earlier cycle returns 404. GitHub build logs use the endpoints in the GitHub source section, not this endpoint.
Read run metrics
Use:
GET /v1/project/:id/job-runs/:runId/metricsThe required permission is project.metrics.read. The response uses the { "status": "success", "data": ... } envelope, and data has exactly these public fields:
{
"run_id": "507f1f77bcf86cd799439011",
"status": "available",
"window": {
"started_at": "2026-09-01T03:00:02.000Z",
"finished_at": "2026-09-01T03:01:12.000Z"
},
"cpu": {
"average_cores": 0.25,
"peak_cores": 0.7
},
"memory": {
"average_bytes": 12000000,
"peak_bytes": 16777216
},
"samples": 7
}For a running run, status is collecting and cpu/memory may contain only latest_cores/latest_bytes, along with sampled_at. For a terminal run, status: available contains averages and peaks. When there is not enough data, the run is short, or the source is unavailable, status is unavailable, samples may be 0, and unknown fields are absent; they are never converted to zero. The active collection target is about 10 seconds, so a run shorter than the first interval may remain unavailable. Metrics for a run from an earlier cycle return 404 through the public API.
Billing and storage
price_per_minute, amount, and total_amount are numbers in BRL cents and may be fractional. JSON preserves those numeric values; human-readable displays may use up to four decimal places. currency is brl and payment_mode is per_minute. Each terminal amount is stored exactly, never rounded up. At cycle settlement the card is charged the whole-cent part and the remaining fraction carries over as a balance due in the next cycle; nothing is lost or rounded up. Billing uses a minimum of 1 full minute and a maximum of 60 minutes. The formula is:
amount = billed_minutes × price_per_minute
| Duration | duration_seconds | billed_minutes |
|---|---|---|
| 1 second | 1 | 1 |
| 59 seconds | 59 | 1 |
| 70 seconds | 70 | 2 |
| 60 minutes | 3,600 | 60 |
Failures, cancellations after the process starts, and deadline_exceeded are charged for consumed time. An active run creates no partial charge, and a run that never started creates no usage. Ephemeral storage uses the configured capacity during the run and adds no storage charge. storage.persistent: true keeps data at dir_path_to_persist between runs and creates a separate GB-hour charge while the project exists, including when paused, until deletion.
Delete a Job
Use the common project deletion endpoint:
DELETE /v1/project/:idDeletion stops new scheduled times before checking history. If there is an active run, a run that has not yet been observed, or a completed run whose charge has not yet been recorded, the API keeps the project and returns 409 with code: JOB_RUNS_PENDING. In that case:
- cancel an active run with
POST /v1/project/:id/job-runs/:runId/cancelwhen needed and authorized; - wait until history shows the terminal state and billing fields;
- retry project deletion.
Do not retry deletion in a loop while JOB_RUNS_PENDING continues. Internal run and metric records remain retained for up to 90 days; materialized usage and financial records remain retained for audit and are not deleted by the cycle reset. Public endpoints expose only the current billing cycle.
JOB_RUNS_PENDING belongs to project deletion; it is not a cancellation error. An attempt to cancel without safe support returns JOB_RUN_CANCELLATION_UNAVAILABLE, and the run must finish or reach the 60-minute limit.
HTTP status and unavailability
| Status | Meaning |
|---|---|
200 | Query or update completed |
201 | Job created |
400 | Invalid data or incompatible operation |
401 | Missing or invalid credential |
403 | Organization, project, or permission not authorized |
404 | Project or run not found |
409 | Operation not allowed in the current state; safe cancellation may return JOB_RUN_CANCELLATION_UNAVAILABLE, and deletion may return JOB_RUNS_PENDING |
429 | Request limit exceeded |
500 | Request could not be processed |
503 | Scheduled Jobs disabled; the catalog returns SCHEDULED_JOBS_UNAVAILABLE |
Troubleshooting
-
failed: read logs, confirm the executable, and investigate the non-zero exit code. Code1is only an example. -
deadline_exceeded: the process did not finish within 60 minutes. Setcommand/argsfor a finite routine or use the cancellation endpoint. -
unavailablemetrics: there are not enough samples or the source was unavailable. Do not treat this state as zero CPU or memory. -
No run at the expected time: confirm the five cron fields and UTC. An occurrence missed while another run was active is not queued.
-
Failure in the GitHub flow before a run: call
GET /v1/project/:id/github/buildsandGET /v1/project/:id/github/builds/:buildId/logs; build and run have distinct states and histories. -
409 JOB_RUN_CANCELLATION_UNAVAILABLE: the run cannot be cancelled safely. Wait for a terminal state or the 60-minute limit; do not treat the response as success or confirmation that it stopped. -
409 JOB_RUNS_PENDINGon deletion: cancel when authorized, wait for terminal status and billing, and retry once.
Last updated on