Scheduled Jobs
Scheduled Jobs run a recurring task without requiring you to keep an application active all the time. Each run has a scheduled time, status, logs, optional metrics, and billing calculated separately from wall-clock duration.
How it works
- UTC schedule: use a
cronexpression with exactly five fields and no seconds. The configured schedule is always interpreted in UTC. - Sequential execution: a project has at most one active run. If the next schedule arrives while another run is active, that occurrence is missed or skipped; it is not queued. The product does not promise to execute every delayed occurrence later.
- Duration limit: a run can remain
runninguntil it finishes, is cancelled, or reaches 60 minutes. At the limit, it finishes asdeadline_exceeded. - No automatic retry: a failure ends the current run. The product does not create another automatic attempt for the same occurrence.
- Public cycle: the API and Console expose the current billing cycle. Internal run and metric records are retained for up to 90 days. Materialized usage and financial records remain available for audit and are not deleted by the public cycle reset; do not apply the run/metric 90-day limit to them.
Execution sources: Docker/OCI and GitHub
Docker/OCI image
Use a public or private Docker/OCI image when the artifact is already available in a registry. In the API, provide the image in config.image, the cron schedule in config.job.cron, and, when needed, config.job.command and config.job.args. Environment variables go in config.envs. The public storage and job objects hold the storage and execution contract, respectively.
The Console V1 flow uses this source: choose Scheduled Job, a Job plan, the image, schedule, variables, and storage. The Console does not ask for a port, domain, exposure, or instances because a Job is not an HTTP application.
The CLI V1 also accepts only a ready OCI image for Jobs. Its config must include an explicit envs array: use envs: [] when no variables are needed or an array of {name, value} objects; do not omit it. In that flow, do not provide github, job.command, or job.args; use the image's default entrypoint. To define commands or arguments, or to use a GitHub repository, use the advanced API described below.
GitHub repository
In the advanced API, replace config.image with config.github to create from a repository. Connect the GitHub account to Zenifra and provide repository_owner, repository_name, and branch; runtime, version, start_command, pre_build_command, build_command, and auto_deploy complete the build flow for the selected source.
Creation starts a build. Check build status and logs in GitHub Builds, using project.logs.read for reading and project.deploy.trigger for manual triggers. Build logs are different from a Job run's logs. After a successful build, config.job.command and config.job.args remain the explicit way to choose the batch process; when omitted, the image defaults are used.
Process, command, and arguments
The process executed by the image determines the run result:
- exit code
0meanssucceeded; - any non-zero exit code means
failed;1is only a common example, not the only failure code; - when
commandandargsare not provided, Zenifra preserves the entrypoint andCMDdefined by the image; commandreplaces the executable andargssupplies arguments. If only one is provided, the missing default continues to come from the image;- the public run status is the source of truth. The run DTO does not expose the raw exit-code number.
Web-server images, such as images that keep listening for requests, normally do not exit on their own. Without an explicit batch command, they may remain running until the 60-minute limit and then become deadline_exceeded. For a recurring task, prefer a process that completes and exits.
States, cancellation, and deadline
The public statuses are running, succeeded, failed, deadline_exceeded, and cancelled.
running: the process has started or is still being observed;succeeded: the process ended with code0;failed: the process ended with any non-zero code;deadline_exceeded: the process did not finish within 60 minutes;cancelled: an authorized person ended the run.
Anyone with project.job-run.cancel can cancel an active run. Cancellation is idempotent: when the run is already terminal, the API only returns that state. The interruption first allows up to 30 seconds for graceful shutdown; only then is forced cleanup requested. A cancelled run is billed up to the moment cancellation was requested; the graceful shutdown time is not charged. 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 never started creates no usage.
Cancellation affects only the active run identified by its ID; it does not pause the cron schedule or prevent future times. To prevent new occurrences, pause the project. A successful request is confirmed only after the run has stopped. If the run finishes while the request is in progress, the API may return the observed terminal state instead of cancelled. Older active runs may not support safe cancellation; in that case, wait for the run to finish or reach the 60-minute limit. Subsequent runs support safe cancellation.
If the run cannot be cancelled safely, the API returns 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.. This response is not success and does not confirm that the run stopped. Wait for a terminal state or the duration limit; already materialized billing remains governed by the run rules and the time actually consumed.
From the CLI, cancel 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.
Batch examples
The examples below use command and args to make behavior explicit. The same structure can be sent inside config.job in POST /v1/project.
Success
{
"command": ["/bin/sh", "-c"],
"args": ["printf 'report ready\\n'; exit 0"]
}Expected result: succeeded, with logs containing report ready.
Failure
{
"command": ["/bin/sh", "-c"],
"args": ["printf 'validation failed\\n' >&2; exit 1"]
}Expected result: failed. Code 1 is only an example; every non-zero code has the same product meaning.
Non-terminating process
{
"command": ["/bin/sh", "-c"],
"args": ["while true; do printf 'still running\\n'; sleep 60; done"]
}Expected result: running while the process is active; deadline_exceeded if nobody cancels it before 60 minutes. This pattern is useful for testing the limit, not for a production batch routine.
Duration, billing, and storage
Run duration
duration_seconds represents wall-clock time between started_at and finished_at. The API presents this value as whole seconds, rounding a fraction up. 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 (for example, an unavailable image) creates no charge. An active run may not have finished_at or a final duration.
Billed minutes
billed_minutes is a separate measure: duration is rounded up to the next full minute, with a minimum of 1 full minute and a maximum of 60. price_per_minute and amount are in BRL cents; currency is brl and payment_mode is per_minute. Divide by 100 to display BRL, and human-readable values may use up to four decimal places. The catalog may return fractional cents: each terminal run amount is stored exactly, never rounded up (a R$0.0005 run is stored as amount: 0.05), with the rate in effect; later catalog changes do not recalculate older runs.
| Observed duration | duration_seconds | billed_minutes |
|---|---|---|
| 1 second | 1 | 1 |
| 59 seconds | 59 | 1 |
| 70 seconds | 70 | 2 |
| 60 minutes | 3,600 | 60 |
The run amount is billed_minutes × price_per_minute, stored without per-run rounding. Therefore, price_per_minute: 2 represents R$0.02 per minute; a 70-second run produces 2 billed minutes and amount: 4. The cycle cost adds the persisted terminal-run amounts rather than the current catalog price. Failures, cancellations after the process starts, and deadline_exceeded are charged for consumed time. An active run does not create a partial charge, and a run that never started creates no usage.
Cycle reset
Public history, total cost, run count, billed minutes, logs, and metrics reset together on the billing date. The reset uses the scheduled date independently of financial settlement. Each run belongs to the cycle where it started; crossing the boundary does not move it when it finishes. The Console shows the next reset date, and terminal cost appears once terminal usage is materialized, before settlement. The API-only GET /v1/project/:id/job-runs/cost-summary requires project.billing.read and returns total_amount, executed_runs, billed_minutes, cycle_started_at, and next_reset_at; the CLI has no equivalent command. Stored amounts for older runs remain internal for audit, idempotency, and billing; they are not deleted.
Storage
Ephemeral storage is created for the run and is available at /data; its data is not preserved for the next run. To keep files between runs, set storage.persistent: true, provide capacity, and set dir_path_to_persist, such as /data or /reports. Persistent storage is billed separately by GB-hour while the project exists, including when paused, until deletion; ephemeral storage adds no storage charge.
Prices may change in the catalog. Read price_per_minute, currency, payment_mode, and plan permissions before showing an estimate. If Scheduled Jobs is disabled, the API returns 503 with SCHEDULED_JOBS_UNAVAILABLE.
Logs, metrics, and history
Run logs
Open a run in the Console or call GET /v1/project/:id/job-runs/:runId/logs. The response associates the text with the requested run and limits the log body to 50 KiB per response. The required permission is project.logs.read. Use run logs to understand the batch process; for a GitHub repository, read build logs separately.
Per-run metrics
Call GET /v1/project/:id/job-runs/:runId/metrics with project.metrics.read. The endpoint is scoped to the authorized project and organization and returns only the run's public DTO:
{
"run_id": "507f1f77bcf86cd799439011",
"status": "collecting",
"sampled_at": "2026-09-01T03:00:12.000Z",
"window": {
"started_at": "2026-09-01T03:00:02.000Z"
},
"cpu": {
"latest_cores": 0.3
},
"memory": {
"latest_bytes": 16777216
},
"samples": 2
}While the run is active, status: collecting shows the latest observed values; updates normally occur around every 10 seconds, so a run shorter than that interval may have no sample. After completion, status: available provides CPU and memory averages and peaks. status: unavailable means there was not enough data, the run was short, or the metrics source was unavailable. Unknown fields are omitted: the API never turns missing data into a fabricated zero.
History and retention
Run and metric records are retained internally for up to 90 days. Materialized usage and financial records remain available for audit and are not deleted by the public cycle reset; do not apply the run/metric 90-day limit to them. The API and Console expose only the current billing cycle. Internal Job retention is independent from GitHub build history. A terminal run can remain stored even when its metrics are unavailable.
Cleanup and troubleshooting
Delete a Job
DELETE /v1/project/:id stops new scheduled times before removing the project. If there is an active run, a run that has not yet been observed, or a charge that has not yet been recorded, the API returns 409 with JOB_RUNS_PENDING. In that case:
- cancel the active run with
POST /v1/project/:id/job-runs/:runId/cancelwhen you haveproject.job-run.cancel; - wait until history shows a terminal status and billing fields;
- try deletion again.
Do not retry deletion in a loop while JOB_RUNS_PENDING continues. Cleaning up a run does not change usage that has already been materialized.
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.
Quick diagnosis
| Symptom | Check |
|---|---|
failed | Read the logs, confirm the executable path, and look for a non-zero exit code. |
deadline_exceeded | The process did not exit within 60 minutes; use command/args for a finite routine or cancel the run. |
unavailable metrics | The run may have been shorter than the first sample or the metrics source may be unavailable; do not interpret this as zero CPU or memory. |
| No new run | Confirm the cron in UTC and check whether a run is already active; the missed occurrence is not queued. |
| Failure before a GitHub run | Check the build and its logs in GitHub Builds; build and run have different 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_PENDING on deletion | Cancel when authorized, wait for history and billing reconciliation, and try once more. |
Next steps
Last updated on