Run scheduled jobs

Let your hosted app do work on a timer, such as refreshing outside data every 15 minutes, and check every run in Console.

A scheduled job is work your hosted app does on a timer, even when no member has the app open. For example:

  • Refresh token balances every 15 minutes, so a holders leaderboard stays current.
  • Pull activity from an outside service every hour into a custom tracker, so quests that depend on it can pay out.
  • Check members' quest progress every morning and update your own records.

Members don't see the jobs themselves. They see the result: standings, quests and rewards that stay up to date.

Jobs run in a Worker app that Domino hosts: on the staged build in test, and on the live app once a promotion puts that build live. Static apps and development previews don't run jobs.

What you can do in Console

The jobs are declared in your app's code. Open Settings → Schedules, with Test or Live selected, to:

  • See every job: when it last ran, its status and duration, what it reported, and when it runs next.
  • Read a job's history with Run history.
  • Run a job now with Run now, for example to check a fix.
  • Pause, archive or resume all of the project's jobs in that environment.
  • Unblock a job whose last run is Uncertain with Recover uncertain run.

See Inspect and control jobs and Timeouts and recovery.

Declare a schedule

Add schedules beside app.secrets and app.storage in your existing Worker configuration, then stage the app:

{
  "app": {
    "kind": "worker",
    "schedules": [
      {
        "name": "refresh-data",
        "path": "/jobs/refresh-data",
        "every": "15m",
        "description": "Refresh custom tracker data from an external service"
      }
    ]
  }
}

This is a fragment of relay.json. Keep your existing build scripts, output directories and Worker settings. Use a route your Worker handles, and set runWorkerFirst: true if public assets could intercept that route.

FieldRule
nameUnique within the app. A lowercase letter followed by lowercase letters, digits or hyphens, up to 64 characters.
pathAn absolute route such as /jobs/refresh-data. Use letters, digits, underscores, hyphens and slashes; no query, hostname, empty segment or traversal.
everyAn integer followed by m, h or d, from 5m through 365d.
cronInstead of every, a five-field UTC cron expression using numeric fields, *, ranges, lists and steps. Minute slots must be at least five minutes apart, including across an hour boundary. Examples: */15 * * * * or 0 9 * * 1-5.
descriptionWhat the job does, from 1 to 500 characters.
  • Up to 10 jobs per app. Declare exactly one of every and cron for each.
  • When they run. Intervals start when the build becomes active. Cron schedules use UTC. A new build restarts the clock from its own activation time.
  • Test and live are separate. Each keeps its own jobs, runs and next run times.

Verify the call

Domino calls each job with a signed POST. Check the signature before doing any work, so nobody else can trigger your job:

import { verifyScheduledRequest } from "@domino-sdk/relay/server";

declare function refreshData(runId: string): Promise<void>;

export default {
  async fetch(request: Request, env: { DOMINO_SCHEDULE_KEY: string }) {
    if (new URL(request.url).pathname !== "/jobs/refresh-data") {
      return new Response("Not found", { status: 404 });
    }
    const run = await verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY);
    if (!run || run.name !== "refresh-data") {
      return new Response("Unauthorized", { status: 401 });
    }
    await refreshData(run.id);
    return new Response(null, { status: 204 });
  },
};

Implement refreshData with your app's own bounded background work. The timestamp check limits replays but doesn't remove duplicates, so use run.id as an idempotency key when writing to an outside service or store.

Feed a custom tracker

A common job collects outside data, such as on-chain balances or activity from another service, and sends it to a custom tracker. Your app decides what to fetch and how to map it to the tracker's declared schema; Domino doesn't collect custom data for you. Regular refreshes keep tracker-based quests and leaderboards current even when members don't open the app.

import {
  hostedManagementClient,
  verifyScheduledRequest,
  type HostedManagementEnv,
} from "@domino-sdk/relay/server";
import { trackerIngestSchema } from "@domino-sdk/relay/schema";

declare function collectRecords(): Promise<unknown>;

