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:
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.
-
Get your API key
Section titled “Get your API key”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) orsk_live_…(live mode) and goes in theAuthorizationheader 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.
-
Create a schedule
Section titled “Create a schedule”Point SchedStack at a destination and a time. The shortest possible request is an
endpointplus 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"}'delayis 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, usefire_at(RFC 3339, e.g."2026-07-01T09:00:00Z"); for wall-clock-in-a-timezone uselocal_fire_atwithtimezone; for recurring, use a 5-fieldcronwithtimezone. Exactly one timing field is required.You get back a
201with 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_runspreviews the next fire instants; for a one-shot there’s just one. -
Verify the signature on what arrives
Section titled “Verify the signature on what arrives”When the schedule fires, SchedStack sends an HTTP request to your endpoint with these headers:
Header Meaning Sched-Delivery-IdThis occurrence’s id ( dlv_…).Sched-AttemptAttempt number, 1-based. Sched-TimestampDispatch time, Unix seconds (also the value bound into the signature when signed). Idempotency-KeyStable per occurrence and across retries — dedup on this. Sched-Signaturet=<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
pathis the URL path of your endpoint (e.g./hook) andbodyis 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)),);}method— the HTTP method of the incoming request (upper-cased internally).path— the request path, e.g.req.path→/hook.rawBody— the exact request body bytes. Capture them before any JSON parsing (e.g.express.raw()orreq.rawBody); a re-serialized object will not match.headers— incoming headers, lower-cased keys.secret— your endpoint or project signing secret.
The full guide — secret rotation, replay-window tuning, and Python/Go verifiers — is in Verifying signatures.
-
Watch it land
Section titled “Watch it land”Once your endpoint returns any
2xx, the delivery flips tosucceeded. 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.
What happens if your endpoint fails
Section titled “What happens if your endpoint fails”This is the core guarantee: an accepted delivery is never silently lost. It either succeeds or ends up somewhere you can see it.
2xx→succeeded.408,429,5xx, or a transport/connection fault → retried with exponential backoff (default: up to 8 attempts,5sbase, ×2, capped at1h). ARetry-Afterheader on a429/503is honored. If every attempt fails, the delivery moves todead_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.
Next steps
Section titled “Next steps”- Core concepts — schedules, deliveries, attempts, and the state machines behind them.
- Verify webhook signatures — the full trust guide, with rotation and multi-language verifiers.
- Recurring schedules & DST —
cron+timezone, with DST-correct firing. - Agent-native (MCP) — let an agent schedule, preview, and inspect deliveries in natural language.