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/json

The minimum scopes are:

OperationScopeResource
Read plansAPI plan access—
Create a Docker/OCI or GitHub Jobproject.createorganization:*
Update the scheduleproject.schedule.updateproject:<project-id>
List runsproject.readproject:<project-id>
Read cycle costproject.billing.readproject:<project-id>
Cancel an active runproject.job-run.cancelproject:<project-id>
Read run logs or GitHub buildsproject.logs.readproject:<project-id>
Read per-run metricsproject.metrics.readproject:<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/plans

The 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 0 produces succeeded;
  • any non-zero code produces failed; 1 is only an example;
  • without command and args, the image entrypoint and CMD are used;
  • a process that does not exit remains running until cancellation or the 60-minute deadline, when it becomes deadline_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=succeeded

page 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/cancel

The 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> --json

The 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/logs

The 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/metrics

The 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

Durationduration_secondsbilled_minutes
1 second11
59 seconds591
70 seconds702
60 minutes3,60060

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/:id

Deletion 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:

  1. cancel an active run with POST /v1/project/:id/job-runs/:runId/cancel when needed and authorized;
  2. wait until history shows the terminal state and billing fields;
  3. 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

StatusMeaning
200Query or update completed
201Job created
400Invalid data or incompatible operation
401Missing or invalid credential
403Organization, project, or permission not authorized
404Project or run not found
409Operation not allowed in the current state; safe cancellation may return JOB_RUN_CANCELLATION_UNAVAILABLE, and deletion may return JOB_RUNS_PENDING
429Request limit exceeded
500Request could not be processed
503Scheduled Jobs disabled; the catalog returns SCHEDULED_JOBS_UNAVAILABLE

Troubleshooting

  • failed: read logs, confirm the executable, and investigate the non-zero exit code. Code 1 is only an example.

  • deadline_exceeded: the process did not finish within 60 minutes. Set command/args for a finite routine or use the cancellation endpoint.

  • unavailable metrics: 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/builds and GET /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_PENDING on deletion: cancel when authorized, wait for terminal status and billing, and retry once.

  • Scheduled Jobs guide

  • Metrics and Logs API

  • GitHub Builds

Last updated on

On this page