{"openapi":"3.1.0","info":{"title":"402cron","version":"1.0.0","summary":"Scheduled HTTP delivery for autonomous agents, paid with x402.","description":"Signed, verified HTTP delivery on a cron schedule. No account, no card, no human. We guarantee that we tried to deliver your task; we never pretend we can guarantee what someone else's server does with it. You pay per delivery attempt, not per outcome. You need your own public https:// endpoint that can echo a one-time verification challenge.","contact":{"email":"abuse@402cron.com","url":"https://402cron.com/abuse"},"termsOfService":"https://402cron.com/terms","x-guidance":"Buy credits first: GET /buy/{pack} answers 402 with x402 payment requirements in the PAYMENT-REQUIRED header. Pay with any x402 client and repeat the same request; the 200 carries your management token, shown ONCE. Send it as `Authorization: Bearer <token>` on every /api/ call. There is no account, no OAuth and no password. Then: POST /api/destinations to claim the https:// URL you control (you get a challenge and a signing secret, also shown once), POST /api/destinations/{id}/verify to answer it, and only then POST /api/tasks pointing at that URL. Balance is counted in DELIVERY ATTEMPTS, not money, and credits never expire. A timeout or an unreachable host is attempted at most THREE times in total (two retries), so one failing cycle costs at most three credits — the contract counts ATTEMPTS, not deliveries. Refusals carry a snake_case `error` code and a `message` saying what to do next — branch on the code, never on the prose. Full machine documentation: https://402cron.com/docs (add ?section=quickstart|verification|headers|signature|notice|errors|billing|limits)."},"servers":[{"url":"https://402cron.com"}],"webhooks":{"taskPaused":{"post":{"operationId":"taskPausedNotice","summary":"Courtesy notice we POST to your destination when a task is paused","description":"Sent once, not retried, never charged. Signed with your destination secret using the SAME canonical string as a delivery: Delivery-Id is \"notice-<taskId>\" and Attempt is 0 — and both travel as headers, so the signature is verifiable. The authoritative channel is GET /api/tasks/{id}.","security":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","const":"task_paused"},"taskId":{"type":"string","format":"uuid"},"state":{"type":"string","enum":["paused_failures","paused_no_credits","paused_by_client","paused_abandoned","paused_destination","expired"]},"reason":{"type":"string"},"resume":{"type":"string","format":"uri"},"status":{"type":"string","format":"uri"}}}}}},"responses":{"200":{"description":"Any 2xx. We ignore the body and never retry."}}}}},"externalDocs":{"description":"Machine documentation","url":"https://402cron.com/docs"},"x-discovery":{"ownershipProofs":["0x8e37022edcf0f21cf3c9f93fee9d4d32519f36f4"]},"tags":[{"name":"payment","description":"Buying delivery credits with x402"},{"name":"destinations","description":"Proving you control the URL we will call"},{"name":"tasks","description":"Scheduling and controlling deliveries"},{"name":"public","description":"Open endpoints, no payment and no token"}],"security":[{"apiKey":[]}],"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","description":"Bearer token. Send it exactly as \"Authorization: Bearer <token>\" — the word Bearer, one space, then the management token from your first purchase (shown once). Lost it? Buy the smallest pack again from the same wallet with ?rotate=1 — the old token dies. The scheme is named apiKey only so catalogue readers that classify auth by that name see these routes as key-protected; \"type: http, scheme: bearer\" is the authoritative description."}},"schemas":{"PaymentRequired":{"type":"object","properties":{"x402Version":{"type":"integer","example":2},"error":{"type":"string","example":"payment required"},"accepts":{"type":"array","items":{"type":"object"}},"message":{"type":"string","description":"Only on a refused payment attempt: what to do next, in order."},"retryable":{"type":"boolean","description":"Only on a refused payment attempt. false: the same header will be refused again — fix and re-sign. true: resend the identical header later."},"rejectedBy":{"type":"string","enum":["us","facilitator"],"description":"\"us\": our own format check refused the header before the facilitator saw it. \"facilitator\": a verdict on the signed bytes."}}},"Purchase":{"type":"object","properties":{"ok":{"type":"boolean"},"pack":{"type":"string"},"creditedDeliveries":{"type":"integer"},"deliveriesLeft":{"type":"integer","description":"Only on a first settlement. A replayed receipt does not carry the balance."},"payment":{"type":"string","description":"Settlement reference on chain"},"managementToken":{"type":"string","description":"New clients only, shown once"}}},"Refusal":{"type":"object","description":"Every refusal looks like this. Branch on `error` and on the HTTP status — never on the sentence.","required":["error"],"properties":{"error":{"type":"string","example":"limit_reached"},"message":{"type":"string","description":"One English sentence: what to do next."}}},"LimitReached":{"allOf":[{"$ref":"#/components/schemas/Refusal"}],"description":"One code for every limit, with a field that says which. 409 means free space; 429 means wait.","properties":{"limit":{"type":"string","enum":["settlement_hourly","live_tasks","destinations","verify_daily_client","verify_daily_destination","origin_daily","buy_hourly"]},"max":{"type":"integer"},"used":{"type":"integer"},"retryTomorrow":{"type":"boolean","description":"Daily allowances only. Deleting things will not help."},"retryable":{"type":"boolean","description":"Hourly allowances only. The same request will be accepted after the wait — do not re-plan around it."},"retryAfterSeconds":{"type":"integer","minimum":1,"maximum":3600,"description":"Hourly allowances: seconds until the calendar UTC hour turns over — the same number as the retry-after header. Absent when we have no measured number to give you; then use your own backoff."}}},"TooLong":{"allOf":[{"$ref":"#/components/schemas/Refusal"}],"properties":{"field":{"type":"string","enum":["name","url","body","headers","request"]},"maxBytes":{"type":"integer"},"got":{"type":"integer"}}}}},"paths":{"/buy/trial":{"get":{"operationId":"buy_trial","summary":"Buy 20 delivery credits","description":"Returns 402 with x402 payment requirements. Pay it with any x402 client and the same request returns 200 with the credits added. Credits never expire and are counted in delivery attempts, not money. The till is one URL and it does not branch on the HTTP method — a POST to it is answered identically — but GET is what this spec, /docs and the catalogue manifest declare, so build on GET. ONE EXCEPTION: HEAD with a payment header is refused (`head_cannot_settle`) and settles nothing, because a HEAD response carries no body and the receipt and one-time token are in the body.","tags":["payment"],"security":[],"parameters":[{"name":"x402Version","in":"query","required":false,"description":"Force the older protocol version. Only for clients that speak x402 v1.","schema":{"type":"integer","enum":[1,2],"default":2}},{"name":"rotate","in":"query","required":false,"description":"Issue a NEW management token for the wallet that pays, and kill the old one. For agents that lost their token. Ignored on a first purchase, which always issues one. Rotation is NOT a separate endpoint: it is this same paid purchase with one query parameter, so it costs what the pack costs.","schema":{"type":"string","enum":["1"]}}],"x-payment-info":{"protocols":[{"x402":{}}],"price":{"mode":"fixed","currency":"USD","amount":"0.02"},"network":"base","asset":"USDC","payTo":"0x8e37022edcf0f21cf3c9f93fee9d4d32519f36f4"},"responses":{"200":{"description":"Payment accepted; credits added.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Purchase"}}}},"402":{"description":"Payment required. The requirements travel in the PAYMENT-REQUIRED header (x402 v2) and, for compatibility, in the body. A 402 that answers a PAYMENT ATTEMPT also carries `error` (why), `message` (what to do, in order), `retryable` and `rejectedBy` — \"us\" when our own format check refused the header before the facilitator was called, \"facilitator\" when the facilitator judged the bytes. Nothing is charged by a 402. The pre-flight checklist is at /docs → payment.beforeYouSign.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentRequired"}}}},"409":{"description":"The same payment is already being processed. Retry the identical header; nothing was charged."},"429":{"description":"Wait the seconds in the retry-after header, then retry; nothing was charged and the nonce is not burnt (re-sign if your authorization has expired meanwhile). Two causes share the status. (1) YOUR OWN hourly allowance: 16 payment attempts per hour per caller — your network address (IPv6 per /64) together with your payer address when your signature verifies locally, the address alone when only the chain can verify it. That one is a full `limit_reached` body with `limit: \"buy_hourly\"`, `max`, `used`, `retryAfterSeconds` and `retryable: true`. (2) The SERVICE-WIDE settlement budget for this hour is used up (60/hour, split 48 for locally verifiable ECDSA signatures and 12 for signatures only the chain can verify). That one is an older refusal: `error` carries a sentence rather than a machine code, plus `reason`, `retryable: true` and `retryAfterSeconds` — branch on the status and the header, not on the text. Both are counted per calendar UTC hour, so retry-after is never more than 3600 seconds.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/LimitReached"},{"$ref":"#/components/schemas/Refusal"}]}}}},"500":{"description":"Settled on chain but we could not record it. The body carries the nonce; do NOT pay again — write to /abuse."},"503":{"description":"Nothing was charged. Either we are not selling right now (the body says which of the conditions is missing) or the service is deliberately stopped (`service_stopped`). A payment you already made keeps its credit."}}}},"/buy/starter":{"get":{"operationId":"buy_starter","summary":"Buy 4000 delivery credits","description":"Returns 402 with x402 payment requirements. Pay it with any x402 client and the same request returns 200 with the credits added. Credits never expire and are counted in delivery attempts, not money. The till is one URL and it does not branch on the HTTP method — a POST to it is answered identically — but GET is what this spec, /docs and the catalogue manifest declare, so build on GET. ONE EXCEPTION: HEAD with a payment header is refused (`head_cannot_settle`) and settles nothing, because a HEAD response carries no body and the receipt and one-time token are in the body.","tags":["payment"],"security":[],"parameters":[{"name":"x402Version","in":"query","required":false,"description":"Force the older protocol version. Only for clients that speak x402 v1.","schema":{"type":"integer","enum":[1,2],"default":2}},{"name":"rotate","in":"query","required":false,"description":"Issue a NEW management token for the wallet that pays, and kill the old one. For agents that lost their token. Ignored on a first purchase, which always issues one. Rotation is NOT a separate endpoint: it is this same paid purchase with one query parameter, so it costs what the pack costs.","schema":{"type":"string","enum":["1"]}}],"x-payment-info":{"protocols":[{"x402":{}}],"price":{"mode":"fixed","currency":"USD","amount":"2"},"network":"base","asset":"USDC","payTo":"0x8e37022edcf0f21cf3c9f93fee9d4d32519f36f4"},"responses":{"200":{"description":"Payment accepted; credits added.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Purchase"}}}},"402":{"description":"Payment required. The requirements travel in the PAYMENT-REQUIRED header (x402 v2) and, for compatibility, in the body. A 402 that answers a PAYMENT ATTEMPT also carries `error` (why), `message` (what to do, in order), `retryable` and `rejectedBy` — \"us\" when our own format check refused the header before the facilitator was called, \"facilitator\" when the facilitator judged the bytes. Nothing is charged by a 402. The pre-flight checklist is at /docs → payment.beforeYouSign.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentRequired"}}}},"409":{"description":"The same payment is already being processed. Retry the identical header; nothing was charged."},"429":{"description":"Wait the seconds in the retry-after header, then retry; nothing was charged and the nonce is not burnt (re-sign if your authorization has expired meanwhile). Two causes share the status. (1) YOUR OWN hourly allowance: 16 payment attempts per hour per caller — your network address (IPv6 per /64) together with your payer address when your signature verifies locally, the address alone when only the chain can verify it. That one is a full `limit_reached` body with `limit: \"buy_hourly\"`, `max`, `used`, `retryAfterSeconds` and `retryable: true`. (2) The SERVICE-WIDE settlement budget for this hour is used up (60/hour, split 48 for locally verifiable ECDSA signatures and 12 for signatures only the chain can verify). That one is an older refusal: `error` carries a sentence rather than a machine code, plus `reason`, `retryable: true` and `retryAfterSeconds` — branch on the status and the header, not on the text. Both are counted per calendar UTC hour, so retry-after is never more than 3600 seconds.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/LimitReached"},{"$ref":"#/components/schemas/Refusal"}]}}}},"500":{"description":"Settled on chain but we could not record it. The body carries the nonce; do NOT pay again — write to /abuse."},"503":{"description":"Nothing was charged. Either we are not selling right now (the body says which of the conditions is missing) or the service is deliberately stopped (`service_stopped`). A payment you already made keeps its credit."}}}},"/buy/month":{"get":{"operationId":"buy_month","summary":"Buy 50000 delivery credits","description":"Returns 402 with x402 payment requirements. Pay it with any x402 client and the same request returns 200 with the credits added. Credits never expire and are counted in delivery attempts, not money. The till is one URL and it does not branch on the HTTP method — a POST to it is answered identically — but GET is what this spec, /docs and the catalogue manifest declare, so build on GET. ONE EXCEPTION: HEAD with a payment header is refused (`head_cannot_settle`) and settles nothing, because a HEAD response carries no body and the receipt and one-time token are in the body.","tags":["payment"],"security":[],"parameters":[{"name":"x402Version","in":"query","required":false,"description":"Force the older protocol version. Only for clients that speak x402 v1.","schema":{"type":"integer","enum":[1,2],"default":2}},{"name":"rotate","in":"query","required":false,"description":"Issue a NEW management token for the wallet that pays, and kill the old one. For agents that lost their token. Ignored on a first purchase, which always issues one. Rotation is NOT a separate endpoint: it is this same paid purchase with one query parameter, so it costs what the pack costs.","schema":{"type":"string","enum":["1"]}}],"x-payment-info":{"protocols":[{"x402":{}}],"price":{"mode":"fixed","currency":"USD","amount":"25"},"network":"base","asset":"USDC","payTo":"0x8e37022edcf0f21cf3c9f93fee9d4d32519f36f4"},"responses":{"200":{"description":"Payment accepted; credits added.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Purchase"}}}},"402":{"description":"Payment required. The requirements travel in the PAYMENT-REQUIRED header (x402 v2) and, for compatibility, in the body. A 402 that answers a PAYMENT ATTEMPT also carries `error` (why), `message` (what to do, in order), `retryable` and `rejectedBy` — \"us\" when our own format check refused the header before the facilitator was called, \"facilitator\" when the facilitator judged the bytes. Nothing is charged by a 402. The pre-flight checklist is at /docs → payment.beforeYouSign.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PaymentRequired"}}}},"409":{"description":"The same payment is already being processed. Retry the identical header; nothing was charged."},"429":{"description":"Wait the seconds in the retry-after header, then retry; nothing was charged and the nonce is not burnt (re-sign if your authorization has expired meanwhile). Two causes share the status. (1) YOUR OWN hourly allowance: 16 payment attempts per hour per caller — your network address (IPv6 per /64) together with your payer address when your signature verifies locally, the address alone when only the chain can verify it. That one is a full `limit_reached` body with `limit: \"buy_hourly\"`, `max`, `used`, `retryAfterSeconds` and `retryable: true`. (2) The SERVICE-WIDE settlement budget for this hour is used up (60/hour, split 48 for locally verifiable ECDSA signatures and 12 for signatures only the chain can verify). That one is an older refusal: `error` carries a sentence rather than a machine code, plus `reason`, `retryable: true` and `retryAfterSeconds` — branch on the status and the header, not on the text. Both are counted per calendar UTC hour, so retry-after is never more than 3600 seconds.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/LimitReached"},{"$ref":"#/components/schemas/Refusal"}]}}}},"500":{"description":"Settled on chain but we could not record it. The body carries the nonce; do NOT pay again — write to /abuse."},"503":{"description":"Nothing was charged. Either we are not selling right now (the body says which of the conditions is missing) or the service is deliberately stopped (`service_stopped`). A payment you already made keeps its credit."}}}},"/api/destinations":{"post":{"operationId":"registerDestination","summary":"Register a destination URL and receive its signing secret (shown once)","description":"Verification is one request from us to the exact URL you registered: POST, headers X-402cron-Challenge: <value> and X-402cron-Event: verify, JSON body {\"challenge\":\"<value>\"}. Reply within 5 seconds, without a redirect, with a body that is the bare value or {\"challenge\":\"<value>\"}; we read the first 512 bytes and do not check the HTTP status (2xx is fine). Then call POST /api/destinations/{id}/verify within 15 minutes of registering. A fixed-response webhook cannot pass: you need an endpoint you can program. Registering a URL that is still pending issues a new challenge and a new signing secret every time — the old secret immediately stops verifying deliveries; a verified URL is left untouched unless you add ?rotate=1. The daily verification allowance (10 per destination, 40 per client, UTC day) does not reset on re-registration. The permission covers that path and everything beneath it — not the whole domain. The URL must be https: over plain http your headers and body travel in the clear, and a signed request can be replayed verbatim by anyone on the path.","tags":["destinations"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","example":"https://your.host/hook"}}}}}},"responses":{"200":{"description":"Registered; pending verification. The signing secret is returned once."},"400":{"description":"The URL was refused: not https, an IP literal, an internal name, a non-standard port, credentials in the URL, one of ours, or a path that hides a dot segment (\"/hook/..%2Fadmin\" — a plain %2F inside a segment is fine). The body carries `reason`."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"409":{"description":"You already hold the maximum number of destinations (25). `limit_reached`; the message says how to free one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LimitReached"}}}},"413":{"description":"The request is over its byte limit (`too_long`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TooLong"}}}},"503":{"description":"Nothing was written: we cannot store a secret safely right now (`not_configured`). We refuse rather than store it in the clear."}}},"get":{"operationId":"listDestinations","summary":"Your destinations, with status — the way to find one to delete","description":"No secrets are returned: the signing secret is shown once, at registration, and never again.","tags":["destinations"],"responses":{"200":{"description":"Up to 25 destinations with their verification status."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."}}}},"/api/destinations/{id}":{"delete":{"operationId":"deleteDestination","summary":"Remove a destination and free the slot","tags":["destinations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Deleted. The slot is free immediately."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such destination for this client. One that belongs to someone else answers 404 too — never 403."},"409":{"description":"`destination_in_use`: a task still points at it and the body names it. A deleted task counts too until its 30-day restore window closes."}}}},"/api/destinations/{id}/verify":{"post":{"operationId":"verifyDestination","summary":"Verify a destination after echoing our challenge","description":"Verification is one request from us to the exact URL you registered: POST, headers X-402cron-Challenge: <value> and X-402cron-Event: verify, JSON body {\"challenge\":\"<value>\"}. Reply within 5 seconds, without a redirect, with a body that is the bare value or {\"challenge\":\"<value>\"}; we read the first 512 bytes and do not check the HTTP status (2xx is fine). Then call POST /api/destinations/{id}/verify within 15 minutes of registering. A fixed-response webhook cannot pass: you need an endpoint you can program. Registering a URL that is still pending issues a new challenge and a new signing secret every time — the old secret immediately stops verifying deliveries; a verified URL is left untouched unless you add ?rotate=1. The daily verification allowance (10 per destination, 40 per client, UTC day) does not reset on re-registration. The attempt is counted whether or not it succeeds — a daily allowance, not a free retry loop. It does not consume a delivery, but it does need a live balance.","tags":["destinations"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Verified. Calling it again on a verified destination is a no-op, not an error."},"400":{"description":"The challenge did not come back, or the URL is no longer allowed by the fence."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such destination for this client. One that belongs to someone else answers 404 too — never 403."},"409":{"description":"The challenge expired (15 minutes; register the URL again to get a new one), or `no_credits`: verification sends a real request and needs a live balance."},"429":{"description":"`limit_reached` with `retryTomorrow: true`. `limit` is one of `verify_daily_destination`, `verify_daily_client` or `origin_daily` — the last one is a cap per TARGET HOST across all clients.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LimitReached"}}}},"502":{"description":"Your endpoint did not answer at all. `detail` carries the network error."},"503":{"description":"The service is deliberately stopped (`service_stopped`). Nothing was sent."}}}},"/api/tasks":{"get":{"operationId":"listTasks","summary":"Your tasks (paginated)","description":"Returns a page of your tasks. Results are ordered by name. `state` filters: `live` (default — everything except deleted), `all` (everything), or one exact state. THERE IS NO SINGLE `paused` STATE: a task stopped by repeated failures is `paused_failures`. Any value outside the enum is REFUSED with `unknown_state` and 400, and the body lists the accepted values — a typo does not silently answer with your whole list.","tags":["tasks"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"after","in":"query","required":false,"description":"Name of the last task from the previous page — returns tasks with names after this one.","schema":{"type":"string"}},{"name":"state","in":"query","required":false,"schema":{"type":"string","enum":["live","all","active","paused_failures","paused_no_credits","paused_by_client","paused_abandoned","paused_destination","expired","deleted"],"default":"live"}}],"responses":{"200":{"description":"A page of tasks."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."}}},"post":{"operationId":"createTask","summary":"Create a scheduled delivery","description":"5 fields, UTC only, one minute at the finest. Operators: * a-b a,b */n a-b/n. Names: SUN-SAT (day), JAN-DEC (month). Macros: @yearly @annually @monthly @weekly @daily @midnight @hourly. Day-of-week is 0-6 with 0 = Sunday; 7 also means Sunday. Write days as names (MON-FRI). Numbers differ between schedulers: Quartz, AWS EventBridge and Cloudflare number Sunday as 1, we and croniter as 0. Translated for you: \"?\". \"?\" as a whole day-of-month or day-of-week field becomes \"*\". A NUMERIC day-of-week next to \"?\" is refused, because \"?\" means the expression was written where Sunday is 1. Refused: Quartz L, Quartz W, Quartz #, six fields (seconds), seven fields (year), @reboot, @every, CRON_TZ= prefixes, time zones of any kind — each with a code saying what to send instead. The response carries the next 3 runs.","tags":["tasks"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","url","cron"],"properties":{"name":{"type":"string","example":"daily-digest"},"url":{"type":"string","format":"uri","example":"https://your.host/hook"},"cron":{"type":"string","description":"5 fields, UTC only, one minute at the finest. Operators: * a-b a,b */n a-b/n. Names: SUN-SAT (day), JAN-DEC (month). Macros: @yearly @annually @monthly @weekly @daily @midnight @hourly. Day-of-week is 0-6 with 0 = Sunday; 7 also means Sunday. Write days as names (MON-FRI). Numbers differ between schedulers: Quartz, AWS EventBridge and Cloudflare number Sunday as 1, we and croniter as 0. Translated for you: \"?\". \"?\" as a whole day-of-month or day-of-week field becomes \"*\". A NUMERIC day-of-week next to \"?\" is refused, because \"?\" means the expression was written where Sunday is 1. Refused: Quartz L, Quartz W, Quartz #, six fields (seconds), seven fields (year), @reboot, @every, CRON_TZ= prefixes, time zones of any kind — each with a code saying what to send instead.","example":"0 9 * * MON-FRI"},"method":{"type":"string","enum":["GET","HEAD","POST","PUT","PATCH","DELETE"],"default":"POST"},"body":{"oneOf":[{"type":"string"},{"type":"object","additionalProperties":true}],"description":"Up to 64 KB, sent verbatim. A JSON object is accepted too and serialised for you. GET and HEAD must not carry one: we sign the body but such a request does not send it, so every delivery would look forged to your endpoint. That combination is refused, 400."},"headers":{"type":"object","additionalProperties":{"type":"string"}},"timeoutMs":{"type":"integer","minimum":250,"maximum":2000,"default":2000},"overlap":{"type":"string","enum":["skip"],"default":"skip","description":"The only supported mode: an occurrence is skipped while the previous one still runs."}}}}}},"responses":{"200":{"description":"Task is live; nextRuns shows the next three executions."},"400":{"description":"Invalid cron, or a refused URL — not https, or a path that hides a dot segment (a plain %2F inside a segment is fine) — or a refused method, overlap mode or timeoutMs. EVERY refusal now carries a stable machine code in `error` (`invalid_cron`, `schedule_impossible`, `delivery_method_not_allowed`, `overlap_mode_unsupported`, `timeout_out_of_range`, `destination_refused`, `unknown_field`, `too_long`, …) — branch on `error`, and see the full catalogue at /docs → errors.codes."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"409":{"description":"Two codes share this status. `destination_not_verified`: no verified destination covers this URL. `limit_reached` with limit `live_tasks`: you already hold the maximum (50)."},"413":{"description":"The request, or one of `name`, `url`, `body`, `headers`, is over its byte limit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TooLong"}}}},"503":{"description":"Headers were given while we cannot encrypt them (`not_configured`). Nothing was written."}}}},"/api/tasks/{id}":{"get":{"operationId":"getTask","summary":"Task state, failure counters, credits left and the next runs","tags":["tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Task status. Fields: `state`, `cron`, `deliveriesLeft`, `lastRunAt`, `failsInARow`, `refusedInARow`, and the schedule pair — `nextRunAt` (the real next run, or `null` when the task is not active) and `nextRunAtScheduled` (when it WOULD next fire if resumed now). When the task is paused, `nextRunNote` explains that missed runs are not backfilled. Older snake_case fields (`next_run_at`, `last_run_at`, `deliveries_left`) are still returned for compatibility."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such task for this client. A task that belongs to someone else answers 404 too — never 403."}}},"patch":{"operationId":"updateTask","summary":"Edit a task","description":"Update one or more fields: `name`, `cron`, `method`, `body`, `headers`, `timeoutMs`, `url`. State is not editable via this endpoint — use pause/resume instead. The schedule is re-computed if `cron` or `url` changes.","tags":["tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"cron":{"type":"string"},"url":{"type":"string","format":"uri"},"method":{"type":"string","enum":["GET","HEAD","POST","PUT","PATCH","DELETE"]},"body":{"oneOf":[{"type":"string"},{"type":"object","additionalProperties":true}]},"headers":{"type":"object","additionalProperties":{"type":"string"}},"timeoutMs":{"type":"integer"}}}}}},"responses":{"200":{"description":"Updated; next runs are returned."},"400":{"description":"Invalid field or value — including a URL that is not https. A field the endpoint does not edit is refused, not ignored, even when it is sent as null."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such task for this client. A task that belongs to someone else answers 404 too — never 403."},"409":{"description":"Two codes share this status. `destination_not_verified`: the destination is not verified for the new URL. `name_taken_by_live_task`: another live task of yours already uses the new name (`liveTaskId` names it)."},"413":{"description":"A changed field is over its byte limit. PATCH measures exactly what POST measures.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TooLong"}}}},"503":{"description":"Headers were given while we cannot encrypt them (`not_configured`). Nothing was written."}}},"delete":{"operationId":"deleteTask","summary":"Soft-delete a task","description":"Marks the task as deleted. It can be restored within 30 days via POST /api/tasks/{id}/restore. The task is removed from the delivery schedule immediately. Spent credits are not refunded.","tags":["tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted; includes restoreUntil date."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such task for this client. A task that belongs to someone else answers 404 too — never 403."}}}},"/api/tasks/{id}/run":{"post":{"operationId":"runTask","summary":"Deliver once now, without touching the schedule","tags":["tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The delivery WAS attempted — the body says how it went, and the attempt is charged either way (the contract counts ATTEMPTS, not deliveries). `ok: true` means the destination accepted it; `ok: false` with a `status` (`refused`, `delivered_failed`, `delivery_timeout`) means it was sent and the destination failed it. Do NOT retry on this: you already paid for the attempt, and a scheduled retry may already be wound (`retryIn`)."},"400":{"description":"`destination_refused`: the URL is no longer one we deliver to. Nothing was sent."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such task for this client. A task that belongs to someone else answers 404 too — never 403."},"409":{"description":"Refused BEFORE anything was sent, so nothing was charged: `task_not_active`, `no_credits`, or `already_run` — a delivery for this task is already in flight, which is `overlap: skip` doing what it promises; two simultaneous calls produce one delivery and one charge. The body carries the machine code in `error`, the same field every other refusal uses, and repeats it in `code` for clients written against the older shape."},"429":{"description":"`limit_reached`: a DAILY cap is full — yours or the one per destination. Nothing was sent and nothing was charged; it resets at the next UTC day, so waiting is the fix, not buying."},"500":{"description":"OUR failure before the request left (`status: \"internal\"`): the attempt was not charged and any credit taken is refunded. Not your fault and not your fix — it is alarmed on our side."},"503":{"description":"The service is deliberately stopped (`service_stopped`). Nothing was sent."}}}},"/api/tasks/{id}/pause":{"post":{"operationId":"pauseTask","summary":"Stop delivering until resumed","tags":["tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Paused. Pausing an already paused task is a no-op, not an error."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such task for this client. A task that belongs to someone else answers 404 too — never 403."},"409":{"description":"`task_deleted`: the task is in the bin and pause never revives it. Restore it first, then pause."}}}},"/api/tasks/{id}/resume":{"post":{"operationId":"resumeTask","summary":"Resume a paused task","tags":["tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Running again from the next scheduled occurrence. One call, always."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such task for this client. A task that belongs to someone else answers 404 too — never 403."},"409":{"description":"Two codes share this status. `task_deleted`: restore it first — resume never revives a deleted task, because its name may already belong to a live one. `no_credits`: buy deliveries first, or the next tick pauses it again and sends your endpoint another notice."}}}},"/api/tasks/{id}/restore":{"post":{"operationId":"restoreTask","summary":"Restore a deleted task","description":"Restore a soft-deleted task within 30 days. The task will be in `paused_by_client` state; resume it explicitly.","tags":["tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Restored and paused. Call POST /api/tasks/{id}/resume to start."},"401":{"description":"Missing or unknown bearer token. Buy credits first — that is where the token comes from."},"404":{"description":"No such task for this client. A task that belongs to someone else answers 404 too — never 403."},"409":{"description":"Task is not deleted, the 30-day window has closed, or a live task already uses the same name (`liveTaskId` names it)."}}}},"/api/pricing":{"get":{"operationId":"getPricing","summary":"Machine-readable tariff","tags":["public"],"security":[],"responses":{"200":{"description":"Packs, prices and the per-attempt unit price."}}}},"/mcp":{"post":{"operationId":"mcp","summary":"Model Context Protocol endpoint (JSON-RPC over streamable HTTP)","description":"The same service, spoken as MCP. Three tools need no key — `get_pricing`, `get_docs`, `get_service_status` — and thirteen more mirror the routes in this document one to one; they read your management token from the `Authorization: Bearer` header of the MCP connection, never as a tool argument. There is NO OAuth and no account, and this endpoint never answers 401: a missing or unknown token comes back as a tool-level error that says how to send it. Both protocol eras are served here: the modern 2026-07-28 (no session, `server/discover`, `Mcp-*` headers) and the older `initialize` handshake that Claude Code, Cursor and SDK 1.x still speak. There is no purchase tool: paying is a plain HTTP x402 exchange against a buy URL, because a signed payment must not travel through a model context. GET and DELETE answer 405.","tags":["public"],"security":[],"responses":{"200":{"description":"A JSON-RPC result. Tool refusals arrive here too, as `isError: true`."},"202":{"description":"A notification (a message with no `id`). Empty body."},"400":{"description":"Malformed JSON-RPC, a batch array, or mismatched MCP-* headers."},"403":{"description":"Origin is `null` or not an http(s) origin."},"405":{"description":"Only POST is accepted. The body is a normal refusal (`wrong_http_method`) whose `allowed` matches the Allow header. A real MCP client stops at the status; every other reader gets the reason. The modern protocol era never sends GET at all.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Refusal"}}}},"413":{"description":"The body is over the size limit."}}}},"/a2a":{"post":{"operationId":"a2a","summary":"Agent2Agent (A2A 1.0) endpoint — a receptionist that answers every message with the same guide","description":"JSON-RPC 2.0 over HTTP, A2A 1.0 only: send the header `A2A-Version: 1.0` (without it the request means 0.3 and is answered with error -32009, which says what to send). `SendMessage` returns one agent message with two parts: a text guide and the same guide as JSON (what the service is, live prices from the tariff, buy URLs, docs). This agent keeps no tasks and offers no streaming or push notifications; those methods return the matching A2A errors. It cannot take payment or create tasks: pay over plain HTTP x402 and manage tasks over this REST API or MCP, because a signed payment must not travel through an agent conversation. JSON-RPC errors arrive on HTTP 200. GET, HEAD and DELETE answer 405. The agent card is at /.well-known/agent-card.json.","tags":["public"],"security":[],"responses":{"200":{"description":"A JSON-RPC result (the guide) or a JSON-RPC error."},"400":{"description":"The body is not valid JSON (-32700)."},"403":{"description":"Origin is `null` or not an http(s) origin (-32600)."},"405":{"description":"Only POST is accepted. The body is a normal refusal (`wrong_http_method`) whose `allowed` matches the Allow header, and it points at the agent card and the docs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Refusal"}}}},"413":{"description":"The body is over the size limit (-32600)."}}}},"/health":{"get":{"operationId":"getHealth","summary":"Liveness — 503 when the service is broken, with the reasons","tags":["public"],"security":[],"responses":{"200":{"description":"Healthy. The body also reports the settlement budget for the current hour (`settlement.used` / `settlement.max`, with `settlement.window`, `settlement.exhausted` and `settlement.windowEndsInSec`) and its split into the two signature lanes: `settlement.lanes.a` (48/hour, signatures we verify locally) and `settlement.lanes.b` (12/hour, signatures only the chain can verify), each with its own `used` and `max`. The total is NOT the sum of the two: it is written separately as an invariant, so a disagreement between them is a signal, not arithmetic to fix. An exhausted budget is deliberately NOT a 503: the scheduler keeps delivering, only the till is shut, and a monitor that reads 503 as \"the site is down\" would drown the real alarms. Operator-only fields (`ipUnknownHits`, `signatureCheck`, `buyHitsSweep`, `catalog`) describe the state of our own defences, not the service; they are returned only for a request carrying the operator bearer token, and their absence from this response is not a fault. They alarm on their own path and none of them turns this endpoint red either."},"503":{"description":"Broken, and says what is broken."}}}}}}