Webhooks
How we call your endpoints, handle failures, and keep things reliable.
How It Works
When a job triggers, Runlater makes an HTTP request to your configured URL. Your endpoint processes the request and returns a response.
- Job triggers (cron schedule or one-time)
- Runlater sends HTTP request to your URL
- Your server processes the request
- Return 2xx status for success
- We log the result and schedule retries if needed
Request Format
Every webhook request includes these headers:
POST /your-endpoint HTTP/1.1 Host: your-app.com Content-Type: application/json X-Runlater-Job-Id: 01JJXYZ... X-Runlater-Execution-Id: 01JJXYZ... X-Runlater-Signature: sha256=a1b2c3d4... Authorization: Bearer your-token (if configured) // Your configured body (if any) {"action": "sync"}
| Header | Description |
|---|---|
X-Runlater-Job-Id |
The ID of the job being executed |
X-Runlater-Execution-Id |
Unique ID for this execution (use for deduplication) |
X-Runlater-Signature |
HMAC-SHA256 signature of the request body |
Verifying Signatures
Every request is signed with your organization's webhook secret. Verify the signature to ensure requests are from Runlater.
Find your webhook secret in Organization Settings → API Keys.
const crypto = require('crypto'); function verifySignature(body, signature, secret) { const expected = crypto .createHmac('sha256', secret) .update(body) .digest('hex'); return `sha256=${expected}` === signature; } app.post('/webhook', (req, res) => { const signature = req.headers['x-runlater-signature']; const body = JSON.stringify(req.body); // or raw body buffer if (!verifySignature(body, signature, process.env.RUNLATER_WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); } // Safe to process - request is from Runlater const jobId = req.headers['x-runlater-job-id']; const executionId = req.headers['x-runlater-execution-id']; // Process the job... res.json({ ok: true }); });
Response Handling
Runlater interprets your response status code:
| Status | Result | Action |
|---|---|---|
2xx |
Success | Job marked complete |
4xx |
Client error | Job marked failed (no retry) |
5xx |
Server error | Job retried with backoff |
Timeout |
Timeout | Job retried with backoff |
Timeouts
Your endpoint has 30 seconds to respond. If no response is received within this time, the execution is marked as a timeout and scheduled for retry.
Automatic Retries
Retry behavior depends on the job type:
One-time Jobs (run_at)
One-time jobs automatically retry with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 20 seconds |
| 2nd retry | 45 seconds |
| 3rd retry | ~1.5 minutes |
| 4th retry | ~2 minutes |
| 5th retry | ~3 minutes |
After 5 failed attempts, the job is marked as permanently failed and you'll receive an alert (if configured).
Recurring Jobs (cron)
Cron jobs do not retry. If a scheduled run fails, the next scheduled run will execute as normal. This prevents retry storms and duplicate executions.
Idempotency
Your webhook handler should be idempotent - safe to call multiple times with the same data. This is important because:
- Network issues may cause duplicate deliveries
- Retries will re-send the same request
- A timeout might occur after your server processed the request
Use the X-Runlater-Execution-Id
header to deduplicate:
// Track processed executions const processed = new Set(); // Use Redis in production app.post('/webhook', async (req, res) => { const executionId = req.headers['x-runlater-execution-id']; if (processed.has(executionId)) { return res.json({ status: 'already_processed' }); } // Process the job... await doWork(); processed.add(executionId); res.json({ status: 'ok' }); });
Custom Headers
You can configure custom headers when creating a job:
curl -X POST https://runlater.eu/api/v1/jobs \ -H "Authorization: Bearer pk_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "My job", "url": "https://myapp.com/webhook", "cron": "0 * * * *", "headers": { "Authorization": "Bearer my-app-token", "X-Custom-Header": "custom-value" } }'