Skip to content

Write endpoints Delay can call

Delay calls your endpoint, waits for an answer, and decides from the status code whether the run worked. Knowing exactly what it sends and how it reads the reply avoids most failed runs, and a few habits make retries and overlapping runs harmless.

What your endpoint receives

A request from Delay looks like this:

POST /backup HTTP/1.1
Host: api.yourapp.com
Content-Type: application/json
User-Agent: GuzzleHttp/7
X-Api-Key: your-endpoint-key
X-Delay-Signature: <64-character hex HMAC-SHA256>
X-Delay-Timestamp: 1792368000
{"type":"full"}
HeaderWhenNotes
Content-Type: application/jsonWhen there is a body: POST, PUT, PATCH, and DELETE with a payloadA Content-Type you set in your headers replaces it. The body is JSON either way. GET and a bodyless DELETE have no body and no Content-Type.
User-Agent: GuzzleHttp/7AlwaysThere is no Delay-specific user agent, so don’t use it to recognise Delay. Use a secret header or the signature instead.
Your headersAlwaysExactly as you configured them on the cron or job
X-Delay-Signature, X-Delay-TimestampWhen your team has a signing secretSee Verify requests from Delay

Delay doesn’t send an Accept header, and it adds no job ID, attempt number, request ID or idempotency key. If your endpoint needs one, put it in the payload or a header yourself (see Make endpoints safe to call twice).

How the payload travels depends on the method:

MethodBody
GETNo body. The payload’s keys are added to the URL’s query string. A payload key replaces a URL parameter with the same name.
POST, PUT, PATCHThe payload as JSON. With no payload, the body is [].
DELETEThe payload as JSON, or no body if there is no payload

A few details of the JSON Delay sends:

  • Forward slashes and non-ASCII characters are escaped (https:\/\/..., é). Any JSON parser reads them normally.
  • Empty objects are sent as [], at any depth: {"meta": {}} arrives as {"meta":[]}. Accept both if your schema is strict.
  • A cron’s payload is a string: a JSON object or array is sent as JSON, anything else is wrapped as {"body": "..."}. See Cron schedules.

How Delay reads your response

Your responseResult
2xxSuccess, then any success assertions are checked
3xxFailure, reason unknown. Redirects are never followed.
4xxFailure, reason http_client_error
5xxFailure, reason http_server_error
No complete response in timeFailure, reason timeout

All the reasons and their fixes are in Failure reasons.

Some consequences:

  • Point Delay at the final URL. An endpoint that redirects http to https, adds www or a trailing slash will fail every time. Run curl -i against the URL and make sure the answer isn’t a 3xx.
  • Every failed job attempt is retried, 4xx included. A 404 or 422 doesn’t stop retries: the job is retried until its queue’s retry list runs out. If retrying can’t help (the order was deleted, the input is invalid for good), return a 2xx with a note, such as 200 with {"skipped": "order not found"}, so Delay stops. See Retries and queue design.
  • Crons never retry. A failed cron run waits for the next scheduled time.
  • Return an error when the work failed. A 200 on failure hides the problem from run history and alerts. If you can’t change the status code, add an assertion that catches the failure in the body.

Timeouts

LimitValue
Request timeoutThe cron’s Request timeout or the queue’s (for jobs). 1 to 300 seconds, default 30.
Opening the connection10 seconds at most (less if the request timeout is shorter)
TLSCertificates are verified. TLS 1.2 or newer is required.

When the timeout passes, Delay closes the connection and records a timeout. Your server may still finish the work after that, and a job will then be retried, so the work can happen twice. Reply fast and make the endpoint safe to call twice to handle that.

Reply fast, work in the background

For anything that takes more than a few seconds, accept the request, reply 202 Accepted straight away, and do the work after replying:

import express from 'express';
const app = express();
app.post('/backup', express.json(), (req, res) => {
res.status(202).json({ accepted: true });
// runBackup is your own async function.
runBackup(req.body).catch((error) => {
console.error('Backup failed', error);
});
});
app.listen(3000);

Two trade-offs to keep in mind:

  • Delay records the 202 as a success. Failures after that point don’t show up in Delay’s run history or alerts, so log or alert on them yourself.
  • Work started inside the web process is lost if the process restarts. For anything important, hand it to your own job queue (Sidekiq, Laravel queues, Celery, BullMQ and so on) and reply once it’s queued.

If the work must finish before you reply, raise the request timeout instead (up to 300 seconds).

Keep responses small

Delay stores the response body up to 65,535 bytes and cuts anything longer. The execution history shows the first 2,000 characters. Assertions see the full body. A short JSON summary is easier to read in the dashboard than an HTML page or a data dump:

{ "status": "ok", "processed": 42 }

Make endpoints safe to call twice

The same request can reach your endpoint more than once:

  • a job attempt times out after your server did the work, and the retry repeats it;
  • someone replays a dead-lettered job or retries failed jobs from the dashboard;
  • your own code sends the same POST /api/jobs twice after a network error (the API has no idempotency key, so each call creates a new job);
  • a cron run is still going when the next one starts, or someone clicks Run now.

Delay doesn’t send an ID you could use to spot repeats, so build your own key.

For jobs, put a key in the payload. It is sent unchanged on every retry and replay:

{ "order_id": 1234, "idempotency_key": "receipt-order-1234" }

Record the key in the same database transaction as the work. If it’s already there, skip the work and return 200:

CREATE TABLE processed_requests (
idempotency_key TEXT PRIMARY KEY,
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Inside the transaction that does the work (PostgreSQL syntax;
-- MySQL: INSERT IGNORE):
INSERT INTO processed_requests (idempotency_key)
VALUES ('receipt-order-1234')
ON CONFLICT (idempotency_key) DO NOTHING;
-- 0 rows inserted: this request was already handled.

Keeping the key and the work in one transaction means a failed attempt doesn’t leave the key behind and block the retry.

For crons, the payload is the same on every run, so derive the key from the period the run covers. A nightly backup can use the UTC date (backup-2026-10-19): a second run for the same night, from Run now or a catch-up after an outage, is then skipped. To stop runs from overlapping, take a lock when the run starts (a database row lock or an advisory lock) and return 200 with a note such as {"skipped": "previous run still active"} if it’s already held. See Overlapping runs.

Make the endpoint reachable

  • Use a public hostname. It must resolve to a public IPv4 address. Private and reserved addresses (10.x, 172.16.x to 172.31.x, 192.168.x, 127.x, 169.254.x) are blocked, and hosts with only an IPv6 address can’t be called. Delay checks this when the request is sent, not when you create the cron or job.
  • Prefer HTTPS. http:// URLs work, but headers and payloads then travel unencrypted.
  • Don’t rely on an IP allowlist. Delay doesn’t publish the addresses it sends from. Protect the endpoint with a secret header or request signing instead.

Health check endpoints

For a cron that checks your service, return a small JSON body that states the result, and assert on it:

{ "status": "ok", "database": "ok", "cache": "ok" }

Pair it with JSON path equals status=ok and Response time under (ms) 3000, so a slow or degraded service fails the run even when it answers 200. If a field is a boolean, compare it with 1, not true (see JSON path equals).

Next steps