<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: delay.run</title>
    <description>The latest articles on DEV Community by delay.run (@delayrun).</description>
    <link>https://dev.to/delayrun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4210417%2F3da89f80-6390-415e-bca5-65d64a57235d.png</url>
      <title>DEV Community: delay.run</title>
      <link>https://dev.to/delayrun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/delayrun"/>
    <language>en</language>
    <item>
      <title>How to verify HMAC-SHA256 signed webhooks in Node and Python</title>
      <dc:creator>delay.run</dc:creator>
      <pubDate>Sat, 31 Oct 2026 22:40:59 +0000</pubDate>
      <link>https://dev.to/delayrun/how-to-verify-hmac-sha256-signed-webhooks-in-node-and-python-2chl</link>
      <guid>https://dev.to/delayrun/how-to-verify-hmac-sha256-signed-webhooks-in-node-and-python-2chl</guid>
      <description>&lt;p&gt;If your app exposes an endpoint that another service calls (a webhook, a scheduled job URL, a callback), anyone who discovers the URL can call it too. Signed requests fix that. The sender computes an HMAC of the request with a shared secret, sends it in a header, and you recompute it and compare. Here's how to do it correctly, including the parts most implementations get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  How HMAC signing works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;You and the sender share a secret, for example a long random string.&lt;/li&gt;
&lt;li&gt;For each request, the sender computes &lt;code&gt;HMAC-SHA256(secret, message)&lt;/code&gt;, where the message is usually a timestamp plus the raw request body.&lt;/li&gt;
&lt;li&gt;The sender puts the signature (and the timestamp) in headers.&lt;/li&gt;
&lt;li&gt;You compute the same HMAC over the same message and compare.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If someone changes the body, or doesn't know the secret, the signatures won't match.&lt;/p&gt;

&lt;p&gt;The exact header names and message format vary by sender, so check their docs. The examples below assume headers called &lt;code&gt;X-Signature&lt;/code&gt; (hex) and &lt;code&gt;X-Timestamp&lt;/code&gt; (Unix seconds), signing &lt;code&gt;timestamp + "." + body&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: use the raw body
&lt;/h2&gt;

&lt;p&gt;The signature covers the exact bytes that were sent. If your framework parses JSON and you re-serialize it, key order and whitespace can change, and the signature won't match. Capture the raw body.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: compare in constant time
&lt;/h2&gt;

&lt;p&gt;A normal &lt;code&gt;==&lt;/code&gt; stops at the first different character, which leaks timing information. Use a constant-time compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: check the timestamp
&lt;/h2&gt;

&lt;p&gt;Without a timestamp check, an attacker who captures one valid request can replay it forever. Reject requests older than a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Node.js (Express)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOLERANCE_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/jobs/run&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;TOLERANCE_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stale or missing timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// raw Buffer&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bad signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="c1"&gt;// ... do the work&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;express.raw()&lt;/code&gt; keeps the body as a Buffer. &lt;code&gt;timingSafeEqual&lt;/code&gt; throws if lengths differ, hence the length check first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Python (Flask)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;abort&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;TOLERANCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/jobs/run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_job&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TOLERANCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# raw bytes
&lt;/span&gt;    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... do the work
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call &lt;code&gt;request.get_data()&lt;/code&gt; before anything touches &lt;code&gt;request.json&lt;/code&gt;, so you get the original bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it locally
&lt;/h2&gt;

&lt;p&gt;Generate a signed request by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'test-secret'&lt;/span&gt;
&lt;span class="nv"&gt;TS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;BODY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"task":"cleanup"}'&lt;/span&gt;
&lt;span class="nv"&gt;SIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s.%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BODY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl dgst &lt;span class="nt"&gt;-sha256&lt;/span&gt; &lt;span class="nt"&gt;-hmac&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-hex&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:3000/jobs/run &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Timestamp: &lt;/span&gt;&lt;span class="nv"&gt;$TS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Signature: &lt;/span&gt;&lt;span class="nv"&gt;$SIG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BODY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then change one character in the body, or use an old timestamp, and confirm you get a 401.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotating secrets
&lt;/h2&gt;

&lt;p&gt;Support two secrets during a rotation: accept a signature that matches either one, switch the sender to the new secret, then remove the old one. Keep secrets in environment variables or a secret manager, never in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combine with idempotency
&lt;/h2&gt;

&lt;p&gt;A valid signature proves who sent the request, not that it's the first time you've seen it. Retries are legitimate duplicates with valid signatures, so handlers should still be idempotent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signed scheduled calls
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://delay.run" rel="noopener noreferrer"&gt;delay.run&lt;/a&gt; signs the HTTP requests it sends with HMAC-SHA256 and a timestamp, so the endpoint it calls on a schedule can be verified with code like the above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/delayrun/make-scheduled-job-handlers-idempotent-safe-to-run-twice-499o"&gt;Make scheduled job handlers idempotent: safe to run twice&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Heartbeat-checks-how-to-know-when-a-scheduled-job-didnt-run-10-31" rel="noopener noreferrer"&gt;Heartbeat checks: how to know when a scheduled job didn't run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/How-to-log-cron-job-output-so-you-can-debug-it-later-10-31" rel="noopener noreferrer"&gt;How to log cron job output so you can debug it later&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Cron-vs-systemd-timers-which-should-run-your-scheduled-jobs-10-31" rel="noopener noreferrer"&gt;Cron vs systemd timers: which should run your scheduled jobs?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/dead-letter-queues-for-http-jobs-what-they-are-and-how-to-use-one" rel="noopener noreferrer"&gt;Dead letter queues for HTTP jobs: what they are and how to use one&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>webhooks</category>
      <category>node</category>
      <category>python</category>
    </item>
    <item>
      <title>Make scheduled job handlers idempotent: safe to run twice</title>
      <dc:creator>delay.run</dc:creator>
      <pubDate>Sat, 31 Oct 2026 22:40:35 +0000</pubDate>
      <link>https://dev.to/delayrun/make-scheduled-job-handlers-idempotent-safe-to-run-twice-499o</link>
      <guid>https://dev.to/delayrun/make-scheduled-job-handlers-idempotent-safe-to-run-twice-499o</guid>
      <description>&lt;p&gt;Any system that calls your endpoint on a schedule, or retries it after a failure, will sooner or later call it twice for the same piece of work. A network timeout after your handler finished, a retry after a slow response, two servers firing the same cron: each of these runs the job again. If your handler sends an email or charges a card, "again" is a real problem. The fix is to make the handler idempotent: running it twice has the same effect as running it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why duplicates are unavoidable
&lt;/h2&gt;

&lt;p&gt;Consider a scheduler calling &lt;code&gt;POST /jobs/send-invoices&lt;/code&gt;. Your handler takes 35 seconds. The caller's timeout is 30 seconds, so it records a failure and retries. Your first run finishes anyway. Now the invoices go out twice.&lt;/p&gt;

&lt;p&gt;You can't fix this purely on the caller's side. Without the caller knowing exactly what your handler completed, "at least once" delivery is the only safe guarantee, and handling duplicates is the handler's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique 1: an idempotency key
&lt;/h2&gt;

&lt;p&gt;Give each unit of work a unique key and record it when you process it. If the key has been seen, skip the work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Flask + PostgreSQL example
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/jobs/send-reminder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_reminder&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. "reminder:user42:2026-11-01"
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DSN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO processed_jobs (key) VALUES (%s) ON CONFLICT DO NOTHING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate, skipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
        &lt;span class="nf"&gt;send_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_jobs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unique constraint does the hard work: if two copies arrive at the same moment, only one insert succeeds.&lt;/p&gt;

