REST API Reference
Complete reference for the Runhooks REST API. Schedule, manage, and monitor HTTP webhooks programmatically.
https://api.runhooks.app/api/v1Prefer the terminal? The Runhooks CLI wraps this API with ready-made commands (npm install -g @runhooks/cli). Using an AI agent? The MCP server lets Claude and Cursor call it directly.
Authentication
Most endpoints require authentication via an API key passed in the Authorization header. API keys are prefixed with rh_live_.
Authorization: Bearer rh_live_xxxxxxxxxxxxHow to get an API key:
- Dashboard — Register at runhooks.app/register, then copy your key from Settings.
- API — Call POST /auth/register-anonymous to get an instant key with no email required.
- CLI — Run
runhooks initto create an account and get a key. See the CLI documentation.
Web sessions also support JWT access tokens (returned by /auth/login and /auth/register). JWT tokens expire after 15 minutes and can be refreshed via /auth/refresh.
Quick Start
Go from zero to a running scheduled webhook in 4 steps:
+-----------+ +-----------+ +-----------+ +--------------+
| Register |---->| Create |---->| List |---->| View |
| Account | | Job | | Jobs | | Executions |
+-----------+ +-----------+ +-----------+ +--------------+
Get API Key Set schedule & Filter & page Check status &
HTTP config response data1. Create an account
const res = await fetch("https://api.runhooks.app/api/v1/auth/register-anonymous", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "My Server" }),
});
const { data } = await res.json();
const apiKey = data.apiKey; // Save this!Save the apiKey from the response — it cannot be retrieved again.
2. Create a job
const res = await fetch("https://api.runhooks.app/api/v1/jobs", {
method: "POST",
headers: {
"Authorization": "Bearer rh_live_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Sync inventory",
schedule: { type: "cron", expression: "*/5 * * * *" },
httpConfig: {
url: "https://api.example.com/sync",
method: "POST",
},
}),
});
const { data: job } = await res.json();3. List your jobs
const res = await fetch("https://api.runhooks.app/api/v1/jobs", {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data } = await res.json();4. View executions
const res = await fetch(`https://api.runhooks.app/api/v1/jobs/${jobId}/executions`, {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data } = await res.json();Response Format
Every response is a JSON object with a consistent wrapper:
{
"success": true,
"data": { ... },
"message": "Optional human-readable message"
}Error responses follow the same shape:
{
"success": false,
"error": "Description of what went wrong"
}Paginated endpoints return data as:
{
"success": true,
"data": {
"data": [ ... ],
"total": 42,
"page": 1,
"limit": 20,
"totalPages": 3
}
}Error status codes
| Status | Meaning |
|---|---|
400 | Bad request — invalid body, missing required fields, or validation failure |
401 | Unauthorized — missing or invalid API key / token |
403 | Forbidden — insufficient plan or permissions |
404 | Not found — resource does not exist or does not belong to you |
409 | Conflict — resource already exists (e.g. duplicate email) |
429 | Too many requests — rate limit exceeded |
500 | Internal server error |
503 | Service unavailable — a backing service (database, Redis) is down |
Rate Limits
Rate limits are enforced per IP or per user depending on the endpoint. When a limit is exceeded the API responds with 429 and standard draft-7 rate-limit headers.
| Scope | Limit | Window | Key |
|---|---|---|---|
| Global (all routes) | 100 requests | 15 minutes | IP |
| Job writes (create/update/delete) | 30 requests | 1 minute | User ID |
| Execution replay | 10 requests | 1 minute | User ID |
| Registration | 5 requests | 1 hour | IP |
| Login / refresh | 10 requests | 1 minute | IP |
Jobs
Jobs are scheduled HTTP requests. Each job has a schedule (cron or interval), an HTTP config (URL, method, headers, body), and a retry policy.
POST /jobs
Create a new scheduled job.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | required | — | Job name (1–255 chars) |
schedule.type | string | required | — | "cron", "interval", or "once" (single future run) |
schedule.expression | string | required | — | Cron expression (e.g. "*/5 * * * *"), interval in ms (e.g. "60000"), or—for once—an ISO-8601 date-time (e.g. "2026-12-25T14:00:00Z", or a naive "2026-12-25T09:00:00" with timezone). A once time must be in the future, and is stored & returned normalized to a UTC instant. |
httpConfig.url | string | required | — | Target URL (must be valid, no private IPs) |
description | string | optional | — | Human-readable description (max 2000 chars) |
schedule.timezone | string | optional | — | IANA timezone. Used by cron, and by once for naive wall-clock expressions. |
httpConfig.method | string | optional | "GET" | GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
httpConfig.headers | object | optional | — | Key-value pairs of HTTP headers |
httpConfig.body | string | optional | — | Request body (max 1 MB) |
httpConfig.timeoutMs | number | optional | 30000 | Request timeout in ms (1,000–300,000, capped by plan) |
retryPolicy.maxRetries | number | optional | 3 | Max retry attempts (0–10, capped by plan) |
retryPolicy.initialDelay | number | optional | 1000 | Initial retry delay in ms (100–60,000) |
retryPolicy.backoffMultiplier | number | optional | 2 | Backoff multiplier (1–10) |
retryPolicy.maxDelay | number | optional | 300000 | Max retry delay in ms (1,000–3,600,000) |
tags | string[] | optional | — | Tags (max 20 items, each max 50 chars) |
const res = await fetch("https://api.runhooks.app/api/v1/jobs", {
method: "POST",
headers: {
"Authorization": "Bearer rh_live_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Refresh cache",
schedule: { type: "cron", expression: "0 * * * *", timezone: "America/New_York" },
httpConfig: {
url: "https://api.example.com/refresh",
method: "POST",
headers: { "X-Api-Key": "secret" },
},
retryPolicy: { maxRetries: 5 },
tags: ["prod", "cache"],
}),
});
const { data: job } = await res.json();Response (201):
{
"success": true,
"message": "Job created successfully",
"data": {
"id": "665a1b2c3d4e5f6a7b8c9d0e",
"name": "Refresh cache",
"schedule": { "type": "cron", "expression": "0 * * * *", "timezone": "America/New_York" },
"httpConfig": {
"url": "https://api.example.com/refresh",
"method": "POST",
"headers": { "X-Api-Key": "secret" },
"timeoutMs": 30000
},
"retryPolicy": { "maxRetries": 5, "initialDelay": 1000, "backoffMultiplier": 2, "maxDelay": 300000 },
"status": "active",
"tags": ["prod", "cache"],
"createdAt": "2025-01-15T12:00:00.000Z",
"updatedAt": "2025-01-15T12:00:00.000Z",
"nextExecutionAt": "2025-01-15T13:00:00.000Z"
}
}One-off jobs. Set schedule.type to "once" and schedule.expression to an ISO-8601 date-time to run a job a single time. Use an absolute instant (with Z or an offset), or a naive wall-clock time plus a timezone. One-off jobs get the same retries, alerts and logs as recurring jobs, then move to completed (or failed) once done.
await fetch("https://api.runhooks.app/api/v1/jobs", {
method: "POST",
headers: {
"Authorization": "Bearer rh_live_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Black Friday warm-up",
// Absolute instant (UTC):
schedule: { type: "once", expression: "2026-11-27T13:00:00Z" },
httpConfig: { url: "https://api.example.com/warm-up", method: "POST" },
}),
});GET /jobs
List your jobs with optional filters and pagination.
| Query param | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number (min 1) |
limit | number | 20 | Results per page (1–100) |
status | string | — | Filter by status: active, paused, completed, failed |
tag | string | — | Filter by tag |
name | string | — | Filter by name (case-insensitive regex, max 100 chars) |
const res = await fetch("https://api.runhooks.app/api/v1/jobs?status=active&limit=10", {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data } = await res.json();
console.log(data.data); // Job[]
console.log(data.totalPages); // numberGET /jobs/:id
Get a single job by ID.
const res = await fetch(`https://api.runhooks.app/api/v1/jobs/${jobId}`, {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data: job } = await res.json();PATCH /jobs/:id
Update a job. Only include the fields you want to change. Also available as PUT.
| Field | Type | Description |
|---|---|---|
name | string | Job name (1–255 chars) |
description | string | Description (max 2000 chars) |
schedule | object | Schedule object (type, expression, timezone) |
httpConfig | object | HTTP config object (url, method, headers, body, timeoutMs) |
retryPolicy | object | Retry policy object |
status | string | Set to "active" or "paused" |
tags | string[] | Replace all tags |
const res = await fetch(`https://api.runhooks.app/api/v1/jobs/${jobId}`, {
method: "PATCH",
headers: {
"Authorization": "Bearer rh_live_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "New name", status: "paused" }),
});
const { data: job } = await res.json();DELETE /jobs/:id
Delete a job permanently.
const res = await fetch(`https://api.runhooks.app/api/v1/jobs/${jobId}`, {
method: "DELETE",
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { success } = await res.json();GET /jobs/:id/executions
List executions for a specific job.
| Query param | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (1–100) |
status | string | — | Filter: pending, running, success, failed, timeout, dead_letter, quota_exceeded |
const res = await fetch(
`https://api.runhooks.app/api/v1/jobs/${jobId}/executions?status=failed`,
{ headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" } },
);
const { data } = await res.json();Response (200):
{
"success": true,
"data": {
"data": [
{
"id": "665b2c3d4e5f6a7b8c9d0e1f",
"jobId": "665a1b2c3d4e5f6a7b8c9d0e",
"status": "success",
"startedAt": "2025-01-15T13:00:00.000Z",
"completedAt": "2025-01-15T13:00:01.234Z",
"durationMs": 1234,
"httpStatusCode": 200,
"responseBody": "{\"ok\":true}",
"attempt": 1
}
],
"total": 1,
"page": 1,
"limit": 20,
"totalPages": 1
}
}Executions
Executions represent individual runs of a job. Use these endpoints to list executions across all jobs or replay a failed execution.
GET /executions
List executions across all your jobs.
| Query param | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (1–100) |
status | string | — | Filter by status |
jobId | string | — | Filter by job ID (must be a job you own) |
const res = await fetch("https://api.runhooks.app/api/v1/executions?limit=5", {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data } = await res.json();POST /executions/:id/replay
Re-run a past execution immediately. Useful for recovering from transient failures. The replay creates a new execution linked to the original.
const res = await fetch(
`https://api.runhooks.app/api/v1/executions/${executionId}/replay`,
{
method: "POST",
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
},
);
const { message } = await res.json(); // "Execution replay enqueued"Response (202):
{
"success": true,
"message": "Execution replay enqueued"
}Alerts
Alerts notify you when jobs fail. Supports email, webhook, and Slack channels.
POST /alerts
Create a new alert configuration.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | required | — | Alert name (1–100 chars) |
channel | string | required | — | "email", "webhook", or "slack" |
target | string | required | — | Email address, webhook URL, or Slack URL (max 500 chars) |
jobId | string|null | optional | — | Scope to a specific job (omit for all jobs) |
consecutiveFailuresThreshold | number | optional | 1 | Consecutive failures before alert fires (1–100) |
cooldownMinutes | number | optional | 60 | Minutes between repeat alerts (0–10,080) |
enabled | boolean | optional | true | Whether the alert is active |
const res = await fetch("https://api.runhooks.app/api/v1/alerts", {
method: "POST",
headers: {
"Authorization": "Bearer rh_live_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "All failures",
channel: "email",
target: "me@example.com",
consecutiveFailuresThreshold: 3,
cooldownMinutes: 30,
}),
});
const { data: alert } = await res.json();Response (201):
{
"success": true,
"message": "Alert config created",
"data": {
"id": "665c3d4e5f6a7b8c9d0e1f2a",
"userId": "665a0a1b2c3d4e5f6a7b8c9d",
"name": "All failures",
"channel": "email",
"target": "me@example.com",
"consecutiveFailuresThreshold": 3,
"cooldownMinutes": 30,
"enabled": true,
"createdAt": "2025-01-15T12:00:00.000Z",
"updatedAt": "2025-01-15T12:00:00.000Z"
}
}GET /alerts
List all your alert configurations. Returns a flat array (not paginated).
const res = await fetch("https://api.runhooks.app/api/v1/alerts", {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data: alerts } = await res.json(); // AlertConfig[]GET /alerts/:id
Get a single alert configuration by ID.
const res = await fetch(`https://api.runhooks.app/api/v1/alerts/${alertId}`, {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data: alert } = await res.json();PUT /alerts/:id
Update an alert configuration. Only include the fields you want to change.
| Field | Type | Description |
|---|---|---|
name | string | Alert name (1–100 chars) |
channel | string | email, webhook, or slack |
target | string | Destination (max 500 chars) |
jobId | string|null | Scope to job or null for all |
consecutiveFailuresThreshold | number | 1–100 |
cooldownMinutes | number | 0–10,080 |
enabled | boolean | Enable or disable |
const res = await fetch(`https://api.runhooks.app/api/v1/alerts/${alertId}`, {
method: "PUT",
headers: {
"Authorization": "Bearer rh_live_xxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled: false }),
});
const { data: alert } = await res.json();DELETE /alerts/:id
Delete an alert configuration.
const res = await fetch(`https://api.runhooks.app/api/v1/alerts/${alertId}`, {
method: "DELETE",
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { message } = await res.json();Account
POST /auth/register
Create a full account with email and password. Returns JWT tokens and an API key.
| Field | Type | Required | Description |
|---|---|---|---|
email | string | required | Valid email address |
password | string | required | 8–128 characters |
name | string | required | Display name (1–255 chars) |
const res = await fetch("https://api.runhooks.app/api/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "me@example.com",
password: "securepass",
name: "My Name",
}),
});
const { data } = await res.json();
// data.user, data.tokens, data.apiKeyResponse (201):
{
"success": true,
"message": "Registration successful. Save the API key -- it cannot be retrieved again.",
"data": {
"user": { "id": "...", "email": "me@example.com", "name": "My Name", "plan": "free", "role": "user" },
"tokens": { "accessToken": "eyJ...", "refreshToken": "eyJ..." },
"apiKey": "rh_live_xxxxxxxxxxxx"
}
}POST /auth/register-anonymous
Create an anonymous account with no email or password. Returns an API key only (no JWT tokens).
| Field | Type | Required | Description |
|---|---|---|---|
name | string | optional | Display name (1–255 chars) |
Response (201):
{
"success": true,
"message": "Anonymous account created. Save the API key -- it cannot be retrieved again.",
"data": {
"user": { "id": "...", "name": "My Server", "plan": "free", "role": "user", "isAnonymous": true },
"apiKey": "rh_live_xxxxxxxxxxxx"
}
}POST /auth/login
Sign in with email and password. Returns JWT tokens for web session use.
| Field | Type | Required | Description |
|---|---|---|---|
email | string | required | Valid email address |
password | string | required | Account password |
const res = await fetch("https://api.runhooks.app/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "me@example.com", password: "securepass" }),
});
const { data } = await res.json();
// data.user, data.tokens.accessToken, data.tokens.refreshTokenPOST /auth/refresh
Exchange a refresh token for new access and refresh tokens. Access tokens expire after 15 minutes; refresh tokens last 30 days.
| Field | Type | Required | Description |
|---|---|---|---|
refreshToken | string | required | A valid refresh token |
const res = await fetch("https://api.runhooks.app/api/v1/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: "eyJ..." }),
});
const { data: tokens } = await res.json();
// tokens.accessToken, tokens.refreshTokenGET /auth/me
Get the authenticated user's profile.
const res = await fetch("https://api.runhooks.app/api/v1/auth/me", {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data: user } = await res.json();Response (200):
{
"success": true,
"data": {
"id": "665a0a1b2c3d4e5f6a7b8c9d",
"email": "me@example.com",
"name": "My Name",
"role": "user",
"plan": "free",
"isAnonymous": false,
"apiKeyPrefix": "rh_live_abc1",
"createdAt": "2025-01-15T12:00:00.000Z",
"updatedAt": "2025-01-15T12:00:00.000Z"
}
}POST /auth/rotate-key
Generate a new API key. The old key stops working immediately.
const res = await fetch("https://api.runhooks.app/api/v1/auth/rotate-key", {
method: "POST",
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data } = await res.json();
// data.apiKey (new key), data.apiKeyPrefixResponse (200):
{
"success": true,
"message": "API key rotated. Save the new key -- it cannot be retrieved again.",
"data": {
"apiKey": "rh_live_yyyyyyyyyyyy",
"apiKeyPrefix": "rh_live_yyyy"
}
}Usage & Limits
GET /usage/me
Get your current plan, limits, and usage.
const res = await fetch("https://api.runhooks.app/api/v1/usage/me", {
headers: { "Authorization": "Bearer rh_live_xxxxxxxxxxxx" },
});
const { data: usage } = await res.json();Response (200):
{
"success": true,
"data": {
"plan": "free",
"limits": {
"maxJobs": 10,
"maxDailyRuns": 200,
"maxMonthlyRuns": 2000,
"maxRetries": 3,
"maxRequestTimeoutMs": 30000,
"maxAlertConfigs": 1,
"retentionDays": 1,
"concurrency": 1
},
"usage": {
"jobs": { "used": 1, "limit": 10, "unlimited": false },
"dailyRuns": { "used": 42, "limit": 200, "unlimited": false, "resetsAt": "2025-01-16T00:00:00.000Z" },
"monthlyRuns": { "used": 350, "limit": 2000, "unlimited": false, "resetsAt": "2025-02-01T00:00:00.000Z" },
"alertConfigs": { "used": 0, "limit": 1, "unlimited": false }
}
}
}Plan limits comparison
| Limit | Free | Starter | Production | Growth |
|---|---|---|---|---|
| Max jobs | 10 | 20 | 50 | 100 |
| Daily runs | 200 | 2,000 | 12,000 | 50,000 |
| Monthly runs | 2,000 | 20,000 | 120,000 | 500,000 |
| Max retries | 3 | 5 | 7 | 10 |
| Request timeout | 30s | 60s | 120s | 300s |
| Alert configs | 1 | 3 | 10 | 50 |
| Log retention | 1 day | 3 days | 14 days | 30 days |
| Concurrency | 1 | 2 | 5 | 15 |
Health Check
GET /health
Public endpoint (no authentication required). Returns the API status and backing service health.
const res = await fetch("https://api.runhooks.app/api/v1/health");
const { data } = await res.json();Response (200 when healthy, 503 when degraded):
{
"success": true,
"data": {
"status": "ok",
"version": "0.1.0",
"uptime": 86400,
"timestamp": "2025-01-15T12:00:00.000Z",
"services": {
"mongodb": { "status": "ok" },
"redis": { "status": "ok", "latencyMs": 2 }
}
}
}Frequently Asked Questions
How do I authenticate with the Runhooks API?
Pass your API key in the Authorization header as Bearer rh_live_... (keys are prefixed with rh_live_). Web sessions can alternatively use short-lived JWT access tokens returned by /auth/login.
How do I get an API key without signing up?
Call POST /auth/register-anonymous. It returns a ready-to-use rh_live_ key with no email required — ideal for scripts and AI agents. Save it, as it cannot be retrieved again.
What are the Runhooks API rate limits?
A global limit of 100 requests per 15 minutes per IP, plus tighter per-user limits on writes (30/min), execution replay (10/min), and auth. Exceeding a limit returns HTTP 429 with standard draft-7 rate-limit headers.
What is the base URL and response format?
The base URL is https://api.runhooks.app/api/v1. Every response is JSON shaped as { "success": true, "data": ... }; errors use the same wrapper with success: false and an error message.
Can I schedule jobs with cron or fixed intervals?
Both. Set schedule.type to "cron" with a cron expression (and optional IANA timezone), or "interval" with a duration in milliseconds.
Can I schedule a job to run once at a specific date and time?
Yes. Set schedule.type to "once" and schedule.expression to an ISO-8601 date-time — either an absolute instant like "2026-12-25T14:00:00Z", or a naive wall-clock time like "2026-12-25T09:00:00" with a timezone (UTC if omitted). The time must be in the future. However you express it, the schedule is stored and returned normalized to a single UTC instant, so a job reads identically whether you created or edited it via the API, CLI, MCP, or dashboard. One-off jobs support the same retries, alerts, and logs as recurring jobs, then move to completed (or failed) once they finish.