Skip to content

Quickstart

You need two things: an API key and an endpoint that can receive an HTTP request. By the end of this page you’ll have scheduled a delivery, verified its signature, and watched it succeed.

If you just want the call:

Terminal window
curl -X POST https://api.schedstack.com/v1/schedules \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{"endpoint":"https://acme.dev/hook","delay":"30s"}'

That schedules one HTTP POST to your endpoint, 30 seconds from now. The rest of this page walks through it and shows you how to trust what arrives.

  1. During the design-partner phase there’s no self-serve signup — keys are issued to you directly. A key looks like sk_test_… (test mode) or sk_live_… (live mode) and goes in the Authorization header on every request:

    Terminal window
    -H "Authorization: Bearer sk_test_…"

    Use your test key for this walkthrough. Test and live are fully isolated: a test key only ever sees test data, and the dispatch-accuracy SLO applies to live mode only. Nothing you do here touches production data.

  2. Point SchedStack at a destination and a time. The shortest possible request is an endpoint plus exactly one timing field:

    Terminal window
    curl -X POST https://api.schedstack.com/v1/schedules \
    -H "Authorization: Bearer sk_test_…" \
    -H "Content-Type: application/json" \
    -d '{
    "endpoint": "https://acme.dev/hook",
    "delay": "30s"
    }'

    delay is a duration string ("30s", "15m", "24h") with a 1-second floor. We use "30s" so you can watch the whole flow end-to-end right now; in real use you’d schedule minutes, hours, or days out. To fire at an exact instant instead, use fire_at (RFC 3339, e.g. "2026-07-01T09:00:00Z"); for wall-clock-in-a-timezone use local_fire_at with timezone; for recurring, use a 5-field cron with timezone. Exactly one timing field is required.

    You get back a 201 with the created schedule:

    {
    "id": "sch_01J9ZK3F8QABCDXY",
    "object": "schedule",
    "mode": "test",
    "kind": "one_shot",
    "state": "active",
    "endpoint": "https://acme.dev/hook",
    "method": "POST",
    "header_keys": [],
    "fire_at": "2026-06-27T18:30:30Z",
    "cron": null,
    "timezone": null,
    "next_fire_at": "2026-06-27T18:30:30Z",
    "next_runs": ["2026-06-27T18:30:30Z"],
    "ttl": null,
    "retry_policy": {
    "max_attempts": 8,
    "strategy": "exponential",
    "base": "5s",
    "factor": 2,
    "max": "1h",
    "jitter": true
    },
    "metadata": {}
    }

    Hold on to id (sch_…) — you’ll use it to check on the delivery. next_runs previews the next fire instants; for a one-shot there’s just one.

  3. When the schedule fires, SchedStack sends an HTTP request to your endpoint with these headers:

    Header Meaning
    Sched-Delivery-Id This occurrence’s id (dlv_…).
    Sched-Attempt Attempt number, 1-based.
    Sched-Timestamp Dispatch time, Unix seconds (also the value bound into the signature when signed).
    Idempotency-Key Stable per occurrence and across retries — dedup on this.
    Sched-Signature t=<unix_seconds>,v1=<hex> — HMAC-SHA256, hex-encoded.

    The signature covers exactly this string, joined by literal .:

    {timestamp}.{delivery_id}.{attempt}.{METHOD}.{path}.{body}

    where path is the URL path of your endpoint (e.g. /hook) and body is the raw request bytes. Recompute the HMAC with your signing secret and constant-time compare. Here’s a minimal verifier:

    import crypto from 'node:crypto';
    // `rawBody` MUST be the exact bytes received (a Buffer), not a re-serialized object.
    export function verify({ method, path, rawBody, headers, secret, toleranceSec = 300 }) {
    const sig = headers['sched-signature'] || '';
    const deliveryId = headers['sched-delivery-id'];
    const attempt = headers['sched-attempt'];
    // Sched-Signature: t=<unix_seconds>,v1=<hex>[,v1=<hex>…]
    const fields = sig.split(',');
    const t = fields.find((f) => f.startsWith('t='))?.slice(2);
    const v1s = fields.filter((f) => f.startsWith('v1=')).map((f) => f.slice(3));
    if (!t || v1s.length === 0) return false;
    // Reject stale or forged timestamps. (The sender enforces no window — you do.)
    if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
    // {timestamp}.{delivery_id}.{attempt}.{METHOD}.{path}.{body}
    const prefix = `${t}.${deliveryId}.${attempt}.${method.toUpperCase()}.${path}.`;
    const signed = Buffer.concat([Buffer.from(prefix), rawBody]);
    const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
    // Accept if ANY v1 matches — multiple means a secret rotation is in progress.
    return v1s.some(
    (s) =>
    s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
    );
    }

    The full guide — secret rotation, replay-window tuning, and Python/Go verifiers — is in Verifying signatures.

  4. Once your endpoint returns any 2xx, the delivery flips to succeeded. List the schedule’s deliveries to confirm:

    Terminal window
    curl https://api.schedstack.com/v1/schedules/sch_01J9ZK3F8QABCDXY/deliveries \
    -H "Authorization: Bearer sk_test_…"
    {
    "object": "list",
    "data": [
    {
    "id": "dlv_01J9ZK3F8QPLMN",
    "object": "delivery",
    "schedule_id": "sch_01J9ZK3F8QABCDXY",
    "mode": "test",
    "status": "succeeded",
    "scheduled_for": "2026-06-27T18:30:30Z",
    "attempt_count": 1,
    "last_status_code": 200,
    "idempotency_key": "dlv_01J9ZK3F8QPLMN",
    "created_at": "2026-06-27T18:30:00Z",
    "finalized_at": "2026-06-27T18:30:30Z"
    }
    ],
    "has_more": false,
    "next_cursor": null
    }

    That’s a verified delivery. Done.

This is the core guarantee: an accepted delivery is never silently lost. It either succeeds or ends up somewhere you can see it.

  • 2xxsucceeded.
  • 408, 429, 5xx, or a transport/connection fault → retried with exponential backoff (default: up to 8 attempts, 5s base, ×2, capped at 1h). A Retry-After header on a 429/503 is honored. If every attempt fails, the delivery moves to dead_letter.
  • 3xx, 4xx (other than 408/429), or a blocked address → treated as terminal and dead-lettered immediately — retrying won’t help.

Nothing is dropped on the floor. Inspect each HTTP try with GET /v1/deliveries/{id}/attempts, and re-send a dead-lettered delivery with POST /v1/deliveries/{id}/replay once you’ve fixed the cause.