Authentication
Every call needs an API key from Dashboard → API keys, sent as a Bearer token. The older x-api-key header still works. A key has full access to its workspace, so keep it on your server. Live (wh_live_) and test (wh_test_) keys both deliver for real; see Live and test keys.
Authorization: Bearer wh_live_YOUR_KEY
Content-Type: application/jsonTypeScript SDK
A zero-dependency Node.js client with typed helpers to schedule, inspect, list, cancel and verify signed deliveries. Server-side only: it uses your API key. Package: @webhookscheduler/sdk.
npm install @webhookscheduler/sdkimport { WebhookScheduler } from "@webhookscheduler/sdk";
const scheduler = new WebhookScheduler({
apiKey: process.env.WEBHOOK_SCHEDULER_API_KEY!,
});
const job = await scheduler.schedule({
url: "https://YOUR_DOMAIN/webhooks/trial-reminder",
runAt: new Date(Date.now() + 86_400_000),
body: { userId: "usr_4821" },
idempotencyKey: "trial-reminder:usr_4821",
});
// Store job.id. If the user upgrades before delivery starts:
await scheduler.cancel(job.id);Schedule a webhook
/api/v1/scheduleCreates a job: at runAt, Webhook Scheduler sends method to url with body as JSON.
Request body
urlstringrequiredWhere to send it: a public HTTPS URL. See Target URL rules.
runAtstring (ISO 8601)requiredWhen to send it, in the future, for example YYYY-MM-DDT09:00:00.000Z. Always include Z or an offset such as +02:00: a time without one is read in the server’s time zone. At most 7 days ahead on Free, 365 on Pro and Scale.
methodstringGET, POST, PUT, PATCH or DELETE, in capitals. Default POST.
bodyany JSONSent as the request body, as JSON. Up to 100 KB. Default {}. Alias: payload.
headersobject of stringsExtra headers for your endpoint, for example { "X-Api-Key": "…" }. Values must be strings. Up to 16 KB in all, 32 headers, names up to 64 characters, values up to 2,048.
You cannot send your own Authorization header. These are removed before delivery, without an error: Authorization, Proxy-Authorization, Cookie, Set-Cookie, Host, Content-Length, Content-Type, Accept-Encoding, User-Agent, Webhook-Signature, X-Webhook-Signature, Forwarded, X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, X-Real-IP, Via, any Proxy-*, and connection-level (hop-by-hop) headers such as Connection or Transfer-Encoding. To authenticate us, check a custom header or the signature.
idempotencyKeystring8 to 80 characters, unique in your workspace. Sending a key again returns the original job, with 201 like a new one, even if the rest of the request differs. The key is free again once that job is deleted at the end of your history period.
referencestringYour own ID, to find or cancel the job later (see List jobs). 1 to 120 characters; starts with a letter or digit, then only letters, digits and : . _ / -. For example invoice:INV-2041. Not unique: several jobs can share it.
criticalitystringSTANDARD (default), IMPORTANT or CRITICAL, in capitals. Alerts can be limited to important webhooks.
curl -X POST https://webhookscheduler.com/api/v1/schedule \
-H "Authorization: Bearer wh_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://webhookscheduler.com/api/demo/receiver",
"method": "POST",
"runAt": "YYYY-MM-DDT09:00:00.000Z",
"body": { "event": "trial.reminder", "userId": "usr_4821" },
"headers": { "X-Source": "billing" },
"idempotencyKey": "trial-reminder:usr_4821",
"reference": "trial:usr_4821",
"criticality": "IMPORTANT"
}'{
"id": "5f0c9a1e-7b2d-4c8e-9a3f-1d2e3f4a5b6c",
"status": "PENDING",
"scheduledFor": "YYYY-MM-DDT09:00:00.000Z",
"idempotencyKey": "trial-reminder:usr_4821",
"reference": "trial:usr_4821",
"criticality": "IMPORTANT"
}Store id: you need it to read or cancel the job. Every scheduled job counts toward your monthly quota, even if you cancel it later.
List jobs
/api/v1/jobsYour workspace’s jobs, newest first.
Query parameters
referencestringExact match on reference. With state=open, finds the jobs to cancel for one customer or invoice.
statestringopen: PENDING, PROCESSING and RETRYING. terminal: SUCCESS, FAILED and CANCELED.
statusstringOne status (PENDING, PROCESSING, RETRYING, SUCCESS, FAILED, CANCELED). Takes precedence over state.
pageintegerStarts at 1.
limitinteger1 to 100. Default 20.
curl "https://webhookscheduler.com/api/v1/jobs?reference=trial:usr_4821&state=open" \
-H "Authorization: Bearer wh_live_YOUR_KEY"{
"jobs": [
{
"id": "5f0c9a1e-7b2d-4c8e-9a3f-1d2e3f4a5b6c",
"status": "PENDING",
"url": "https://webhookscheduler.com/api/demo/receiver",
"method": "POST",
"scheduledFor": "YYYY-MM-DDT09:00:00.000Z",
"idempotencyKey": "trial-reminder:usr_4821",
"reference": "trial:usr_4821",
"criticality": "IMPORTANT",
"createdAt": "2026-10-24T08:41:00.000Z"
}
],
"totalCount": 1,
"totalPages": 1,
"page": 1
}Get a job
/api/v1/jobs/{id}One job with its payload, headers and every attempt, newest first. While a job is retrying, scheduledFor is the time of the next attempt. A finished job is deleted at the end of your history period (Limits); after that this returns 404.
curl https://webhookscheduler.com/api/v1/jobs/JOB_ID \
-H "Authorization: Bearer wh_live_YOUR_KEY"{
"id": "5f0c9a1e-7b2d-4c8e-9a3f-1d2e3f4a5b6c",
"status": "SUCCESS",
"url": "https://webhookscheduler.com/api/demo/receiver",
"method": "POST",
"payload": { "event": "trial.reminder", "userId": "usr_4821" },
"headers": { "X-Source": "billing" },
"scheduledFor": "YYYY-MM-DDT09:00:00.000Z",
"idempotencyKey": "trial-reminder:usr_4821",
"reference": "trial:usr_4821",
"criticality": "IMPORTANT",
"createdAt": "2026-10-24T08:41:00.000Z",
"updatedAt": "2026-10-25T09:00:01.000Z",
"attempts": [
{
"id": "c2a7e9d4-3f1b-4e6a-8d5c-9b0a1f2e3d4c",
"statusCode": 200,
"responseBody": "{\"ok\":true}",
"error": null,
"durationMs": 143,
"createdAt": "2026-10-25T09:00:01.000Z"
}
]
}statusCode is null when your endpoint never answered (timeout, DNS, refused connection); error then starts with a code such as ECONNABORTED. responseBody keeps the first 1,500 characters of the answer.
Cancel a job
/api/v1/jobs/{id}/cancelCancels a job that is PENDING or RETRYING. No request body.
curl -X POST https://webhookscheduler.com/api/v1/jobs/JOB_ID/cancel \
-H "Authorization: Bearer wh_live_YOUR_KEY"{
"id": "5f0c9a1e-7b2d-4c8e-9a3f-1d2e3f4a5b6c",
"status": "CANCELED",
"url": "https://webhookscheduler.com/api/demo/receiver",
"scheduledFor": "YYYY-MM-DDT09:00:00.000Z"
}PROCESSING, SUCCESS, FAILED or already CANCELED: Only pending or retrying jobs can be canceled.A cancel can cross a delivery
If sending has already started, your endpoint may still be called. Have it re-check your own state (is the user still on trial?) before acting.
Recipe: a cancelable reminder
Schedule the reminder when the trial starts, store the returned id on the user, and cancel it if the user upgrades first. Walkthrough: cancelable trial reminders.
1. When the trial starts
curl -X POST https://webhookscheduler.com/api/v1/schedule \
-H "Authorization: Bearer wh_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://YOUR_DOMAIN/webhooks/trial-reminder",
"runAt": "YYYY-MM-DDT09:00:00.000Z",
"body": { "userId": "usr_4821" },
"idempotencyKey": "trial-reminder:usr_4821"
}'
# → 201 {"id":"5f0c9a1e-7b2d-4c8e-9a3f-1d2e3f4a5b6c",...} Save the id on the user.2. If the user upgrades first
curl -X POST https://webhookscheduler.com/api/v1/jobs/5f0c9a1e-7b2d-4c8e-9a3f-1d2e3f4a5b6c/cancel \
-H "Authorization: Bearer wh_live_YOUR_KEY"
# → 200 {"status":"CANCELED",...}Send now
/api/v1/execute-nowSends one request right away and returns your endpoint’s answer. It takes url, method, headers and body with the same rules as Schedule. It is recorded as a job (its id is jobId), counts toward your monthly quota, and is never retried.
The API answers 200 even when your endpoint fails: read success and status.
curl -X POST https://webhookscheduler.com/api/v1/execute-now \
-H "Authorization: Bearer wh_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://webhookscheduler.com/api/demo/receiver",
"body": { "event": "manual.test" }
}'{
"success": false,
"status": 404,
"data": "Not Found",
"durationMs": 87,
"jobId": "5f0c9a1e-7b2d-4c8e-9a3f-1d2e3f4a5b6c"
}status is null when your endpoint did not answer. data is the answer, parsed as JSON when it is JSON.
Target URL rules
To stop webhooks from reaching private networks (SSRF: making our servers call addresses they should not), a URL must:
- use
https://, on the default port 443 (no:8443,:5678…); - use a domain name with at least one dot: no IP address, no single-word host such as
intranet; - have no
user:password@part; - not be
localhostor end in.localhost,.local,.internal,.lan,.home,.testor.invalid, nor be a cloud metadata host; - resolve only to public IP addresses (no private, loopback, link-local or reserved ranges);
- be at most 2,048 characters.
The URL is checked when you schedule (400 UNSAFE_TARGET_URL) and again right before each delivery. The connection then goes to the IP address that was checked, so a DNS change in between cannot redirect it. A URL that fails the second check is not sent and shows as Blocked target.
What your endpoint receives
POST /webhooks/trial-reminder HTTP/1.1
Host: YOUR_DOMAIN
Content-Type: application/json
User-Agent: WebhookScheduler/1.0 (+SSRFSafe)
Accept-Encoding: identity
Webhook-Signature: t=1792832400,v1=5d41402abc4b2a76b9719d911017c592...
X-Source: billing
{"event":"trial.reminder","userId":"usr_4821"}- The method and URL you scheduled; your
bodyas JSON ({}if none); your allowed headers. Content-Typeis alwaysapplication/json.Webhook-Signatureis present when your workspace has a signing secret (new workspaces do).- Success is a 2xx answer within 10 seconds. Anything else is a failed attempt.
- Redirects are never followed. A 3xx answer is a failed attempt, retried like a 5xx. Use the final URL.
- We store the first 1,500 characters of your answer, shown in the dashboard and in
attempts[].responseBody. - Delivery is at least once: a webhook is claimed once before sending, but after a crash at the wrong moment it can arrive twice. Put your own ID in
bodyand ignore repeats.
Retries
Retried: timeouts, no response, 3xx, 408, 425, 429 and 5xx. The job is RETRYING in between. Not retried: every other 4xx (400, 401, 403, 404, 410, 422…), a blocked target and a payload over the limit; the job is FAILED at once.
The wait doubles after each failed attempt: 30 seconds, 1 minute, 2, 4, 8… up to 6 hours between two attempts. Free: 4 retries over ~8 min (5 attempts in all); Pro: 10 retries over ~8.5 h; Scale: 14 retries over ~33 h. Each retry moves scheduledFor to the next attempt. Fixing a failed job: Fix and resend.
Webhook signatures
Each delivery carries Webhook-Signature: t=TIMESTAMP,v1=SIGNATURE. The signature is an HMAC-SHA256, in hex, of TIMESTAMP, a dot, and the raw request body, keyed with your workspace’s signing secret.
- Use the whole secret from Settings → Security as the key,
whsec_prefix included. - Sign the raw bytes you received. A body parsed and re-serialized can differ by one space and the check fails.
- Refuse timestamps more than 5 minutes away from now.
import crypto from "node:crypto";
// The full secret from Settings → Security, "whsec_" prefix included.
const SECRET = process.env.WEBHOOK_SCHEDULER_SECRET;
export function verifyWebhookSignature(rawBody, header) {
if (typeof header !== "string") return false;
const parts = Object.fromEntries(
header.split(",").map(part => part.split("="))
);
// Refuse old or future timestamps (replays): 5 minutes of tolerance.
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return false;
}
if (!/^[a-f0-9]{64}$/i.test(parts.v1 ?? "")) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${parts.t}.${rawBody}`)
.digest();
return crypto.timingSafeEqual(Buffer.from(parts.v1, "hex"), expected);
}import express from "express";
import { verifyWebhookSignature } from "./verify.js";
const app = express();
// express.raw keeps the exact bytes that were signed.
// Do not put express.json() in front of this route.
app.post(
"/webhooks/trial-reminder",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyWebhookSignature(rawBody, req.headers["webhook-signature"])) {
return res.status(401).send("Invalid signature");
}
const data = JSON.parse(rawBody); // the "body" you scheduled
res.status(200).send("ok"); // answer first, within the delivery timeout
// ...then do the work with data.userId
}
);Job statuses
PENDINGPROCESSINGRETRYINGSUCCESSFAILEDCANCELEDLimits
Monthly quota: every job created in the calendar month (UTC) counts: scheduled, canceled, sent with Send now, or created by Run again and Edit and reschedule. It resets on the 1st. History: a finished job (delivered, failed or canceled) is deleted that many days after its last change.
Rate limits
Calls per minute, counted per API key:
Every answer has X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time, in seconds). Over the limit you get 429 with code: RATE_LIMIT_EXCEEDED and a Retry-After header in seconds.
API errors
These are our API’s answers to your request, not your endpoint’s answers to a delivery (for those, see A delivery failed). Every error is JSON with an error message; typed errors add code and details, validation errors add an issues array.
{
"error": "Validation failed",
"issues": [
{ "code": "custom", "message": "runAt must be in the future", "path": ["runAt"] }
]
}runAt must be in the future, an invalid url, a reference with a space or #, a lowercase method or criticality, a header value that is not a string, a body over 100 KB.Security model
- Deliveries are HTTPS only, checked against the target URL rules when scheduled and again right before sending.
- Redirects are never followed: a 3xx is a failed attempt, retried.
- The connection is pinned to the checked IP address, against DNS rebinding.
- Every delivery is signed when the workspace has a signing secret.
- Each job is claimed once before sending to avoid duplicates. Delivery is at least once, not exactly once.
The full model, with retention and abuse controls: Security and reliability.