Skip to main content
Schedy is at-least-once. Attempts are retried, and on restart any task left mid-run is re-queued rather than dropped. A crash between a receiver’s 2xx and the status write can therefore redeliver.
Make receivers idempotent. Send an Idempotency-Key header (Schedy forwards it downstream) or dedupe on your side, so a redelivery is harmless.
Tasks that came due while the server was down are caught up on the next scan rather than skipped.

Request headers

Every delivery identifies itself. The User-Agent is schedy unless the task’s own headers set one. X-Schedy-Task-Id carries the task’s id, so a receiver can correlate a request with GET /tasks/{id} - its attempt history and retries - without embedding the id in every payload. It is set after the task’s custom headers, so a task cannot claim another task’s id.

Signed requests

Set SCHEDY_SIGNING_SECRET and Schedy signs every outgoing request so your receiver can verify it genuinely came from Schedy, and not from anyone who happened to learn the URL. Two headers are attached: The signature covers timestamp.body rather than the body alone, so a captured request cannot be replayed indefinitely: reject anything whose timestamp is outside a small freshness window (a few minutes) and each request is usable only briefly. To verify, recompute the HMAC over the timestamp, a literal ., and the raw request body (before any JSON parsing), then compare in constant time:
GET and HEAD deliveries carry no body, so they are signed over <timestamp>. (an empty body). The timestamp still authenticates the request and bounds replays.
A single global secret is used for every task. There are no per-task secrets or rotation yet - rotating the secret invalidates in-flight signatures until receivers are updated.

Blocked targets

So Schedy cannot be turned into an SSRF proxy into its host’s network, task URLs that resolve to private, loopback, link-local (including the 169.254.169.254 cloud-metadata endpoint), or unspecified addresses are rejected at dial time. The check runs on the resolved IP, so a public DNS name that points at one of those ranges is blocked too. Set SCHEDY_ALLOW_PRIVATE_TARGETS to allow them on a trusted self-hosted network.