Verify requests from Delay
Turn on signing so your endpoint can tell a real request from Delay apart from one anybody could send to the same URL. Delay signs each request with your team’s secret, and your endpoint checks the signature before it does any work.
Turn on signing
- Open Settings > Webhook signing. Only team admins can open this page, and the secret belongs to the team that is selected in the header.
- Click Generate secret, then Generate in the dialog.
- Click the copy icon (Copy secret) and store it where your endpoint reads its config, for example as
DELAY_SIGNING_SECRET.
Requests are unsigned until a secret exists. To see the secret again later, click the eye icon (Show secret) on the same page.
Each team has its own secret. If one endpoint receives requests from two teams (say, staging and production), it needs both secrets. See teams for environments.
What Delay signs
| Request | Signed |
|---|---|
| Job requests to your URL (jobs) | Yes |
| Cron requests to your URL (cron schedules) | Yes |
Completion callbacks to your callback_url | Yes |
| Alert posts to Slack or Discord | No |
Headers and signed string
Every signed request carries two headers:
| Header | Value |
|---|---|
X-Delay-Signature | HMAC-SHA256 of the signed string, as 64 lowercase hex characters, with no prefix |
X-Delay-Timestamp | The time Delay signed the request, in Unix seconds, for example 1792329896 |
The signed string is the timestamp, a period, and the raw body exactly as it was sent:
{timestamp}.{raw body}What the raw body is depends on the method:
| Method | Body Delay sends | Signed string |
|---|---|---|
GET | None (a payload goes in the query string) | 1792329896. |
DELETE without a payload | None | 1792329896. |
DELETE with a payload | The payload as JSON | 1792329896.{"reason":"expired"} |
POST, PUT, PATCH | The payload as JSON, or [] when there is no payload | 1792329896.[] |
Only the timestamp and the body are signed. The method, URL, query string and other headers are not.
Here is a request Delay sent for a job with the payload {"user_id": 42, "url": "https://x.test/a/b", "name": "Zoë"}, signed with the test secret test_secret_123:
POST /webhooks/delay HTTP/1.1Content-Type: application/jsonX-Delay-Signature: 75457ae035d74a4fa56956b7f3e2096b7944f7675ff543b8c7a147eb3b17586dX-Delay-Timestamp: 1792330589
{"user_id":42,"url":"https:\/\/x.test\/a\/b","name":"Zo\u00eb"}Notice the body: Delay’s JSON escapes slashes (\/) and non-ASCII characters (ë becomes \u00eb). If you parse the JSON and encode it again, you get different bytes and the signature won’t match. Always verify the raw body, then parse it.
Verify the signature
Your endpoint should:
- Read the raw request body before anything parses it.
- Reject the request if either header is missing.
- Reject it if the timestamp is more than 5 minutes from your server’s clock (see replay window).
- Compute HMAC-SHA256 of
timestamp + "." + raw bodywith your secret, as hex. - Compare it with
X-Delay-Signaturein constant time. - Only then parse the body and do the work.
Return a 401 when the check fails. Delay records any non-2xx response as a failed run, so a broken check shows up in your run history (and job attempts are retried).
Plain PHP, for example as the entry point of the route that receives Delay’s requests:
<?php
function verify_delay_signature(string $rawBody, ?string $signature, ?string $timestamp, string $secret): bool{ if ($signature === null || $timestamp === null || ! ctype_digit($timestamp)) { return false; }
// Reject requests signed more than 5 minutes ago (or ahead): they may be replays. if (abs(time() - (int) $timestamp) > 300) { return false; }
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
return hash_equals($expected, $signature);}
$secret = getenv('DELAY_SIGNING_SECRET') ?: throw new RuntimeException('DELAY_SIGNING_SECRET is not set');$rawBody = file_get_contents('php://input'); // the exact bytes Delay sent
$valid = verify_delay_signature( $rawBody, $_SERVER['HTTP_X_DELAY_SIGNATURE'] ?? null, $_SERVER['HTTP_X_DELAY_TIMESTAMP'] ?? null, $secret,);
if (! $valid) { http_response_code(401); exit('Invalid signature');}
$data = json_decode($rawBody, true); // parse only after verifying// ... handle the request, then return a 2xxLaravel: read the secret through config(), not env(), because env() returns null once the config is cached. Add it to config/services.php:
'delay' => [ 'signing_secret' => env('DELAY_SIGNING_SECRET'),],Then add a middleware, app/Http/Middleware/VerifyDelaySignature.php:
<?php
namespace App\Http\Middleware;
use Closure;use Illuminate\Http\Request;use RuntimeException;use Symfony\Component\HttpFoundation\Response;
class VerifyDelaySignature{ public function handle(Request $request, Closure $next): Response { $secret = config('services.delay.signing_secret') ?: throw new RuntimeException('services.delay.signing_secret is not set'); $signature = $request->header('X-Delay-Signature'); $timestamp = $request->header('X-Delay-Timestamp');
if (! is_string($signature) || ! is_string($timestamp) || ! ctype_digit($timestamp)) { abort(401, 'Invalid signature'); }
// Reject requests signed more than 5 minutes ago (or ahead): they may be replays. if (abs(now()->getTimestamp() - (int) $timestamp) > 300) { abort(401, 'Stale request'); }
$expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $secret);
abort_unless(hash_equals($expected, $signature), 401, 'Invalid signature');
return $next($request); }}Attach it to the route. Routes in routes/web.php also check CSRF tokens, so put the route in routes/api.php or exclude its path from CSRF protection:
use App\Http\Middleware\VerifyDelaySignature;
Route::post('/webhooks/delay', HandleDelayWebhook::class) // your controller ->middleware(VerifyDelaySignature::class);$request->getContent() is the raw body, so the check works even though Laravel also parses the JSON for $request->input().
Express, with express.raw() so the handler gets the body as bytes:
import crypto from 'node:crypto';import express from 'express';
const SECRET = process.env.DELAY_SIGNING_SECRET;if (!SECRET) throw new Error('DELAY_SIGNING_SECRET is not set');
function verifyDelaySignature(rawBody, signature, timestamp, secret) { if (typeof signature !== 'string' || typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) { return false; }
// Reject requests signed more than 5 minutes ago (or ahead): they may be replays. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return false; }
const expected = Buffer.from( crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'), ); const received = Buffer.from(signature);
// timingSafeEqual throws when lengths differ, so compare lengths first. return received.length === expected.length && crypto.timingSafeEqual(received, expected);}
const app = express();
// express.raw() keeps the exact bytes Delay sent. Register this route before any express.json().app.post('/webhooks/delay', express.raw({ type: '*/*' }), (req, res) => { // Requests without a body (GET, DELETE with no payload) are signed over an empty string. const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
if (!verifyDelaySignature(rawBody, req.get('X-Delay-Signature'), req.get('X-Delay-Timestamp'), SECRET)) { return res.status(401).send('Invalid signature'); }
const data = rawBody.length > 0 ? JSON.parse(rawBody.toString('utf8')) : null; // parse only after verifying // ... handle the request, then return a 2xx res.sendStatus(200);});
app.listen(3000);If express.json() runs first, it consumes the body and the check fails. express.raw() accepts up to 100 kB by default; pass a larger limit if your payloads are bigger. Works with Express 4 and 5.
Flask, with request.get_data() for the raw body:
import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
SECRET = os.environ["DELAY_SIGNING_SECRET"]app = Flask(__name__)
def verify_delay_signature(raw_body: bytes, signature: str | None, timestamp: str | None, secret: str) -> bool: if not signature or not timestamp or not (timestamp.isascii() and timestamp.isdigit()): return False
# Reject requests signed more than 5 minutes ago (or ahead): they may be replays. if abs(time.time() - int(timestamp)) > 300: return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), signature.encode())
@app.post("/webhooks/delay")def delay_webhook(): raw_body = request.get_data() # the exact bytes Delay sent; read it before get_json()
if not verify_delay_signature( raw_body, request.headers.get("X-Delay-Signature"), request.headers.get("X-Delay-Timestamp"), SECRET, ): abort(401)
data = request.get_json(silent=True) # parse only after verifying # ... handle the request, then return a 2xx return "", 200Needs Python 3.10 or newer. In FastAPI, the same function works with raw_body = await request.body().
The examples handle POST. The same check works for any method: a GET arrives with an empty body and is signed over {timestamp}. with nothing after the period.
Test your code with known values
To test the HMAC step on its own, use these values (skip the timestamp check, because this timestamp will be old by the time you read this):
| Secret | Timestamp | Raw body | Expected signature |
|---|---|---|---|
test_secret_123 | 1792329896 | {"user_id":42} | 5c56618f4a5ca384162942c9929a73ec5b38f75ca404f06f23254cc2c50c20b2 |
test_secret_123 | 1792329896 | [] | dff7c6c17ca85d93592330f502e519a7ccb3466c2cc02fc753d7a16950b11903 |
test_secret_123 | 1792329896 | (empty) | 0578bfbe4baa46338ca172b12fcaa840798d0de033ba0345952b4a1db2fe29f0 |
The example request above is a fourth test case: same secret, with its own timestamp and body.
Choose a replay window
Delay doesn’t reject old requests for you. Someone who captures a signed request could send it again later, and the signature would still match. The timestamp check stops that: a request is only accepted for a few minutes after Delay signed it.
Five minutes (300 seconds) is a reasonable default, and the examples use it. The value is your choice: shorter is stricter, longer forgives clock drift. Keep your server’s clock synced (NTP).
Retries don’t conflict with the window. Each attempt is signed when it is sent, so a retry three hours later carries a fresh timestamp.
Rotate the secret
Delay keeps one secret per team. Regenerate secret replaces it at once: from that moment every request is signed with the new secret, and there’s no period where both work. You also can’t learn the new secret before you regenerate it. So expect a short gap, and keep it small:
- Make the secret easy to change: read it from an environment variable or secret store, so you can update it without changing code.
- Pick a quiet moment, when no crons are about to run. To avoid alert emails for rejected requests, create a maintenance window for the next few minutes.
- Click Regenerate secret, then Regenerate in the dialog, and copy the new secret.
- Put the new secret in your endpoint’s config right away and remove the old one.
Accepting the old and the new secret side by side doesn’t close this gap, because Delay stops using the old secret the moment you regenerate. Requests that arrive before your endpoint has the new secret fail your check:
- Job attempts are retried on their queue’s schedule (the first retry comes after 60 seconds by default), so a short gap usually heals itself.
- Cron runs aren’t retried. A run that was rejected is recorded as failed, and the cron runs again at its next scheduled time.
- Callbacks aren’t retried.
If you can’t accept any failed runs, switch your endpoint to log mismatches without rejecting them for the few minutes of the swap, then turn rejection back on.
If verification fails
- You verify a parsed body. Frameworks that parse JSON first (
express.json(),json.loadsthenjson.dumps) change the bytes. Use the raw body. - Something read the body first. In Node.js, another body parser in front of your route leaves nothing for yours.
- Wrong secret. The secret was regenerated, or you copied the secret of another team. Each team has its own.
- Empty body.
GETrequests andDELETErequests without a payload are signed over an empty body. APOSTwithout a payload is signed over[]. - Clock drift. If your server’s clock is off by more than your window, fresh requests look stale.
- No signature headers at all. The team has no secret yet, or the request isn’t from Delay. Slack and Discord alert posts are never signed.
Protect your endpoint
- Use
https://URLs. Delay also callshttp://URLs, but then the body and headers travel unencrypted. Delay checks TLS certificates, and an invalid one fails the run with an SSL/TLS error. - Delay doesn’t publish a list of source IP addresses, so verify signatures instead of allowlisting IPs.
- A hard-to-guess path (
/webhooks/delay/7f3a9c) cuts down on noise, but the signature is the real check.
Next steps
- Completion callbacks: the signed results Delay posts after a run.
- Write endpoints Delay can call: status codes, timeouts and idempotency.
- Set up alerts: hear about failed runs, including rejected ones.
- Troubleshooting: other reasons a run fails.