Fire a cron job on demand to test the webhook chain before 3am

Infrai's trigger route runs a scheduled job now and returns the full execution record — status code, duration, your endpoint's response — without touching the schedule.

Yes. POST /v1/cron/trigger/{id} runs the job right now, against the real URL, and returns the execution record synchronously — status, HTTP code your endpoint replied with, duration, and the first slice of its response body. On Infrai the schedule is untouched: the 3am run still happens, and the on-demand run is flagged is_manual_trigger: true so you can tell rehearsals from the real thing later.

That flag is the reason this is nicer than the usual workaround of temporarily rewriting cron_expr to */2 * * * * and forgetting to change it back. Infrai keeps both kinds of run in the same history, distinguishable by one boolean.

Fire it

The job id comes back from POST /v1/cron/create, or from GET /v1/cron/list if you’ve lost it. No request body is needed — the id lives in the path.

curl -X POST https://api.infrai.cc/v1/cron/trigger/cron_ClghAw3odH2JJnRtx5oKEq1g \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

What comes back is one CronRun, not an acknowledgement. The call blocks until your endpoint answers or the job’s timeout_seconds elapses, which is what makes it useful as a test.

{
  "ok": true,
  "data": {
    "run_id": "cronrun_hfBnNYoYhTVSxhZJPhpQwyCx",
    "job_id": "cron_ClghAw3odH2JJnRtx5oKEq1g",
    "scheduled_at": "2026-07-26T05:05:09.121765Z",
    "fired_at": "2026-07-26T05:05:09.121765Z",
    "status": "failed",
    "retry_count": 0,
    "is_manual_trigger": true,
    "duration_ms": 0,
    "skipped_reason": null,
    "http_status": 405,
    "output": "<!doctype html><html lang=\"en\"><head><title>Example Domain</title>",
    "error": "HTTP 405",
    "error_code": "CRON_TASK_URL_UNREACHABLE"
  }
}

That is a genuine failure from our own testing, and it’s the most instructive one: the target only allows GET, and Infrai fires a POST. If your handler is a GET-only route, this is the exact 405 you’ll see at 3am — except you’d have seen it at 3am.

Reading the record

Five fields answer almost every “did the chain work” question.

FieldWhat it tells you
statusqueued, running, succeeded, failed, timeout or skipped
http_statusThe status code your endpoint returned. Anything non-2xx lands as failed
outputCaptured response body from your endpoint, truncated
error_codeCRON_TASK_5XX, CRON_TASK_URL_UNREACHABLE, CRON_RUN_TIMEOUT and friends
duration_msRound-trip time — compare it against timeout_seconds before you ship

History is a separate read, and it’s where you confirm the rehearsal and the scheduled run behaved the same way.

curl https://api.infrai.cc/v1/cron/runs/list/cron_ClghAw3odH2JJnRtx5oKEq1g \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

A script that fails your build when the chain is broken

Put this in CI after a deploy that changes the webhook handler. It fires the job, inspects the record, and exits non-zero on anything that isn’t a clean 2xx — so a broken chain stops the pipeline instead of surfacing as a silent 3am no-op.

import json
import os
import sys
import urllib.error
import urllib.request

KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
    sys.exit("INFRAI_API_KEY is not set")

JOB_ID = os.environ.get("CRON_JOB_ID", "cron_ClghAw3odH2JJnRtx5oKEq1g")
BASE = "https://api.infrai.cc"


def call(method, path):
    req = urllib.request.Request(
        BASE + path,
        method=method,
        headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
        data=b"{}" if method == "POST" else None,
    )
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as exc:
        body = json.loads(exc.read() or b"{}")
        sys.exit(f"{method} {path} -> {exc.code} {body.get('error', {}).get('message', '')}")


run = call("POST", f"/v1/cron/trigger/{JOB_ID}")["data"]
print("run", run["run_id"], run["status"], "http", run.get("http_status"))

if run["status"] != "succeeded":
    print("output:", (run.get("output") or "")[:300])
    sys.exit(f"webhook chain is broken: {run.get('error_code') or run['status']}")

history = call("GET", f"/v1/cron/runs/list/{JOB_ID}")["data"]["items"]
manual = [r for r in history if r["is_manual_trigger"]]
print(f"{len(history)} runs recorded, {len(manual)} of them manual rehearsals")

The failure you’ll hit first

Trigger a paused job and you get a 400, not a silent no-op:

{
  "ok": false,
  "error": {
    "code": "INVALID_ARGUMENT",
    "http_status": 400,
    "message": "cron job 'cron_ClghAw3odH2JJnRtx5oKEq1g' is disabled",
    "retryable": false
  }
}

Resume it, then fire. Both calls are free.

curl -X POST https://api.infrai.cc/v1/cron/resume/cron_ClghAw3odH2JJnRtx5oKEq1g \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

When a one-shot is the better rehearsal

Manual trigger exercises the dispatcher and your endpoint. It does not exercise the scheduler — the part that evaluates the expression, applies the timezone and picks a fire time. If what you actually doubt is the schedule, book a one-shot a minute out instead. Pass run_at in place of cron_expr and the job becomes max_runs: 1, with next_run_at populated (which recurring jobs leave null).

curl -X POST https://api.infrai.cc/v1/cron/create \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "rehearsal-backup-report",
    "task": "https://example.com/hooks/nightly-backup",
    "run_at": "2026-07-27T12:00:00Z",
    "payload": {"rehearsal": true},
    "timeout_seconds": 120
  }'
ApproachProvesCosts you
POST /v1/cron/trigger/{id}URL, auth, payload shape, response timeNothing; schedule untouched
One-shot with run_atThe scheduler path too, including timezoneA job you should delete afterwards
Editing cron_expr via PATCH /v1/cron/update/{id}Same as a one-shotYou must remember to change it back
Hand-rolled curl to your own endpointOnly your handlerSkips retries, headers and the timeout the scheduler applies

Limitations worth knowing before you rely on this

There’s no dry-run. A manual trigger is a real POST to a real URL with your real payload, so pointing a production job at a production endpoint and “just testing” will write production rows. Aim a scratch job at staging if that matters.

The captured output is truncated, so it’s a smoke signal rather than a log — for the full picture read GET /v1/cron/runs/get/{id}/{run_id} or your own application logs. And a failing run is recorded, not escalated: if you want to be told, set on_failure_webhook at create time.

Two honest comparisons. Inngest and Trigger.dev both ship a local dev server that replays events against code running on your laptop, and for iterating on handler logic that’s a better loop than any remote trigger — you’d stick with them if the thing under test is the function body. Cronhooks and Cron To Go offer a similar “run now” button, which is fine if scheduled HTTP delivery is genuinely all you need from that vendor.

Infrai’s argument isn’t that its trigger button is better. It’s that the same key already reaches the queue your handler drains, the email it sends and the error tracker it reports to, so the second problem after “did the webhook fire” doesn’t need a fifth account.

What this costs

Nothing. Create, list, get, trigger, pause, resume and the run-history reads are all free and rate-limited on Infrai, and none of them draw down the trial credit a new account starts with. Confirm the current billing class rather than trusting a page:

curl "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; print([c['billing'] for c in json.load(sys.stdin)['capabilities'] if c['id']=='cron.trigger'])"

Billing on this platform tends to move down rather than up, so treat that output as the source of truth.

References

Browse more cron developer guides