export default {
  async fetch(request: Request, env: HostedManagementEnv & { DOMINO_SCHEDULE_KEY: string }) {
    if (new URL(request.url).pathname !== "/jobs/refresh-data") {
      return new Response("Not found", { status: 404 });
    }
    const run = await verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY);
    if (!run || run.name !== "refresh-data") {
      return new Response("Unauthorized", { status: 401 });
    }
    // Fetch and map records for your custom source, including its verified accounts.
    const input = trackerIngestSchema.parse({
      actionId: run.id,
      records: await collectRecords(),
    });
    await hostedManagementClient(env).trackers.ingest("external-data", input);
    return new Response(null, { status: 204 });
  },
};

Implement collectRecords to fetch and map your outside source. Page through large datasets and keep each run bounded.

Report what a run did

Have each job report what it did: an outcome, a summary, counts and per-item notes. Console then shows it under the job and in its run history. Without a report, Domino records only the HTTP status, so a 204 reads as Succeeded even when some of the work failed.

import { scheduleResult, verifyScheduledRequest } from "@domino-sdk/relay/server";

declare const members: string[];
declare function refreshMember(member: string): Promise<void>;

export default {
  async fetch(request: Request, env: { DOMINO_SCHEDULE_KEY: string }) {
    const run = await verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY);
    if (!run || run.name !== "refresh-data") {
      return new Response("Unauthorized", { status: 401 });
    }
    const failed: { level: "error"; subject: string; message: string }[] = [];
    for (const member of members) {
      try {
        await refreshMember(member);
      } catch {
        failed.push({ level: "error", subject: member, message: "Refresh failed" });
      }
    }
    return scheduleResult({
      outcome: failed.length === 0 ? "succeeded" : failed.length < members.length ? "partial" : "failed",
      summary: `${members.length - failed.length} of ${members.length} members refreshed`,
      metrics: { processed: members.length, failed: failed.length },
      diagnostics: failed,
    });
  },
};
FieldRule
outcomesucceeded, partial or failed. partial and failed record the run as Failed, whatever the HTTP status.
summaryOptional, up to 500 characters. It also ends the run's error, such as App reported a partial run: 4 of 5 members refreshed.
metricsOptional. Up to 50 named numbers, such as counts and gauges. Names start with a letter and use letters, digits, _, . or -.
diagnosticsOptional. Up to 50 lines, each with a level (error, warning or info), a message of up to 500 characters, and an optional subject (such as a member or record ID) and code.

Fit a run into 30 seconds

Each run must finish within 30 seconds, including every call it makes to Domino.

  • Batch tracker ingests. Ingests into one tracker run one at a time on the server, so parallel calls into the same tracker wait for each other and gain nothing. Send one trackers.ingest call per tracker per run, with up to 100 records, instead of one call per member.
  • Time calls from what's left. Pass a call's timeout from the time remaining, such as trackers.ingest(reference, input, { timeout }). The client's default is 30 seconds, the whole run.
  • Retry with the same action ID. A timed-out ingest may still have been stored. Sending the same actionId again returns the first answer instead of ingesting twice. An action ID is 1–100 letters, digits, _ or -: the run's id, or ${run.id}-${step}, fits; a : or . is refused before anything is sent.
  • Stop early and say so. When time runs low, stop, report a partial outcome with what's left, and let the next run continue.

Delete old evidence nightly

Domino already deletes submitted evidence 90 days after the decision, unless you changed your project's period or turned it off; see how long evidence is kept. A job is how you delete it sooner, or on a different schedule per quest, to keep a shorter promise to members. This one deletes the evidence of one quest's attempts decided more than 30 days ago, every night at 03:00 UTC. See delete old evidence on a schedule for what a pass deletes and keeps.

Deleting evidence needs review permission, which the hosted app's own key doesn't have, and in live a pass also needs an organization admin's Console session or personal key, or a service key. An organization admin creates a service key with only Review, in the environment the job runs in, and you save it as an app secret named RETENTION_KEY:

{
  "app": {
    "kind": "worker",
    "secrets": {
      "RETENTION_KEY": { "description": "Service key with review permission, for the evidence retention job" }
    },
    "schedules": [
      {
        "name": "evidence-retention",
        "path": "/jobs/evidence-retention",
        "cron": "0 3 * * *",
        "description": "Delete selfie evidence decided more than 30 days ago"
      }
    ]
  }
}
import {
  hostedManagementClient,
  scheduleResult,
  verifyScheduledRequest,
  type HostedManagementEnv,
} from "@domino-sdk/relay/server";

