Local Development with the Dev Emulator
Test your Runlater integration locally without deploying, ngrok, or tunnels. The dev emulator mimics the Runlater API and executes tasks against localhost.
The problem
When you integrate Runlater into your app, the production service calls your public URL to execute tasks.
But during development your app runs on
localhost:3000
— which Runlater can't reach. You'd normally need ngrok or a similar tunnel.
The dev emulator solves this. It's a tiny server that runs on your machine, accepts the same API calls as production, and immediately executes tasks against your local app. No tunnel, no deploy, no waiting.
Prefer tunnels? Use runlocal.eu to expose your localhost
and point your Runlater tasks at the tunnel URL. One command: npx runlocal 3000.
Quick start
Run the emulator with Docker:
docker run -p 8080:8080 ghcr.io/runlater-eu/dev
Or run from source if you have Go installed:
git clone https://github.com/runlater-eu/dev.git cd dev go run .
The emulator starts on
http://localhost:8080
and shows colored logs in your terminal:
┌─────────────────────────────────────┐
│ runlater dev emulator │
│ Local development server │
└─────────────────────────────────────┘
Listening on http://0.0.0.0:8080
Inbound URL http://0.0.0.0:8080/in/{slug}
Ping URL http://0.0.0.0:8080/ping/{token}
API key any value accepted
Point the SDK at the emulator
The runlater-js
SDK supports a baseUrl
option. Set it to the
emulator in development:
import Runlater from "runlater-js" export const rl = new Runlater({ apiKey: process.env.RUNLATER_KEY ?? "dev", baseUrl: process.env.RUNLATER_URL, // optional, defaults to production })
Then set the environment variable in development:
# Development — uses local emulator
RUNLATER_KEY=dev
RUNLATER_URL=http://localhost:8080
In production, leave RUNLATER_URL
unset
and the SDK uses the default (https://runlater.eu).
How it works
The emulator implements the same REST API as production. When you create a task, it immediately executes
an HTTP request to your URL — typically your local app running on localhost:3000.
| Task type | Production | Dev emulator |
|---|---|---|
| rl.send() | Queued, executed by worker pool | Executed immediately (1s delay) |
| rl.delay() | Held for the delay duration, then executed | Executed immediately (1s delay) |
| rl.schedule() | Executed at the specified time | Executed immediately (1s delay) |
| rl.cron() | Runs on schedule (via /schedules) |
Stored, trigger manually via /trigger
|
The 1-second simulated delay prevents your code from accidentally relying on instant execution, which won't happen in production.
Example: Testing a webhook handler
Start your app and the emulator, then use the SDK as normal. Here's a Next.js example:
"use server" import { rl } from "@/lib/runlater" export async function onSignup(userId: string) { // In dev: immediately calls localhost:3000/api/send-welcome // In prod: Runlater holds it for 30 minutes, then calls your public URL await rl.delay("http://localhost:3000/api/send-welcome", { delay: "30m", body: { user_id: userId }, }) }
In your terminal, you'll see the emulator execute the request:
14:30:05 --> POST /api/v1/tasks [t_a1b2c3 created, delayed 1800s] 14:30:06 -> POST http://localhost:3000/api/send-welcome 14:30:06 <- 200 [245ms]
Testing inbound webhooks
The emulator also supports inbound webhooks. Create an endpoint, then send test payloads to it:
# Create an endpoint that forwards to your local app curl -X POST http://localhost:8080/api/v1/endpoints \ -H "Content-Type: application/json" \ -d '{"name": "Stripe", "slug": "stripe", "forward_urls": ["http://localhost:3000/webhooks/stripe"]}' # Simulate a Stripe webhook curl -X POST http://localhost:8080/in/stripe \ -H "Content-Type: application/json" \ -d '{"type": "payment_intent.succeeded", "data": {"amount": 2000}}'
The emulator receives the payload and immediately forwards it to your local app, just like production would. Events are stored in memory so you can list them via the API.
Testing cron schedules
Cron schedules are stored but not automatically scheduled — that would defeat the purpose of a
dev server you start and stop freely. Use the
/trigger
endpoint to run them on demand:
# Create a cron schedule curl -X POST http://localhost:8080/api/v1/schedules \ -H "Content-Type: application/json" \ -d '{"url": "http://localhost:3000/api/cleanup", "cron": "0 2 * * *", "name": "Nightly cleanup"}' # Response: {"data": {"id": "sched_abc123", ...}, "message": "Schedule created"} # Trigger it manually curl -X POST http://localhost:8080/api/v1/schedules/sched_abc123/trigger # Your localhost:3000/api/cleanup gets called # Check execution history curl http://localhost:8080/api/v1/schedules/sched_abc123/executions
Testing monitors (heartbeats)
The emulator supports heartbeat monitors. Create a monitor, then ping it from your cron jobs or background tasks to verify the integration works:
# Create a monitor curl -X POST http://localhost:8080/api/v1/monitors \ -H "Content-Type: application/json" \ -d '{"name": "Nightly backup", "schedule_type": "interval", "interval_seconds": 86400}' # Response includes ping_url: http://localhost:8080/ping/{token} # Ping it (using the token from the response) curl http://localhost:8080/ping/abc123...
Lane management
You can pause and resume lanes in the emulator, just like in production. This is useful for testing how your app behaves when lane processing is paused:
# List lanes curl http://localhost:8080/api/v1/lanes # Pause a lane curl -X POST http://localhost:8080/api/v1/lanes/emails/pause # Resume a lane curl -X POST http://localhost:8080/api/v1/lanes/emails/resume
Batch task creation
Send up to 1000 tasks in a single request. Each task defines its own URL and can override method, headers, and body. Top-level fields serve as defaults:
curl -X POST http://localhost:8080/api/v1/tasks/batch \
-H "Content-Type: application/json" \
-d '{"lane": "emails", "tasks": [{"url": "http://localhost:3000/api/send", "body": {"to": "a@b.com"}}, {"url": "http://localhost:3000/api/send", "body": {"to": "c@d.com"}}]}'
Docker networking
If you run the emulator in Docker and your app on the host, the emulator can't reach
localhost:3000
inside the container.
Use host.docker.internal
instead:
# Your task URL when the emulator runs in Docker: http://host.docker.internal:3000/api/webhook # Or add --network=host to skip this entirely (Linux only): docker run --network=host ghcr.io/runlater-eu/dev
What's different from production
- No authentication — any API key (or none) is accepted
- No persistence — all data resets when you restart
- No retries — tasks execute once, so you see failures immediately
- No scheduling — delays and run_at are ignored, tasks run immediately
- 1-second simulated delay — mimics real-world network latency
- No monitor alerting — pings update status but monitors never fire alerts
-
No declarative sync
— the
PUT /api/v1/syncendpoint is not emulated - No rate limits, SSRF protection, or tier limits
CLI flags
| Flag | Default | Description |
|---|---|---|
| --port | 8080 | Port to listen on |
| --host | 0.0.0.0 | Host to bind to |
| --no-color | false |
Disable colored output (also respects
NO_COLOR
env var)
|