&lt;p&gt;Return a 2xx for duplicates. If you return an error, the caller retries again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the key
&lt;/h2&gt;

&lt;p&gt;The key should describe the work, not the delivery attempt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Good: &lt;code&gt;invoice-run:2026-11&lt;/code&gt;, &lt;code&gt;reminder:user42:trial-day-3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bad: a random UUID generated per request, a timestamp of when the call arrived&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For recurring jobs, include the period: &lt;code&gt;daily-report:2026-11-01&lt;/code&gt;. Then a second call on the same day is skipped, and tomorrow's call runs normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique 2: make the work itself naturally idempotent
&lt;/h2&gt;

&lt;p&gt;Some operations are safe to repeat by design:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Not idempotent&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Idempotent&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;renewed_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-12-01'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting a value is safe; incrementing it isn't. Upserts, "mark as done" flags and "recalculate from source" jobs are all naturally safe to run again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technique 3: claim, then process
&lt;/h2&gt;

&lt;p&gt;For batch jobs, mark each item as claimed in the same step as selecting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;emails&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claimed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;emails&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two overlapping runs each get different rows, and a re-run picks up only what's left.&lt;/p&gt;

&lt;h2&gt;
  
  
  External side effects
&lt;/h2&gt;

&lt;p&gt;The idempotency key protects your database, but what about the payment provider or email API you call? Many such APIs accept their own idempotency key header. Pass your job key through so the provider can also drop duplicates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.payments.example/charges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{...},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an API has no such option, record "sent" in your database before marking the job complete, and accept that a crash in between could still send twice. Design for the rare duplicate to be harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Respond fast, work later
&lt;/h2&gt;

&lt;p&gt;A long-running handler makes duplicates more likely, because callers time out. Accept the request, store the work, and return &lt;code&gt;202 Accepted&lt;/code&gt; quickly, then process it in a background worker. The idempotency key goes with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it
&lt;/h2&gt;

&lt;p&gt;The simplest test: call the endpoint twice with the same payload, in parallel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:5000/jobs/send-reminder &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"job_key":"test-1","user_id":42}'&lt;/span&gt; &amp;amp;
&lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One response should say "sent" and the other "duplicate".&lt;/p&gt;

&lt;h2&gt;
  
  
  Where retries come from
&lt;/h2&gt;

&lt;p&gt;A hosted scheduler like &lt;a href="https://delay.run" rel="noopener noreferrer"&gt;delay.run&lt;/a&gt; calls your URL on a cron schedule or after a delay and retries failed jobs with backoff, logging each attempt. Retries are what make jobs reliable, and idempotent handlers are what make retries safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/delayrun/how-to-retry-failed-cron-jobs-with-exponential-backoff-in-bash-and-python-2n59"&gt;How to retry failed cron jobs with exponential backoff in Bash and Python&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Heartbeat-checks-how-to-know-when-a-scheduled-job-didnt-run-10-31" rel="noopener noreferrer"&gt;Heartbeat checks: how to know when a scheduled job didn't run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/How-to-log-cron-job-output-so-you-can-debug-it-later-10-31" rel="noopener noreferrer"&gt;How to log cron job output so you can debug it later&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Cron-vs-systemd-timers-which-should-run-your-scheduled-jobs-10-31" rel="noopener noreferrer"&gt;Cron vs systemd timers: which should run your scheduled jobs?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/dead-letter-queues-for-http-jobs-what-they-are-and-how-to-use-one" rel="noopener noreferrer"&gt;Dead letter queues for HTTP jobs: what they are and how to use one&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webhooks</category>
      <category>backend</category>
      <category>reliability</category>
      <category>api</category>
    </item>
    <item>
      <title>How to retry failed cron jobs (with exponential backoff, in Bash and Python)</title>
      <dc:creator>delay.run</dc:creator>
      <pubDate>Wed, 28 Oct 2026 16:04:42 +0000</pubDate>
      <link>https://dev.to/delayrun/how-to-retry-failed-cron-jobs-with-exponential-backoff-in-bash-and-python-2n59</link>
      <guid>https://dev.to/delayrun/how-to-retry-failed-cron-jobs-with-exponential-backoff-in-bash-and-python-2n59</guid>
      <description>&lt;p&gt;Cron has exactly one opinion about failure: it doesn't have one. If your job exits with an error at 02:00, cron writes nothing, retries nothing and tells nobody. The next attempt is whenever the schedule says, which might be tomorrow.&lt;/p&gt;

&lt;p&gt;Most failures in scheduled jobs are temporary: a database restarting, an API returning 503, a network blip. A couple of retries with a pause in between turns most of those into non-events. Here's how to add retries properly, and where to stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: decide what's worth retrying
&lt;/h2&gt;

&lt;p&gt;Retrying makes sense for errors that might fix themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;timeouts and connection errors&lt;/li&gt;
&lt;li&gt;HTTP 429 (too many requests), 502, 503, 504&lt;/li&gt;
&lt;li&gt;"database is locked", "connection reset" and similar&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retrying does &lt;strong&gt;not&lt;/strong&gt; help, and can make things worse, for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP 400, 401, 403, 404: the request is wrong and will stay wrong&lt;/li&gt;
&lt;li&gt;validation errors, missing files, bad configuration&lt;/li&gt;
&lt;li&gt;bugs in your code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retrying a permanent error just delays the alert and adds load. Fail fast on those.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: back off, and add jitter
&lt;/h2&gt;

&lt;p&gt;Retrying immediately usually hits the same problem. Wait longer after each attempt: 5 seconds, then 10, then 20. This is exponential backoff. Add a little randomness (jitter) so that many jobs failing at once don't all retry at the same moment and knock the service over again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries in Bash
&lt;/h2&gt;

&lt;p&gt;A small function you can drop into any cron script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail

retry&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift
  local &lt;/span&gt;&lt;span class="nv"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5 &lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; attempt &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; max &lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; giving up after &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt; attempts (exit &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;sleep_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; delay &lt;span class="o"&gt;+&lt;/span&gt; RANDOM &lt;span class="o"&gt;%&lt;/span&gt; delay &lt;span class="k"&gt;))&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; attempt &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt; failed (exit &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;), retrying in &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;sleep_for&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sleep_for&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; attempt &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
    &lt;span class="nv"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; delay &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

retry 4 /usr/local/bin/export-orders.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30 2 * * * /usr/local/bin/export-with-retry.sh &amp;gt;&amp;gt; /var/log/export.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four attempts with this delay pattern finish within a couple of minutes. Keep the total retry time well below the job's interval, or the next scheduled run will start while this one is still retrying. If that's possible, wrap the job in &lt;code&gt;flock -n&lt;/code&gt; so runs can't overlap.&lt;/p&gt;

&lt;h3&gt;
  
  
  For HTTP calls, curl can do it
&lt;/h3&gt;

&lt;p&gt;If the job is a single request, curl's built-in retries are enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 30 &lt;span class="nt"&gt;--retry&lt;/span&gt; 4 &lt;span class="nt"&gt;--retry-delay&lt;/span&gt; 5 &lt;span class="nt"&gt;--retry-max-time&lt;/span&gt; 120 &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/tasks/export
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--retry&lt;/code&gt; only retries transient problems: timeouts and responses like 408, 429, 500, 502, 503 and 504. Recent curl versions also honour a &lt;code&gt;Retry-After&lt;/code&gt; header. Avoid &lt;code&gt;--retry-all-errors&lt;/code&gt; unless you really want to retry 4xx responses too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries in Python
&lt;/h2&gt;

