Skip to main content

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:

lib/runlater.ts
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:

.env.local
# 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:

app/actions/signup.ts
"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:

Terminal
# 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:

Terminal
# 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:

Terminal
# 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:

Terminal
# 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:

Terminal
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/sync endpoint 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)
Source code The dev emulator is open source at github.com/runlater-eu/dev. Zero external dependencies, ~13MB Docker image. Contributions welcome.

Back to documentation