type Env = HostedManagementEnv & { DOMINO_SCHEDULE_KEY: string; RETENTION_KEY: string };

const day = 24 * 60 * 60 * 1000;

export default {
  async fetch(request: Request, env: Env) {
    const run = await verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY);
    if (!run || run.name !== "evidence-retention") {
      return new Response("Unauthorized", { status: 401 });
    }
    // The app's own bindings, with the service key in place of the app's key.
    const management = hostedManagementClient({ ...env, DOMINO_MANAGEMENT_KEY: env.RETENTION_KEY });
    const decidedBefore = run.scheduledAt - 30 * day;
    let deleted = 0;
    let more = true;
    // Each call deletes up to 100 attempts' evidence. Stop well inside the 30 seconds; the next night continues.
    for (let call = 0; more && Date.now() - run.startedAt < 20_000; call++) {
      const pass = await management.attempts.redactEvidenceBefore({
        actionId: `${run.id}-${call}`,
        quest: "selfie",
        decidedBefore,
        reason: "Retention: selfies are kept for 30 days",
      });
      deleted += pass.attempts.length;
      more = pass.more;
    }
    return scheduleResult({
      outcome: more ? "partial" : "succeeded",
      summary: `${deleted} attempts' evidence deleted${more ? ", more left for the next run" : ""}`,
      metrics: { deleted },
    });
  },
};

Each call's action ID comes from the run, so a retried call answers its first result instead of deleting again. The service key appears as the actor on each deleted attempt and in Audit history.

Read member progress

A job that checks members' quests reads each member's progress: their state on each active quest, their recent attempts and their point balances. The read stays small however long the member has taken part.

import { hostedManagementClient, type HostedManagementEnv } from "@domino-sdk/relay/server";

export async function completedQuests(env: HostedManagementEnv, member: string) {
  const progress = await hostedManagementClient(env).members.progress(member);
  return progress.quests.filter((quest) => quest.completed).map((quest) => quest.quest);
}

From a terminal, domino members progress MEMBER --json prints the same read. It needs read permission, which the hosted app's management key has.

Inspect and control jobs

Open Settings → Schedules in Console, with Test or Live selected:

  • The list shows the active build's jobs, each one's last run time, status and duration, its error and result, and the next run.
  • Run history under a job shows its latest 20 runs, newest first, each with its ID, build, status, error and result. Domino keeps the latest 20 runs of each job.
  • Run now queues one run, which goes through the same checks as a timed run.
  • Paused or Archived, chosen in the state menu, stops new runs for the whole project in that environment, and Active restores either. Console asks for a reason before it changes the state, and Audit history records it. Your choice stays through new builds and promotions, and pausing test's jobs leaves live's running.

Removing a job from relay.json and staging stops it in test; promoting that build stops it in live. Deleting the project stops all its jobs, and purging it removes their records.

The same controls are in the CLI:

domino schedules list --json
domino schedules list --environment live --json
domino schedules runs refresh-data --json
domino schedules run refresh-data
domino schedules pause --reason "Rotating the API key"
domino schedules resume
domino schedules archive --reason "App retired"

Timeouts and recovery

  • One try per run. Domino tries each timed run once and never retries it automatically. Runs missed during downtime are skipped, and the next one runs as usual.
  • Failures don't stop the schedule. A failed response, or a partial or failed outcome, records a failure, and the next run still happens. So does a run whose build has no Worker to run it, since the job never started.
  • No overlap. The same job never runs twice at once.

If Domino can't tell whether a run finished, it blocks that job, even across new builds. The run's error says what Domino saw: the HTTP status and cause from the dispatch, no answer within 30 seconds, or the error that interrupted the call. An exception your app throws before answering is shown there too, so keep secrets out of error messages. Once the timeout has passed, Console shows the run as Uncertain and offers recovery:

Check that the job stopped

Look at your app's own records and any outside work the job started, and confirm the job has stopped.

Recover the job

Choose Recover uncertain run, turn on I confirmed the previous job has stopped, and choose Allow future runs. The CLI equivalent is:

domino schedules recover refresh-data --yes

Recovery allows future runs; it never replays the uncertain one. Fix app errors in source and stage a new build, then use Run now to check the fix. A fix reaches live's jobs when that build is promoted.

For exactly when a run counts as failed or uncertain, see Schedule dispatch guarantees.

On this page