<?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: Syncoders</title>
    <description>The latest articles on DEV Community by Syncoders (@syncoders).</description>
    <link>https://dev.to/syncoders</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%2F4208172%2Fcc6a0d24-d93c-491f-aed8-4c4629f0af82.png</url>
      <title>DEV Community: Syncoders</title>
      <link>https://dev.to/syncoders</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/syncoders"/>
    <language>en</language>
    <item>
      <title>Building an AI Shorts pipeline with ffmpeg: 9:16, Ken Burns zoom, captions that match the voice</title>
      <dc:creator>Syncoders</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:53:35 +0000</pubDate>
      <link>https://dev.to/syncoders/building-an-ai-shorts-pipeline-with-ffmpeg-916-ken-burns-zoom-captions-that-match-the-voice-1lc1</link>
      <guid>https://dev.to/syncoders/building-an-ai-shorts-pipeline-with-ffmpeg-916-ken-burns-zoom-captions-that-match-the-voice-1lc1</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ask an LLM for a scene plan in JSON, and check every field before you use it.&lt;/li&gt;
&lt;li&gt;Crop each image to 9:16 first, then add a slow zoom with ffmpeg's &lt;code&gt;zoompan&lt;/code&gt; filter.&lt;/li&gt;
&lt;li&gt;Make each scene as long as its voice-over (measured with &lt;code&gt;ffprobe&lt;/code&gt;), not as long as the LLM guessed.&lt;/li&gt;
&lt;li&gt;Split the narration into short caption chunks, time them by length, and burn them into the video.&lt;/li&gt;
&lt;li&gt;Encode every scene the same way, then join them with no re-encoding.&lt;/li&gt;
&lt;li&gt;Retry rate limits and server errors. Fail fast on bad requests and bad keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Calling an image API and a text-to-speech API is the easy part of an "AI video generator". Turning their output into a video people will actually watch is the work. This post builds that pipeline with Node and plain ffmpeg. Each step is one ffmpeg command you can paste into a terminal first, then wrap in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few words first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LLM:&lt;/strong&gt; a large language model, the kind of AI that writes text (and JSON).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTS (text-to-speech):&lt;/strong&gt; a service or program that turns text into a voice recording.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ffmpeg / ffprobe:&lt;/strong&gt; free command-line tools. ffmpeg converts and edits video and audio. ffprobe reads facts about a file, such as its length.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16:9 and 9:16:&lt;/strong&gt; width-to-height ratios. 16:9 is a landscape YouTube video. 9:16 is a vertical Short, Reel or TikTok.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter:&lt;/strong&gt; an ffmpeg step that changes the picture or sound, like &lt;code&gt;scale&lt;/code&gt; (resize) or &lt;code&gt;crop&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The whole pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv70lgcyrzbj7z3gyhyae.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv70lgcyrzbj7z3gyhyae.png" alt="Pipeline in three rows. Plan: brief, LLM writes a scene plan in JSON, validate it. Assets, per scene, in parallel: image from an image API, and a voice-over from text-to-speech that gets measured with ffprobe. Render: ffmpeg per scene crops to 9:16, zooms and adds captions, then ffmpeg concat joins the clips into final.mp4." width="800" height="602"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One brief, like "3 tips for better sleep", goes in. The LLM breaks it into scenes. Each scene gets an image and a voice-over. ffmpeg turns each scene into a short clip, then joins the clips into one MP4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: get a scene plan you can trust
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; the LLM's answer crashes your code, or produces a video that's far too long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; you ask for 6 scenes in JSON. The model wraps the JSON in a code fence, adds "Here's your plan!" before it, returns 14 scenes, and writes &lt;code&gt;"durationSec": "3s"&lt;/code&gt; as text instead of a number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; An LLM predicts likely text. It usually follows your format, but nothing forces it to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; ask for JSON, then check every field before you use it. On OpenAI-compatible APIs, &lt;code&gt;response_format: { type: "json_object" }&lt;/code&gt; asks the model for JSON only. The plan looks like this:&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3 tips for better sleep"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scenes"&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;"imagePrompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dim bedroom, warm lamp, cinematic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"narration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Keep the same wake-up time, even on weekends."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"durationSec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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="nl"&gt;"imagePrompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phone face down on a nightstand"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"narration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Put screens away thirty minutes before bed."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"durationSec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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, in your code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Strip any code fences and text around the JSON.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JSON.parse&lt;/code&gt; it.&lt;/li&gt;
&lt;li&gt;Check every field has the right type.&lt;/li&gt;
&lt;li&gt;Clamp the numbers: for example 2 to 12 seconds per scene, and a maximum scene count.&lt;/li&gt;
&lt;li&gt;If anything is still wrong, throw an error your job queue will retry.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 2: turn a still image into a moving 9:16 clip
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; the Short looks stretched, or looks frozen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; your image API returns a 16:9 landscape picture. You resize it to 720×1280, and the round sun becomes a tall oval. Or you keep it sharp and still, and viewers swipe away because nothing moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Resizing a landscape image to a portrait size stretches it, because the shapes don't match. The right move is to &lt;strong&gt;crop&lt;/strong&gt;: keep a 9:16 slice from the middle and drop the sides.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhn7pivs0fuoby81khfpa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhn7pivs0fuoby81khfpa.png" alt="Three panels. A 16:9 source image with a green box showing the 9:16 slice a crop keeps. Wrong: the same image squashed to 9:16, with the round sun stretched into an oval. Right: a real ffmpeg frame, cropped to 9:16 first, then zoomed." width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For motion, the cheapest effect that looks deliberate is the &lt;strong&gt;Ken Burns effect&lt;/strong&gt;: a slow zoom into a still photo. It's named after the documentary maker who used it heavily. ffmpeg's &lt;code&gt;zoompan&lt;/code&gt; filter does it. Each frame shows a slightly smaller, centred part of the image, stretched to fill the screen, so the picture slowly pushes in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j6q6vcrrbw42slw4222.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j6q6vcrrbw42slw4222.png" alt="Left: the cropped image with three boxes showing the visible area at frame 1 (zoom 1.00), frame 85 (zoom 1.07, the end of a 3.4 second scene) and the 1.15 cap. Middle and right: the real first and last frames of the clip, showing the slow push-in." width="800" height="602"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; scale up, crop to 9:16, then zoom. Here's the whole command for one scene:&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;W&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;720&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;H&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1280&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;FPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;25&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;DUR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3.4
&lt;span class="nv"&gt;FRAMES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;85   &lt;span class="c"&gt;# DUR × FPS&lt;/span&gt;

ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-loop&lt;/span&gt; 1 &lt;span class="nt"&gt;-i&lt;/span&gt; scene.png &lt;span class="nt"&gt;-i&lt;/span&gt; voice.mp3 &lt;span class="nt"&gt;-filter_complex&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
[0:v]scale=900:1600:force_original_aspect_ratio=increase,crop=900:1600,&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
zoompan=z='min(zoom+0.0008,1.15)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=&lt;/span&gt;&lt;span class="nv"&gt;$FRAMES&lt;/span&gt;&lt;span class="s2"&gt;:s=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;W&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;H&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:fps=&lt;/span&gt;&lt;span class="nv"&gt;$FPS&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
format=yuv420p[v]"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-map&lt;/span&gt; &lt;span class="s2"&gt;"[v]"&lt;/span&gt; &lt;span class="nt"&gt;-map&lt;/span&gt; 1:a &lt;span class="nt"&gt;-af&lt;/span&gt; apad &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$DUR&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-ar&lt;/span&gt; 44100 &lt;span class="nt"&gt;-ac&lt;/span&gt; 2 segment.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each part does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-loop 1 -i scene.png&lt;/code&gt; repeats the still image as a video stream. &lt;code&gt;-i voice.mp3&lt;/code&gt; adds the voice-over.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scale=900:1600:force_original_aspect_ratio=increase,crop=900:1600&lt;/code&gt; grows the image until it covers 900×1600, then cuts away what sticks out. That gives a 9:16 picture whatever shape the source was. It's 1.25× bigger than the output on purpose: &lt;code&gt;zoompan&lt;/code&gt; jitters when it zooms into a small image.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;z='min(zoom+0.0008,1.15)'&lt;/code&gt; zooms in 0.08% per frame, which is 2% per second at 25 fps, and stops at 15%. The &lt;code&gt;x&lt;/code&gt; and &lt;code&gt;y&lt;/code&gt; parts keep the zoom centred.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;d=$FRAMES:s=${W}x${H}:fps=$FPS&lt;/code&gt; sets the clip's frame count, output size and frame rate.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;format=yuv420p&lt;/code&gt; uses the pixel format phones and browsers can play.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-af apad&lt;/code&gt; pads the voice with silence, and &lt;code&gt;-t $DUR&lt;/code&gt; cuts the clip to the exact length.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For 16:9, swap the numbers: &lt;code&gt;1600:900&lt;/code&gt; and &lt;code&gt;s=1280x720&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: make each scene as long as its voice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; the narration gets cut off mid-word at the end of a scene.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; the LLM planned 3 seconds for "Keep the same wake-up time, even on weekends." The voice takes 3.11 seconds to say it, so "weekends" gets clipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; The LLM has no idea how fast your TTS voice speaks. Its &lt;code&gt;durationSec&lt;/code&gt; is a guess.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsdcf44589eayprsuds6a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsdcf44589eayprsuds6a.png" alt="Timeline bars. Voice-over: 3.11 seconds. Planned scene: 3.0 seconds, so the last 0.11 seconds of the voice is cut off. Fixed scene: max(3.0, 3.11 + 0.25) = 3.36 seconds, which includes a 0.25 second breath after the voice ends." width="799" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; generate the voice first, measure it with ffprobe, then set the scene length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;duration &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="nv"&gt;csv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 voice.mp3
&lt;span class="c"&gt;# 3.108571&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;speech&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;probeDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioPath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// runs the ffprobe command above&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dur&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durationSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;speech&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// never shorter than the voice, plus a breath&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line is the difference between a clip that sounds edited and one that sounds broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: burn in captions that follow the voice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; viewers with the sound off don't know what the video says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; someone scrolls past your Short on a train, with their phone on mute. There's no text on screen, so they never hear your three tips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Many people watch Shorts on mute, and a voice-over alone tells them nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; split the narration into chunks of 3 or 4 words, give each chunk a start and end time, and draw them onto the video. The timed chunks are saved as an &lt;strong&gt;SRT&lt;/strong&gt; file, a plain-text subtitle format. ffmpeg's &lt;code&gt;subtitles&lt;/code&gt; filter draws ("burns") them into the picture. It uses &lt;strong&gt;libass&lt;/strong&gt;, a subtitle library that standard ffmpeg builds include.&lt;/p&gt;

