Webhook Scheduler
Log inStart free

Reference

API reference

Base URL https://webhookscheduler.com. Requests and responses are JSON. Job and attempt IDs are UUIDs. New here? The quickstart gets you to a delivered webhook first.

On this page

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.

Headershttp
Authorization: Bearer wh_live_YOUR_KEY
Content-Type: application/json

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

bash
npm install @webhookscheduler/sdk
typescript
import { 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

POST/api/v1/schedule

Creates a job: at runAt, Webhook Scheduler sends method to url with body as JSON.

Request body

urlstringrequired

Where to send it: a public HTTPS URL. See Target URL rules.

runAtstring (ISO 8601)required

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

methodstring

GET, POST, PUT, PATCH or DELETE, in capitals. Default POST.

bodyany JSON

Sent as the request body, as JSON. Up to 100 KB. Default {}. Alias: payload.

headersobject of strings

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

idempotencyKeystring

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

referencestring

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

criticalitystring

STANDARD (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"
  }'
Response · 201 Createdjson
{
  "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

GET/api/v1/jobs

Your workspace’s jobs, newest first.

Query parameters

referencestring

Exact match on reference. With state=open, finds the jobs to cancel for one customer or invoice.

statestring

open: PENDING, PROCESSING and RETRYING. terminal: SUCCESS, FAILED and CANCELED.

statusstring

One status (PENDING, PROCESSING, RETRYING, SUCCESS, FAILED, CANCELED). Takes precedence over state.

pageinteger

Starts at 1.

limitinteger

1 to 100. Default 20.

bash
curl "https://webhookscheduler.com/api/v1/jobs?reference=trial:usr_4821&state=open" \
  -H "Authorization: Bearer wh_live_YOUR_KEY"
Response · 200 OKjson
{
  "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

GET/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.

bash
curl https://webhookscheduler.com/api/v1/jobs/JOB_ID \
  -H "Authorization: Bearer wh_live_YOUR_KEY"
Response · 200 OKjson
{
  "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

POST/api/v1/jobs/{id}/cancel

Cancels a job that is PENDING or RETRYING. No request body.

bash
curl -X POST https://webhookscheduler.com/api/v1/jobs/JOB_ID/cancel \
  -H "Authorization: Bearer wh_live_YOUR_KEY"
Response · 200 OKjson
{
  "id": "5f0c9a1e-7b2d-4c8e-9a3f-1d2e3f4a5b6c",
  "status": "CANCELED",
  "url": "https://webhookscheduler.com/api/demo/receiver",
  "scheduledFor": "YYYY-MM-DDT09:00:00.000Z"
}
400
When:The job is PROCESSING, SUCCESS, FAILED or already CANCELED: Only pending or retrying jobs can be canceled.
404
When:No job with this ID in your workspace.
409
When:The job started sending while the cancel was applied.

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

bash
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

bash
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

POST/api/v1/execute-now

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

bash
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" }
  }'
Response · 200 OK · endpoint answered 404json
{
  "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 localhost or end in .localhost, .local, .internal, .lan, .home, .test or .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

Example deliveryhttp
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 body as JSON ({} if none); your allowed headers.
  • Content-Type is always application/json. Webhook-Signature is 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 body and 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.
verify.jsjavascript
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

PENDING
Dashboard:Scheduled
Meaning:Stored and waiting for its runAt.
PROCESSING
Dashboard:Processing
Meaning:Being sent right now.
RETRYING
Dashboard:Retrying
Meaning:An attempt failed for a temporary reason and the next one is scheduled. scheduledFor is the next attempt.
SUCCESS
Dashboard:Delivered
Meaning:Your endpoint answered 2xx.
FAILED
Dashboard:Failed
Meaning:Final: a definitive 4xx, every retry used, a blocked target, or a retry could not be queued.
CANCELED
Dashboard:Canceled
Meaning:Canceled before it was sent.

Limits

Webhooks per month
Free:100
Pro:100,000
Scale:1,000,000
Schedule ahead
Free:7 days
Pro:365 days
Scale:365 days
Retries
Free:4
Pro:10
Scale:14
Active API keys
Free:1
Pro:10
Scale:50
History kept
Free:7 days
Pro:30 days
Scale:90 days

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.

body
Value:100 KB of JSON
headers
Value:16 KB in all, 32 headers, names up to 64 characters, values up to 2,048
url
Value:2,048 characters
idempotencyKey
Value:8 to 80 characters
reference
Value:1 to 120 characters
Delivery timeout
Value:10 seconds
Answer stored
Value:first 1,500 characters
List page size
Value:1 to 100, default 20

Rate limits

Calls per minute, counted per API key:

POST /api/v1/schedule
Per minute:120
POST /api/v1/execute-now
Per minute:60
GET /api/v1/jobs
Per minute:120
GET /api/v1/jobs/{id}
Per minute:120
POST /api/v1/jobs/{id}/cancel
Per minute:30

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.

Response · 400 Bad Requestjson
{
  "error": "Validation failed",
  "issues": [
    { "code": "custom", "message": "runAt must be in the future", "path": ["runAt"] }
  ]
}
400 · Validation failed
What it means:A field is missing or wrong. Common: 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.
What to do:Read issues[].path and issues[].message. Field rules: Schedule a webhook.
400 · UNSAFE_TARGET_URL
What it means:The url is not a public HTTPS address our rules accept.
What to do:Target URL rules
400 (cancel)
What it means:Only pending or retrying jobs can be canceled: the job is being sent, or is already delivered, failed or canceled.
What to do:Nothing to cancel. Read the job to see its status.
401
What it means:The API key is missing, mistyped or revoked.
What to do:Send Authorization: Bearer wh_live_…. Check the key in API keys.
403 · QUOTA_EXCEEDED
What it means:Your plan’s monthly webhook limit is reached. Every job created this month (UTC) counts: canceled ones, execute-now calls and Run again included.
What to do:Wait for the 1st of next month (UTC), or upgrade. details has limit and usedThisMonth.
403 · PLAN_LIMIT_EXCEEDED
What it means:runAt is further ahead than your plan allows, or your paid plan has ended.
What to do:Pick an earlier time, or update billing in Settings.
404
What it means:No job with this ID in your workspace: a typo, another workspace’s ID, or a finished job deleted after your history period.
What to do:Check the ID (a UUID).
405
What it means:Wrong HTTP method for this endpoint, for example GET on /schedule.
What to do:Schedule, execute-now and cancel use POST; list and get use GET.
409
What it means:The job changed state while it was being canceled: it started sending.
What to do:Read the job again; it may be delivered.
429 · RATE_LIMIT_EXCEEDED
What it means:Too many calls in one minute with this key.
What to do:Wait the number of seconds in Retry-After. See Rate limits.
503 · QUEUE_UNAVAILABLE
What it means:Our queue was unavailable. Nothing was scheduled and it does not count toward your monthly quota.
What to do:Try again after a few seconds, with the same idempotencyKey if you sent one.
500
What it means:An error on our side.
What to do:Try again later, with backoff.

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.