Skip to content

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:

Terminal window
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 (status is success), or
  • when the last attempt fails and the job is dead-lettered (status is dead_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"
}
FieldValue
typeAlways job_execution.
job_idId of the attempt that finished. It equals the job_id from the API only when attempt 1 finished the job.
original_job_idId of the first attempt: the job_id that POST /api/jobs returned. Equal to job_id when attempt 1 finished the job.
statussuccess or dead_lettered.
response_statusHTTP status of that attempt, or null when there was no response (timeout, connection error).
duration_msHow long that attempt took, in milliseconds.
error_messagenull on success. Otherwise, for example, HTTP 503 error, Assertion failed: ... or the connection error.
attempt_number1 for the first attempt, 2 or more for a retry.
completed_atWhen 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"
}
FieldValue
typeAlways cron_execution.
cron_idThe cron’s id (the id that POST /api/crons returns).
execution_idId of this execution.
statussuccess when the response was 2xx and every assertion passed, otherwise failed.
response_statusHTTP status, or null when there was no response.
duration_msHow long the execution took, in milliseconds.
error_messageSet for timeouts, connection errors and failed assertions. null for a plain HTTP error such as a 500, so check response_status too.
completed_atWhen the execution finished, in ISO 8601 (UTC).

How Delay sends a callback

  • POST with Content-Type: application/json and the JSON above as the body.
  • Signed with X-Delay-Signature and X-Delay-Timestamp when 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