&lt;p&gt;Most TTS services don't tell you when each word is spoken. Giving each chunk time in proportion to its length is close enough to read along:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4r2g4tge6nv6d6vlvb2i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4r2g4tge6nv6d6vlvb2i.png" alt="Timeline of one 3.36 second scene. Three caption cues sit above the real voice waveform: " width="800" height="416"&gt;&lt;/a&gt;&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;function&lt;/span&gt; &lt;span class="nf"&gt;captionCues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxWords&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&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;chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxWords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;words&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;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;k&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="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&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="nx"&gt;k&lt;/span&gt;&lt;span class="o"&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;.,!?;:&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;k&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;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// break at punctuation&lt;/span&gt;
    &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;n&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&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="mi"&gt;2&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&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="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&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;cue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;d&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="nx"&gt;c&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&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;cue&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toSrt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;cues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&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="p"&gt;${&lt;/span&gt;&lt;span class="nx"&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="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; --&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;For the example line, this writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1
00:00:00,000 --&amp;gt; 00:00:01,576
Keep the same wake-up

2
00:00:01,576 --&amp;gt; 00:00:02,056
time,

3
00:00:02,056 --&amp;gt; 00:00:03,358
even on weekends.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the &lt;code&gt;subtitles&lt;/code&gt; filter to the Step 2 command, just before &lt;code&gt;format=yuv420p&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;,subtitles=scene-0.srt:force_style='FontName=DejaVu Sans,FontSize=10,Bold=1,Outline=1.6,Shadow=0.8,Alignment=2,MarginV=64'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things that cost me time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FontSize&lt;/code&gt; is not in pixels.&lt;/strong&gt; For SRT files, libass lays text out on a canvas 288 units tall, then scales it to the video. So &lt;code&gt;FontSize=10&lt;/code&gt; on a 1280-pixel-tall video is about 44 px. For text about 6% of the short side, use &lt;code&gt;FontSize = 0.06 × min(W, H) × 288 / H&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File paths inside a filter string are painful.&lt;/strong&gt; Colons, backslashes and quotes all need escaping, and Windows drive letters make it worse. Run ffmpeg from the folder that holds the SRT and pass just the file name. Never put user text directly into a filter string; write it to a file first.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 5: a free voice for drafts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; every test render costs money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; you're tuning caption sizes and render the same video 30 times. With a paid voice API, you pay for the same three sentences 30 times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Paid TTS bills per character, even for drafts nobody will see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; use an offline voice for drafts. If your ffmpeg build includes &lt;strong&gt;flite&lt;/strong&gt; (a small offline speech engine), ffmpeg can speak 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;ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; lavfi &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"flite=textfile=line.txt:voice=slt"&lt;/span&gt; &lt;span class="nt"&gt;-af&lt;/span&gt; &lt;span class="nv"&gt;loudnorm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;I&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-16&lt;/span&gt; &lt;span class="nt"&gt;-ar&lt;/span&gt; 44100 voice.mp3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On macOS, &lt;code&gt;say -o voice.aiff -f line.txt&lt;/code&gt; works too. On Linux and Windows, &lt;code&gt;espeak-ng -w voice.wav -f line.txt&lt;/code&gt; does. These voices sound robotic, so keep them for drafts. Put them behind the same interface as your paid voice, so switching is one environment variable:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;VoiceProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;speak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: join the scenes without re-encoding
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; joining the clips is slow and loses quality, or the audio drifts out of sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; joining a 40-scene video crawls along because every frame is compressed again, or halfway through, the voice starts to lag behind the picture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; &lt;strong&gt;Re-encoding&lt;/strong&gt; means decoding the video and compressing it again, which is slow and loses a bit of quality each time. ffmpeg's &lt;strong&gt;concat demuxer&lt;/strong&gt; can join clips without re-encoding, but only if every clip was encoded the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; encode every scene with identical settings (resolution, frame rate, pixel format, codec, audio sample rate and channel count), then join them:&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;printf&lt;/span&gt; &lt;span class="s2"&gt;"file '%s'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; scene-0.mp4 scene-1.mp4 scene-2.mp4 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; list.txt
ffmpeg &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; concat &lt;span class="nt"&gt;-safe&lt;/span&gt; 0 &lt;span class="nt"&gt;-i&lt;/span&gt; list.txt &lt;span class="nt"&gt;-c&lt;/span&gt; copy &lt;span class="nt"&gt;-movflags&lt;/span&gt; +faststart final.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-c copy&lt;/code&gt; copies the video and audio as they are, so it's almost instant.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+faststart&lt;/code&gt; moves the file's index to the front, so the video starts playing in a browser before it has fully downloaded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If concat complains or the audio drifts, one clip was encoded differently. Usually it's a mono voice file that skipped &lt;code&gt;-ac 2&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: survive real APIs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; renders that work on your laptop fail in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; the image API answers "429 Too Many Requests" on scene 12 of 40, and the whole job dies. You retry it, and it pays for a new LLM plan before failing again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Real APIs have rate limits and bad moments. Some errors fix themselves if you wait. Others never will, like a wrong API key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj3pqb1sk3w0tt68o8qri.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj3pqb1sk3w0tt68o8qri.png" alt="Flowchart: an API call fails. On 429 (rate limit) or 5xx (server error), wait and retry at 1 s, 2 s, 4 s and so on, plus random jitter; if it works, carry on with the saved plan. On 400, 401 or 403 (bad request or bad key), or if retries keep failing, fail the job with a clear message." width="800" height="1334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; three rules.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retry only errors that can fix themselves.&lt;/strong&gt; Retry 429 and 5xx with &lt;strong&gt;exponential backoff&lt;/strong&gt; (wait 1 s, then 2 s, then 4 s) plus &lt;strong&gt;jitter&lt;/strong&gt; (a small random extra wait, so many jobs don't retry at the same instant). Don't retry 400, 401 or 403. Fail the job at once with a clear message instead of burning credits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save finished steps on the job.&lt;/strong&gt; Store the validated plan on the job record. When the job retries after an image failure, it skips the LLM call it already paid for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limit how much runs at once.&lt;/strong&gt; Render, say, 3 scenes at a time and 2 jobs at a time, and make both configurable. Firing 40 requests at once with &lt;code&gt;Promise.all&lt;/code&gt; is how you get rate-limited.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For one machine, a simple file-based job store is enough: one &lt;code&gt;job.json&lt;/code&gt; per job, written atomically, plus a worker loop that picks up queued jobs. On startup, put any job still marked &lt;code&gt;running&lt;/code&gt; back in the queue, so a crash doesn't strand it. When you outgrow one machine, put Redis/BullMQ or Postgres &lt;code&gt;SKIP LOCKED&lt;/code&gt; behind the same interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Validate the LLM's JSON. Clamp scene count and durations.&lt;/li&gt;
&lt;li&gt;Scale up, crop to the target shape, then &lt;code&gt;zoompan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Measure the voice with ffprobe. Scene length = max(planned, speech + 0.25 s).&lt;/li&gt;
&lt;li&gt;SRT chunks of 3 to 4 words, burned in with &lt;code&gt;subtitles&lt;/code&gt;. Font size is in 288-unit canvas terms, not pixels.&lt;/li&gt;
&lt;li&gt;Run ffmpeg in the asset folder. Text goes in files.&lt;/li&gt;
&lt;li&gt;Identical encode settings, then &lt;code&gt;concat -c copy&lt;/code&gt; with &lt;code&gt;+faststart&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Retry only what's retryable, keep the plan on the job, limit concurrency.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Questions about any of the ffmpeg filters above? Ask in the comments and I'll answer.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ffmpeg</category>
      <category>ai</category>
      <category>node</category>
      <category>video</category>
    </item>
    <item>
      <title>7 ways Stripe webhooks break in production (and the code that fixes each)</title>
      <dc:creator>Syncoders</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:31:51 +0000</pubDate>
      <link>https://dev.to/syncoders/7-ways-stripe-webhooks-break-in-production-and-the-code-that-fixes-each-3ln0</link>
      <guid>https://dev.to/syncoders/7-ways-stripe-webhooks-break-in-production-and-the-code-that-fixes-each-3ln0</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check the signature on the raw request body, never on re-built JSON.&lt;/li&gt;
