# agent-cronjob > Agent-to-agent scheduled triggers. Register your own webhook (no account, no API key), prove you > control it with a challenge, receive an HMAC secret and a management token once, then get POSTed > on a cron / interval / one-off schedule — signed, retried with exponential backoff, and logged so > you can audit every run's delay and outcome. - API base: https://cronjob.agentasy.ai (all /v1 paths below are relative to it) - Entry point: https://new-cronjob.agentasy.ai/ (GET = registration walkthrough, POST = create a job) - OpenAPI 3.1: https://cronjob.agentasy.ai/openapi.json (includes the outgoing requests under `webhooks`) - MCP server: https://cronjob.agentasy.ai/mcp (Streamable HTTP, stateless, no auth to connect; tools mirror this API 1:1: create_job, verify_challenge, claim_credentials, get_job, pause_job, resume_job, delete_job, get_deliveries, …; card: https://cronjob.agentasy.ai/.well-known/mcp/server-card.json) - Health: https://cronjob.agentasy.ai/status - Time: all timestamps are ISO 8601 UTC; X-Cronjob-Timestamp is unix seconds. ## Contents 1. Flow overview 2. Create a job 3. Verify the webhook (echo or callback) 4. Collect credentials 5. Signature (anchor #signature; body signature #body-signature) 6. Triggers, acknowledgement and retries 7. Missed runs (catch-up) 8. Managing the job 9. Run log and stats 10. Limits and errors ## 1. Flow overview 1. `POST https://new-cronjob.agentasy.ai/` (or `POST https://cronjob.agentasy.ai/v1/jobs`) with your webhook URL and schedule → 202 with `job_id` and a one-time `setup_token`. 2. We POST a `url_verification` challenge to your webhook. Prove control: - **echo**: reply 2xx with `{"challenge":""}` (or the bare value), or - **callback**: reply 2xx, then `POST /v1/jobs/{job_id}/verify {"challenge":""}`. Both are accepted for every job, whatever `verification_mode` says. Deadline 1 (verify): 15 minutes after job creation — unverified jobs are deleted; re-sends do not extend it. 3. Credentials, shown exactly once: in the `/verify` response (callback), or in the first `GET /v1/jobs/{job_id}/verification` (Bearer setup_token) after echo verification. Deadline 2 (collect credentials): 15 minutes after verification — uncollected jobs are deleted. 4. Triggers arrive on schedule with `X-Cronjob-Signature`. Verify it (section 5), de-duplicate on `delivery_id`, reply 2xx. 5. Manage with `Authorization: Bearer `: pause, resume, delete, rotate, read the log. Nothing triggers before verification. Store `hmac_secret` and `management_token` as secrets; they cannot be shown again (rotate them instead). ## 2. Create a job ```http POST https://new-cronjob.agentasy.ai/ Content-Type: application/json { "webhook_url": "https://your-agent.example/hooks/abc", "name": "hourly digest", "schedule": { "cron": "0 * * * *", "timezone": "UTC" }, "payload": { "task": "hourly-digest" }, "max_runs": null, "catchup_policy": "run_once", "verification_mode": "callback" } ``` `POST https://cronjob.agentasy.ai/v1/jobs` is identical. Fields: | Field | Required | Default | Notes | |---|---|---|---| | `webhook_url` | yes | | https, public address, ≤ 2048 chars. Private / loopback / link-local / metadata / CGNAT addresses are refused (every DNS answer is checked, again on every send). Redirects are never followed. A secret in the path or query is fine: it is stored encrypted and never shown back. | | `schedule` | yes | | Exactly one of `{"cron": "...", "timezone": "..."}`, `{"interval_sec": N}`, `{"run_at": "..."}` | | `name` | no | null | ≤ 200 chars | | `payload` | no | `{}` | JSON object, ≤ 8 KB, sent unchanged in every trigger | | `max_runs` | no | null | 1 … 1000000; job becomes `completed` (`status_reason: max_runs_reached`) after N dispatched runs. Changeable later with `PATCH /v1/jobs/{id}` (section 8). | | `catchup_policy` | no | `run_once` | `skip` · `run_once` · `run_all` (section 7) | | `catchup_window_sec` | no | 3600 | 0 … 604800 | | `retry_max_attempts` | no | 5 | attempts per run incl. the first, 1 … 10 | | `retry_base_sec` | no | 30 | 1 … 3600 | | `retry_max_backoff_sec` | no | 3600 | ≤ 86400 | | `timeout_ms` | no | 10000 | 1000 … 30000 per request | | `verification_mode` | no | `echo` | informational; either mode works | | `outgoing_auth` | no | null | `{"header": "Authorization", "value": "Bearer "}` — a header your webhook requires. `header`: `Authorization` (default) or an `X-…` name (not `X-Cronjob-…`); `value`: 1 … 4096 chars, no CR/LF. Sent on the url_verification POST and on every trigger; never replaces our `X-Cronjob-*` headers. Stored encrypted, write-only: `GET /v1/jobs/{id}` shows only `{"header": "...", "configured": true}`. | | `signature_in_body` | no | false | `true` = every trigger body also carries `cronjob_signature` (section 5, [body signature](#body-signature)), for platforms that drop the `X-Cronjob-*` headers or re-serialize the JSON (e.g. Grok Bot routines). Changeable later with `PATCH /v1/jobs/{id}`. | Schedules: - cron: 5 fields (minute hour day-of-month month day-of-week) or `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`. No seconds field, no `@reboot`. `timezone` is an IANA name (`Asia/Taipei`, `America/New_York`, …). **Default timezone: UTC** when `timezone` is omitted. DST is handled by the zone. - `interval_sec`: 60 … 31622400. First trigger = verification time + interval. - `run_at`: ISO 8601 with `Z` or an offset (e.g. `2030-01-01T09:00:00+08:00`), in the future. Fires once. - Minimum spacing between two triggers is 60 s (cron expressions are checked too). Response `202 Accepted` (`Cache-Control: no-store`): ```json { "job_id": "job_01JC...", "status": "pending_verification", "setup_token": "act_st_...", "verification": { "mode": "callback", "status": "pending", "expires_at": "...", "challenge_sent_to": "your-agent.example", "job_url": "https://cronjob.agentasy.ai/v1/jobs/job_01JC...", "verify_url": "https://cronjob.agentasy.ai/v1/jobs/job_01JC.../verify", "poll_url": "https://cronjob.agentasy.ai/v1/jobs/job_01JC.../verification", "retry_url": "https://cronjob.agentasy.ai/v1/jobs/job_01JC.../verification/retry" }, "next_steps": ["..."] } ``` ## 3. Verify the webhook Right after the 202 we send: ``` POST Content-Type: application/json X-Cronjob-Event: url_verification X-Cronjob-Job-Id: job_01JC... Authorization: (only if you set outgoing_auth) {"type":"url_verification","job_id":"job_01JC...","challenge":"","verify_url":"https://cronjob.agentasy.ai/v1/jobs/job_01JC.../verify"} ``` This request is not signed (you have no secret yet). Answer within `timeout_ms`. Answering a forged challenge is harmless — it can only complete a job whose setup you started — but you may ignore challenges whose `job_id` is not one you just created. **echo** — respond `200` with `{"challenge":""}` (or the bare value as text). Then poll (section 4). **callback** — respond with any 2xx, then send the value back. This suits webhooks that cannot choose their response body (e.g. a routine trigger that runs later and can call an API): ```http POST https://cronjob.agentasy.ai/v1/jobs/{job_id}/verify Content-Type: application/json {"challenge": ""} ``` → `200` with your credentials (section 4). No Authorization header: possession of the challenge is the proof. A wrong value → `403 challenge_mismatch`; after 10 wrong values that challenge is void (retry to get a new one). Did not get the challenge (or it expired)? Inspect and resend with the setup token: ```http GET https://cronjob.agentasy.ai/v1/jobs/{job_id}/verification Authorization: Bearer ``` `verification.last_result` tells you what happened: `echo_ok`, `awaiting_callback`, `http_500`, `timeout`, `conn_refused`, `ssrf_blocked: …`, `redirect_not_followed: http_302`, `challenge_invalidated`. ```http POST https://cronjob.agentasy.ai/v1/jobs/{job_id}/verification/retry Authorization: Bearer ``` → `202`; a new challenge is sent and the old one stops working. Re-send limit: at most 5 re-sends (6 challenges in total), at least 60 s apart (`429 retry_too_soon` with Retry-After; beyond the limit `409 retry_limit_reached`). Deadline 1 (verify): 15 minutes after job creation — unverified jobs are deleted; re-sends do not extend it; create a new job. ## 4. Collect credentials - callback: the `/verify` response is the one-time delivery. - echo: the first `GET https://cronjob.agentasy.ai/v1/jobs/{job_id}/verification` (Bearer setup_token) after verification returns them; `verification.credentials` is then `issued_in_this_response`, later calls say `already_issued`. ```json { "job_id": "job_01JC...", "status": "active", "hmac_secret": "acs_...", "management_token": "act_mt_...", "next_run_at": "2026-11-03T04:00:00.000Z", "signature": { "header": "X-Cronjob-Signature", "format": "v1=[, v1=]", "algorithm": "HMAC-SHA256, key = hmac_secret as UTF-8 bytes", "signed_string": "v1:{X-Cronjob-Timestamp}:{X-Cronjob-Delivery-Id}:{raw request body}" }, "signature_spec_url": "https://cronjob.agentasy.ai/llms.txt#signature", "notice": "hmac_secret and management_token are shown only once; store them now." } ``` Deadline 2 (collect credentials): 15 minutes after verification — uncollected jobs are deleted (nobody could manage it). Triggers start right after verification (interval schedules: one interval later). With **echo**, poll `/verification` immediately after answering the challenge and make your receiver ready to verify before the first trigger; until it is, answer triggers with `503` (retried) rather than `401` (final). ## 5. Signature Every trigger carries: | Header | Meaning | |---|---| | `X-Cronjob-Timestamp` | unix seconds when this attempt was sent | | `X-Cronjob-Delivery-Id` | `dlv_`, identical on every attempt of the same run (idempotency id) | | `X-Cronjob-Signature` | `v1=`, or `v1=, v1=` during a secret rotation | Algorithm (scheme `v1`): ``` signed_string = "v1:" + X-Cronjob-Timestamp + ":" + X-Cronjob-Delivery-Id + ":" + raw_request_body signature = lowercase_hex( HMAC-SHA256( key = UTF-8 bytes of hmac_secret (the whole "acs_..." string), signed_string ) ) ``` To verify: 1. Use the raw body bytes exactly as received (do not parse and re-serialize the JSON). 2. Reject if `|now − X-Cronjob-Timestamp| > 300` seconds (replay protection; keep your clock in sync). 3. Compute the signature and compare it in constant time with **each** `v1=` value; accept if any matches. Ignore schemes other than `v1` (future versions are added alongside, never in place). 4. De-duplicate on `X-Cronjob-Delivery-Id`: retries and crash recovery can deliver the same run again. 5. On failure answer `401`: a 4xx is final (that run is not retried) and counts as a failed run; 20 failed runs in a row disable the job. If you cannot verify *yet* (e.g. you have not stored the secret), answer `503` instead so the run is retried. Node.js (≥ 18, no dependencies): ```js import { createHmac, timingSafeEqual } from 'node:crypto'; /** * secret: your hmac_secret (the acs_... string, used as-is) * headers: request headers with lower-case names (Node's http module already gives you that) * rawBody: the exact request body bytes (Buffer or string) — do not re-serialize parsed JSON */ export function verifyCronjobSignature(secret, headers, rawBody, nowSec = Math.floor(Date.now() / 1000)) { const ts = headers['x-cronjob-timestamp']; const deliveryId = headers['x-cronjob-delivery-id']; const header = headers['x-cronjob-signature']; if (!ts || !deliveryId || !header || !/^\d+$/.test(ts)) return false; if (Math.abs(nowSec - Number(ts)) > 300) return false; // replay window: 5 minutes const expected = createHmac('sha256', secret) .update(`v1:${ts}:${deliveryId}:`) .update(rawBody) .digest(); // During a secret rotation the header holds two signatures ("v1=, v1="): accept either. return header.split(',').some((part) => { const [scheme, hex] = part.trim().split('='); if (scheme !== 'v1' || !/^[0-9a-f]{64}$/.test(hex ?? '')) return false; return timingSafeEqual(Buffer.from(hex, 'hex'), expected); }); } ``` Python (≥ 3.10, standard library only): ```python import hashlib import hmac import time def verify_cronjob_signature(secret: str, headers: dict, raw_body: bytes, now: float | None = None) -> bool: """secret: your hmac_secret (acs_...); headers: any case; raw_body: the exact request body bytes.""" h = {k.lower(): v for k, v in headers.items()} ts = h.get("x-cronjob-timestamp", "") delivery_id = h.get("x-cronjob-delivery-id", "") header = h.get("x-cronjob-signature", "") if not (ts.isdigit() and delivery_id and header): return False if abs((time.time() if now is None else now) - int(ts)) > 300: # replay window: 5 minutes return False signed = f"v1:{ts}:{delivery_id}:".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() # During a secret rotation the header holds two signatures ("v1=, v1="): accept either. for part in header.split(","): scheme, _, value = part.strip().partition("=") if scheme == "v1" and hmac.compare_digest(value.encode(), expected.encode()): return True return False ``` Rotation: `POST /v1/jobs/{job_id}/rotate-secret` returns the new secret once; until `previous.expires_at` (default 24 h, `grace_sec` 0 … 86400) every trigger is signed with both, newest first. Switch your verifier to the new secret, or keep both until the grace period ends. ### Body signature (`signature_in_body: true`) Some platforms (e.g. **Grok Bot routine webhooks**) strip our `X-Cronjob-*` headers and may re-serialize the JSON before your agent sees it. Create the job with `"signature_in_body": true` (or `PATCH /v1/jobs/{job_id}` with `{"signature_in_body": true}`) and every trigger body gets one more top-level key: ```json "cronjob_signature": { "version": "v1", "timestamp": 1793678400, "delivery_id": "dlv_01JC...", "signatures": [""], "signature": "" } ``` `timestamp` and `delivery_id` are the same values as `X-Cronjob-Timestamp` / `X-Cronjob-Delivery-Id`. `signatures` has one entry per active secret, newest first (two during a rotation grace period, like the header); `signature` = `signatures[0]`. Algorithm: ``` canonical = canonical JSON of the whole body WITHOUT the cronjob_signature key: object keys sorted recursively (by UTF-16 code units, = JavaScript's default sort), no whitespace, array order kept, strings/numbers written as JSON.stringify does signed_string = "v1b:" + timestamp + ":" + delivery_id + ":" + canonical signature = lowercase_hex( HMAC-SHA256( key = UTF-8 bytes of hmac_secret, signed_string ) ) ``` To verify: 1. Parse the body as JSON and take out `cronjob_signature`. Missing → reject (answer `401`). 2. Rebuild the canonical JSON of the rest of the body (key order and whitespace as received do not matter). 3. Compute the signature and compare it in constant time with **each** entry of `signatures`; accept if any matches. 4. Reject if `|now − timestamp| > 300` seconds (replay protection). 5. De-duplicate on `delivery_id` (retries carry the same one). The `X-Cronjob-Signature` header is still sent and still signs the exact raw bytes (which now include `cronjob_signature`), so receivers that do get the headers can keep verifying those. Only the trigger is affected; the url_verification challenge is unchanged. Keep payload numbers to integers or strings if a non-JavaScript platform might rewrite them (e.g. `1e-7` vs `1e-07`). Node.js (≥ 18, no dependencies): ```js import { createHmac, timingSafeEqual } from 'node:crypto'; // Canonical JSON: object keys sorted recursively, no whitespace, array order kept. const canonicalJson = (v) => Array.isArray(v) ? `[${v.map(canonicalJson).join(',')}]` : v !== null && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}` : JSON.stringify(v); /** * secret: your hmac_secret (the acs_... string, used as-is) * body: the trigger body as a parsed object (re-serialized by your platform is fine) */ export function verifyCronjobBodySignature(secret, body, nowSec = Math.floor(Date.now() / 1000)) { const sig = body?.cronjob_signature; if (!sig || sig.version !== 'v1' || !Number.isInteger(sig.timestamp) || typeof sig.delivery_id !== 'string') return false; if (Math.abs(nowSec - sig.timestamp) > 300) return false; // replay window: 5 minutes const { cronjob_signature: _omit, ...rest } = body; const expected = createHmac('sha256', secret) .update(`v1b:${sig.timestamp}:${sig.delivery_id}:${canonicalJson(rest)}`) .digest(); // During a secret rotation "signatures" holds two values (new first): accept either. const candidates = Array.isArray(sig.signatures) ? sig.signatures : [sig.signature]; return candidates.some((hex) => typeof hex === 'string' && /^[0-9a-f]{64}$/.test(hex) && timingSafeEqual(Buffer.from(hex, 'hex'), expected)); } ``` Python (≥ 3.10, standard library only): ```python import hashlib import hmac import json import time def _canonical_json(v) -> str: """Object keys sorted by UTF-16 code units (like JavaScript), no whitespace, array order kept.""" if isinstance(v, dict): keys = sorted(v, key=lambda k: k.encode("utf-16-be")) return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + _canonical_json(v[k]) for k in keys) + "}" if isinstance(v, list): return "[" + ",".join(_canonical_json(x) for x in v) + "]" if isinstance(v, float) and v.is_integer() and abs(v) < 1e21: return str(int(v)) # JavaScript writes 1.0 as 1 return json.dumps(v, ensure_ascii=False) def verify_cronjob_body_signature(secret: str, body: dict, now: float | None = None) -> bool: """secret: your hmac_secret (acs_...); body: the trigger body as a parsed dict.""" sig = body.get("cronjob_signature") if isinstance(body, dict) else None if not isinstance(sig, dict) or sig.get("version") != "v1": return False ts, delivery_id = sig.get("timestamp"), sig.get("delivery_id") if not isinstance(ts, int) or isinstance(ts, bool) or not isinstance(delivery_id, str): return False if abs((time.time() if now is None else now) - ts) > 300: # replay window: 5 minutes return False rest = {k: v for k, v in body.items() if k != "cronjob_signature"} signed = f"v1b:{ts}:{delivery_id}:{_canonical_json(rest)}".encode() expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() # During a secret rotation "signatures" holds two values (new first): accept either. candidates = sig.get("signatures") if isinstance(sig.get("signatures"), list) else [sig.get("signature")] return any(isinstance(c, str) and hmac.compare_digest(c.encode(), expected.encode()) for c in candidates) ``` ## 6. Triggers, acknowledgement and retries ``` POST Content-Type: application/json User-Agent: agent-cronjob/ (+https://cronjob.agentasy.ai) X-Cronjob-Job-Id: job_01JC... X-Cronjob-Delivery-Id: dlv_01JC... X-Cronjob-Attempt: 1 X-Cronjob-Scheduled-At: 2026-11-03T04:00:00.000Z X-Cronjob-Timestamp: 1793678400 X-Cronjob-Signature: v1=5f2c... Authorization: (only if you set outgoing_auth) {"type":"trigger","job_id":"job_01JC...","delivery_id":"dlv_01JC...","attempt":1,"scheduled_at":"2026-11-03T04:00:00.000Z","sent_at":"2026-11-03T04:00:00.213Z","run_number":12,"payload":{"task":"hourly-digest"}} ``` With `signature_in_body: true` the body ends with `"cronjob_signature": {...}` ([body signature](#body-signature)). - A **run** is one schedule point; a **delivery** is one HTTP attempt. All attempts of a run share `delivery_id`; `X-Cronjob-Attempt` counts from 1. - **Ack** = any 2xx within `timeout_ms`. Only the first 1 KB of your response body is kept (in the run log). Respond fast and do long work asynchronously. - **Retried** (exponential backoff): connection errors, timeouts, 5xx, 408, 429. Delay before attempt n+1 is `min(retry_max_backoff_sec, retry_base_sec × 2^(n−1))` × random 0.8–1.2 (defaults: ~30 s, 60 s, 120 s, 240 s). A 429 with `Retry-After` is honoured (capped at `retry_max_backoff_sec`). - **Final, not retried**: any other 4xx (you rejected it), 3xx (redirects are never followed), a send blocked because the host now resolves to a non-public address. - **410 Gone**: the run fails and the job is **paused** (`status_reason: webhook_gone_410`); resume when ready. - If the next schedule point arrives while a run is still retrying, that run is abandoned as `superseded` (no backlog). - 20 consecutive finally-failed runs → the job is `disabled` (`status_reason: consecutive_failures`); `POST /v1/jobs/{job_id}/resume` re-enables it. - At-least-once: if the service crashes mid-request it resends the same `delivery_id` after restart. De-duplicate. ## 7. Missed runs (catch-up) If the service was down when a run was due: | `catchup_policy` | Behaviour | |---|---| | `skip` | every missed point is logged as `missed`; the next trigger is the next future point | | `run_once` (default) | one immediate trigger with `"catchup": true, "missed_count": k` at the top level of the body (your `payload` is untouched); the rest are logged as `missed` | | `run_all` | each missed point is sent in order (newest 10 at most; older ones logged as `missed`) | Points more than `catchup_window_sec` late are always `missed`. A run up to 60 s late is a normal on-time run. Points that pass while the job is **paused** are not made up and not logged. ## 8. Managing the job All with `Authorization: Bearer `. An unknown job and a wrong token both give `401 invalid_token`. ```http GET https://cronjob.agentasy.ai/v1/jobs/{job_id} Authorization: Bearer ``` → settings, `status` (`pending_verification` · `active` · `paused` · `completed` · `disabled` · `deleted`), `status_reason`, `run_count`, `next_run_at`, masked webhook (host only) and credential prefixes. ```http POST https://cronjob.agentasy.ai/v1/jobs/{job_id}/pause Authorization: Bearer ``` ```http POST https://cronjob.agentasy.ai/v1/jobs/{job_id}/resume Authorization: Bearer ``` Pause works from `active` (repeating it is fine). Resume works from `paused` or `disabled` and continues at the next schedule point after now; a job whose `max_runs` is used up becomes `completed`. Resume does not apply to `completed` jobs: raise `max_runs` with `PATCH` (below) instead. ```http PATCH https://cronjob.agentasy.ai/v1/jobs/{job_id} Authorization: Bearer Content-Type: application/json {"signature_in_body": true} ``` → the updated job. Changeable in place: `signature_in_body` (applies from the next attempt sent) and `max_runs`; other fields → `400`. ```http PATCH https://cronjob.agentasy.ai/v1/jobs/{job_id} Authorization: Bearer Content-Type: application/json {"max_runs": 20} ``` `max_runs` follows the create rules (integer 1 … 1000000, or `null` = unlimited) and must be greater than `run_count` (runs already done); equal or lower → `400` (`field: "max_runs"`). - `completed` with `status_reason: max_runs_reached` + a higher limit (or `null`) → **reactivated in place**: `status: active`, `status_reason: null`, `next_run_at` = the next schedule occurrence after now. Occurrences while it was completed are not caught up. There is no separate resume for completed jobs; the live-jobs-per-host limit applies (`429 host_job_limit`). A `once` job has nothing left to run → `409`; so does a job completed for any other reason. - `paused` / `disabled` / `pending_verification` keep their status and only get the new limit. - `active`: the new limit counts from the runs already dispatched; lowering it to that number stops new runs (the job completes once in-flight runs finish). - `deleted` → `409`. ```http POST https://cronjob.agentasy.ai/v1/jobs/{job_id}/rotate-secret Authorization: Bearer Content-Type: application/json {"grace_sec": 86400} ``` → `{"hmac_secret": "acs_...", "prefix": "...", "previous": {"prefix", "status": "retiring", "expires_at"}}` (shown once). ```http POST https://cronjob.agentasy.ai/v1/jobs/{job_id}/rotate-token Authorization: Bearer Content-Type: application/json {"grace_sec": 0} ``` → `{"management_token": "act_mt_...", ...}` (shown once). The old token stops working after `grace_sec` (default 0 = immediately, max 3600). Use the new token from now on. ```http DELETE https://cronjob.agentasy.ai/v1/jobs/{job_id} Authorization: Bearer ``` → `{"status": "deleted"}`. Stops for good and revokes every credential (the token stops working too). Not available yet: changing the schedule, webhook or payload in place, and manual triggers. To change the schedule or webhook, create a new job and delete the old one. ## 9. Run log and stats ```http GET https://cronjob.agentasy.ai/v1/jobs/{job_id}/runs?limit=50 Authorization: Bearer ``` Newest first. Query: `since`, `until` (ISO 8601, filter `scheduled_at`), `status` (`pending` · `succeeded` · `failed` · `missed` · `superseded` · `cancelled`), `limit` (1 … 200, default 50), `cursor` (from `next_cursor`). Each run: `run_id`, `run_number`, `delivery_id`, `kind` (`scheduled` · `catchup`), `status`, `scheduled_at`, `first_sent_at`, `first_delay_ms`, `attempts`, `missed_count`, `final_at`, `final_reason`, `late_cause` (`host_pause` when the run was late or missed and overlaps a recorded pause of our service host, e.g. a VM snapshot/restore; otherwise `null`). ```http GET https://cronjob.agentasy.ai/v1/jobs/{job_id}/runs/{run_id} Authorization: Bearer ``` → the run plus `deliveries[]`: `attempt`, `scheduled_at`, `sent_at`, `delay_ms`, `duration_ms`, `status_code`, `outcome` (`succeeded` · `retryable` · `failed` · `lost` · `cancelled` · `in_flight` · `scheduled`), `error` (`timeout`, `conn_refused`, `conn_reset`, `dns_error`, `unreachable`, `ssrf_blocked`, …), `response_excerpt`, `next_attempt_at`, `late_cause`. ```http GET https://cronjob.agentasy.ai/v1/jobs/{job_id}/stats?window=24h Authorization: Bearer ``` `window`: `1h` · `24h` · `7d` · `30d`. Returns `runs`, `by_status`, `success_rate` (succeeded ÷ (succeeded + failed + superseded)), `on_time_rate` (first attempt ≤ 5 s after `scheduled_at`), `first_delay_ms` p50 / p95 / max, `late` (`count` late runs, of which `host_pause` were caused by a host pause; `missed_host_pause` missed occurrences during one). ### Service health ```http GET https://cronjob.agentasy.ai/status ``` No auth, aggregate numbers only (no job data). `status` is `ok` or `degraded`; `degraded_reasons` says why: `scheduler_stale` (no scheduler heartbeat for 30 s), `late_p95` (`deliveries.last_1h.first_delay_p95_ms` > 30000 ms), `late_last` (`deliveries.last_first_delay_ms` > 30000 ms, sent within the hour), `missed` (`deliveries.last_1h.missed_count` > 0). `scheduler.max_tick_gap_sec_1h` is the longest pause between scheduler ticks in the last hour (normally 0–1). `host_pause` lists pauses of the service host itself (`last`, `count_1h`, `count_24h`); they explain lateness but never change `status`. ## 10. Limits and errors | Limit | Value | |---|---| | Job creation | 10 per hour per IP | | Live jobs per webhook host | 20 (anything not deleted/completed) | | Management API | 60 per minute per job | | Setup-token endpoints | 60 per minute per job | | /verify | 30 per minute per IP | | Failed authentication | 30 per minute per IP | | Any request | 300 per minute per IP | | Request body | 64 KB | | Concurrent sends to one host | 4 | Errors: `{"error": "", "message": "...", ...}` with `Cache-Control: no-store`; 429 carries `Retry-After`. | Status | `error` | |---|---| | 400 | `invalid_request` (schema; `field` names the culprit), `invalid_json`, `bad_request` | | 401 | `invalid_token` | | 403 | `challenge_mismatch` | | 404 | `not_found` | | 409 | `already_verified`, `retry_limit_reached`, `invalid_state` | | 410 | `verification_expired`, `verification_failed` | | 413 / 415 | `payload_too_large`, `unsupported_media_type` | | 422 | `webhook_url_blocked` (`reason`), `webhook_url_unresolvable` | | 429 | `rate_limited` (`limit`), `retry_too_soon`, `host_job_limit` | | 500 | `internal_error` | ## Terms & pricing - [Terms of Service](https://cronjob.agentasy.ai/terms): public beta, best effort, no SLA; acceptable use (Markdown: https://cronjob.agentasy.ai/terms.md) - [Pricing](https://cronjob.agentasy.ai/pricing): free during public beta; beta limits (Markdown: https://cronjob.agentasy.ai/pricing.md) ## Optional - [OpenAPI 3.1](https://cronjob.agentasy.ai/openapi.json): machine-readable, every endpoint and response - [Registration walkthrough](https://new-cronjob.agentasy.ai/index.md): short, self-contained version of sections 1–4 - [Full text](https://cronjob.agentasy.ai/llms-full.txt): this guide plus the walkthrough, terms and pricing in one file - [API catalog](https://cronjob.agentasy.ai/.well-known/api-catalog): RFC 9727 linkset pointing to the above - [MCP server](https://cronjob.agentasy.ai/mcp): the same API as MCP tools over Streamable HTTP (discovery: https://cronjob.agentasy.ai/.well-known/mcp) --- # Create a scheduled trigger (agent-cronjob) You are an agent. This page is enough to have this service POST to your webhook on a schedule — signed, retried and logged. No account, no API key. Complete reference: https://cronjob.agentasy.ai/llms.txt · OpenAPI 3.1: https://cronjob.agentasy.ai/openapi.json ## 1. Create the job POST https://new-cronjob.agentasy.ai/ (same as POST https://cronjob.agentasy.ai/v1/jobs), Content-Type: application/json ```json { "webhook_url": "https://your-agent.example/hooks/abc", "schedule": { "cron": "*/15 * * * *", "timezone": "Asia/Taipei" }, "payload": { "task": "daily-digest" }, "max_runs": null, "catchup_policy": "run_once", "verification_mode": "echo" } ``` - `schedule`: exactly one of `{"cron": "<5 fields>", "timezone": ""}`, `{"interval_sec": 3600}` or `{"run_at": ""}`. Minimum spacing 60 s. The cron timezone defaults to **UTC**; send `timezone` for anything else. - `webhook_url` must be https on a public address (private, loopback, link-local and metadata addresses are refused; redirects are never followed). - Optional: `name`, `max_runs` (job completes after N runs; raise it later with `PATCH`), `catchup_policy` (`skip` | `run_once` | `run_all`), `catchup_window_sec`, `retry_max_attempts`, `retry_base_sec`, `retry_max_backoff_sec`, `timeout_ms`. Unknown fields are rejected (400). - Optional `outgoing_auth`: `{"header": "Authorization", "value": "Bearer "}` if your webhook itself requires a header (e.g. a routine webhook). Sent on the challenge and every trigger; stored encrypted, never shown back. `header` defaults to `Authorization` (or any `X-…` name). - Optional `signature_in_body`: `true` if your platform drops our `X-Cronjob-*` headers before your agent sees the request (e.g. **Grok Bot routines**). Every trigger body then also carries a `cronjob_signature` object you can verify from the JSON alone (https://cronjob.agentasy.ai/llms.txt#body-signature). Default `false`; change it later with `PATCH https://cronjob.agentasy.ai/v1/jobs/{job_id}`. Response 202: `job_id`, `setup_token` (keep it; shown once) and `verification` links. ## 2. Prove you control the webhook (deadline 1: 15 minutes after creation) We POST to your webhook (header `X-Cronjob-Event: url_verification`): ```json { "type": "url_verification", "job_id": "job_...", "challenge": "", "verify_url": "https://cronjob.agentasy.ai/v1/jobs/job_.../verify" } ``` Complete it either way — both always work, whatever `verification_mode` you sent (that field is informational): - **echo**: answer 2xx with body `{"challenge": ""}` (`Content-Type: application/json`) or the bare value as `text/plain`. - **callback**: answer 2xx, then `POST ` with `{"challenge": ""}`. The response contains your credentials (step 3) — store them. Not received? `POST https://cronjob.agentasy.ai/v1/jobs/{job_id}/verification/retry` with `Authorization: Bearer ` (at most 5 re-sends (6 challenges in total), at least 60 s apart). Deadline 1 (verify): 15 minutes after job creation — unverified jobs are deleted; re-sends do not extend it. ## 3. Collect your credentials (once) `GET https://cronjob.agentasy.ai/v1/jobs/{job_id}/verification` with `Authorization: Bearer `. After verification the first call returns `hmac_secret` (acs_...) and `management_token` (act_mt_...). They are shown exactly once. Deadline 2 (collect credentials): 15 minutes after verification — uncollected jobs are deleted. Collect them right away: triggers start right after verification, and until your receiver has the secret answer triggers with `503` (retried), not `401` (final). ## 4. Receive triggers ``` POST X-Cronjob-Job-Id, X-Cronjob-Delivery-Id (same on every retry), X-Cronjob-Attempt, X-Cronjob-Scheduled-At, X-Cronjob-Timestamp (unix seconds), X-Cronjob-Signature: v1=[, v1=] { "type": "trigger", "job_id", "delivery_id", "attempt", "scheduled_at", "sent_at", "run_number", "payload" } ``` Verify: `hex(HMAC-SHA256(key = hmac_secret as UTF-8, "v1:" + X-Cronjob-Timestamp + ":" + X-Cronjob-Delivery-Id + ":" + raw_body))` equals any `v1=` value. Reject if the timestamp is more than 5 minutes off; de-duplicate on delivery_id. Copy-paste Node and Python verifiers: https://cronjob.agentasy.ai/llms.txt#signature With `signature_in_body: true` the body also has `"cronjob_signature": {"version": "v1", "timestamp", "delivery_id", "signatures": [...], "signature"}`: verify that instead when the headers do not reach you (https://cronjob.agentasy.ai/llms.txt#body-signature). Reply 2xx to acknowledge. 5xx/408/429/timeouts are retried with exponential backoff; other 4xx are final; 410 pauses the job. ## 5. Manage the job (Authorization: Bearer ) | Method | Path | | |---|---|---| | GET | https://cronjob.agentasy.ai/v1/jobs/{job_id} | settings and status | | PATCH | https://cronjob.agentasy.ai/v1/jobs/{job_id} | `{"signature_in_body": true}` and/or `{"max_runs": 20}`; raising `max_runs` reactivates a job completed by `max_runs_reached` | | POST | https://cronjob.agentasy.ai/v1/jobs/{job_id}/pause · /resume | | | DELETE | https://cronjob.agentasy.ai/v1/jobs/{job_id} | stop forever, revoke credentials | | POST | https://cronjob.agentasy.ai/v1/jobs/{job_id}/rotate-secret | `{"grace_sec": 86400}` old secret keeps co-signing | | POST | https://cronjob.agentasy.ai/v1/jobs/{job_id}/rotate-token | `{"grace_sec": 0}` (≤ 3600) | | GET | https://cronjob.agentasy.ai/v1/jobs/{job_id}/runs?since=&until=&status=&limit=&cursor= | trigger log | | GET | https://cronjob.agentasy.ai/v1/jobs/{job_id}/runs/{run_id} | every attempt | | GET | https://cronjob.agentasy.ai/v1/jobs/{job_id}/stats?window=24h | on-time rate, p50/p95 delay | Errors are JSON `{"error": "", "message": "..."}`; 429 responses carry Retry-After. Terms: https://new-cronjob.agentasy.ai/terms · Pricing: https://new-cronjob.agentasy.ai/pricing (free during public beta) --- # agent-cronjob — Terms of Service Version: 2026-11-03 · Effective: 2026-11-03 · Operator: agentasy.ai · Contact: hello@agentasy.ai ## English (authoritative for agents) 1. **What this is.** agent-cronjob (cronjob.agentasy.ai, new-cronjob.agentasy.ai) sends signed HTTP POST triggers to a webhook you control, on a cron / interval / one-off schedule. No accounts: a job is owned by whoever holds its management token. Default time zone is UTC. 2. **Beta, best effort, no SLA.** The service is in public beta and runs on a single host. That host can be paused or restarted without notice; triggers may be late, missed (handled by your `catchup_policy`), or delivered more than once. Always de-duplicate on `delivery_id`. Do not use it for anything safety-critical or where a missed or duplicate trigger causes harm. We may change or end the service. 3. **Acceptable use.** You must: - register only webhook URLs you control (ownership is proven by the challenge; never point jobs at third-party URLs); - stay within the published limits (see /pricing) and not try to get around them (e.g. many IPs or hosts to multiply quotas); - not use the service to attack, flood, scan, or spam any system, or to send unlawful content in `payload`. We may rate-limit, pause, disable, or delete any job, and block IPs or webhook hosts, without notice when we believe these terms are broken or the service is at risk. Jobs are disabled automatically after 20 consecutive finally-failed runs. 4. **Data we store.** - Job settings: webhook URL (encrypted at rest, AES-256-GCM, never shown back), its host name in plain text, schedule, name, payload (≤ 8 KB). - Credentials: HMAC secret (encrypted); management and setup tokens are stored only as hashes. - Run and delivery log: scheduled/sent times, delay, HTTP status, error, and up to 1 KB of your webhook's response body. - Retention: run/delivery detail older than 30 days is reduced to daily aggregate counts and deleted; deleted jobs are purged 30 days after deletion. Unverified jobs are deleted after 15 minutes, and verified jobs whose credentials are not collected within 15 minutes are deleted. Database backups are kept up to 30 days. - We do not sell this data. 4a. **Privacy.** - No accounts, cookies, analytics scripts or trackers. - Your IP address is used in memory only, for rate limiting; it is not written to the database or to our service logs. Our request logs record method, path (job ids collapsed), status code, time and a coarse user-agent class (e.g. browser, AI crawler name) — not the raw user-agent string and not your IP. These logs rotate by size (at most ~60 MB kept) and are not shared. - Cloudflare, Inc. acts as our network provider and processor: all traffic passes through Cloudflare, which processes IP addresses, user-agents and request metadata under its own privacy policy (https://www.cloudflare.com/privacypolicy/). We see only aggregate analytics from Cloudflare. - Data deletion: delete your job via the API (`DELETE /v1/jobs/{id}`), or email us; detail is purged per the retention rules above. 5. **Your security responsibilities.** The HMAC secret and management token are shown once. Keep them secret, rotate them if exposed, and verify the `X-Cronjob-Signature` on every trigger. Anyone with the management token can control the job. We cannot recover lost credentials. 6. **No warranty; limitation of liability.** The service is provided "as is" and "as available", free of charge, without warranties of any kind. To the maximum extent permitted by law, the operator is not liable for any indirect or consequential damages, or for loss caused by late, missed, duplicate, or failed triggers, data loss, or suspension. In any case the operator's total liability for all claims relating to the service is limited to USD 100. 7. **Changes.** We may update these terms; the version and date at the top change, and material changes are announced on cronjob.agentasy.ai at least 14 days before they take effect. Continued use means acceptance. 8. **Governing law.** These terms are governed by the laws of the Republic of China (Taiwan). The Taiwan Taipei District Court has jurisdiction as the court of first instance. 9. **Contact.** hello@agentasy.ai (abuse reports, questions, privacy and data deletion requests). --- ## 繁體中文(台灣) 1. **服務內容:** agent-cronjob 依 cron/固定間隔/單次排程,對你掌控的 webhook 送出有簽章的 HTTP POST 觸發。沒有帳號;持有管理 token 的人就是 job 擁有者。預設時區 UTC。 2. **測試版、盡力而為、無 SLA:** 服務在公開測試階段,跑在單一主機上,主機可能未經通知暫停或重啟;觸發可能延遲、漏跑(依你設定的 `catchup_policy` 補跑)或重複送達,請一律以 `delivery_id` 去重。請勿用於攸關安全、或漏送/重送會造成損害的用途。我們可能變更或終止服務。 3. **使用規範:** 只能登記你自己掌控的 webhook URL(以 challenge 證明,不得指向第三方網址);遵守公布的限制、不得規避(例如用多個 IP 或主機放大額度);不得用來攻擊、灌流量、掃描、濫發或傳送違法內容。我們認為違反條款或危及服務時,可不經通知限速、暫停、停用或刪除 job,並封鎖 IP 或 webhook 主機。連續 20 次最終失敗的 job 會被自動停用。 4. **儲存的資料:** webhook URL(AES-256-GCM 加密、不回顯)及其主機名稱(明文)、排程、名稱、payload(≤ 8 KB);HMAC 密鑰加密保存,管理/setup token 只存雜湊;執行紀錄含排程與送出時間、延遲、HTTP 狀態碼、錯誤及回應內容前 1 KB。超過 30 天的明細只保留每日彙總後刪除;已刪除的 job 30 天後清除;15 分鐘內未驗證、或驗證後 15 分鐘內未領取憑證的 job 會被刪除;資料庫備份最多保留 30 天。我們不販售資料。 4a. **隱私:** 不需帳號,不使用 cookie、分析腳本或追蹤器。你的 IP 只在記憶體中用於限速,不寫入資料庫或服務日誌。服務日誌只記錄方法、路徑(job id 已遮蔽)、狀態碼、時間及粗略的 user-agent 類別(例如瀏覽器、AI 爬蟲名稱),不記錄原始 user-agent 或 IP;日誌依大小輪替(最多約 60 MB)且不對外提供。Cloudflare, Inc. 為我們的網路服務商與資料處理者,所有流量都經過 Cloudflare,其依自身隱私政策(https://www.cloudflare.com/privacypolicy/)處理 IP、user-agent 等請求資料;我們只看得到彙總統計。刪除資料:可透過 API(`DELETE /v1/jobs/{id}`)刪除 job,或來信;明細依上述保留規則清除。 5. **你的安全責任:** HMAC 密鑰與管理 token 只顯示一次,請妥善保管、外洩時輪替,並驗證每次觸發的 `X-Cronjob-Signature`。持有管理 token 者即可控制 job;遺失無法找回。 6. **不提供保證、責任限制:** 本服務免費、依「現狀」提供,不作任何保證。在法律允許的最大範圍內,營運者不對間接或衍生損害,或因觸發延遲、漏送、重送、失敗、資料遺失或停權造成的損失負責。無論如何,營運者就本服務相關之所有請求,賠償總額以 100 美元為上限。 7. **條款變更:** 我們可能更新條款,頂端版本與日期會隨之變更,重大變更會在 cronjob.agentasy.ai 至少於生效前 14 天公告。繼續使用即視為同意。 8. **準據法與管轄:** 本條款以中華民國法律為準據法,並以臺灣臺北地方法院為第一審管轄法院。 9. **聯絡:** hello@agentasy.ai(濫用檢舉、問題、隱私與資料刪除請求)。 --- # agent-cronjob — Pricing Version: 2026-11-03 · Operator: agentasy.ai · Contact: hello@agentasy.ai ## English **Free during public beta.** No account, no API key, no card. Best effort, no SLA (see /terms). Future paid tiers (e.g. higher limits, longer retention, SLA) are **to be decided**; no prices are set. Beta jobs will get advance notice before any change (at least 14 days, announced on cronjob.agentasy.ai). ### Beta limits | Item | Limit | |---|---| | Minimum schedule interval | 60 s (cron and interval); maximum interval 366 days | | Live jobs per webhook host | 20 | | Job creation | 10 per hour per IP | | Concurrent sends to one host | 4 | | Management API / setup-token endpoints | 60 per minute per job | | /verify · failed auth | 30 per minute per IP each | | Any request | 300 per minute per IP | | Request body | 64 KB · payload ≤ 8 KB · webhook URL ≤ 2048 chars · name ≤ 200 chars | | Retries | 1–10 attempts per run (default 5), backoff base 1–3600 s (default 30), cap ≤ 86400 s (default 3600) | | Webhook timeout | 1–30 s (default 10 s) | | max_runs | up to 1,000,000 | | Catch-up window | 0–7 days (default 1 h) | | Auto-disable | after 20 consecutive finally-failed runs | | Run log retention | 30 days of detail, then daily aggregates | | Verification / credential pickup | 15 min each | ## 繁體中文(台灣) **公開測試期間免費。** 不需帳號、API key 或信用卡;盡力而為、無 SLA(見 /terms)。未來付費方案(例如更高限制、更長保留、SLA)**尚未決定**,目前沒有定價;任何變更前會先通知測試期使用者(至少 14 天前於 cronjob.agentasy.ai 公告)。 ### 測試期限制 | 項目 | 限制 | |---|---| | 最短排程間隔 | 60 秒(cron 與 interval);最長間隔 366 天 | | 每個 webhook 主機的有效 job | 20 個 | | 建立 job | 每 IP 每小時 10 次 | | 對同一主機同時送出 | 4 個 | | 管理 API/setup token 端點 | 每 job 每分鐘 60 次 | | /verify、驗證失敗 | 各為每 IP 每分鐘 30 次 | | 所有請求 | 每 IP 每分鐘 300 次 | | 請求大小 | 64 KB;payload ≤ 8 KB;webhook URL ≤ 2048 字元;名稱 ≤ 200 字元 | | 重試 | 每次執行 1–10 次(預設 5),退避基數 1–3600 秒(預設 30),上限 ≤ 86400 秒(預設 3600) | | webhook 逾時 | 1–30 秒(預設 10 秒) | | max_runs | 最多 1,000,000 | | 補跑視窗 | 0–7 天(預設 1 小時) | | 自動停用 | 連續 20 次最終失敗 | | 執行紀錄保留 | 明細 30 天,之後只留每日彙總 | | 驗證/領取憑證期限 | 各 15 分鐘 |