Rotor POSTs a signed JSON payload to your endpoint whenever a lifecycle event occurs. You register one or more webhook endpoints and choose which event types each one receives.
Event types
| Event | When it fires |
|---|---|
job.completed | A job finishes successfully |
job.failed | A job exhausts all retry attempts and enters terminal failure |
job.stalled | A job's lock expires while it was active (usually a crashed worker) |
approval.pending | A job step is waiting for human approval |
approval.approved | An approval request was approved |
approval.rejected | An approval request was rejected |
guardrail.blocked | A guardrail rule blocked a job from executing |
queue.drained | A queue's waiting count dropped to zero |
queue.error | A queue-level error occurred (e.g. Redis unreachable) |
webhook.test | Fired by rotor.webhooks.test() to verify your endpoint |
Create a webhook endpoint
import { Rotor } from "@rotorsh/sdk";
const rotor = new Rotor({ apiKey: process.env.ROTOR_API_KEY! });
const webhook = await rotor.webhooks.create({
url: "https://yourapp.com/webhooks/rotor",
eventTypes: ["job.completed", "job.failed", "approval.pending"],
});
console.log(webhook.id); // wh_<32hex>
console.log(webhook.secret); // shown ONCE — store it in your secrets manager immediatelyThe webhook secret is returned only at creation time. If you lose it, delete
the webhook and create a new one. Store the secret in an environment variable
(ROTOR_WEBHOOK_SECRET) before the creation response goes out of scope.
Verify signatures
Every request Rotor sends includes three headers:
| Header | Contents |
|---|---|
Rotor-Webhook-Id | Unique delivery ID |
Rotor-Webhook-Timestamp | Unix timestamp of the delivery |
Rotor-Webhook-Signature | v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
Rotor uses a Svix-compatible HMAC-SHA256 scheme. Verify with the SDK helper:
import { verifyRotorSignature } from "@rotorsh/sdk";
import type { IncomingMessage, ServerResponse } from "http";
export async function POST(req: Request): Promise<Response> {
const body = await req.text();
const id = req.headers.get("rotor-webhook-id")!;
const timestamp = req.headers.get("rotor-webhook-timestamp")!;
const signature = req.headers.get("rotor-webhook-signature")!;
let event: unknown;
try {
event = verifyRotorSignature(body, signature, process.env.ROTOR_WEBHOOK_SECRET!, {
id,
timestamp,
maxAgeSeconds: 300, // reject deliveries older than 5 minutes
});
} catch (err) {
return new Response("Invalid signature", { status: 401 });
}
// event is now typed and verified — handle it
await handleRotorEvent(event as RotorWebhookEvent);
return new Response("OK", { status: 200 });
}Always return 200 quickly — do your processing asynchronously or in a
background task. If your endpoint takes longer than 10 seconds to respond,
Rotor treats the delivery as a timeout and schedules a retry.
Handle specific events
job.completed
import type { RotorWebhookEvent } from "@rotorsh/sdk";
async function handleRotorEvent(event: RotorWebhookEvent) {
switch (event.type) {
case "job.completed": {
const { jobId, queueName, result, completedAt } = event.data;
console.log(`Job ${jobId} in ${queueName} finished:`, result);
// update your DB, trigger downstream steps, etc.
break;
}
case "job.failed": {
const { jobId, queueName, failReason, attempts } = event.data;
console.error(`Job ${jobId} failed after ${attempts} attempts: ${failReason}`);
// send a Slack alert, open a support ticket, etc.
break;
}
case "approval.pending": {
const { jobId, approvalId, queueName, payload } = event.data;
// notify a reviewer — e.g. post to Slack
await notifyReviewer({ approvalId, payload });
break;
}
}
}Pipe job events to Slack
case "job.failed": {
const { jobId, queueName, failReason } = event.data;
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `❌ Job \`${jobId}\` failed in \`${queueName}\`\n>${failReason}`,
}),
});
break;
}Route approval.pending to a bot
case "approval.pending": {
const { approvalId, queueName, payload } = event.data;
await fetch("https://yourapp.com/internal/approval-bot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ approvalId, queueName, payload }),
});
break;
}Test a webhook endpoint
Send a webhook.test event to confirm your endpoint is reachable and signature verification works:
await rotor.webhooks.test("wh_abc123def456...");Your endpoint will receive a payload like:
{
"type": "webhook.test",
"webhookId": "wh_abc123def456...",
"data": {
"message": "This is a test event from Rotor.",
"timestamp": "2026-05-20T10:00:00.000Z"
}
}Verify your endpoint returns 200 and that signature verification passes before relying on it in production.
Retry behavior
If your endpoint returns a non-2xx status code, times out, or is unreachable, Rotor retries with exponential backoff — up to 5 attempts over approximately 3 hours. After 5 failed attempts, the delivery is abandoned and marked as permanently failed.
| Attempt | Approximate delay |
|---|---|
| 1 | Immediate |
| 2 | ~30 seconds |
| 3 | ~5 minutes |
| 4 | ~30 minutes |
| 5 | ~2 hours |
Delivery failures do not affect your job execution. Your jobs continue running — only the webhook notification is retried.
Manage endpoints
// List all webhook endpoints
const webhooks = await rotor.webhooks.list();
// Get a specific endpoint
const webhook = await rotor.webhooks.get("wh_abc123def456...");
console.log(webhook.url, webhook.eventTypes, webhook.createdAt);
// Delete an endpoint
await rotor.webhooks.delete("wh_abc123def456...");Deleting a webhook stops all future deliveries immediately. In-flight deliveries that have already started may still complete.
callback_status on schedule runs
When a schedule fires a job and that job has a callback configured, the callback_status field on the run record reflects the webhook delivery outcome:
| Value | Meaning |
|---|---|
pending | Delivery has not been attempted yet |
2xx | Endpoint responded with a success status |
4xx | Endpoint responded with a client error |
5xx | Endpoint responded with a server error |
timeout | Endpoint did not respond within the timeout window |
null | No callback configured for this run |