docs/using
Using Dispatch
Integrate scheduled webhooks into your application — from first API key to production delivery.
ai setup
prompt · readyLet your AI integrate Dispatch
Paste this into a coding agent inside your repo. It reads your codebase, finds where scheduling fits, and wires Dispatch in the way this guide describes — client module, webhook handler, env, idempotency, the lot.
works with claude code · cursor · windsurf · copilot agent mode
01/introduction
What Dispatch is
Dispatch is a webhook scheduler: you give it a URL, a UTC timestamp, and an optional JSON payload, and it sends a POST to that URL at that moment. It replaces the cron box, the delay queue, and the retry loop you would otherwise build to make one HTTP request in the future.
02/quickstart
Zero to a fired webhook
Five steps, about five minutes, three of which you only ever do once.
- 01
Create an account
Register for Dispatch. Email, password, done — there is nothing to deploy or host.
- 02
Create a project
In the dashboard, open
projectsand create one. Call it whatever the environment is — "production" is a fine first name. - 03
Generate an API key
On the project page, generate a key and copy it when it is shown — that is the only time you will see it.
- 04
Schedule your first job
Point
webhookUrlat any endpoint you control (or a request bin), setfireAta couple of minutes out, and send it:shell # fireAt must be in the future — set it a couple of minutes outcurl -X POST https://api-dispatch.imtiyazsayyid.in/api/v1/jobs \-H "Content-Type: application/json" \-H "x-api-key: sk_your_key_here" \-d '{"title": "Hello from Dispatch","webhookUrl": "https://your-app.com/hooks/hello","fireAt": "2026-06-12T12:00:00Z","payload": { "greeting": "right on time" }}'201 · response (abridged) {"status": true,"message": "Job scheduled successfully","data": {"id": "3f6c2b8e-7a41-4c9d-9b21-d5a0c3e8f612","status": "SCHEDULED","fireAt": "2026-06-12T12:00:00.000Z"}} - 05
See it fire
Open
jobsin the dashboard. AtfireAtthe status lamp flipsSCHEDULED → FIRING → SUCCESS, and the delivery log records the attempt — status code, response body, timing.
03/authentication
API keys
Your code authenticates with a project-scoped API key. A key can only touch its own project's jobs — one for staging, one for production, revoke either without touching the other.
getting a key
Keys are generated in the dashboard: open your project, then api keys → new key. Label it for where it will live — "production server", "CI" — and copy it immediately. It is shown exactly once.
# every scheduling call carries a project's API keycurl https://api-dispatch.imtiyazsayyid.in/api/v1/jobs \ -H "x-api-key: sk_4f8a2c9b51de836b91c0a7e2d4f6a8b0" \ ...key security
- Keys look like
sk_followed by 64 hex characters and are stored as a SHA-256 hash — Dispatch cannot show a key again after creation. - The dashboard shows only a label, the last four characters, and when the key was last used.
- Revoke a key at any time from the project page; revocation is immediate.
- Treat keys like passwords: environment variables, never client code, never the repo.
04/scheduling a job
POST /api/v1/jobs
One request schedules one job. The job is stored, armed in the scheduler, and fires exactly once at fireAt.
/api/v1/jobsauth · x-api-keySchedules a job. Returns the created job object with a 201.
request body
titlestringrequired
Human-readable name, shown in the dashboard and echoed back in the webhook payload.
webhookUrlstring · urlrequired
The endpoint Dispatch will POST to. Must be a valid URL and reachable from wherever Dispatch runs.
fireAtstring · ISO 8601 UTCrequired
When to fire, e.g. 2026-06-13T09:30:00Z. Must be UTC (trailing Z) and in the future.
payloadobjectoptional
Arbitrary JSON delivered in the webhook body. Defaults to {}.
curl -X POST https://api-dispatch.imtiyazsayyid.in/api/v1/jobs \ -H "Content-Type: application/json" \ -H "x-api-key: sk_4f8a2c9b51de836b91c0a7e2d4f6a8b0" \ -d '{ "title": "Trial expiry reminder", "webhookUrl": "https://your-app.com/hooks/trial-expiry", "fireAt": "2026-06-13T09:30:00Z", "payload": { "userId": "usr_812", "plan": "pro" } }'{ "status": true, "message": "Job scheduled successfully", "data": { "id": "3f6c2b8e-7a41-4c9d-9b21-d5a0c3e8f612", "projectId": "9a1d4e7b-2c58-4f03-8e6a-1b9c7d2f5a30", "title": "Trial expiry reminder", "webhookUrl": "https://your-app.com/hooks/trial-expiry", "payload": { "userId": "usr_812", "plan": "pro" }, "fireAt": "2026-06-13T09:30:00.000Z", "status": "SCHEDULED", "retryCount": 0, "createdAt": "2026-06-12T08:14:02.000Z", "updatedAt": "2026-06-12T08:14:02.000Z" }}what makes a valid fireAt
An ISO 8601 timestamp in UTC with a trailing Z — 2026-06-13T09:30:00Z. Offsets like +05:30are rejected with a 400, and so is any timestamp that isn't in the future. Dispatch arms the timer for the exact millisecond, so send the moment you mean, not a rounded-down minute.
what payload is for
Whatever JSON you put in payloadcomes back verbatim in the delivery body when the job fires. Use it to carry the context your handler needs — a user ID, a plan name — so the handler doesn't have to look anything up to know what the moment means. If you omit it, the delivery carries {}.
what happens after you post
The job is written to the database and a timer is armed for fireAt. The 201 response returns the full job object — keep data.id if you might cancel later. From there the job sits in SCHEDULED until it fires; you can watch it in the dashboard the whole way.
05/cancelling a job
DELETE /api/v1/jobs/:id
Cancellation disarms the timer and parks the job in CANCELLED — terminal, with its history intact.
/api/v1/jobs/:idauth · x-api-keyCancels a scheduled job. Only jobs in SCHEDULED state can be cancelled — it is the only state where there is still anything to call off.
curl -X DELETE \ https://api-dispatch.imtiyazsayyid.in/api/v1/jobs/3f6c2b8e-7a41-4c9d-9b21-d5a0c3e8f612 \ -H "x-api-key: sk_4f8a2c9b51de836b91c0a7e2d4f6a8b0"{ "status": true, "message": "Job cancelled successfully", "data": null}cancelling a job that already fired
If the job is in any other state — already SUCCESS, mid-retry in FAILED, DEAD, or even FIRING at that instant — the request returns a 400 and changes nothing:
{ "status": false, "message": "Only scheduled jobs can be cancelled", "data": null}A 404 means the job ID doesn't exist or belongs to a different project than the key you used.
06/webhook delivery
What arrives at your endpoint
Every delivery is a POST with a JSON body. The shape never varies.
{ "jobId": "3f6c2b8e-7a41-4c9d-9b21-d5a0c3e8f612", "title": "Trial expiry reminder", "payload": { "userId": "usr_812", "plan": "pro" }, "firedAt": "2026-06-13T09:30:00.004Z"}fields
jobIdstring · uuidrequired
The job's ID — also your idempotency key, since retries can deliver the same job more than once.
titlestringrequired
The title you scheduled the job with.
payloadobjectrequired
Exactly the JSON you passed at scheduling time; {} if you passed none.
firedAtstring · ISO 8601required
When this attempt actually fired, in UTC.
headers
POST /hooks/trial-expiry HTTP/1.1Content-Type: application/jsonContent-Type: application/json is the only header Dispatch sets. There is no signature header yet — see verifying the sender for the interim approach.
the response dispatch expects
Return any 2xx to mark the delivery SUCCESS. Any other status — or a connection error — marks it FAILED and schedules a retry. Whatever body your endpoint returns is captured in the delivery log, so a short diagnostic string is worth returning; it is what you will read in the dashboard when something breaks.
retries
The retry clock is fixed: 30 seconds after the first failure, 1 minute after the second, 5 minutes as the configured ceiling — and a job is marked DEAD on its third failed attempt. Every attempt is logged with its status code, response body, and timing.
timeout · know the gap
07/job lifecycle
The state machine
Six states, one loop. The same status lamps you see in the dashboard.
Armed and waiting for fireAt. The only state a job can be cancelled from — and the state a failed job returns to between retries.
The request to your endpoint is in flight right now.
Your endpoint answered 2xx. Terminal.
The last attempt got a non-2xx or a network error. A retry is armed unless this was attempt 3.
Three attempts failed. Dispatch stops; the full attempt history stays in the logs. Terminal.
Cancelled via DELETE /jobs/:id before it fired. Terminal.
08/code examples
Both sides of the wire
Scheduling a job from your code, and receiving it on your server. Real snippets — error handling, idempotency, and all.
scheduling a job
// dispatch.js — Node 18+, no SDK neededconst DISPATCH_URL = process.env.DISPATCH_URL ?? "https://api-dispatch.imtiyazsayyid.in";const DISPATCH_API_KEY = process.env.DISPATCH_API_KEY; // sk_... export async function scheduleJob({ title, webhookUrl, fireAt, payload }) { const res = await fetch(`${DISPATCH_URL}/api/v1/jobs`, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": DISPATCH_API_KEY, }, body: JSON.stringify({ title, webhookUrl, fireAt: fireAt.toISOString(), // UTC with trailing Z — required payload, }), }); const body = await res.json().catch(() => null); if (!res.ok || !body?.status) { throw new Error(body?.message ?? `Dispatch returned ${res.status}`); } return body.data; // the created job — keep data.id if you may cancel it} const job = await scheduleJob({ title: "Trial expiry reminder", webhookUrl: "https://your-app.com/hooks/trial-expiry", fireAt: new Date(Date.now() + 24 * 60 * 60 * 1000), payload: { userId: "usr_812" },});console.log(`scheduled ${job.id}, fires at ${job.fireAt}`);handling the webhook
The contract is simple: return any 2xx and the delivery is SUCCESS; return anything else — or be unreachable — and Dispatch retries with backoff. Two rules follow from it: respond before you do the heavy work, and make your handler idempotent, because retries mean a delivery can arrive more than once.
// server.js — Expressimport express from "express"; const app = express();app.use(express.json()); app.post("/hooks/trial-expiry", (req, res) => { const { jobId, title, payload, firedAt } = req.body; // Acknowledge with a 2xx first — anything else (including a crash or // a slow handler) makes Dispatch retry, so do the heavy work after. res.status(200).json({ received: true }); setImmediate(async () => { try { // Retries mean at-least-once delivery: use jobId as an idempotency // key so a retried delivery can't send the email twice. await sendTrialExpiryEmail(jobId, payload.userId); } catch (err) { console.error(`hook ${jobId} (${title}) failed:`, err); } });}); app.listen(5000);verifying the sender
hmac signatures · coming soon
# Planned delivery headers — NOT sent yet:## x-dispatch-signature: t=1765703400,v1=<hex hmac-sha256>## where v1 = HMAC-SHA256(project_signing_secret, "{t}.{raw_body}").# Verification will be: recompute the HMAC over the exact raw body,# compare in constant time, and reject timestamps older than ~5 minutes.In the meantime, don't accept anonymous POSTs: put a secret in the webhook URL when you schedule, and check it on receipt. It's not a signature, but it stops drive-by requests cold.
// Until signatures ship: schedule with a long random token in the URL...// webhookUrl: "https://your-app.com/hooks/trial-expiry?token=" + WEBHOOK_TOKEN// ...and verify it on every delivery, in constant time. import { timingSafeEqual } from "node:crypto"; function verifyToken(req) { const token = Buffer.from(String(req.query.token ?? "")); const expected = Buffer.from(process.env.WEBHOOK_TOKEN); return token.length === expected.length && timingSafeEqual(token, expected);}09/api reference
The jobs API
Plain HTTP and JSON. Scheduling endpoints take your API key; the read endpoints take a dashboard session token.
base url
https://api-dispatch.imtiyazsayyid.in/api/v1Dispatch is a hosted service — that base URL is all you need. (Running your own instance instead? The API is identical; swap in your own origin and see the self-hosting guide.)
Every response — success or error — uses the same envelope. On errors, status is false and messagesays why, alongside the HTTP status code (400 for validation, 401 for bad credentials, 404 for resources that aren't yours).
{ "status": true, // false on any error "message": "Job scheduled successfully", "data": { ... } // the resource, or null}/api/v1/jobsauth · x-api-keySchedules a job and returns the created job object with a 201. Documented in full in Scheduling a job.
request body
titlestringrequired
Human-readable name, echoed back in the webhook payload.
webhookUrlstring · urlrequired
The endpoint Dispatch will POST to.
fireAtstring · ISO 8601 UTCrequired
When to fire. Future, UTC, trailing Z.
payloadobjectoptional
Arbitrary JSON delivered in the webhook body.
/api/v1/jobs/:idauth · x-api-keyCancels a SCHEDULED job; 400 for any other state. Documented in full in Cancelling a job.
/api/v1/jobsauth · bearer tokenLists jobs across all of your projects, newest first. This is what the dashboard's jobs page calls.
query parameters
statusenumoptional
Filter by status: SCHEDULED, FIRING, SUCCESS, FAILED, DEAD, or CANCELLED.
projectIdstringoptional
Limit results to one project.
pagenumber · default 1optional
Page number, starting at 1.
limitnumber · default 20, max 100optional
Results per page.
curl "https://api-dispatch.imtiyazsayyid.in/api/v1/jobs?status=SCHEDULED&limit=20" \ -H "Authorization: Bearer <session-token>"{ "status": true, "message": "Jobs fetched successfully", "data": { "jobs": [ { "id": "3f6c2b8e-7a41-4c9d-9b21-d5a0c3e8f612", "title": "Trial expiry reminder", "webhookUrl": "https://your-app.com/hooks/trial-expiry", "fireAt": "2026-06-13T09:30:00.000Z", "status": "SCHEDULED", "retryCount": 0, "project": { "id": "9a1d4e7b-…", "name": "production" } } ], "pagination": { "total": 42, "page": 1, "limit": 20, "totalPages": 3 } }}/api/v1/jobs/:idauth · bearer tokenFetches one job with its full delivery history. logs is ordered newest first; each entry records the attempt number, outcome, response code, and whatever body your endpoint returned.
{ "status": true, "message": "Job fetched successfully", "data": { "id": "3f6c2b8e-7a41-4c9d-9b21-d5a0c3e8f612", "title": "Trial expiry reminder", "status": "SUCCESS", "fireAt": "2026-06-13T09:30:00.000Z", "project": { "id": "9a1d4e7b-…", "name": "production" }, "logs": [ { "id": "c81f0a4d-…", "attempt": 1, "status": "SUCCESS", "responseCode": 200, "responseBody": "{\"received\":true}", "firedAt": "2026-06-13T09:30:00.124Z" } ] }}