> ## Documentation Index
> Fetch the complete documentation index at: https://schedy.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Delivery Guarantee

> Schedy is at-least-once: attempts are retried and mid-run tasks are re-queued on restart, so make your receivers idempotent.

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.

<Tip>
  **Make receivers idempotent.** Send an `Idempotency-Key` header (Schedy forwards it downstream) or dedupe on your side, so a redelivery is harmless.
</Tip>

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:

| Header               | Value                                                                              |
| -------------------- | ---------------------------------------------------------------------------------- |
| `X-Schedy-Timestamp` | Unix seconds when the request was signed.                                          |
| `X-Schedy-Signature` | `sha256=<hex>`, an HMAC-SHA256 of `<timestamp>.<raw-body>` keyed with your secret. |

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:

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from "node:crypto";

  // express.raw({ type: "*/*" }) so `req.body` is the exact bytes Schedy signed.
  function verifySchedy(req, secret, toleranceSec = 300) {
    const ts = req.get("X-Schedy-Timestamp");
    const sig = req.get("X-Schedy-Signature") || "";
    if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;

    const expected =
      "sha256=" +
      crypto.createHmac("sha256", secret).update(`${ts}.${req.body}`).digest("hex");

    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  from flask import request


  def verify_schedy(secret: str, tolerance_sec: int = 300) -> bool:
      ts = request.headers.get("X-Schedy-Timestamp", "")
      sig = request.headers.get("X-Schedy-Signature", "")
      if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance_sec:
          return False

      # request.get_data() returns the raw body bytes. Read it before any JSON
      # parsing: decoding and re-serializing would reorder keys or restyle spacing
      # and no longer match the exact bytes Schedy signed.
      signed = ts.encode() + b"." + request.get_data()
      expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

      # compare_digest is the constant-time compare; encode both so a hostile
      # signature header can never raise on non-ASCII input.
      return hmac.compare_digest(sig.encode(), expected.encode())
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"strconv"
  	"time"
  )

  const toleranceSec = 300

  // schedyHandler verifies the Schedy signature before handling the delivery.
  func schedyHandler(secret string) http.HandlerFunc {
  	return func(w http.ResponseWriter, r *http.Request) {
  		ts := r.Header.Get("X-Schedy-Timestamp")
  		sig := r.Header.Get("X-Schedy-Signature")

  		sec, err := strconv.ParseInt(ts, 10, 64)
  		if err != nil || abs(time.Now().Unix()-sec) > toleranceSec {
  			http.Error(w, "bad timestamp", http.StatusUnauthorized)
  			return
  		}

  		// io.ReadAll gives the raw body bytes, exactly what Schedy signed.
  		// Verify them before decoding JSON: unmarshalling then re-marshalling
  		// would change the bytes and break the MAC.
  		body, err := io.ReadAll(r.Body)
  		if err != nil {
  			http.Error(w, "unreadable body", http.StatusBadRequest)
  			return
  		}

  		mac := hmac.New(sha256.New, []byte(secret))
  		mac.Write([]byte(ts))
  		mac.Write([]byte("."))
  		mac.Write(body)
  		expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

  		// hmac.Equal is the constant-time compare.
  		if !hmac.Equal([]byte(sig), []byte(expected)) {
  			http.Error(w, "bad signature", http.StatusUnauthorized)
  			return
  		}

  		// Signature valid; `body` holds the verified payload.
  		w.WriteHeader(http.StatusOK)
  	}
  }

  func abs(n int64) int64 {
  	if n < 0 {
  		return -n
  	}
  	return n
  }
  ```
</CodeGroup>

<Note>
  `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.
</Note>

<Warning>
  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.
</Warning>

## 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.
