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.
| Field | What it tells you |
|---|---|
status | queued, running, succeeded, failed, timeout or skipped |
http_status | The status code your endpoint returned. Anything non-2xx lands as failed |
output | Captured response body from your endpoint, truncated |
error_code | CRON_TASK_5XX, CRON_TASK_URL_UNREACHABLE, CRON_RUN_TIMEOUT and friends |
duration_ms | Round-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
}'
| Approach | Proves | Costs you |
|---|---|---|
POST /v1/cron/trigger/{id} | URL, auth, payload shape, response time | Nothing; schedule untouched |
One-shot with run_at | The scheduler path too, including timezone | A job you should delete afterwards |
Editing cron_expr via PATCH /v1/cron/update/{id} | Same as a one-shot | You must remember to change it back |
Hand-rolled curl to your own endpoint | Only your handler | Skips 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.