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/runner

Set 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_here

Schedule 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 resume

02

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

PresetSchedule
"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.

Use a timezone for recurring schedules so daylight-saving transitions are evaluated in the intended region.

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 targets must use 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>" }
})
Email destinations require 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

scheduledWaiting for its next run.
runningCurrently being delivered.
completedOne-time job delivered successfully.
failedDelivery failed after all retries.
cancelledManually cancelled.
pausedWill not run until resumed.

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)
}
Verify against the raw request body before parsing JSON. Requests without a payload body are not signed.

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_here

Endpoints

MethodEndpointPurpose
POST/api/v1/jobsCreate a one-time or recurring job
DELETE/api/v1/jobs/:jobIdCancel a job
POST/api/v1/jobs/:jobId/pausePause a scheduled job
POST/api/v1/jobs/:jobId/resumeResume a paused job
POST/api/v1/inbound/:slugCreate 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

FieldTypeNotes
urlstringPublic HTTP(S) endpoint. Use instead of destination.
destinationobjectBuilt-in Slack, Discord, Telegram, or email destination.
runAtstring | numberFuture ISO date string or timestamp. Use instead of runEvery.
runEverystring | objectPreset or weekly schedule object. Use instead of runAt.
timezonestringOptional IANA timezone for recurring schedules.
payloadobjectOptional JSON body, limited to 256 KiB.
onCompletestringOptional 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