Completion callbacks
A completion callback is a POST that Delay sends to your callback_url when a job or cron run finishes, with the result as JSON. Use it to update your own records: the API has no endpoint for reading a job’s status.
Set a callback URL
Add callback_url when you create the job or cron:
curl -X POST https://app.delay.run/api/jobs \ -H "Authorization: Bearer $DELAY_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "queue": "invoices", "url": "https://api.yourapp.com/charge", "payload": {"invoice_id": 1234}, "callback_url": "https://api.yourapp.com/delay-callback?invoice=1234" }'In the dashboard, it’s the Callback URL (optional) field in the Create job dialog and on the cron job form. For crons, see POST /api/crons.
When callbacks fire
Jobs send one callback per job:
- when an attempt succeeds (
statusissuccess), or - when the last attempt fails and the job is dead-lettered (
statusisdead_lettered).
A job sends no callback for a failed attempt that will be retried, when it’s blocked by the monthly request limit, or when Delay’s worker fails while running it (the job is then dead-lettered without a callback).
Crons send a callback after every execution, success or failure, including Run now. An execution blocked by the monthly request limit sends none.
Job callback payload
This job failed on all 4 attempts and was dead-lettered, so the callback comes from attempt 4:
{ "type": "job_execution", "job_id": "019a0c4f-2d90-7e18-b6a4-91c3e5f07d22", "original_job_id": "019a0c4e-7b21-7d3a-9f4e-2c1b8a6d5e40", "status": "dead_lettered", "response_status": 503, "duration_ms": 1204, "error_message": "HTTP 503 error", "attempt_number": 4, "completed_at": "2026-10-18T17:46:12+00:00"}| Field | Value |
|---|---|
type | Always job_execution. |
job_id | Id of the attempt that finished. It equals the job_id from the API only when attempt 1 finished the job. |
original_job_id | Id of the first attempt: the job_id that POST /api/jobs returned. Equal to job_id when attempt 1 finished the job. |
status | success or dead_lettered. |
response_status | HTTP status of that attempt, or null when there was no response (timeout, connection error). |
duration_ms | How long that attempt took, in milliseconds. |
error_message | null on success. Otherwise, for example, HTTP 503 error, Assertion failed: ... or the connection error. |
attempt_number | 1 for the first attempt, 2 or more for a retry. |
completed_at | When that attempt finished, in ISO 8601 (UTC). |
Cron callback payload
{ "type": "cron_execution", "cron_id": "019a0b77-5c12-7a40-8e3d-0f6b2a9c1e58", "execution_id": "019a0d10-3e6f-7b92-a1c7-54d8e2f0b913", "status": "success", "response_status": 200, "duration_ms": 342, "error_message": null, "completed_at": "2026-10-18T03:00:01+00:00"}| Field | Value |
|---|---|
type | Always cron_execution. |
cron_id | The cron’s id (the id that POST /api/crons returns). |
execution_id | Id of this execution. |
status | success when the response was 2xx and every assertion passed, otherwise failed. |
response_status | HTTP status, or null when there was no response. |
duration_ms | How long the execution took, in milliseconds. |
error_message | Set for timeouts, connection errors and failed assertions. null for a plain HTTP error such as a 500, so check response_status too. |
completed_at | When the execution finished, in ISO 8601 (UTC). |
How Delay sends a callback
POSTwithContent-Type: application/jsonand the JSON above as the body.- Signed with
X-Delay-SignatureandX-Delay-Timestampwhen your team has a signing secret, the same way as requests to your targets. The signature covers the exact body. See Verify requests from Delay. - 10 second timeout. Redirects aren’t followed, and URLs that resolve to a private or reserved address are blocked.
- Sent once. Delay never retries a callback and ignores your response: a 500, a timeout or a refused connection isn’t recorded anywhere you can see.
- Callbacks don’t count toward your monthly request quota.
Match a callback to your records
Each retry is a new job with a new id (Jobs), so the job_id in a callback often isn’t the one the API returned. Match on original_job_id instead: it’s always the id of the first attempt, the job_id from POST /api/jobs.
The callback body doesn’t include your payload. To get your own reference back, put it in the callback URL, as in the example above (?invoice=1234). Delay posts to that exact URL, query string included, so your handler can read the reference from it.
A replay or a bulk Retry in Jobs creates a new job with its own id and the same callback URL. Its callbacks carry the new job’s id as original_job_id, not the id the API returned, so match them by the reference in the URL. You can then get a second callback for the same reference. Handle callbacks idempotently: a success that arrives after a dead_lettered for the same invoice means the replay worked.
Handle callbacks reliably
A callback is sent once and never retried, so your handler can miss one, for example during a deploy.
- Return a 2xx within 10 seconds and do slow work afterwards.
- Where you can, have the target endpoint record its own result, and treat the callback as a notification.
- Check Jobs and Dead letters in the dashboard for anything that never called back.
Next steps
- Jobs: statuses, retries and attempt numbers.
- Verify requests from Delay: check the signature on callbacks.
- POST /api/jobs: every field you can set on a job, including
callback_url. - Replay failed jobs: what happens when you replay a dead-lettered job.