Runner documentation
Schedule HTTP jobs without running a queue.
Use the TypeScript SDK or REST API to run one-time and recurring jobs, deliver notifications, and trigger work from inbound webhooks.
01
Getting started
Create an API key from a project in the dashboard, install the SDK, and schedule your first job.
Install the SDK
npm install @maksymdolynchuk/runnerSet your API key
API keys are project-scoped. Runner shows the full key once when you create it, so store it as a secret.
# .env
RUNNER_API_KEY=rn_your_api_key_hereSchedule your first job
The SDK sends requests to https://runner.q1w2.dev by default. Pass baseUrl to the constructor only when targeting another Runner deployment.
import { Runner } from "@maksymdolynchuk/runner"
const runner = new Runner(process.env.RUNNER_API_KEY)
const job = await runner.schedule({
url: "https://myapp.com/api/welcome-email",
runAt: Date.now() + 1000 * 60 * 60,
payload: {
userId: "user_123"
}
})
console.log(job.id) // UUID used by cancel, pause, and resume02
Scheduling
Every job needs exactly one schedule: a future runAt value or a recurring runEvery value.
One-time jobs
Use runAt with a future timestamp or an ISO date string. Past dates and invalid dates are rejected.
// Run in one hour
await runner.schedule({
url: "https://myapp.com/api/follow-up",
runAt: Date.now() + 1000 * 60 * 60
})
// Run at an exact instant
await runner.schedule({
url: "https://myapp.com/api/follow-up",
runAt: "2026-06-01T09:00:00Z"
})Recurring jobs
Use a preset for common schedules or a schedule object for a weekly run. In a custom schedule, hour and minute default to 0.
await runner.schedule({
url: "https://myapp.com/api/digest",
runEvery: "morning",
timezone: "America/New_York"
})
await runner.schedule({
url: "https://myapp.com/api/weekly-summary",
runEvery: {
day: "monday",
hour: 9,
minute: 30
},
timezone: "Europe/Berlin"
})Available presets
| Preset | Schedule |
|---|---|
| "midnight" | Every day at 00:00 |
| "morning" | Every day at 08:00 |
| "evening" | Every day at 18:00 |
| "startOfMonth" | First day of the next month at 00:00 |
| "endOfMonth" | Last day of the month at 23:59 |
Timezones
Pass an IANA timezone name when a schedule should follow local wall-clock time. Without timezone, Runner uses the server runtime timezone. One-time ISO timestamps with an offset already identify an exact instant.
03
Delivery
Deliver a JSON payload to an HTTP endpoint or use a built-in destination.
HTTP requests
Runner sends an HTTP POST with Content-Type: application/json and User-Agent: Runner/1.0. The optional payload is serialized as the request body.
await runner.schedule({
url: "https://myapp.com/api/send-invoice",
runAt: "2026-06-01T10:00:00Z",
payload: {
invoiceId: "inv_456",
customerId: "cust_789",
amount: 2999
}
})http or https, resolve only to public addresses, and contain no URL credentials. Redirect targets are checked too.Built-in destinations
Use destination instead of url. Slack reads payload.text. Discord reads payload.content or payload.text. Telegram reads payload.text. Email reads payload.html or payload.text.
// Slack
await runner.schedule({
destination: {
type: "slack",
webhookUrl: "https://hooks.slack.com/services/..."
},
runEvery: "morning",
payload: { text: "Daily digest ready" }
})
// Discord
await runner.schedule({
destination: {
type: "discord",
webhookUrl: "https://discord.com/api/webhooks/..."
},
runAt: Date.now() + 60_000,
payload: { content: "Build complete" }
})
// Telegram
await runner.schedule({
destination: {
type: "telegram",
botToken: "123:ABC",
chatId: "-100123456"
},
runEvery: "evening",
payload: { text: "Daily summary" }
})
// Email
await runner.schedule({
destination: {
type: "email",
to: "user@example.com",
subject: "Your monthly report"
},
runEvery: "startOfMonth",
payload: { html: "<p>Report ready.</p>" }
})RESEND_API_KEY on the Runner deployment. Slack and Discord webhook URLs must resolve only to public addresses.Payload limits
REST request bodies and stored job payloads are limited to 256 KiB. Runner records at most 100 KiB of a delivery response body for execution history.
04
Reliability and control
Runner records every execution, retries failed deliveries, and lets you stop or restart scheduled work.
Retries
A delivery succeeds only when the destination returns a 2xx status. After the initial attempt, Runner retries up to three times with exponential backoff: after 30 seconds, 2 minutes, and 8 minutes. A job becomes failed after the last retry fails.
Cancel, pause, and resume
Cancel marks a job as cancelled. Pause applies to a job currently in the scheduled state. Resuming a recurring job calculates its next occurrence; resuming a one-time job keeps its original scheduled instant.
await runner.pause(job.id)
await runner.resume(job.id)
await runner.cancel(job.id)Job states
Completion jobs
Set onComplete to create an immediate one-time HTTP job after a successful delivery. The follow-up receives the original payload. For recurring jobs, a follow-up is created after each successful run.
await runner.schedule({
url: "https://myapp.com/api/generate-invoice",
runAt: "2026-06-01T10:00:00Z",
payload: { invoiceId: "inv_456" },
onComplete: "https://myapp.com/api/send-receipt"
})Failure notifications
Configure Telegram, Slack, or email alerts in project settings. Runner sends an alert only after a job exhausts all automatic retries.
05
Webhooks
Receive external events and verify that outbound HTTP deliveries came from Runner.
Inbound webhooks
Create an inbound webhook in project settings with a public target URL and an optional delay in milliseconds. A JSON POST to its generated URL creates a one-time job that forwards the payload to that target.
curl -X POST "https://runner.q1w2.dev/api/v1/inbound/wh_abc123" \
-H "Content-Type: application/json" \
-d '{ "event": "payment.completed", "amount": 2999 }'Inbound webhook payloads are limited to 256 KiB and each generated inbound URL accepts up to 60 requests per minute.
Outbound signature verification
Generate a webhook signing secret in project settings. For outbound HTTP jobs with a non-empty body, Runner adds an X-Runner-Signature header in the form t=timestamp,v1=signature. Compute HMAC-SHA256 over timestamp.body and reject stale timestamps.
import crypto from "node:crypto"
function verify(body, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map(part => part.split("="))
)
const timestamp = Number(parts.t)
if (!timestamp || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + body)
.digest("hex")
const actualBuffer = Buffer.from(parts.v1)
const expectedBuffer = Buffer.from(expected)
return actualBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(actualBuffer, expectedBuffer)
}06
REST API
Use the REST API directly when the TypeScript SDK is not the right fit.
Authentication
Send the project API key as a bearer token. Create-job requests accept up to 100 requests per minute per API key.
Authorization: Bearer rn_your_api_key_hereEndpoints
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/v1/jobs | Create a one-time or recurring job |
| DELETE | /api/v1/jobs/:jobId | Cancel a job |
| POST | /api/v1/jobs/:jobId/pause | Pause a scheduled job |
| POST | /api/v1/jobs/:jobId/resume | Resume a paused job |
| POST | /api/v1/inbound/:slug | Create a job from an inbound webhook |
Create a job
Send either url or destination, and either runAt or runEvery. A newly created job returns 201 Created.
const response = await fetch("https://runner.q1w2.dev/api/v1/jobs", {
method: "POST",
headers: {
"Authorization": "Bearer rn_...",
"Content-Type": "application/json",
"Idempotency-Key": "welcome-email-user_123"
},
body: JSON.stringify({
url: "https://myapp.com/api/welcome-email",
runAt: Date.now() + 3600000,
payload: { userId: "user_123" }
})
})
const job = await response.json()Create-job fields
| Field | Type | Notes |
|---|---|---|
| url | string | Public HTTP(S) endpoint. Use instead of destination. |
| destination | object | Built-in Slack, Discord, Telegram, or email destination. |
| runAt | string | number | Future ISO date string or timestamp. Use instead of runEvery. |
| runEvery | string | object | Preset or weekly schedule object. Use instead of runAt. |
| timezone | string | Optional IANA timezone for recurring schedules. |
| payload | object | Optional JSON body, limited to 256 KiB. |
| onComplete | string | Optional public HTTP(S) follow-up URL. |
Idempotency
Add an Idempotency-Key header to a create-job request to avoid duplicate jobs. Keys are scoped to the project. If a matching job already exists, Runner returns that job with 200 OK instead of creating another one.
Create-job response
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://myapp.com/api/welcome-email",
"status": "scheduled",
"scheduleType": "once",
"destinationType": "http",
"runAt": "2026-06-01T10:00:00.000Z",
"runEvery": null,
"nextRunAt": "2026-06-01T10:00:00.000Z",
"createdAt": "2026-05-31T18:00:00.000Z"
}Inspect jobs in the dashboard
View jobs, execution history, response status codes, captured response bodies, retry counts, API keys, webhooks, and project settings.
Open dashboard