Smart Services

by wizewerx

Docs
PricingConsole

Jobs

Register a schedule and we call your URL when it is due. Postgres is the source of truth, so a schedule cannot vanish with a node.

What it is

Jobs runs scheduled and delayed work for your app: you register a cron expression or a future timestamp with an API call, and we POST your URL when it is due. There is nothing to deploy and nothing to keep running on your side — your handler is an ordinary HTTP endpoint.

Before you start

You need your app API key. It is shown once, when the app is created. If you no longer have it, open the Smart Services console → your app → Settings and use Rotate Key — the old key stops working immediately.

Send it on every request, in either header:

Authorization: Bearer <key>
x-api-key: <key>

Both are accepted. Authorization: Bearer wins if you send both.

Base URL: https://smart-services.io

The jobs service must be enabled on your app, or every endpoint here returns 403. Everything is scoped to your app: you cannot see or touch another app's schedules.

Your first call

Register a nightly cron. name, cron, timezone and target.url are all required.

curl -X POST https://smart-services.io/api/v1/jobs/cron.create \
  -H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "refresh-stats-nightly",
    "cron": "0 3 * * *",
    "timezone": "Europe/Amsterdam",
    "target": {
      "url": "https://your-app.example/api/internal/refresh-stats",
      "method": "POST",
      "headers": { "Authorization": "Bearer your-own-internal-token" },
      "body": { "scope": "all" }
    }
  }'

The same call with fetch:

const res = await fetch(
  "https://smart-services.io/api/v1/jobs/cron.create",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SMART_SERVICES_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "refresh-stats-nightly",
      cron: "0 3 * * *",
      timezone: "Europe/Amsterdam",
      target: {
        url: "https://your-app.example/api/internal/refresh-stats",
        method: "POST",
        headers: { Authorization: "Bearer your-own-internal-token" },
        body: { scope: "all" },
      },
    }),
  }
);

const data = await res.json();
console.log(data.id, data.nextRunAt);

The response, 201:

{
  "id": "cmu0adp7c00bw229r44xu6iec",
  "nextRunAt": "2026-09-19T01:00:00.000Z"
}

nextRunAt is in UTC and is computed at registration, not left for later. If it came back, the schedule is live.

The rest of the endpoints

MethodPathWhat it does
POST/api/v1/jobs/cron.createRegister a recurring schedule. Returns id and nextRunAt.
GET/api/v1/jobs/cron.listEvery schedule for your app, with status, nextRunAt and lastRunAt.
POST/api/v1/jobs/cron.pauseStop a schedule firing. Body { "id" } or { "name" }.
POST/api/v1/jobs/cron.resumeStart it again. nextRunAt is recomputed from now.
POST/api/v1/jobs/cron.deleteDelete it. Also clears its pending deliveries.
POST/api/v1/jobs/enqueueOne-off job. Pass runAt (ISO) or delayMs, plus target.url.
GET/api/v1/jobs/runs.listDelivery history. Query: kind (cron|oneoff), refId, status, limit (max 200).
POST/api/v1/jobs/run.retryRe-queue one failed delivery. Body { "deliveryId" }.
GET/api/v1/jobs/<id>Status of a one-off job, with its result once it has completed.

Note the method split: cron.list and runs.list are GET; everything else is POST.

The fields on cron.create

FieldRequiredNotes
nameyesUnique per app. Reusing one is a 409.
cronyes5-field (0 3 * * *), 6-field with leading seconds, or a descriptor like @daily / @hourly / @weekly.
timezoneyesIANA name, e.g. Europe/London. DST is handled.
target.urlyesWe POST here. It must be reachable from the public internet.
target.methodnoPOST (default) or PUT. Nothing else.
target.headersnoYour own auth goes here. We add none.
target.bodynoSent as JSON.
catchUpnoone (default), none, or all.
concurrencynoDefault 1.
retryPolicynoDefaults { maxAttempts: 8, baseDelayMs: 5000, factor: 2, maxDelayMs: 600000 }.

