docs/using

Using Dispatch

Integrate scheduled webhooks into your application — from first API key to production delivery.

ai setup

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

  1. 01

    Create an account

    Register for Dispatch. Email, password, done — there is nothing to deploy or host.

  2. 02

    Create a project

    In the dashboard, open projectsand create one. Call it whatever the environment is — "production" is a fine first name.

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

  4. 04

    Schedule your first job

    Point webhookUrl at any endpoint you control (or a request bin), set fireAt a couple of minutes out, and send it:

    shell
    # fireAt must be in the future — set it a couple of minutes out
    curl -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"
    }
    }
  5. 05

    See it fire

    Open jobs in the dashboard. At fireAt the status lamp flips SCHEDULED → 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.

passing the key
# every scheduling call carries a project's API key
curl 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.

POST/api/v1/jobsauth · x-api-key

Schedules a job. Returns the created job object with a 201.

request body

title

stringrequired

Human-readable name, shown in the dashboard and echoed back in the webhook payload.

webhookUrl

string · urlrequired

The endpoint Dispatch will POST to. Must be a valid URL and reachable from wherever Dispatch runs.

fireAt

string · ISO 8601 UTCrequired

When to fire, e.g. 2026-06-13T09:30:00Z. Must be UTC (trailing Z) and in the future.

payload

objectoptional

Arbitrary JSON delivered in the webhook body. Defaults to {}.

request
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" }
}'
201 · response
{
"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 Z2026-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.

DELETE/api/v1/jobs/:idauth · x-api-key

Cancels a scheduled job. Only jobs in SCHEDULED state can be cancelled — it is the only state where there is still anything to call off.

request
curl -X DELETE \
https://api-dispatch.imtiyazsayyid.in/api/v1/jobs/3f6c2b8e-7a41-4c9d-9b21-d5a0c3e8f612 \
-H "x-api-key: sk_4f8a2c9b51de836b91c0a7e2d4f6a8b0"
200 · response
{
"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:

400 · response
{
"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.

delivery body
{
"jobId": "3f6c2b8e-7a41-4c9d-9b21-d5a0c3e8f612",
"title": "Trial expiry reminder",
"payload": { "userId": "usr_812", "plan": "pro" },
"firedAt": "2026-06-13T09:30:00.004Z"
}

fields

jobId

string · uuidrequired

The job's ID — also your idempotency key, since retries can deliver the same job more than once.

title

stringrequired

The title you scheduled the job with.

payload

objectrequired

Exactly the JSON you passed at scheduling time; {} if you passed none.

firedAt

string · ISO 8601required

When this attempt actually fired, in UTC.

headers

request headers
POST /hooks/trial-expiry HTTP/1.1
Content-Type: application/json

Content-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

Dispatch does not currently set an explicit timeout on the delivery request — it waits as long as the Node.js runtime's default fetch limits allow (several minutes). A strict delivery timeout is planned. Don't lean on the gap: respond within a few seconds and do slow work after acknowledging.

07/job lifecycle

The state machine

Six states, one loop. The same status lamps you see in the dashboard.

fireAt reached2xx responsenon-2xx · network errorattempt 3 of 3retry · backoff 30s → 1m → 5mDELETE /jobs/:idSCHEDULEDFIRINGSUCCESSFAILEDDEADCANCELLED
A FAILED job is re-armed with backoff until its third attempt fails, then it goes DEAD. Only SCHEDULED jobs can be cancelled.
SCHEDULED

Armed and waiting for fireAt. The only state a job can be cancelled from — and the state a failed job returns to between retries.

FIRING

The request to your endpoint is in flight right now.

SUCCESS

Your endpoint answered 2xx. Terminal.

FAILED

The last attempt got a non-2xx or a network error. A retry is armed unless this was attempt 3.

DEAD

Three attempts failed. Dispatch stops; the full attempt history stays in the logs. Terminal.

CANCELLED

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 needed
const 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 — Express
import 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

Dispatch does not sign deliveries yet. HMAC signatures are the next item on the roadmap and will look like this when they ship:
planned · x-dispatch-signature
# 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.

interim · shared-token check
// 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

base url
https://api-dispatch.imtiyazsayyid.in/api/v1

Dispatch 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).

response envelope
{
"status": true, // false on any error
"message": "Job scheduled successfully",
"data": { ... } // the resource, or null
}
POST/api/v1/jobsauth · x-api-key

Schedules a job and returns the created job object with a 201. Documented in full in Scheduling a job.

request body

title

stringrequired

Human-readable name, echoed back in the webhook payload.

webhookUrl

string · urlrequired

The endpoint Dispatch will POST to.

fireAt

string · ISO 8601 UTCrequired

When to fire. Future, UTC, trailing Z.

payload

objectoptional

Arbitrary JSON delivered in the webhook body.

DELETE/api/v1/jobs/:idauth · x-api-key

Cancels a SCHEDULED job; 400 for any other state. Documented in full in Cancelling a job.

GET/api/v1/jobsauth · bearer token

Lists jobs across all of your projects, newest first. This is what the dashboard's jobs page calls.

query parameters

status

enumoptional

Filter by status: SCHEDULED, FIRING, SUCCESS, FAILED, DEAD, or CANCELLED.

projectId

stringoptional

Limit results to one project.

page

number · default 1optional

Page number, starting at 1.

limit

number · default 20, max 100optional

Results per page.

request
curl "https://api-dispatch.imtiyazsayyid.in/api/v1/jobs?status=SCHEDULED&limit=20" \
-H "Authorization: Bearer <session-token>"
200 · response
{
"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 }
}
}
GET/api/v1/jobs/:idauth · bearer token

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

200 · response (abridged)
{
"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"
}
]
}
}