Skip to content

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

  1. Open Settings > Webhook signing. Only team admins can open this page, and the secret belongs to the team that is selected in the header.
  2. Click Generate secret, then Generate in the dialog.
  3. 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

RequestSigned
Job requests to your URL (jobs)Yes
Cron requests to your URL (cron schedules)Yes
Completion callbacks to your callback_urlYes
Alert posts to Slack or DiscordNo

Headers and signed string

Every signed request carries two headers:

HeaderValue
X-Delay-SignatureHMAC-SHA256 of the signed string, as 64 lowercase hex characters, with no prefix
X-Delay-TimestampThe 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:

MethodBody Delay sendsSigned string
GETNone (a payload goes in the query string)1792329896.
DELETE without a payloadNone1792329896.
DELETE with a payloadThe payload as JSON1792329896.{"reason":"expired"}
POST, PUT, PATCHThe payload as JSON, or [] when there is no payload1792329896.[]

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.1
Content-Type: application/json
X-Delay-Signature: 75457ae035d74a4fa56956b7f3e2096b7944f7675ff543b8c7a147eb3b17586d
X-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:

  1. Read the raw request body before anything parses it.
  2. Reject the request if either header is missing.
  3. Reject it if the timestamp is more than 5 minutes from your server’s clock (see replay window).
  4. Compute HMAC-SHA256 of timestamp + "." + raw body with your secret, as hex.
  5. Compare it with X-Delay-Signature in constant time.
  6. 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 2xx

Laravel: 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().

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):

SecretTimestampRaw bodyExpected signature
test_secret_1231792329896{"user_id":42}5c56618f4a5ca384162942c9929a73ec5b38f75ca404f06f23254cc2c50c20b2
test_secret_1231792329896[]dff7c6c17ca85d93592330f502e519a7ccb3466c2cc02fc753d7a16950b11903
test_secret_1231792329896(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:

  1. Make the secret easy to change: read it from an environment variable or secret store, so you can update it without changing code.
  2. 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.
  3. Click Regenerate secret, then Regenerate in the dialog, and copy the new secret.
  4. 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.loads then json.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. GET requests and DELETE requests without a payload are signed over an empty body. A POST without 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 calls http:// 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