Limits and errors

Errors are JSON with an error string:

{ "error": "Invalid cron expression: 0 3 * *. Use standard 5-field cron (e.g. \"0 3 * * *\"), 6-field with leading seconds, or a descriptor like \"@daily\" / \"@hourly\" / \"@weekly\"." }
StatusMeans
400Missing a required field, an invalid cron expression, a timezone that yields no next run, runAt that isn't a date, or neither runAt nor delayMs.
401Missing or invalid API key.
403Jobs is not enabled on your app.
404No schedule or delivery with that id or name, for your app.
409A schedule with that name already exists on your app.
500We failed to write the schedule. Nothing was registered — retry.

A bad cron expression is refused at registration, never accepted and silently ignored. Same for a timezone we cannot compute a next run in.

There is no per-request rate limit on these endpoints. Jobs is a flat, untiered service with no quota on the number of schedules. What is bounded is delivery: retries are exponential up to maxAttempts (default 8), starting at 5 seconds and capped at 10 minutes, and any non-2xx response from your endpoint counts as a failure. Your app's current service state shows in the console.

Gotchas

A schedule registered anywhere else does not exist. On 2026-09-12 a routine node reschedule moved the old Dkron scheduler to a host with no volume. It came up with an empty store and every cron on the platform stopped firing — for 22 hours, silently, while Postgres looked perfectly healthy, because it was — it was not the thing in charge. Contentcharge had registered its crons directly against Dkron rather than through this API, so when the store was lost, so were they, permanently, with no record they had ever existed. Dkron was decommissioned on 2026-09-14. Postgres is now the only source of truth, and if your schedule is not a row we wrote, nobody can see it, repair it, or prove it is running.

Make your endpoint idempotent. Delivery is at-least-once, not exactly-once. A run that times out will be retried even if your handler finished the work. This is the single thing ventures get wrong, and it is the answer to "why did it fire twice".

Punctual to within about 30 seconds. The worker ticks on an interval, so a job due at 03:00:00 fires somewhere between 03:00:00 and roughly 03:00:30. Do not build anything needing second-level precision on this.

Return quickly. A slow handler holds a worker slot and may be counted as a timeout and retried. Acknowledge fast, do the work in the background.

Resume recomputes from now. A schedule paused for a week has a nextRunAt a week in the past; restoring it would fire the instant you resumed. So it doesn't — if you resume at 04:00 on a 0 3 * * * schedule, the next run is tomorrow at 03:00, not in a minute. Pause also clears nextRunAt entirely, so a paused schedule reads as paused and not as overdue.

We never replay a backlog. If we could not run a job when it was due, catchUp decides what happens on recovery: one (the default) runs the missed occurrence if it is less than an hour late, none skips it, all runs it however late. At most one occurrence runs. A 5-minute cron down for a day does not fire 288 times when it comes back. If you need every missed run, that queue is yours to build.

We add no auth to the call. Whatever is in target.headers is all your endpoint gets. If the URL is public — and it has to be reachable from our network — put a shared secret in a header and check it, or you have shipped an endpoint anyone can trigger.

A .test or .local URL will never work. Neither will anything behind your VPN. A run that shows failed with no error text is almost always an endpoint we could not resolve or reach.

Check runs.list after you integrate. A schedule that fires into a 500 every night looks identical from the outside to one that works. runs.list records the HTTP status, the latency, and an excerpt of the response and the error for each attempt; run.retry re-queues one that failed. And if cron.list shows nextRunAt as null or far in the past on an active schedule, it is not being ticked — that is our bug, tell us.

target.method only honours POST and PUT. Anything else is silently coerced to POST. There is no GET delivery.

Not every @ descriptor is a descriptor. We accept one only if we can compute a real next run from it, so @daily, @hourly and @weekly work while @every 30m and @midnight are rejected with a 400. That rejection is deliberate: the old scheduler waved them through, registration returned a cheerful 201, and the schedule then sat there with no next run, forever, firing nothing. Better a 400 you read today.