&lt;p&gt;For jobs written in Python, the same logic with a permanent/transient split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Permanent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_api&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com/export&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                     &lt;span class="c1"&gt;# transient: let retry handle it
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;Permanent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;Permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempt &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; failed (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;); retrying in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call_api&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather not write this yourself, the &lt;code&gt;tenacity&lt;/code&gt; library provides decorators for the same patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: make the job safe to run twice
&lt;/h2&gt;

&lt;p&gt;Retries mean the same work may run more than once. A request can succeed on the server and still time out on your side, so you retry something that already happened. Protect against that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use an idempotency key.&lt;/strong&gt; Many payment and messaging APIs accept a key per operation and ignore duplicates. Derive it from the job and date, such as &lt;code&gt;export-2026-10-28&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record what's done.&lt;/strong&gt; Write a marker (a database row, a file) when the work completes, and check it at the start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use upserts, not inserts.&lt;/strong&gt; &lt;code&gt;INSERT ... ON CONFLICT DO UPDATE&lt;/code&gt; survives a second run; a plain &lt;code&gt;INSERT&lt;/code&gt; creates duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rule 4: alert when retries run out
&lt;/h2&gt;

&lt;p&gt;A retry loop hides problems by design, so you need to hear about the ones it can't fix. Two simple options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert on final failure:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; retry 4 /usr/local/bin/export-orders.sh&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"text":"export-orders failed after 4 attempts"}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Alert after N failed runs in a row&lt;/strong&gt;, which avoids paging anyone for a single bad night:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/export-orders.failures
&lt;span class="k"&gt;if &lt;/span&gt;retry 4 /usr/local/bin/export-orders.sh&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;((&lt;/span&gt; n &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; 3 &lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;export-orders failed &lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt; runs in a row&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rule 5: keep failed work somewhere you can replay it
&lt;/h2&gt;

&lt;p&gt;For jobs that process items (orders, emails, webhooks), don't let a failure lose the item. Write failed items to a "dead letter" table or file with the error, and add a small command to retry them once the cause is fixed. That turns an outage into a backlog instead of missing data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or let the scheduler handle it
&lt;/h2&gt;

&lt;p&gt;If the job is an HTTP endpoint, a hosted scheduler can take over most of this. &lt;a href="https://delay.run" rel="noopener noreferrer"&gt;delay.run&lt;/a&gt;, for example, retries failed jobs with backoff (by default after 1 minute, 30 minutes and 3 hours) and moves them to a dead letter queue you can replay. Recurring crons aren't retried; instead it alerts by email, Slack or Discord after the number of failures in a row you choose, and again when the job recovers, with every run's response logged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cron never retries. Add retries in the job or use a scheduler that does.&lt;/li&gt;
&lt;li&gt;Retry only transient errors, with exponential backoff and jitter.&lt;/li&gt;
&lt;li&gt;Keep total retry time shorter than the schedule interval.&lt;/li&gt;
&lt;li&gt;Make the job idempotent, because retries will repeat work.&lt;/li&gt;
&lt;li&gt;Alert when retries are exhausted, ideally after a few failures in a row.&lt;/li&gt;
&lt;li&gt;Save failed items so they can be replayed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/how-to-run-a-cron-job-on-the-last-day-of-the-month" rel="noopener noreferrer"&gt;How to run a cron job on the last day of the month&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/delayrun/make-scheduled-job-handlers-idempotent-safe-to-run-twice-499o"&gt;Make scheduled job handlers idempotent: safe to run twice&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Heartbeat-checks-how-to-know-when-a-scheduled-job-didnt-run-10-31" rel="noopener noreferrer"&gt;Heartbeat checks: how to know when a scheduled job didn't run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/dead-letter-queues-for-http-jobs-what-they-are-and-how-to-use-one" rel="noopener noreferrer"&gt;Dead letter queues for HTTP jobs: what they are and how to use one&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cron</category>
      <category>bash</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to run cron jobs on serverless: Vercel, Cloudflare Workers and Netlify</title>
      <dc:creator>delay.run</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:58:41 +0000</pubDate>
      <link>https://dev.to/delayrun/how-to-run-cron-jobs-on-serverless-vercel-cloudflare-workers-and-netlify-228</link>
      <guid>https://dev.to/delayrun/how-to-run-cron-jobs-on-serverless-vercel-cloudflare-workers-and-netlify-228</guid>
      <description>&lt;p&gt;Serverless apps don't have a long-running machine where you can open a crontab. But you still need nightly cleanups, hourly syncs and Monday reports. Every major platform now has its own way to run a function on a schedule. They look similar, but they differ in configuration, security and what happens when a run fails.&lt;/p&gt;

&lt;p&gt;Here's how to set up a scheduled job on Vercel, Cloudflare Workers and Netlify, and what to watch out for on each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vercel Cron Jobs
&lt;/h2&gt;

&lt;p&gt;On Vercel, a cron job is a schedule that calls one of your routes. You declare it in &lt;code&gt;vercel.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"crons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/cron/cleanup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 3 * * *"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then write the route as a normal function. In a Next.js App Router project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/cron/cleanup/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CRON_SECRET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deleteExpiredSessions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Secure it with &lt;code&gt;CRON_SECRET&lt;/code&gt;.&lt;/strong&gt; Add an environment variable called &lt;code&gt;CRON_SECRET&lt;/code&gt; to the project, and Vercel sends it as a &lt;code&gt;Bearer&lt;/code&gt; token in the &lt;code&gt;Authorization&lt;/code&gt; header on cron invocations. Without the check, anyone who finds the path can run your job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedules are in UTC.&lt;/strong&gt; &lt;code&gt;0 3 * * *&lt;/code&gt; is 3:00 UTC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production only.&lt;/strong&gt; Crons run against the production deployment, not previews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan limits apply.&lt;/strong&gt; The number of cron jobs and how precisely they fire depend on your plan. Check the current limits before you design around a schedule like every five minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a normal function invocation.&lt;/strong&gt; The usual function duration limits apply, and a failed run isn't automatically retried.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cloudflare Workers Cron Triggers
&lt;/h2&gt;

&lt;p&gt;On Cloudflare, cron triggers call a &lt;code&gt;scheduled&lt;/code&gt; handler in your Worker rather than an HTTP route. Configure them in &lt;code&gt;wrangler.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"nightly-jobs"&lt;/span&gt;
&lt;span class="n"&gt;main&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"src/index.ts"&lt;/span&gt;
&lt;span class="n"&gt;compatibility_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"2026-01-01"&lt;/span&gt;

&lt;span class="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;triggers&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;crons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"0 3 * * *"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"*/30 * * * *"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then export a &lt;code&gt;scheduled&lt;/code&gt; handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ScheduledController&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 3 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;runNightlyCleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*/30 * * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;syncInventory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No public URL.&lt;/strong&gt; The handler isn't reachable over HTTP, so there's nothing to secure with a secret. That's a nice property.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;controller.cron&lt;/code&gt; tells you which schedule fired&lt;/strong&gt;, so one Worker can serve several schedules.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schedules are in UTC.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test locally&lt;/strong&gt; with &lt;code&gt;npx wrangler dev --test-scheduled&lt;/code&gt;. The dev server then exposes a route you can request to trigger the handler; the wrangler output shows the URL to use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;ctx.waitUntil()&lt;/code&gt;&lt;/strong&gt; for async work so the runtime doesn't end the invocation early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Past runs&lt;/strong&gt; appear in the Worker's logs and metrics in the dashboard. Check there first when a job seems not to run.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Netlify Scheduled Functions
&lt;/h2&gt;

&lt;p&gt;Netlify lets you attach a schedule to a function in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// netlify/functions/daily-digest.mts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@netlify/functions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendDailyDigest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 8 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shortcuts like &lt;code&gt;@hourly&lt;/code&gt; and &lt;code&gt;@daily&lt;/code&gt; are also accepted. Like the others, schedules run in UTC, they only run on published deploys, and scheduled functions have a shorter execution limit than regular ones. For longer work, have the scheduled function hand off to a background function or a queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common limits
&lt;/h2&gt;

&lt;p&gt;All three platforms solve "run this code on a schedule". None of them is a full job system. Plan for these gaps:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. UTC only.&lt;/strong&gt; If a job must run at 9:00 in a specific time zone all year, either accept a DST shift twice a year, or schedule hourly and check the local hour inside the function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hourCycle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h23&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Europe/Berlin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Timeouts.&lt;/strong&gt; Scheduled functions share the platform's execution limits. Process work in batches, save a cursor, and pick up where you left off on the next run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. No retries.&lt;/strong&gt; If the run throws, you usually find out later, if at all. Catch errors, log them, and send yourself an alert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncInventory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALERT_WEBHOOK_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`syncInventory failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Overlap.&lt;/strong&gt; If a run takes longer than the interval, two can run at once. Use a lock (a row in your database, a key in KV with an expiry) or make the job idempotent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Vendor lock-in.&lt;/strong&gt; Your schedules live in platform config. Moving hosts means rewriting them.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use an external scheduler instead
&lt;/h2&gt;

&lt;p&gt;If your job is already an HTTP endpoint, you can keep the schedule outside your hosting platform entirely. A hosted scheduler calls the endpoint on a cron, so the same setup keeps working on Vercel, Cloudflare, Render, Fly or your own servers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://delay.run" rel="noopener noreferrer"&gt;delay.run&lt;/a&gt; is one option built for this: it calls your URL on a standard five-field cron or after a delay, logs every run with its request, response and latency, retries failed one-off jobs with backoff, and alerts by email, Slack or Discord when a cron fails several times in a row. Requests are HMAC-signed, so your endpoint can verify they came from the scheduler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which should you pick?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One or two simple jobs on Vercel or Netlify:&lt;/strong&gt; use the built-in cron. Add the auth check and error alerts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers:&lt;/strong&gt; Cron Triggers are excellent, with no public URL and multiple schedules per Worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jobs that must not fail silently, or you're on several platforms:&lt;/strong&gt; use an external scheduler with logs, retries and alerts, and keep your endpoints portable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you choose, write the job so that it can safely run twice, finish within the time limit, and tell you when it fails. That covers most of what can go wrong with scheduled code on serverless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/how-to-send-a-reminder-24-hours-after-signup-without-a-queue" rel="noopener noreferrer"&gt;How to send a reminder 24 hours after signup without a queue&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Cron-vs-systemd-timers-which-should-run-your-scheduled-jobs-10-31" rel="noopener noreferrer"&gt;Cron vs systemd timers: which should run your scheduled jobs?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/how-to-run-a-cron-job-on-the-last-day-of-the-month" rel="noopener noreferrer"&gt;How to run a cron job on the last day of the month&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/delayrun/make-scheduled-job-handlers-idempotent-safe-to-run-twice-499o"&gt;Make scheduled job handlers idempotent: safe to run twice&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>serverless</category>
      <category>cron</category>
      <category>vercel</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>How to schedule an HTTP request (call a URL or webhook on a schedule)</title>
      <dc:creator>delay.run</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:52:11 +0000</pubDate>
      <link>https://dev.to/delayrun/how-to-schedule-an-http-request-call-a-url-or-webhook-on-a-schedule-4j86</link>
      <guid>https://dev.to/delayrun/how-to-schedule-an-http-request-call-a-url-or-webhook-on-a-schedule-4j86</guid>
      <description>&lt;p&gt;A surprising number of background jobs boil down to one thing: call this URL every hour. Refresh a cache, sync orders from an API, send a daily digest, ping a webhook. Your app already has the logic behind an endpoint. You just need something to hit it on schedule.&lt;/p&gt;

&lt;p&gt;This guide covers three ways to do that, the curl flags that make the difference between a reliable job and a silent one, and how to protect the endpoint so only your scheduler can trigger it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: crontab and curl
&lt;/h2&gt;

&lt;p&gt;If you have a Linux server, this is the quickest route. Run &lt;code&gt;crontab -e&lt;/code&gt; and add a line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/15 * * * * curl -fsS --max-time 30 https://example.com/tasks/sync &amp;gt;&amp;gt; /var/log/sync.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That calls the URL every 15 minutes. The flags matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-f&lt;/code&gt; makes curl exit with an error on HTTP 4xx and 5xx responses. Without it, a 500 from your app counts as success.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-sS&lt;/code&gt; hides the progress bar but still prints errors.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--max-time 30&lt;/code&gt; stops a hung request from piling up behind the next run.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;gt;&amp;gt; ... 2&amp;gt;&amp;amp;1&lt;/code&gt; keeps a log, so you can see what happened at 3 am.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Sending a POST with JSON and a secret
&lt;/h3&gt;

&lt;p&gt;Most task endpoints should reject anonymous requests. Keep the token in a file readable only by the cron user, not in the crontab itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/bin/call-sync.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/myapp/cron-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 30 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://example.com/tasks/sync &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"source":"cron"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/15 * * * * /usr/local/bin/call-sync.sh &amp;gt;&amp;gt; /var/log/sync.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting the request in a script also avoids cron's &lt;code&gt;%&lt;/code&gt; problem. Inside a crontab, &lt;code&gt;%&lt;/code&gt; means newline, so a JSON body or a &lt;code&gt;date +%F&lt;/code&gt; in the line breaks in confusing ways unless you escape it as &lt;code&gt;\%&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrying transient failures
&lt;/h3&gt;

&lt;p&gt;curl can retry on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 30 &lt;span class="nt"&gt;--retry&lt;/span&gt; 3 &lt;span class="nt"&gt;--retry-delay&lt;/span&gt; 10 https://example.com/tasks/sync
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--retry&lt;/code&gt; covers timeouts and responses like 429, 502, 503 and 504. Only use it if the endpoint is safe to call twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: a systemd timer
&lt;/h2&gt;

&lt;p&gt;On systemd-based servers, timers are a more structured alternative to cron. You get logs in the journal and the option to catch up on missed runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/sync.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Call the sync endpoint&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/call-sync.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/sync.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Run sync every 15 minutes&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nn"&gt;[Timer]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*:0/15&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nn"&gt;[Install]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; sync.timer
systemctl list-timers sync.timer
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; sync.service &lt;span class="nt"&gt;--since&lt;/span&gt; today
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Persistent=true&lt;/code&gt; runs the job once at boot if the machine was off when it was due.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: a hosted HTTP scheduler
&lt;/h2&gt;

&lt;p&gt;Both options above need a server that's always on, and both fail quietly when that server goes away. If the job is only an HTTP call, a hosted scheduler removes the server from the picture. You give it a URL and a schedule, and it makes the request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://delay.run" rel="noopener noreferrer"&gt;delay.run&lt;/a&gt; works this way: one API call creates a recurring cron (standard five-field syntax, as often as every minute) or a one-off job that runs right away or after a delay of up to 24 hours. Every run is logged with the request, response body, status code and latency, and you can get alerts by email, Slack or Discord when a cron fails several times in a row. It's plain HTTP with no SDK, so it works with any language or framework.&lt;/p&gt;

&lt;p&gt;The main thing to compare between hosted options is what you can see after the fact: whether you get the response body of a failed run, and whether anything tells you when a schedule keeps failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secure the endpoint
&lt;/h2&gt;

&lt;p&gt;A task URL that anyone can trigger is an invitation for abuse. Pick one of these:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A shared secret in a header.&lt;/strong&gt; Simple and good enough for most internal jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Express&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tasks/sync&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CRON_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... do the work ...&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A signed request.&lt;/strong&gt; Better when the scheduler supports it. The sender computes an HMAC of the body and a timestamp with a shared key, and your endpoint recomputes it. The timestamp stops someone replaying an old request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 5 minutes&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact header names and the string that gets signed depend on the scheduler, so check its docs. Always verify against the raw request body, before JSON parsing changes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design the endpoint for scheduled calls
&lt;/h2&gt;

&lt;p&gt;A few habits make scheduled HTTP jobs much easier to live with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Return a real status code.&lt;/strong&gt; 200 when the work succeeded, 5xx when it didn't. Schedulers and curl &lt;code&gt;-f&lt;/code&gt; rely on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respond with a short summary.&lt;/strong&gt; &lt;code&gt;{"synced": 42}&lt;/code&gt; in the response body shows up in logs and makes "did it actually do anything?" easy to answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be idempotent.&lt;/strong&gt; Retries, overlaps and manual re-runs will happen. Running the same job twice shouldn't send two emails or charge a card twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it short or hand off.&lt;/strong&gt; If the work takes minutes, accept the request, queue the work, and return quickly. Long requests hit timeouts on proxies and serverless platforms.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;curl uses &lt;code&gt;-f&lt;/code&gt; and &lt;code&gt;--max-time&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;secrets live in a file or environment variable, not in the crontab&lt;/li&gt;
&lt;li&gt;the endpoint rejects requests without the secret or a valid signature&lt;/li&gt;
&lt;li&gt;the endpoint returns 5xx on failure&lt;/li&gt;
&lt;li&gt;there's a log of every run, and something alerts you when runs keep failing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get those right and "call this URL every 15 minutes" becomes a job you can forget about, in the good way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/How-to-log-cron-job-output-so-you-can-debug-it-later-10-31" rel="noopener noreferrer"&gt;How to log cron job output so you can debug it later&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/delayrun/how-to-verify-hmac-sha256-signed-webhooks-in-node-and-python-2chl"&gt;How to verify HMAC-SHA256 signed webhooks in Node and Python&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/how-to-send-a-reminder-24-hours-after-signup-without-a-queue" rel="noopener noreferrer"&gt;How to send a reminder 24 hours after signup without a queue&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Cron-vs-systemd-timers-which-should-run-your-scheduled-jobs-10-31" rel="noopener noreferrer"&gt;Cron vs systemd timers: which should run your scheduled jobs?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webhooks</category>
      <category>cron</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Cron job timezone problems: how to run cron in a specific time zone (and survive DST)</title>
      <dc:creator>delay.run</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:46:09 +0000</pubDate>
      <link>https://dev.to/delayrun/cron-job-timezone-problems-how-to-run-cron-in-a-specific-time-zone-and-survive-dst-23f</link>
      <guid>https://dev.to/delayrun/cron-job-timezone-problems-how-to-run-cron-in-a-specific-time-zone-and-survive-dst-23f</guid>
      <description>&lt;p&gt;You set a job for &lt;code&gt;0 9 * * *&lt;/code&gt;. It runs at 10:00. Or at 4:00. Or it ran fine all summer and has been an hour late since the clocks changed. Almost every "cron runs at the wrong time" problem comes down to one fact: cron doesn't know what time zone you meant. It uses whatever the system it runs on is set to.&lt;/p&gt;

&lt;p&gt;Here's how to find out which time zone your cron uses, how to change it per job, and how to avoid the daylight saving time traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Find out what time zone cron is using
&lt;/h2&gt;

&lt;p&gt;On most Linux servers, cron uses the system time zone. Check it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;timedatectl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;               Local time: Wed 2026-10-28 15:00:12 UTC
           Universal time: Wed 2026-10-28 15:00:12 UTC
                Time zone: Etc/UTC (UTC, +0000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloud images default to UTC almost everywhere. If you developed on a laptop set to your local time, that's the source of the offset.&lt;/p&gt;

&lt;p&gt;Note that cron reads the time zone when it starts. If you change the system time zone, restart the cron service so it picks up the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;timedatectl set-timezone Europe/Berlin
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart cron      &lt;span class="c"&gt;# "crond" on RHEL, Fedora and similar&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing the whole server's time zone affects logs and every other job too. Usually it's better to leave the server on UTC and handle time zones per job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Set a time zone for a single crontab
&lt;/h2&gt;

&lt;p&gt;Some cron implementations support a &lt;code&gt;CRON_TZ&lt;/code&gt; variable at the top of a crontab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CRON_TZ=Europe/Berlin
0 9 * * 1-5 /usr/local/bin/morning-report.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works in &lt;strong&gt;cronie&lt;/strong&gt;, the cron used by RHEL, Fedora, CentOS and Arch. Support in other implementations varies, so check &lt;code&gt;man 5 crontab&lt;/code&gt; on your system and search for &lt;code&gt;CRON_TZ&lt;/code&gt;. If it's not mentioned, it's probably ignored without any warning.&lt;/p&gt;

&lt;p&gt;Don't confuse it with &lt;code&gt;TZ=...&lt;/code&gt; in the crontab. &lt;code&gt;TZ&lt;/code&gt; sets the time zone for the &lt;strong&gt;command&lt;/strong&gt; (so &lt;code&gt;date&lt;/code&gt; inside your script prints local time), but it doesn't change &lt;strong&gt;when&lt;/strong&gt; cron fires the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The portable trick: run hourly and check the local hour
&lt;/h2&gt;

&lt;p&gt;If you can't rely on &lt;code&gt;CRON_TZ&lt;/code&gt;, schedule the job every hour and let it decide whether this is the right hour in the time zone you care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 * * * * [ "$(TZ=America/New_York date +\%H)" = 09 ] &amp;amp;&amp;amp; /usr/local/bin/morning-report.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs at 9:00 New York time all year, through both daylight saving changes, on any cron implementation. The cost is 23 no-op wake-ups a day, which is nothing. Remember to escape &lt;code&gt;%&lt;/code&gt; as &lt;code&gt;\%&lt;/code&gt; inside a crontab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Understand what DST does to your schedule
&lt;/h2&gt;

&lt;p&gt;Daylight saving time creates one hour that doesn't exist (spring forward) and one hour that happens twice (fall back). Jobs scheduled inside those hours behave differently depending on the cron implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A job at &lt;strong&gt;2:30 am&lt;/strong&gt; on the spring-forward night may be skipped, or run at 3:00.&lt;/li&gt;
&lt;li&gt;A job at &lt;strong&gt;1:30 am&lt;/strong&gt; on the fall-back night may run twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Classic Vixie-derived cron has special handling for small clock changes to reduce this, but don't depend on details that differ across versions. The simple rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Avoid scheduling local-time jobs between 1:00 and 3:00 am.&lt;/li&gt;
&lt;li&gt;If a job must run exactly once a day, make it idempotent: check whether today's run already happened before doing the work.&lt;/li&gt;
&lt;li&gt;If the time doesn't matter to humans (backups, cleanup), schedule in UTC, which has no DST.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Time zones in other schedulers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;systemd timers&lt;/strong&gt; accept a time zone directly in &lt;code&gt;OnCalendar&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Timer]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Mon..Fri *-*-* 09:00:00 Europe/Berlin&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check how systemd reads it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-analyze calendar &lt;span class="s2"&gt;"Mon..Fri *-*-* 09:00:00 Europe/Berlin"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints the next elapse time in both your local time and UTC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes CronJobs&lt;/strong&gt; have a &lt;code&gt;timeZone&lt;/code&gt; field (stable since Kubernetes 1.27):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CronJob&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;morning-report&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt;
  &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/Berlin"&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&lt;/span&gt;
          &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;report&lt;/span&gt;
              &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example/report:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;timeZone&lt;/code&gt;, the schedule follows the time zone of the kube-controller-manager, which is usually UTC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Actions, Vercel Cron Jobs and Cloudflare Workers Cron Triggers&lt;/strong&gt; evaluate schedules in UTC. Convert your local time to UTC, and remember the offset changes with DST. A job that must run at 9:00 local time year-round needs either two schedules you switch twice a year, or the "run hourly and check" approach inside the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Log the time zone with every run
&lt;/h2&gt;

&lt;p&gt;When debugging, print both local and UTC time at the start of the job. It ends arguments about what time it "really" ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"start local=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T %Z'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; utc=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# ... the actual work ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 9 * * * /usr/local/bin/job.sh &amp;gt;&amp;gt; /var/log/job.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Converting a local time to UTC quickly
&lt;/h2&gt;

&lt;p&gt;GNU &lt;code&gt;date&lt;/code&gt; can do the conversion for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'TZ="Europe/Berlin" 09:00 next Monday'&lt;/span&gt; &lt;span class="s1"&gt;'+%H:%M UTC'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it once for a date in summer and once in winter to see both offsets.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cleaner option for HTTP jobs
&lt;/h2&gt;

&lt;p&gt;If the job is a call to one of your endpoints, it doesn't need to live on a server with a system clock you have to think about. &lt;a href="https://delay.run" rel="noopener noreferrer"&gt;delay.run&lt;/a&gt; runs standard five-field cron schedules against your URL, retries one-off jobs that fail, and logs each run's request, response and latency, which makes it easy to see exactly when a run happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cron uses the system time zone, which on servers is usually UTC.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CRON_TZ&lt;/code&gt; works in some implementations, not all. Check &lt;code&gt;man 5 crontab&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TZ=&lt;/code&gt; changes the command's clock, not the schedule.&lt;/li&gt;
&lt;li&gt;The hourly-check trick works everywhere and handles DST.&lt;/li&gt;
&lt;li&gt;systemd timers and Kubernetes CronJobs let you set a time zone explicitly.&lt;/li&gt;
&lt;li&gt;Keep local-time jobs out of the 1-3 am window, and make daily jobs safe to run twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Heartbeat-checks-how-to-know-when-a-scheduled-job-didnt-run-10-31" rel="noopener noreferrer"&gt;Heartbeat checks: how to know when a scheduled job didn't run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/dead-letter-queues-for-http-jobs-what-they-are-and-how-to-use-one" rel="noopener noreferrer"&gt;Dead letter queues for HTTP jobs: what they are and how to use one&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/How-to-log-cron-job-output-so-you-can-debug-it-later-10-31" rel="noopener noreferrer"&gt;How to log cron job output so you can debug it later&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/delayrun/how-to-verify-hmac-sha256-signed-webhooks-in-node-and-python-2chl"&gt;How to verify HMAC-SHA256 signed webhooks in Node and Python&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cron</category>
      <category>linux</category>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Cron expression for every Monday at 9am (and other weekly schedules)</title>
      <dc:creator>delay.run</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:39:57 +0000</pubDate>
      <link>https://dev.to/delayrun/cron-expression-for-every-monday-at-9am-and-other-weekly-schedules-2anf</link>
      <guid>https://dev.to/delayrun/cron-expression-for-every-monday-at-9am-and-other-weekly-schedules-2anf</guid>
      <description>&lt;p&gt;Short answer: the cron expression for &lt;strong&gt;every Monday at 9:00 am&lt;/strong&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 9 * * 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's minute &lt;code&gt;0&lt;/code&gt;, hour &lt;code&gt;9&lt;/code&gt;, any day of the month, any month, day of week &lt;code&gt;1&lt;/code&gt; (Monday). The rest of this post covers the variations people usually need next, and a few traps in the day-of-week field that make jobs run on the wrong day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the five fields
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;â”Œâ”€â”€â”€â”€â”€â”€â”€â”€ minute        (0-59)
â”‚ â”Œâ”€â”€â”€â”€â”€â”€ hour          (0-23)
â”‚ â”‚ â”Œâ”€â”€â”€â”€ day of month  (1-31)
â”‚ â”‚ â”‚ â”Œâ”€â”€ month         (1-12 or JAN-DEC)
â”‚ â”‚ â”‚ â”‚ â”Œ day of week   (0-7 or SUN-SAT, 0 and 7 are both Sunday)
â”‚ â”‚ â”‚ â”‚ â”‚
0 9 * * 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hours use a 24-hour clock, so 9 pm is &lt;code&gt;21&lt;/code&gt;. Most cron implementations accept three-letter names, so &lt;code&gt;0 9 * * MON&lt;/code&gt; means the same thing and is easier to read in a crontab you'll revisit next year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weekly schedules you can copy
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schedule&lt;/th&gt;
&lt;th&gt;Expression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Every Monday at 9:00&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 9 * * 1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every Monday at 9:30&lt;/td&gt;
&lt;td&gt;&lt;code&gt;30 9 * * 1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every Monday at 9 am and 5 pm&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 9,17 * * 1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every weekday (Mon-Fri) at 9:00&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 9 * * 1-5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monday, Wednesday and Friday at 9:00&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 9 * * 1,3,5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every Sunday at midnight&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 0 * * 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every Saturday and Sunday at 10:00&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 10 * * 6,0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every 15 minutes on Mondays, 9 to 5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*/15 9-16 * * 1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every Monday at 9:00, January to June&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0 9 * 1-6 1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the 9-to-5 example uses &lt;code&gt;9-16&lt;/code&gt; for the hour. &lt;code&gt;*/15 9-17&lt;/code&gt; would also fire at 17:00, 17:15, 17:30 and 17:45.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: "first Monday of the month" doesn't work the obvious way
&lt;/h2&gt;

&lt;p&gt;This looks right, but isn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 9 1-7 * 1     # NOT "first Monday"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When both day-of-month and day-of-week are restricted (neither is &lt;code&gt;*&lt;/code&gt;), standard cron runs the job when &lt;strong&gt;either&lt;/strong&gt; matches. So this runs on days 1 to 7 of every month &lt;strong&gt;and&lt;/strong&gt; on every Monday. That's up to 11 runs a month instead of one.&lt;/p&gt;

&lt;p&gt;The reliable fix is to schedule on days 1 to 7 and let the command check the weekday:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 9 1-7 * * [ "$(date +\%u)" = 1 ] &amp;amp;&amp;amp; /usr/local/bin/monthly-report.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;date +%u&lt;/code&gt; prints 1 for Monday through 7 for Sunday. The &lt;code&gt;%&lt;/code&gt; has to be escaped as &lt;code&gt;\%&lt;/code&gt; in a crontab, because cron treats an unescaped &lt;code&gt;%&lt;/code&gt; as a newline. Forgetting that is a classic reason a job "never runs".&lt;/p&gt;

&lt;p&gt;The same pattern works for "last Friday of the month":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 9 22-31 * * [ "$(date +\%u)" = 5 ] &amp;amp;&amp;amp; [ "$(date -d '+7 days' +\%m)" != "$(date +\%m)" ] &amp;amp;&amp;amp; /path/to/job.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second test checks that a week from today falls in the next month, which is only true for the last Friday. &lt;code&gt;date -d&lt;/code&gt; is GNU syntax; on macOS use &lt;code&gt;date -v+7d&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: Sunday is 0, and also 7
&lt;/h2&gt;

&lt;p&gt;Most cron versions accept both &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;7&lt;/code&gt; for Sunday. A range like &lt;code&gt;1-7&lt;/code&gt; therefore means Monday to Sunday, every day. If you want the weekend, write &lt;code&gt;6,0&lt;/code&gt; or &lt;code&gt;SAT,SUN&lt;/code&gt;, not &lt;code&gt;6-7&lt;/code&gt; unless you've checked your implementation supports it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: other schedulers use different formats
&lt;/h2&gt;

&lt;p&gt;The five-field format above is standard Unix cron, also used by most hosted schedulers, GitHub Actions and Kubernetes CronJobs. Some systems differ:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quartz and Spring&lt;/strong&gt; use six or seven fields, starting with seconds, and need &lt;code&gt;?&lt;/code&gt; in one of the day fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 0 9 ? * MON        # second minute hour day-of-month month day-of-week
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also note that in Quartz, day-of-week runs 1 to 7 starting with &lt;strong&gt;Sunday&lt;/strong&gt;, so &lt;code&gt;2&lt;/code&gt; is Monday. Copying &lt;code&gt;0 9 * * 1&lt;/code&gt; into Quartz-style config won't do what you expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS EventBridge&lt;/strong&gt; uses six fields with a year at the end, and also requires &lt;code&gt;?&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cron(0 9 ? * MON *)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a scheduler rejects your expression, count the fields first. That's the cause most of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: 9 am in which time zone?
&lt;/h2&gt;

&lt;p&gt;Cron runs in the time zone of the machine or service running it. On a cloud server that's very often UTC, so &lt;code&gt;0 9 * * 1&lt;/code&gt; fires at 9:00 UTC, which may be 10:00 or 11:00 for your team in Europe, or the middle of the night in Asia.&lt;/p&gt;

&lt;p&gt;Check what the server thinks the time is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;timedatectl          &lt;span class="c"&gt;# on systemd-based Linux&lt;/span&gt;
&lt;span class="nb"&gt;date&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either convert your schedule to UTC, or use a scheduler that lets you set a time zone per job. If you convert, remember that daylight saving time shifts the offset twice a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the schedule before you rely on it
&lt;/h2&gt;

&lt;p&gt;A weekly job gives you one chance a week to notice it's wrong. Before saving it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Paste the expression into a cron expression explainer and read the next few run times it lists.&lt;/li&gt;
&lt;li&gt;Temporarily change it to run a few minutes from now, check it works, then set the real schedule.&lt;/li&gt;
&lt;li&gt;Log the output so a failure isn't silent:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 9 * * 1 /usr/local/bin/weekly-report.sh &amp;gt;&amp;gt; /var/log/weekly-report.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Running it without a server
&lt;/h2&gt;

&lt;p&gt;If the job is really "call this URL every Monday at 9", you don't need a machine with a crontab at all. A hosted scheduler like &lt;a href="https://delay.run" rel="noopener noreferrer"&gt;delay.run&lt;/a&gt; takes the same five-field expression, calls your endpoint on schedule, and logs each run with its status code and response, so you can see last Monday's result instead of guessing.&lt;/p&gt;

&lt;p&gt;Whichever route you take, &lt;code&gt;0 9 * * 1&lt;/code&gt; is the line you need. Just double-check the time zone and the day-of-month field, and it will fire once a week, exactly when you expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/how-to-run-a-cron-job-on-the-last-day-of-the-month" rel="noopener noreferrer"&gt;How to run a cron job on the last day of the month&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/delayrun/make-scheduled-job-handlers-idempotent-safe-to-run-twice-499o"&gt;Make scheduled job handlers idempotent: safe to run twice&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Heartbeat-checks-how-to-know-when-a-scheduled-job-didnt-run-10-31" rel="noopener noreferrer"&gt;Heartbeat checks: how to know when a scheduled job didn't run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/dead-letter-queues-for-http-jobs-what-they-are-and-how-to-use-one" rel="noopener noreferrer"&gt;Dead letter queues for HTTP jobs: what they are and how to use one&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cron</category>
      <category>linux</category>
      <category>devops</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Cron job not running? A step-by-step checklist (including “it works when I run it manually”)</title>
      <dc:creator>delay.run</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:20:56 +0000</pubDate>
      <link>https://dev.to/delayrun/cron-job-not-running-a-step-by-step-checklist-including-it-works-when-i-run-it-manually-1nb6</link>
      <guid>https://dev.to/delayrun/cron-job-not-running-a-step-by-step-checklist-including-it-works-when-i-run-it-manually-1nb6</guid>
      <description>&lt;p&gt;You added a line to your crontab, waited, and nothing happened. Or the script works perfectly when you run it in your terminal, but cron never seems to run it. This is one of the most common Linux problems, and it nearly always comes down to a short list of causes. Work through them in this order and you will find it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Is the cron daemon actually running?
&lt;/h2&gt;

&lt;p&gt;Start with the obvious. On Debian and Ubuntu the service is called &lt;code&gt;cron&lt;/code&gt;; on RHEL, Fedora, Rocky and Alma it is &lt;code&gt;crond&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status cron      &lt;span class="c"&gt;# Debian / Ubuntu&lt;/span&gt;
systemctl status crond     &lt;span class="c"&gt;# RHEL family&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it is inactive, start and enable it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; cron
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside Docker containers there is usually no cron daemon at all unless you install and start one yourself. A crontab file in an image does nothing on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Did cron try to run the job?
&lt;/h2&gt;

&lt;p&gt;Cron logs every job it starts. Check the log before you change anything else, because it tells you whether the problem is the schedule or the command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;CRON /var/log/syslog          &lt;span class="c"&gt;# Debian / Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/cron         &lt;span class="c"&gt;# RHEL family&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; cron &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"1 hour ago"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No line for your job&lt;/strong&gt; at the expected time: the schedule or the crontab file is wrong (steps 3 and 4).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A line saying &lt;code&gt;CMD (...)&lt;/code&gt;&lt;/strong&gt; but nothing happened: cron ran it and the command failed (steps 5 to 8).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Check the schedule syntax
&lt;/h2&gt;

&lt;p&gt;A standard crontab line has five time fields, then the command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# m   h   dom mon dow   command
*/5   *   *   *   *     /home/app/bin/sync.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common mistakes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Six fields. Standard cron has no seconds field. &lt;code&gt;0 */5 * * * *&lt;/code&gt; is valid in Spring or Quartz, but in crontab the sixth value becomes part of the command.&lt;/li&gt;
&lt;li&gt;Day-of-month and day-of-week both set. &lt;code&gt;0 9 1 * 1&lt;/code&gt; runs on the 1st of the month &lt;strong&gt;and&lt;/strong&gt; on every Monday, not only on Mondays that fall on the 1st.&lt;/li&gt;
&lt;li&gt;Expecting &lt;code&gt;*/7&lt;/code&gt; in the minute field to mean "every 7 minutes forever". It restarts every hour, so you get :00, :07 … :56, then :00 again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paste the expression into a cron explainer if you are unsure, and confirm it says what you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Check which crontab you edited
&lt;/h2&gt;

&lt;p&gt;There are several places cron reads from, and they use different formats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;crontab -e&lt;/code&gt; edits &lt;strong&gt;your user's&lt;/strong&gt; crontab. No user column.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/etc/crontab&lt;/code&gt; and files in &lt;code&gt;/etc/cron.d/&lt;/code&gt; have an &lt;strong&gt;extra user column&lt;/strong&gt; after the five time fields: &lt;code&gt;0 3 * * * root /usr/local/bin/backup.sh&lt;/code&gt;. Leave it out and the job silently fails.&lt;/li&gt;
&lt;li&gt;Files in &lt;code&gt;/etc/cron.d/&lt;/code&gt; are ignored if the file name contains a dot (on Debian-based systems), so &lt;code&gt;backup.cron&lt;/code&gt; is skipped while &lt;code&gt;backup&lt;/code&gt; works. They also need to be owned by root and not group- or world-writable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sudo crontab -e&lt;/code&gt; edits &lt;strong&gt;root's&lt;/strong&gt; crontab, which is a different file from yours. Run &lt;code&gt;crontab -l&lt;/code&gt; and &lt;code&gt;sudo crontab -l&lt;/code&gt; to see both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also make sure the file ends with a newline. Some cron implementations ignore the last line if it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. “It runs manually but not in cron”: the environment
&lt;/h2&gt;

&lt;p&gt;This is the big one. When you run a script in your terminal, you have your shell profile, your &lt;code&gt;PATH&lt;/code&gt;, your virtualenv and your current directory. Cron has almost none of that. Its &lt;code&gt;PATH&lt;/code&gt; is typically just &lt;code&gt;/usr/bin:/bin&lt;/code&gt;, its shell is &lt;code&gt;/bin/sh&lt;/code&gt;, and it starts in your home directory.&lt;/p&gt;

&lt;p&gt;So a script that calls &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;python3&lt;/code&gt; from a virtualenv, &lt;code&gt;aws&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt; or anything in &lt;code&gt;/usr/local/bin&lt;/code&gt; may simply not find the binary.&lt;/p&gt;

&lt;p&gt;Fixes, in order of preference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Use absolute paths everywhere&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/local/bin/node /home/app/jobs/sync.js

&lt;span class="c"&gt;# Or set PATH at the top of the crontab&lt;/span&gt;
&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the script needs environment variables (API keys, &lt;code&gt;DATABASE_URL&lt;/code&gt;), cron will not load them from &lt;code&gt;.bashrc&lt;/code&gt;. Source a file explicitly or set them in the script.&lt;/p&gt;

&lt;p&gt;To reproduce cron's environment in your terminal and see the real error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;env&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;SHELL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/bin/sh &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin &lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'/home/app/bin/sync.sh'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Relative paths and the working directory
&lt;/h2&gt;

&lt;p&gt;A script that reads &lt;code&gt;./config.yml&lt;/code&gt; or writes &lt;code&gt;logs/out.txt&lt;/code&gt; works from the project folder but not from cron, which starts in &lt;code&gt;$HOME&lt;/code&gt;. Either &lt;code&gt;cd&lt;/code&gt; first or use absolute paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /home/app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./bin/report.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Percent signs and permissions
&lt;/h2&gt;

&lt;p&gt;In a crontab line, &lt;code&gt;%&lt;/code&gt; means "newline" and everything after the first one is sent to the command as standard input. This breaks &lt;code&gt;date +%Y-%m-%d&lt;/code&gt; in a surprising way. Escape it as &lt;code&gt;\%&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 2 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/bin/tar czf /backups/site-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +&lt;span class="se"&gt;\%&lt;/span&gt;F&lt;span class="si"&gt;)&lt;/span&gt;.tgz /var/www
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check the script itself: is it executable (&lt;code&gt;chmod +x&lt;/code&gt;), does it start with a correct shebang (&lt;code&gt;#!/usr/bin/env bash&lt;/code&gt;), and can the cron user read every file it touches?&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Capture the output
&lt;/h2&gt;

&lt;p&gt;By default, cron emails output to the local user, which on most servers means it disappears. Redirect it to a file so you can see what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;*&lt;/span&gt;/5 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /home/app/bin/sync.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /home/app/logs/sync.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; matters: most errors go to standard error. If you have a working mail setup, &lt;code&gt;MAILTO=you@example.com&lt;/code&gt; at the top of the crontab sends output by email instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Time zone and reboot surprises
&lt;/h2&gt;

&lt;p&gt;Cron uses the server's time zone. A job set for &lt;code&gt;0 9 * * *&lt;/code&gt; on a server in UTC runs at 9:00 UTC, not 9:00 where you are. Check with &lt;code&gt;timedatectl&lt;/code&gt;. Some cron versions support &lt;code&gt;CRON_TZ=Europe/Berlin&lt;/code&gt; at the top of the file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@reboot&lt;/code&gt; jobs run when the cron daemon starts, which can be before the network or your database is ready. Add a short wait or a retry loop in the script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem: cron fails silently
&lt;/h2&gt;

&lt;p&gt;Once your job works, there is a second issue: the next time it breaks, nothing will tell you. A crontab that stops gives no output and no error. The usual ways to cover that are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log to a file &lt;strong&gt;and&lt;/strong&gt; have something watch the file or the job's last run time.&lt;/li&gt;
&lt;li&gt;Use a "dead man's switch" style monitor that alerts when a job fails to check in.&lt;/li&gt;
&lt;li&gt;Move the schedule off the server entirely and have a hosted scheduler call an HTTP endpoint in your app, so retries, run history and alerts live in one place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you go the HTTP route, &lt;a href="https://delay.run" rel="noopener noreferrer"&gt;delay.run&lt;/a&gt; is one option: it calls your URL on a cron schedule, logs each response and alerts you by email, Slack or Discord when runs keep failing.&lt;/p&gt;

&lt;p&gt;Whichever you choose, the checklist above will get the job running. Making sure you hear about it next time it stops is what saves you the Monday-morning surprise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/Heartbeat-checks-how-to-know-when-a-scheduled-job-didnt-run-10-31" rel="noopener noreferrer"&gt;Heartbeat checks: how to know when a scheduled job didn't run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://delayrun.hashnode.dev/dead-letter-queues-for-http-jobs-what-they-are-and-how-to-use-one" rel="noopener noreferrer"&gt;Dead letter queues for HTTP jobs: what they are and how to use one&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://telegra.ph/How-to-log-cron-job-output-so-you-can-debug-it-later-10-31" rel="noopener noreferrer"&gt;How to log cron job output so you can debug it later&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/delayrun/how-to-verify-hmac-sha256-signed-webhooks-in-node-and-python-2chl"&gt;How to verify HMAC-SHA256 signed webhooks in Node and Python&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cron</category>
      <category>linux</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