&lt;li&gt;Stripe can send the same event twice. Save each event ID first and skip repeats.&lt;/li&gt;
&lt;li&gt;Return 200 only when the work is really done. Use a short "lease" so a slow or crashed attempt can't swallow an event.&lt;/li&gt;
&lt;li&gt;Events can arrive out of order. Ask Stripe for the current state instead of trusting the event.&lt;/li&gt;
&lt;li&gt;Guest buyers' emails are in &lt;code&gt;customer_details.email&lt;/code&gt;, not &lt;code&gt;customer_email&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Grant access from the webhook, never from the success page.&lt;/li&gt;
&lt;li&gt;A failed renewal should start reminder emails, not an instant downgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A few words first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhook:&lt;/strong&gt; an HTTP POST that Stripe sends to your server when something happens, like a payment succeeding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint:&lt;/strong&gt; the URL on your server that receives it, e.g. &lt;code&gt;/api/webhooks/stripe&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event:&lt;/strong&gt; the JSON inside that POST. Each one has a unique &lt;code&gt;id&lt;/code&gt; (like &lt;code&gt;evt_123&lt;/code&gt;) and a &lt;code&gt;type&lt;/code&gt; (like &lt;code&gt;checkout.session.completed&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status code:&lt;/strong&gt; the number you answer with. &lt;strong&gt;2xx&lt;/strong&gt; (e.g. 200) means "got it, done". Anything else means "that didn't work".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How a healthy endpoint works
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmr53wxafnw84zoguvu4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmr53wxafnw84zoguvu4q.png" alt="Flowchart: Stripe sends a POST, the endpoint reads the raw body, checks the signature (400 if invalid), claims the event ID (200 if already done, 409 if another attempt is running), does the work (release and 500 on failure), then marks the event done and returns 200." width="800" height="1234"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every request should go through these five steps. Three facts about Stripe explain why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe may send an event more than once.&lt;/strong&gt; If your endpoint is slow, returns an error, or the connection drops before Stripe sees your 200, Stripe sends the event again. In live mode it keeps retrying, with growing gaps, for up to three days. You can also resend any event by hand from the Dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe doesn't promise any order.&lt;/strong&gt; An older event can arrive after a newer one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your status code is the only thing Stripe listens to.&lt;/strong&gt; A 2xx stops the retries. Anything else schedules another try.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of the seven bugs below breaks one of the five steps. The code uses a Next.js App Router route handler, but the ideas work the same in Express, FastAPI or anything else that speaks HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Checking the signature on parsed JSON
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; every webhook fails signature verification, even though your secret is correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; you deploy, the first real payment comes in, and your logs fill with 400 errors. Nobody gets what they paid for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Stripe signs each webhook so you can prove it really came from Stripe. The &lt;strong&gt;signature&lt;/strong&gt; is a code computed from the request body and your webhook secret (&lt;code&gt;whsec_...&lt;/code&gt;), sent in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header. The method is &lt;strong&gt;HMAC-SHA256&lt;/strong&gt;, a standard way to prove a message came from someone who knows the secret and wasn't changed on the way.&lt;/p&gt;

&lt;p&gt;The signature covers the exact bytes Stripe sent. Many frameworks parse the JSON before your code runs. If you then rebuild it with &lt;code&gt;JSON.stringify()&lt;/code&gt;, the spacing and line breaks no longer match what Stripe sent. Different bytes give a different HMAC, so the check fails every time.&lt;/p&gt;

&lt;p&gt;The tempting "fix" is to switch verification off. That lets anyone on the internet send your server a fake "payment succeeded" event.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqtblw312f2gggdhzefej.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqtblw312f2gggdhzefej.png" alt="Two paths. Right: keep the bytes exactly as sent, compute the HMAC of the same bytes, it matches. Wrong: JSON.parse then JSON.stringify changes spacing and line breaks, so the HMAC never matches and you return 400." width="800" height="1245"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; read the body as raw text and hand that to Stripe's library.&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/webhooks/stripe/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&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;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                    &lt;span class="c1"&gt;// the exact bytes Stripe sent&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&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="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="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&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="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="o"&gt;!&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;STRIPE_WEBHOOK_SECRET&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;span class="k"&gt;catch&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="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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                            &lt;span class="c1"&gt;// steps 3 to 5, shown below&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other frameworks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Express:&lt;/strong&gt; put &lt;code&gt;express.raw({ type: "application/json" })&lt;/code&gt; on the webhook route only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI:&lt;/strong&gt; use &lt;code&gt;await request.body()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Django:&lt;/strong&gt; use &lt;code&gt;request.body&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Keep the five-minute window.&lt;/strong&gt; By default the library rejects a signature more than 300 seconds old. That stops someone who captured one real request from replaying it next week. Stripe signs every retry again with a fresh timestamp, so this never blocks a real retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Doing the work twice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; one purchase triggers two of everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; Stripe sends the same event twice, and the customer gets two welcome emails and two licence keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Stripe would rather send an event twice than lose it. If your 200 gets lost on the way back, Stripe never sees it and sends the event again. Your handler runs a second time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9amks17qt6belns0cyp1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9amks17qt6belns0cyp1.png" alt="Sequence: Stripe sends evt_123, the endpoint claims it, sends the welcome email and marks it done, but the 200 is lost. Stripe sends evt_123 again; the claim says already done, so the endpoint returns 200 duplicate and sends no second email." width="799" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: make the handler idempotent.&lt;/strong&gt; &lt;strong&gt;Idempotent&lt;/strong&gt; means that running it twice has the same effect as running it once.&lt;/p&gt;

&lt;p&gt;The standard way is to save each &lt;code&gt;event.id&lt;/code&gt; in a table where the ID is the primary key. That is a &lt;strong&gt;unique constraint&lt;/strong&gt;: the database refuses a second row with the same ID. Before doing any work, try to insert the ID. If a finished row is already there, skip the event and return 200.&lt;/p&gt;

&lt;p&gt;The order matters: &lt;strong&gt;save the ID first, then do the work.&lt;/strong&gt; If you first ask "have I seen this ID?" and only save it at the end, two copies arriving at the same moment both see "no" and both run. With the insert first, the database lets exactly one of them win.&lt;/p&gt;

&lt;p&gt;There's one more case to handle before this is safe, and it's in the next section, together with the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Telling Stripe "done" when the work isn't done
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; Stripe is told an event succeeded, so it stops retrying, but the work never happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example 1:&lt;/strong&gt; your database is down for a minute. The handler logs the error and returns 200 anyway. The customer paid, and their plan never updates.&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;// Don't do this: a failure looks like success to Stripe&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;await&lt;/span&gt; &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="s2"&gt;ok&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;Everyday example 2:&lt;/strong&gt; your handler is waiting on a slow API, so Stripe gives up on it and retries. The retry sees the event ID is already saved, answers 200 "duplicate", and Stripe stops. Then the first attempt fails. Again, the customer paid and got nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Your status code is the only thing Stripe listens to. In example 2, a plain "seen it" row can't tell "finished" apart from "still running" or "crashed halfway".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: return an error whenever the work isn't finished, and use a claim with a lease.&lt;/strong&gt; A &lt;strong&gt;lease&lt;/strong&gt; is a claim with an expiry time: "I'm working on &lt;code&gt;evt_123&lt;/code&gt; until 12:01:00". Each row gets a &lt;code&gt;status&lt;/code&gt; (&lt;code&gt;processing&lt;/code&gt; or &lt;code&gt;done&lt;/code&gt;) and a &lt;code&gt;locked_until&lt;/code&gt; time. When an event arrives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No row yet:&lt;/strong&gt; insert one. This attempt owns the event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;done&lt;/code&gt;:&lt;/strong&gt; it's a duplicate. Return 200.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;processing&lt;/code&gt;, lease still valid:&lt;/strong&gt; another attempt is working on it. Return &lt;strong&gt;409&lt;/strong&gt;, so Stripe tries again later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;processing&lt;/code&gt;, lease expired:&lt;/strong&gt; the earlier attempt crashed. Take the event over.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fluzguu9j3q9c2sn1y8n2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fluzguu9j3q9c2sn1y8n2.png" alt="Sequence: attempt 1 claims evt_123 with a 60 second lease and makes a slow API call. Stripe gives up and retries; the claim says busy, so the retry returns 409. Attempt 1 fails and releases the lease. Stripe's later retry claims the event, does the work, marks it done and gets a 200. If attempt 1 had crashed, its lease would run out after 60 seconds and the next retry would take over." width="800" height="709"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Postgres, a single statement makes the claim. It either inserts a new row or takes over an expired lease, with no gap where two attempts can both win:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&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="c1"&gt;-- the Stripe event id&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- 'processing' or 'done'&lt;/span&gt;
  &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;                  &lt;span class="c1"&gt;-- when the lease runs out&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Claim: returns a row only if this attempt now owns the event&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt; &lt;span class="p"&gt;(&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;locked_until&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&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;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'60 seconds'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
  &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;locked_until&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'60 seconds'&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt;&lt;span class="p"&gt;.&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;'processing'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;processed_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row comes back, read the row's &lt;code&gt;status&lt;/code&gt;: &lt;code&gt;done&lt;/code&gt; means duplicate, anything else means busy. The handler then looks like this:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&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;claim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;claimEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// "claimed" | "duplicate" | "busy"&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;claim&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;duplicate&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="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="s2"&gt;duplicate&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;200&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;claim&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;busy&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="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="s2"&gt;in progress&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;409&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;await&lt;/span&gt; &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                         &lt;span class="c1"&gt;// update the DB, send the email&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markDone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                   &lt;span class="c1"&gt;// status = 'done'&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="s2"&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="na"&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="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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;releaseEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;               &lt;span class="c1"&gt;// locked_until = now(), so a retry can run it&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="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="s2"&gt;retry&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;500&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;Two rules keep this safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make the lease longer than your slowest run.&lt;/strong&gt; Give your Stripe client a timeout, e.g. &lt;code&gt;new Stripe(key, { timeout: 8000, maxNetworkRetries: 2 })&lt;/code&gt;, so one hung call can't outlast the lease.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the handler short.&lt;/strong&gt; If provisioning takes 20 seconds, write what you need to the database, put the slow part on a queue keyed by &lt;code&gt;event.id&lt;/code&gt;, and return.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Return 200 only when the work is committed, or when you're deliberately ignoring an event type you don't handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Trusting the order events arrive in
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; a customer who cancelled gets their paid plan back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; a customer cancels at 12:00:05. An older "subscription updated, status active" event from 12:00:01 was delayed by a retry and arrives after the cancel. Your code applies it, and the cancelled customer has Pro again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Stripe doesn't guarantee delivery order. A retried event arrives whenever its retry runs, which can be after newer events.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbif7oyys1pzkrjb1cgo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsbif7oyys1pzkrjb1cgo.png" alt="Sequence: Stripe creates event A (updated, active) at 12:00:01 and event B (deleted) at 12:00:05. B arrives first and the plan becomes Free. A arrives late and, trusting the payload, the plan becomes Pro, which is wrong. The fix: retrieve the subscription from the Stripe API, which says canceled, so the plan stays Free." width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; treat the event as a nudge, not as the truth. When a subscription event arrives, ask Stripe for the subscription as it is right now and save that:&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;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.subscription.updated&lt;/span&gt;&lt;span class="dl"&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="s2"&gt;customer.subscription.deleted&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;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// the state right now&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// "active", "past_due", "canceled", ...&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cheaper option is to compare &lt;code&gt;event.created&lt;/code&gt; with the timestamp of the last event you applied and skip older ones. But &lt;code&gt;event.created&lt;/code&gt; is in whole seconds, so two events in the same second tie. Re-fetching costs one API call and always gives the current state, so it's the one I'd pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Looking up the buyer by &lt;code&gt;customer_email&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; some buyers pay and get nothing, with no error anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; you share a Payment Link on social media and ten people buy. Your code looks for &lt;code&gt;customer_email&lt;/code&gt;, finds &lt;code&gt;null&lt;/code&gt; every time, and none of them get access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; A &lt;strong&gt;Payment Link&lt;/strong&gt; is a checkout page you create in the Stripe Dashboard without writing code. The &lt;code&gt;checkout.session.completed&lt;/code&gt; event has two email fields, and they mean different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customer_email&lt;/code&gt; is the email &lt;strong&gt;you passed in&lt;/strong&gt; when your code created the session. For Payment Links, pricing tables and most guest checkouts it's &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customer_details.email&lt;/code&gt; is the email &lt;strong&gt;the buyer typed&lt;/strong&gt; on the Checkout page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code that reads only &lt;code&gt;customer_email&lt;/code&gt; passes every test where you created the session yourself, then silently drops every Payment Link order in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; identify the buyer by your own ID first, then fall back to the email they typed. &lt;code&gt;client_reference_id&lt;/code&gt; is an ID you can attach when you create a session, such as your user ID.&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;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout.session.completed&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Session&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no_payment_required&lt;/span&gt;&lt;span class="dl"&gt;"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_reference_id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;userId&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;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_email&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;provision&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check &lt;code&gt;payment_status&lt;/code&gt;. With delayed payment methods, like bank debits, the session completes before the money arrives. The money is confirmed later by a separate &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt; event, so provision there too.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Granting access on the success page
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; people get access without paying, or paid customers land on a broken page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; your &lt;code&gt;/thanks&lt;/code&gt; page turns on Pro when it loads. Someone posts the link in a Discord server, and everyone who opens it gets Pro for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; The &lt;code&gt;success_url&lt;/code&gt; is just the page the buyer's browser goes to after Checkout. Anyone can open it, and anyone can share it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2nkdb0uy7pwrejhqy1k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2nkdb0uy7pwrejhqy1k.png" alt="Flowchart: after the buyer pays, Stripe sends the webhook, which grants access in your database. Separately the browser opens your success_url, which only asks whether the payment is done. Anyone with the link can open that page too." width="800" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; grant access only from the webhook. Treat the success page as a status screen: show "Thanks, we're confirming your payment" and ask your own API whether the webhook has run yet.&lt;/p&gt;

&lt;p&gt;The success page still needs to know which checkout it belongs to. Stripe replaces the literal text &lt;code&gt;{CHECKOUT_SESSION_ID}&lt;/code&gt; in your &lt;code&gt;success_url&lt;/code&gt; with the real session ID. That's where a sneaky bug lives. In a JavaScript &lt;strong&gt;template literal&lt;/strong&gt; (a backtick string), &lt;code&gt;${...}&lt;/code&gt; inserts a variable:&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;// Bug: ${CHECKOUT_SESSION_ID} is a JavaScript variable lookup&lt;/span&gt;
&lt;span class="nx"&gt;success_url&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;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/thanks?session_id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CHECKOUT_SESSION_ID&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="c1"&gt;// Correct: no $, so Stripe sees the literal text and fills it in&lt;/span&gt;
&lt;span class="nx"&gt;success_url&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;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/thanks?session_id={CHECKOUT_SESSION_ID}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The buggy line throws a ReferenceError, or produces &lt;code&gt;session_id=undefined&lt;/code&gt; if a variable with that name happens to exist. Building the URL with &lt;code&gt;new URL()&lt;/code&gt; and &lt;code&gt;searchParams.set()&lt;/code&gt; breaks it a third way: the braces get encoded to &lt;code&gt;%7BCHECKOUT_SESSION_ID%7D&lt;/code&gt;, and Stripe no longer recognises them. Searching your code for &lt;code&gt;success_url&lt;/code&gt; takes a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Cutting access on the first failed renewal
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What goes wrong:&lt;/strong&gt; good customers get locked out over a card problem that would have fixed itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyday example:&lt;/strong&gt; a customer's card expired last month. The renewal fails at 3 a.m., your code downgrades them on the spot, and they wake up locked out, even though Stripe was going to retry the charge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; &lt;code&gt;invoice.payment_failed&lt;/code&gt; fires when a renewal charge is declined. It's tempting to treat it as "they stopped paying". But Stripe retries the card on its own schedule (Smart Retries), and the customer can still update their card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; use the event to start &lt;strong&gt;dunning&lt;/strong&gt;, the process of reminding a customer to fix a failed payment. Email them a link to update their card. Let access follow the subscription's &lt;code&gt;status&lt;/code&gt; as Stripe moves it from &lt;code&gt;past_due&lt;/code&gt; to &lt;code&gt;unpaid&lt;/code&gt; or &lt;code&gt;canceled&lt;/code&gt;, depending on your settings.&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;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.payment_failed&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;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Invoice&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;sendUpdateCardEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// a reminder, not a downgrade&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="c1"&gt;// Access comes from the subscription status, not from the failed invoice.&lt;/span&gt;
&lt;span class="c1"&gt;// Including "past_due" gives a grace period while Stripe retries; leave it out if you don't want one.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasAccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trialing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;past_due&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;h2&gt;
  
  
  Testing all of this without a Stripe account
&lt;/h2&gt;

&lt;p&gt;You can test every case offline, because Stripe's library can sign test payloads for you:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_test_dummy&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;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;whsec_test_secret&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;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;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// a fixture shaped like a real Stripe event&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateTestHeaderString&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;payload&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="c1"&gt;// POST the payload with this "stripe-signature" header to your handler&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then write one test per case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate.&lt;/strong&gt; Send the same signed event twice. Expect two 200s and one side effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow attempt.&lt;/strong&gt; Make the work slow and send a second copy while it runs. Expect 409, then a 200 on a later copy, and one side effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crash.&lt;/strong&gt; Claim an event, then stop before finishing. After the lease runs out, a new copy should process it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure.&lt;/strong&gt; Make the database throw. Expect 500, and the event not marked done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out of order.&lt;/strong&gt; Send &lt;code&gt;deleted&lt;/code&gt;, then the older &lt;code&gt;updated&lt;/code&gt; (active). Expect the subscription to stay &lt;code&gt;canceled&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old signature.&lt;/strong&gt; Sign with a timestamp an hour old (&lt;code&gt;timestamp: now - 3600&lt;/code&gt;). Expect 400.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tampered.&lt;/strong&gt; Change one byte of the body after signing. Expect 400.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong secret.&lt;/strong&gt; Sign with a different &lt;code&gt;whsec_&lt;/code&gt;. Expect 400.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guest checkout.&lt;/strong&gt; Send &lt;code&gt;client_reference_id: null&lt;/code&gt;, &lt;code&gt;customer_email: null&lt;/code&gt; and &lt;code&gt;customer_details.email: "a@b.com"&lt;/code&gt;. Expect the buyer to get access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before going live, also run &lt;code&gt;stripe listen --forward-to localhost:3000/api/webhooks/stripe&lt;/code&gt;, trigger each event type, and resend one old event from the Dashboard to confirm nothing happens twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Verify the raw body. Keep the five-minute window.&lt;/li&gt;
&lt;li&gt;Save &lt;code&gt;event.id&lt;/code&gt; first, under a unique constraint.&lt;/li&gt;
&lt;li&gt;Return 200 only when the work is done. Use a lease, answer 409 while busy, and release the claim on failure.&lt;/li&gt;
&lt;li&gt;Don't trust arrival order. Re-fetch the subscription.&lt;/li&gt;
&lt;li&gt;Find buyers by your own ID, then &lt;code&gt;customer_details.email&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Grant access from the webhook, never from the success page. Keep &lt;code&gt;{CHECKOUT_SESSION_ID}&lt;/code&gt; literal.&lt;/li&gt;
&lt;li&gt;A failed renewal starts reminder emails, not a downgrade.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Before you go live, I keep a free &lt;a href="https://syncoder.gumroad.com/l/fdfup" rel="noopener noreferrer"&gt;Stripe Webhooks Go-Live Checklist&lt;/a&gt; (pay what you want, $0 is fine) that covers what to check before you trust your endpoint with live money.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you'd rather not build the event ledger, leases and retries yourself, &lt;a href="https://webhookproof-web.vercel.app" rel="noopener noreferrer"&gt;Webhook-Proof Stripe&lt;/a&gt; is my paid kit that does them for Next.js and FastAPI.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webhooks</category>
      <category>nextjs</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
