<?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: DevToolsmith</title>
    <description>The latest articles on DEV Community by DevToolsmith (@toolkitonline).</description>
    <link>https://dev.to/toolkitonline</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%2F3835996%2F61f81be0-056b-4410-8e4d-d3c8f59aa05b.png</url>
      <title>DEV Community: DevToolsmith</title>
      <link>https://dev.to/toolkitonline</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toolkitonline"/>
    <language>en</language>
    <item>
      <title>Stop Self-Hosting Headless Chrome Just to Take a Screenshot</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 20:26:26 +0000</pubDate>
      <link>https://dev.to/toolkitonline/stop-self-hosting-headless-chrome-just-to-take-a-screenshot-5b6k</link>
      <guid>https://dev.to/toolkitonline/stop-self-hosting-headless-chrome-just-to-take-a-screenshot-5b6k</guid>
      <description>&lt;p&gt;Every few months a teammate opens a pull request titled something like "add OG image generation," and every few months it turns into a saga. The feature itself is one line of intent: render a webpage to an image. The implementation is where the weekend goes.&lt;/p&gt;

&lt;p&gt;If you have ever shipped screenshots, PDFs, or Open Graph images from a real production environment, you already know the shape of this problem. Let me walk through why it is harder than it looks, and a pattern that keeps it boring.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trap of "just use Puppeteer"
&lt;/h3&gt;

&lt;p&gt;The first version is always clean. You install Puppeteer, launch a headless browser, navigate to a URL, and capture the buffer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&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;puppeteer&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;browser&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;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&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;page&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle0&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;image&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;png&lt;/span&gt;&lt;span class="dl"&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On your laptop this is flawless. Then you deploy it.&lt;/p&gt;

&lt;p&gt;In a serverless function, the Chromium binary blows past your bundle size limit, so you reach for a slimmed build. Cold starts now add seconds because the browser has to boot. Under any real concurrency you hit the memory ceiling and functions start getting killed. Custom fonts on the target page do not render unless you ship the font files too. And the next time a dependency bump touches the Chromium version, something silently breaks.&lt;/p&gt;

&lt;p&gt;None of this is your product. It is infrastructure you did not want to own, sitting on the critical path of a feature your users barely think about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Treat rendering as an HTTP call
&lt;/h3&gt;

&lt;p&gt;The pattern that has saved me the most time is simple: do not run the browser yourself. Make rendering a stateless HTTP request. You send a URL and a format, you get back bytes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.captureapi.dev/v1/screenshot&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CAPTURE_API_KEY&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;png&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="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;image&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No browser in your bundle. No memory tuning. No cold-start penalty from booting Chromium. The render layer becomes a dependency you call, not a service you babysit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three outputs you actually need
&lt;/h3&gt;

&lt;p&gt;In practice, most teams need the same three things, and they need them from one place rather than three separate hacks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots&lt;/strong&gt; for previews, thumbnails, monitoring, and dashboards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDFs&lt;/strong&gt; for invoices, reports, and exportable documents, generated from the same rendered page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Graph images&lt;/strong&gt; so links to your app look right when shared on social platforms.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Wiring up three different home-grown solutions for these is how you end up with three different sets of Chromium bugs. One endpoint that switches on a format parameter keeps the surface area small.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two things that matter at scale
&lt;/h3&gt;

&lt;p&gt;When you go from one screenshot to thousands, two features stop being nice-to-haves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch processing.&lt;/strong&gt; When you need to regenerate every OG image after a template change, or screenshot a list of pages on a schedule, firing one request per URL and managing the concurrency yourself reintroduces the exact problem you were trying to escape. Sending a list of URLs in a single batch request pushes that concurrency management to the service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge caching.&lt;/strong&gt; A huge share of render requests are repeats. The same blog post, the same product page, the same OG image requested again and again. If every one of those re-renders a full browser page, you are paying for work you already did. Caching the rendered result at the edge means repeat requests come back fast without re-rendering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this leaves you
&lt;/h3&gt;

&lt;p&gt;The honest takeaway is not that rendering is impossible to self-host. Plenty of teams do it. It is that the time you spend on Chromium binaries, memory limits, font handling, and cache invalidation is time you are not spending on the thing you are actually building.&lt;/p&gt;

&lt;p&gt;If you would rather make rendering a single HTTP call and move on, CaptureAPI handles screenshots, PDFs, and Open Graph images from one endpoint, with batch processing and edge caching built in. It is free to try, so you can drop it into a function and see whether "rendering as an HTTP call" feels as boring as it should: &lt;a href="https://captureapi.dev" rel="noopener noreferrer"&gt;https://captureapi.dev&lt;/a&gt;&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build CaptureAPI, a rendering API for website screenshots, PDFs, and Open Graph images. It is free to try at &lt;a href="https://captureapi.dev" rel="noopener noreferrer"&gt;https://captureapi.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop Writing Regex for Invoices: Turn Any PDF Into Structured JSON With One API Call</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 20:25:50 +0000</pubDate>
      <link>https://dev.to/toolkitonline/stop-writing-regex-for-invoices-turn-any-pdf-into-structured-json-with-one-api-call-9l2</link>
      <guid>https://dev.to/toolkitonline/stop-writing-regex-for-invoices-turn-any-pdf-into-structured-json-with-one-api-call-9l2</guid>
      <description>&lt;p&gt;If you have ever been handed a folder of invoices and asked to "just get the totals into a spreadsheet," you already know the trap. It sounds like a one-afternoon script. Three weeks later you are maintaining a regex zoo, a per-vendor template system, and a Slack channel full of people asking why last Tuesday's batch came back empty.&lt;/p&gt;

&lt;p&gt;This article walks through why document parsing is harder than it looks, and a simpler pattern for getting clean, structured data out of PDFs without building and babysitting your own extraction stack.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why "just parse the PDF" goes wrong
&lt;/h3&gt;

&lt;p&gt;PDFs are a presentation format, not a data format. A number that looks like a total to a human is, under the hood, a glyph positioned at some coordinate with no semantic label. So most teams reach for the same stack:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;OCR to pull raw text.&lt;/li&gt;
&lt;li&gt;Regex and string heuristics to find fields.&lt;/li&gt;
&lt;li&gt;A template per document layout to map positions to fields.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works in a controlled demo. In production it degrades for predictable reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Layouts drift.&lt;/strong&gt; A vendor moves the invoice number, and your positional template silently returns the wrong cell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variety explodes.&lt;/strong&gt; Ten suppliers become two hundred. You cannot hand-author a template for each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge cases are the norm.&lt;/strong&gt; Multi-page invoices, line items that wrap, scanned receipts, mixed currencies. Each one is a new patch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The maintenance never ends because the input never stabilizes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pattern: treat extraction as a single API call
&lt;/h3&gt;

&lt;p&gt;Instead of owning OCR, heuristics, and templates, you can treat extraction the way you treat geocoding or email validation: a service that takes a messy input and returns a typed result. You send a document, you get back JSON with named fields. Your job shrinks to handling the JSON.&lt;/p&gt;

&lt;p&gt;Here is what that looks like in practice with ParseFlow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.parseflow.dev/v1/extract &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$PARSEFLOW_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@invoice.pdf"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"type=invoice"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a representative response:&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invoice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vendor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Northwind Supplies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INV-20418"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issue_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1284.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"line_items"&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;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Thermal paper rolls"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"qty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;50.40&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;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Label printer ink"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"qty"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;78.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;234.00&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;From there it is ordinary code. In Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.parseflow.dev/v1/extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PARSEFLOW_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_invoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;line_items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No OCR step you maintain. No template to author for the next vendor. You get a typed object and move on to the part that is actually your business logic — writing to a database, kicking off an approval, reconciling against a PO.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this fits in a workflow
&lt;/h3&gt;

&lt;p&gt;The single-call shape makes it easy to drop into existing automation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inbox to database:&lt;/strong&gt; a new invoice email arrives, your function extracts it and inserts a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload form to review queue:&lt;/strong&gt; a user uploads a receipt, you store structured fields and only flag the ones below a confidence threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch backfill:&lt;/strong&gt; loop over an archive of historical PDFs and normalize them into one schema.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because it is just an HTTP call returning JSON, it slots into Python scripts, serverless functions, and no-code automation tools alike.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to keep in your own hands
&lt;/h3&gt;

&lt;p&gt;A service handles extraction, but you still own the parts that depend on your domain: validation rules (does this total match the line items?), idempotency (don't double-import the same invoice), and human review for low-confidence cases. The goal is not to remove judgment from the loop. It is to delete the brittle, undifferentiated OCR-and-template layer that was never your real work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Closing
&lt;/h3&gt;

&lt;p&gt;If your document pipeline is one vendor layout change away from a 2am page, it is worth trying the single-call approach before you build yet another template engine. ParseFlow takes a PDF — invoice, receipt, contract, or ID — and hands back structured JSON you can use immediately. It is free to try, so the fastest way to evaluate it is to send it the exact document that keeps breaking your current setup and see what comes back: &lt;a href="https://parseflow.dev" rel="noopener noreferrer"&gt;https://parseflow.dev&lt;/a&gt;&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build ParseFlow, a document extraction API that turns PDFs into structured JSON. It is free to try at &lt;a href="https://parseflow.dev" rel="noopener noreferrer"&gt;https://parseflow.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Involuntary Churn Is Eating Your MRR: A Practical Guide to Recovering Failed Payments</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 20:25:14 +0000</pubDate>
      <link>https://dev.to/toolkitonline/involuntary-churn-is-eating-your-mrr-a-practical-guide-to-recovering-failed-payments-4oh3</link>
      <guid>https://dev.to/toolkitonline/involuntary-churn-is-eating-your-mrr-a-practical-guide-to-recovering-failed-payments-4oh3</guid>
      <description>&lt;p&gt;Ask most SaaS founders what their churn rate is and they'll give you a number. Ask them how much of that churn is involuntary, caused by failed payments rather than customers actively quitting, and you usually get a blank stare.&lt;/p&gt;

&lt;p&gt;That gap matters, because involuntary churn is often the single most recoverable revenue in your entire business. The customer still wants your product. Their card just failed. Nobody made a decision to leave.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "failed payment" actually means
&lt;/h3&gt;

&lt;p&gt;When a recurring charge fails, it's rarely a rejection of your product. The common causes are mundane:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expired cards.&lt;/strong&gt; The customer signed up two years ago and the card on file expired last month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insufficient funds.&lt;/strong&gt; A temporary blip that clears in a day or two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issuer declines.&lt;/strong&gt; The bank flags a recurring charge as suspicious and blocks it, even though the customer would happily approve it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard declines.&lt;/strong&gt; A genuinely dead card that needs replacing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first three are almost always recoverable. The trick is retrying at the right time and prompting the customer when needed, without annoying them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why default handling leaves money on the table
&lt;/h3&gt;

&lt;p&gt;If you're on Stripe, you already get some retry logic. By default it retries a failed charge a handful of times on a fixed schedule and can send a basic email. That's better than nothing, but it's blunt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Retries happen on a generic cadence, not timed to when a card is statistically more likely to clear (for example, shortly after a payday or once an insufficient-funds hold lifts).&lt;/li&gt;
&lt;li&gt;The dunning email is generic and easy to ignore.&lt;/li&gt;
&lt;li&gt;After the retry window closes, the subscription is simply cancelled, and that recovered-or-not outcome is invisible unless you go digging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can build smarter logic yourself with webhooks. Here's the shape of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Listen for failed charges&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="nx"&gt;endpointSecret&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;enqueueRetry&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="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// not "every 3 days" — schedule around likely-clear windows&lt;/span&gt;
      &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;smartRetrySchedule&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;attempt_count&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// trigger a recovery email&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you'd need a job runner to fire those retries, an email system for the recovery sequence, logic to stop on success or hard decline, and a dashboard to actually see what you recovered. It's all doable. It's also a project you'll keep deprioritizing because there's always something more urgent than plumbing.&lt;/p&gt;

&lt;h3&gt;
  
  
  A simpler path
&lt;/h3&gt;

&lt;p&gt;This is the exact problem PaymentRescue solves, so you don't have to build and maintain that pipeline yourself.&lt;/p&gt;

&lt;p&gt;You connect it to Stripe in a few minutes. From there it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detects&lt;/strong&gt; every failed charge automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries&lt;/strong&gt; on a schedule designed around when cards are more likely to clear, instead of a fixed "every N days" rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emails&lt;/strong&gt; the customer with a clear, well-timed prompt to update their payment method when a retry alone won't fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reports&lt;/strong&gt; exactly how much revenue it recovered, so the impact isn't a guess.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's deliberately a leaner alternative to the heavier recovery suites. No multi-day onboarding, no dashboards you'll never open, just the recovery layer. There's a free tier, so you can connect it, watch a single billing cycle, and see real recovered charges before you decide anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;Before you spend another dollar on acquisition, look at the revenue you've already earned and are quietly losing. Pull your failed-payment and past-due numbers. If a meaningful slice of your churn is involuntary, recovering it is the highest-leverage revenue work you can do this quarter, and it's a fraction of the cost of replacing those customers.&lt;/p&gt;

&lt;p&gt;You can wire it up yourself with the snippet above, or let PaymentRescue handle the whole loop at paymentrescue.dev.&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build PaymentRescue, a failed-payment recovery and dunning tool that wins back revenue from declined subscription cards. It is free to try at &lt;a href="https://paymentrescue.dev" rel="noopener noreferrer"&gt;https://paymentrescue.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Accessibility Overlays Won't Pass a WCAG Audit. Here's What Actually Will.</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 20:24:38 +0000</pubDate>
      <link>https://dev.to/toolkitonline/accessibility-overlays-wont-pass-a-wcag-audit-heres-what-actually-will-3o18</link>
      <guid>https://dev.to/toolkitonline/accessibility-overlays-wont-pass-a-wcag-audit-heres-what-actually-will-3o18</guid>
      <description>&lt;p&gt;If you've shipped a site in the last few years, you've probably been pitched an accessibility "overlay" — a single script that promises instant ADA compliance with a floating widget in the corner. It's an appealing offer. It's also, for the failures that matter most, not how accessibility works.&lt;/p&gt;

&lt;p&gt;The reason is structural. An overlay runs on top of your rendered page. Assistive technology — screen readers, switch devices, voice control — consumes the underlying DOM and the accessibility tree the browser builds from it. If your markup is broken, a widget layered above it doesn't repair the tree. The user still hits an unlabeled button. The contrast still fails. And the legal exposure that site owners worry about doesn't go away, because compliance is measured against the actual WCAG success criteria, not against the presence of a widget.&lt;/p&gt;

&lt;p&gt;So let's talk about what an audit actually checks, and how to fix the common failures at the source.&lt;/p&gt;

&lt;h3&gt;
  
  
  The failures you'll see most often
&lt;/h3&gt;

&lt;p&gt;Across real-world audits, the same handful of WCAG 2.2 issues come up again and again:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Insufficient color contrast&lt;/strong&gt; (WCAG 1.4.3). Normal-size text needs a contrast ratio of at least 4.5:1 against its background; large text needs 3:1. Light-gray-on-white placeholder text is the classic offender.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images missing alt text&lt;/strong&gt; (WCAG 1.1.1). Every meaningful image needs a text alternative. Decorative images need an explicitly empty alt so screen readers skip them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controls with no accessible name&lt;/strong&gt; (WCAG 4.1.2). An icon-only button with no label is announced as just "button." Users have no idea what it does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form inputs with no associated label&lt;/strong&gt; (WCAG 1.3.1, 3.3.2). A placeholder is not a label. It disappears on focus and isn't reliably announced.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Fixing them in the markup
&lt;/h3&gt;

&lt;p&gt;Most of these are quick edits once you can see them. A few concrete examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Before: icon button with no accessible name --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"icon-btn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;svg&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- After: name it --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"icon-btn"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Close dialog"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;svg&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Before: input relying on placeholder --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Email address"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- After: real associated label --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Email address&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For images, decide whether each one carries information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Meaningful image --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"chart-q3.png"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Q3 revenue up 18 percent over Q2"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Decorative image, intentionally skipped --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"divider.png"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Contrast is mostly a design-token problem. Pick foreground and background values that clear 4.5:1, verify the ratio, and apply them consistently rather than patching one element at a time.&lt;/p&gt;

&lt;h3&gt;
  
  
  A practical workflow
&lt;/h3&gt;

&lt;p&gt;The fastest loop I've found is: scan, fix at the source, re-scan.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scan&lt;/strong&gt; the page against WCAG 2.2 to get a specific list of failing elements, each tied to a success criterion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the fix&lt;/strong&gt; for each one so you're editing markup, not guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix&lt;/strong&gt; the highest-impact items first — usually contrast and missing names, because they affect the most users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-scan&lt;/strong&gt; to confirm the items actually cleared, and to catch anything you introduced while editing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Automated scanning won't catch everything — judgment calls like whether alt text is meaningful, or whether a custom widget's keyboard interaction makes sense, still need a human. But a good scan removes the noise so your manual time goes to the things that genuinely need it.&lt;/p&gt;

&lt;p&gt;If you want a quick way to run that loop, &lt;a href="https://fixmyweb.dev" rel="noopener noreferrer"&gt;FixMyWeb&lt;/a&gt; scans a page against WCAG 2.2 / ADA, flags issues like contrast and missing alt text, and explains how to fix each one — no overlay, no plugin pretending the work is done. It's free to try, so you can point it at a URL and see your real exposure before someone else does. The honest version of accessibility is always the one where you can show exactly what failed and exactly what you changed.&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build FixMyWeb, a web accessibility auditor (WCAG 2.2 / ADA) that flags issues and explains how to fix each one. It is free to try at &lt;a href="https://fixmyweb.dev" rel="noopener noreferrer"&gt;https://fixmyweb.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>webdev</category>
      <category>css</category>
      <category>ux</category>
    </item>
    <item>
      <title>Your GDPR Audit Goes Stale the Moment You Ship — Here's How to Catch the Drift</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 20:24:02 +0000</pubDate>
      <link>https://dev.to/toolkitonline/your-gdpr-audit-goes-stale-the-moment-you-ship-heres-how-to-catch-the-drift-4f5e</link>
      <guid>https://dev.to/toolkitonline/your-gdpr-audit-goes-stale-the-moment-you-ship-heres-how-to-catch-the-drift-4f5e</guid>
      <description>&lt;p&gt;Every privacy team I talk to has the same quiet anxiety. They paid for a compliance audit, filed the PDF, and now they have no idea whether anything they shipped since then broke it. The audit was a photograph. The product is a film.&lt;/p&gt;

&lt;p&gt;This gap between point-in-time review and continuous change is where most real-world GDPR and EU AI Act violations actually come from. Not from teams ignoring the rules, but from the rules quietly going out of date as features evolve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why audits decay so fast
&lt;/h3&gt;

&lt;p&gt;A formal audit captures your data flows, consent mechanisms, and processing activities on a specific date. Then your team ships. You add a third-party analytics script. You wire in an LLM feature. You start collecting an extra field at signup "just for now." Each change is small. Each one can move you out of the scope your audit signed off on.&lt;/p&gt;

&lt;p&gt;The EU AI Act makes this sharper. If your product uses AI in ways that trigger transparency obligations or risk classification, a feature you added last sprint can create a documentation duty that did not exist when your last review happened. GDPR and the AI Act now overlap, and the surface area only grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "checking" should actually look like
&lt;/h3&gt;

&lt;p&gt;Compliance checking should behave like a test suite, not like a tax filing. You run it often, it tells you specifically what is wrong, and you fix the highest-impact items first.&lt;/p&gt;

&lt;p&gt;A useful check answers three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What do I actually expose right now? (Not what a questionnaire says I do.)&lt;/li&gt;
&lt;li&gt;Which concrete requirements does that touch under GDPR and the AI Act?&lt;/li&gt;
&lt;li&gt;Of the gaps found, which one should I fix first?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the mental model as a simple loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scan(target)            -&amp;gt; observed signals (cookies, notices, data fields, AI usage)
map(signals -&amp;gt; rules)   -&amp;gt; matched GDPR + AI Act requirements
diff(observed, required)-&amp;gt; list of gaps
rank(gaps)              -&amp;gt; prioritized fix list (severity x effort)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important step is the last one. Most teams do not lack a list of problems. They lack a credible ordering. A flat list of 80 findings with equal weight produces paralysis. A ranked list of "fix these three this week" produces action.&lt;/p&gt;

&lt;h3&gt;
  
  
  A practical cadence
&lt;/h3&gt;

&lt;p&gt;You do not need to turn this into a heavyweight program. A lightweight cadence that works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On every meaningful release:&lt;/strong&gt; run a scan of the changed surface. Treat new gaps like new bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weekly:&lt;/strong&gt; scan the full property so slow drift (a quietly added script, an expired notice) does not accumulate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before a launch or a new market:&lt;/strong&gt; run a focused scan and clear the top-priority items first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is to stop treating compliance as an event and start treating it as a signal you watch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where tooling fits
&lt;/h3&gt;

&lt;p&gt;You can assemble this manually: keep a requirements matrix, review each release against it, and maintain the mapping by hand. It works, but it is slow and it rots, which is exactly the problem we started with.&lt;/p&gt;

&lt;p&gt;This is the gap CompliPilot was built to close. You point it at a website or product, and it checks what you actually expose against EU AI Act and GDPR requirements, then returns concrete gaps with a prioritized fix list. It is meant to be the fast, self-serve check you run continuously rather than the expensive set-piece audit you commission once a year. You can run a scan for free and see what surfaces before you change a single line.&lt;/p&gt;

&lt;p&gt;Compliance does not fail because teams are careless. It fails because the check happens once and the product never stops moving. Close that loop and most of the risk disappears.&lt;/p&gt;

&lt;p&gt;You can try a scan at &lt;a href="https://complipilot.dev" rel="noopener noreferrer"&gt;https://complipilot.dev&lt;/a&gt;&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build CompliPilot, a scanner that checks your site or product against EU AI Act and GDPR and returns a prioritized fix list. It is free to try at &lt;a href="https://complipilot.dev" rel="noopener noreferrer"&gt;https://complipilot.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>security</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop Hand-Designing Open Graph Images: Automate Link Previews for Every Page</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 18:36:18 +0000</pubDate>
      <link>https://dev.to/toolkitonline/stop-hand-designing-open-graph-images-automate-link-previews-for-every-page-ic8</link>
      <guid>https://dev.to/toolkitonline/stop-hand-designing-open-graph-images-automate-link-previews-for-every-page-ic8</guid>
      <description>&lt;p&gt;Open Graph images are the single biggest factor in whether your shared links look credible or broken. Yet most sites ship one generic image on every page because making a unique one by hand is tedious. Here is a more sustainable approach: treat preview images as generated data, not hand-made design.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem, concretely
&lt;/h3&gt;

&lt;p&gt;When you share a link, the receiving platform reads your page's &lt;code&gt;og:image&lt;/code&gt; meta tag and renders a card. If that tag is missing, points to a low-res logo, or is the same image on all 200 pages, your links look generic in every feed, Slack channel, and group chat. Studies of social sharing consistently show that posts with a clear, relevant preview image get meaningfully more engagement than those without.&lt;/p&gt;

&lt;p&gt;The reason teams skip it is not ignorance. It is friction. Opening a design tool, duplicating a template, swapping the title text, exporting at the right dimensions, and uploading the file takes 10 to 20 minutes per page. Nobody keeps that up across a real publishing schedule. So the back catalog stays bare and new posts get whatever the default is.&lt;/p&gt;

&lt;h3&gt;
  
  
  The insight: it is template work
&lt;/h3&gt;

&lt;p&gt;Look at a typical preview card and ask what actually changes between pages. Usually just the title, maybe the author and a category tag. The layout, background, logo, and fonts are constant. That is the textbook definition of a job you should template once and generate programmatically, not redo by hand each time.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to solve it
&lt;/h3&gt;

&lt;p&gt;The cleanest pattern is to generate the image at build time or on first request, then cache it. Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// During your build or in an API route&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;getOgImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&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;params&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Returns a ready Open Graph image URL&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`https://getcardforge.dev/api/card?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In your page head&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;meta property="og:image" content={getOgImage(post)} /&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can build this yourself with a headless browser plus an HTML template, and for a small site that is reasonable. The honest caveats appear at scale: headless renderers are memory-hungry, font loading is a recurring source of subtle bugs, cold starts add latency, and you end up babysitting an image pipeline that has nothing to do with your actual product. You also have to think about caching so a busy crawler does not trigger a thousand regenerations of the same card.&lt;/p&gt;

&lt;p&gt;If you would rather not own that pipeline, a hosted generator does the same job: you pass a URL or the metadata fields, it returns the Open Graph image. The things that are annoying to build yourself, batch generation for covering an existing archive in one pass, signed delivery URLs so your cards are not trivially scraped, and edge caching so identical cards are served instantly, come included.&lt;/p&gt;

&lt;h3&gt;
  
  
  A practical rollout
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Define one template with your title, author, and brand styling.&lt;/li&gt;
&lt;li&gt;Generate cards for new pages automatically in your build step.&lt;/li&gt;
&lt;li&gt;Run a one-time batch over your existing back catalog.&lt;/li&gt;
&lt;li&gt;Validate with a link-preview debugger before you call it done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal is to make good previews the default state of your site rather than a manual chore you fall behind on. CardForge is one way to do that without standing up and maintaining your own rendering infrastructure, but whichever route you pick, the principle holds: generate, do not hand-draw.&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build CardForge, an Open Graph image generator that turns page metadata into preview cards, in batch. It is free to try at &lt;a href="https://getcardforge.dev" rel="noopener noreferrer"&gt;https://getcardforge.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why Your Webhook Returns 200 But Still Fails (and How to Actually Debug It)</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 18:35:42 +0000</pubDate>
      <link>https://dev.to/toolkitonline/why-your-webhook-returns-200-but-still-fails-and-how-to-actually-debug-it-4e1n</link>
      <guid>https://dev.to/toolkitonline/why-your-webhook-returns-200-but-still-fails-and-how-to-actually-debug-it-4e1n</guid>
      <description>&lt;p&gt;Webhooks are deceptively simple until they break silently. The provider says "delivered," your endpoint returns 200, and yet the thing that was supposed to happen never happens. This post walks through the real reasons that occurs and a repeatable way to debug it without adding ten more log lines.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem, concretely
&lt;/h3&gt;

&lt;p&gt;You wired up a Stripe webhook for &lt;code&gt;checkout.session.completed&lt;/code&gt;. In the Stripe dashboard the event shows as delivered. Your handler logs show a clean 200 response. But the order never gets marked paid.&lt;/p&gt;

&lt;p&gt;Three things tend to be happening, and all of them are invisible if you only read your own logs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Signature verification is silently failing&lt;/strong&gt;, and your code is catching the error, logging nothing useful, and still returning 200 so the provider stops retrying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You verified against the parsed body&lt;/strong&gt; instead of the raw request bytes. Most frameworks JSON-parse and re-serialize the body before you ever touch it, which changes the bytes and breaks the HMAC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The event you think you're handling isn't the one arriving&lt;/strong&gt; — a different event type, a test-mode key, or a payload shape you didn't expect.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The common thread: you cannot fix what you cannot see. You need eyes on the actual request that hit your server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Capture the raw request
&lt;/h3&gt;

&lt;p&gt;Before touching your handler, capture a real event somewhere you can inspect every byte. Point the webhook at a capture URL, trigger one event, and read the exact headers and body that arrived. Now the &lt;code&gt;Stripe-Signature&lt;/code&gt; header, the &lt;code&gt;Content-Type&lt;/code&gt;, and the raw payload are all in front of you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Verify against the raw body
&lt;/h3&gt;

&lt;p&gt;The single most common Stripe webhook bug is verifying a re-serialized body. The fix is to hand the verifier the untouched raw bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;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="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Note: express.raw, NOT express.json — you need the original bytes&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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="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="k"&gt;try&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;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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// raw Buffer, not a parsed object&lt;/span&gt;
        &lt;span class="nx"&gt;sig&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="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// handle event.type here&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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="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;Signature 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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Webhook Error: &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="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you swap &lt;code&gt;express.raw&lt;/code&gt; for &lt;code&gt;express.json&lt;/code&gt;, the signature will fail every time, and a careless &lt;code&gt;catch&lt;/code&gt; that returns 200 will hide it forever. Logging &lt;code&gt;err.message&lt;/code&gt; and returning a non-2xx status is what surfaces the truth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Replay instead of re-triggering
&lt;/h3&gt;

&lt;p&gt;Once you have a captured event, stop creating new test events by hand. Re-issuing a checkout or pushing a commit just to fire a webhook is slow and non-deterministic. Replay the same captured request against your endpoint so the payload and headers are identical on every run. You iterate in seconds, and you can confirm the fix against the exact event that was failing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Simulate edge cases with custom responses
&lt;/h3&gt;

&lt;p&gt;Providers retry on non-2xx. To test your retry handling and idempotency, set a custom response (say, a 500) and watch how your integration behaves when the first delivery "fails." This is how you catch double-processing bugs before they hit production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting it together
&lt;/h3&gt;

&lt;p&gt;The debugging loop is: capture the raw request, verify against the untouched bytes, replay until your handler is green, then simulate failure responses to harden it. You can stitch this together with a throwaway endpoint and a lot of logging, or use a dedicated inspector that gives you an instant capture URL, full header and body inspection, one-click replay, and built-in Stripe and GitHub signature verification in one place. HookLab is one tool that does exactly that, and it's free to try if you want to skip the boilerplate.&lt;/p&gt;

&lt;p&gt;Either way, the principle is the same: make the request visible, and the bug stops hiding.&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build HookLab, a webhook testing tool with instant capture URLs, request inspection, custom responses, replay and signature verification. It is free to try at &lt;a href="https://gethooklab.dev" rel="noopener noreferrer"&gt;https://gethooklab.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>javascript</category>
      <category>debugging</category>
    </item>
    <item>
      <title>You Set Up HTTPS. You Still Forgot These 5 HTTP Security Headers.</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 18:35:06 +0000</pubDate>
      <link>https://dev.to/toolkitonline/you-set-up-https-you-still-forgot-these-5-http-security-headers-51ej</link>
      <guid>https://dev.to/toolkitonline/you-set-up-https-you-still-forgot-these-5-http-security-headers-51ej</guid>
      <description>&lt;p&gt;Getting a green padlock feels like the security box is checked. It isn't. HTTPS encrypts the connection, but it says nothing about clickjacking, script injection, or whether browsers will quietly downgrade your visitors to HTTP. That's the job of HTTP response headers, and most sites I audit are missing several of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;Here's the concrete situation. You ship a site, it loads over HTTPS, lighthouse looks fine, and you move on. Then someone runs your domain through a header scanner and you get a column of red: no &lt;code&gt;Content-Security-Policy&lt;/code&gt;, no &lt;code&gt;Strict-Transport-Security&lt;/code&gt;, no &lt;code&gt;X-Frame-Options&lt;/code&gt;, no &lt;code&gt;Referrer-Policy&lt;/code&gt;, no &lt;code&gt;Permissions-Policy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each gap is a real, exploitable surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No CSP&lt;/strong&gt; means an injected &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; (from a compromised dependency or a stored XSS) runs with full trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No HSTS&lt;/strong&gt; means a visitor's first request can be intercepted and downgraded to plain HTTP before the redirect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No X-Frame-Options or &lt;code&gt;frame-ancestors&lt;/code&gt;&lt;/strong&gt; means your page can be loaded in a hidden iframe and clickjacked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Referrer-Policy&lt;/strong&gt; means full URLs (sometimes with tokens in query strings) leak to third parties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A wide-open Permissions-Policy&lt;/strong&gt; means embedded content can request camera, mic, or geolocation you never intended to grant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scanner tells you all this. What it usually does not tell you is the exact config to fix it, and that's where most people stall.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to fix it
&lt;/h3&gt;

&lt;p&gt;The good news: these are response headers, so you add them once at the server or CDN layer and every page inherits them. A solid baseline for an Nginx site looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=63072000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;includeSubDomains&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;preload"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"DENY"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Permissions-Policy&lt;/span&gt; &lt;span class="s"&gt;"camera=(),&lt;/span&gt; &lt;span class="s"&gt;microphone=(),&lt;/span&gt; &lt;span class="s"&gt;geolocation=()"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Content-Security-Policy&lt;/span&gt; &lt;span class="s"&gt;"default-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;object-src&lt;/span&gt; &lt;span class="s"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;frame-ancestors&lt;/span&gt; &lt;span class="s"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;base-uri&lt;/span&gt; &lt;span class="s"&gt;'self'"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes that save real debugging time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start CSP in report-only mode.&lt;/strong&gt; Ship &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; first, watch what it would have blocked, then promote it to enforcing. Going straight to a strict CSP on a live site usually breaks inline scripts and third-party widgets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HSTS &lt;code&gt;preload&lt;/code&gt; is a commitment.&lt;/strong&gt; Once you submit to the preload list, removing HTTPS becomes painful. Add &lt;code&gt;preload&lt;/code&gt; only when you're confident the whole domain and subdomains are HTTPS-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Frame-Options&lt;/code&gt; is being superseded by CSP &lt;code&gt;frame-ancestors&lt;/code&gt;.&lt;/strong&gt; Set both for now; older browsers still read the former.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After you add the headers, redeploy and check the live response. You can inspect them quickly from the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://yoursite.example | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'content-security|strict-transport|x-frame|referrer-policy|permissions-policy'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a header you added isn't showing up, it's almost always a caching layer or a reverse proxy stripping it before the response reaches the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Closing
&lt;/h3&gt;

&lt;p&gt;You can do all of this by hand: read the spec for each header, write the directives, deploy, curl, repeat. It works, it's just slow, and CSP in particular is easy to get subtly wrong.&lt;/p&gt;

&lt;p&gt;If you'd rather skip the doc-diving, &lt;strong&gt;HeaderShield&lt;/strong&gt; does this end to end: you paste a URL, it reads your real response headers, explains each one in plain language, scores the result, and generates a ready-to-paste snippet for Nginx, Apache, or Cloudflare. It's free to try in the browser, so it's an easy way to get from "wall of red" to a fixed baseline in a few minutes. Either path gets you to a hardened site; pick whichever fits your day.&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build HeaderShield, an HTTP security-header scanner that explains each gap and gives you ready-to-paste Nginx/Apache/Cloudflare snippets. It is free to try at &lt;a href="https://headershield.dev" rel="noopener noreferrer"&gt;https://headershield.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your structured data is probably broken, and your crawler isn't telling you</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 18:33:50 +0000</pubDate>
      <link>https://dev.to/toolkitonline/your-structured-data-is-probably-broken-and-your-crawler-isnt-telling-you-20k</link>
      <guid>https://dev.to/toolkitonline/your-structured-data-is-probably-broken-and-your-crawler-isnt-telling-you-20k</guid>
      <description>&lt;p&gt;Most on-page audits catch the obvious stuff: a missing title here, a duplicate meta description there. The thing that quietly costs you rich results is structured data that exists but is invalid, and most flat-list crawlers either skip it or bury it. Here is why it happens and how to catch it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem, concretely
&lt;/h3&gt;

&lt;p&gt;You add FAQ schema to a product page to win that expandable rich result in Google. You paste a JSON-LD block into the head, ship it, and move on. Six weeks later the rich result never showed up, and nobody knows why.&lt;/p&gt;

&lt;p&gt;The usual culprits are small and silent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;@type&lt;/code&gt; that does not match the content (FAQPage with no &lt;code&gt;mainEntity&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A required property missing (&lt;code&gt;acceptedAnswer&lt;/code&gt; without &lt;code&gt;text&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A trailing comma or a stray character that makes the JSON parse fail entirely.&lt;/li&gt;
&lt;li&gt;Schema that contradicts what is actually on the page, which Google can flag as spammy and ignore.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these throw a visible error. The page renders fine. The schema is just dead weight, and a standard "issues" crawl that only counts titles and headings walks right past it.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to catch it
&lt;/h3&gt;

&lt;p&gt;First, validate the JSON itself. A block that does not parse is invisible to search engines. Even a quick local check surfaces the dumb-but-fatal errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pull every JSON-LD block and check it parses + has a @type&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script[type="application/ld+json"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;)];&lt;/span&gt;

&lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Block &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="s2"&gt;: missing @type`&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="s2"&gt;`Block &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="s2"&gt;: invalid JSON -&amp;gt;`&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="nx"&gt;message&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;If that logs an error, the schema was never going to work, no matter how perfect the markup looked.&lt;/p&gt;

&lt;p&gt;Second, check required properties for the specific type you are using. FAQPage needs &lt;code&gt;mainEntity&lt;/code&gt; with &lt;code&gt;Question&lt;/code&gt; items, each carrying an &lt;code&gt;acceptedAnswer&lt;/code&gt;. Article needs &lt;code&gt;headline&lt;/code&gt;, &lt;code&gt;author&lt;/code&gt;, and &lt;code&gt;datePublished&lt;/code&gt;. Validating "it parsed" is not the same as "it is complete."&lt;/p&gt;

&lt;p&gt;Third, and this is the step people skip: do it across the whole site, not just the one page you remember adding schema to. Templates drift. A change to one component can break structured data on a thousand URLs at once, and you will never notice from a single spot check.&lt;/p&gt;

&lt;h3&gt;
  
  
  A repeatable checklist
&lt;/h3&gt;

&lt;p&gt;When you audit a page or a site, run structured data through the same gate every time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does every JSON-LD block parse as valid JSON?&lt;/li&gt;
&lt;li&gt;Does each block have a &lt;code&gt;@type&lt;/code&gt; that matches the page content?&lt;/li&gt;
&lt;li&gt;Are the required properties for that type present?&lt;/li&gt;
&lt;li&gt;Does the schema agree with the visible content (no invented reviews, no fake ratings)?&lt;/li&gt;
&lt;li&gt;Is it consistent across templated pages, not just the homepage?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bake that into your audit and you stop shipping silently-dead schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  Doing it without a spreadsheet
&lt;/h3&gt;

&lt;p&gt;You can do all of this by hand, and the snippet above is a fine start for a single page. But across a site, manually opening dev tools on every URL gets old fast, and a heavyweight desktop crawler is more setup than the job needs when you just want a quick read.&lt;/p&gt;

&lt;p&gt;This is the gap SEOScope fills. You paste a URL, it crawls the page or site, validates the structured data alongside the rest of the on-page checks, flags broken links and page weight, and returns one score with the fixes ranked by impact, so invalid schema shows up at the top instead of buried in row 240. It runs in the browser with nothing to install, and there is a free tier to point at a page and see what it flags.&lt;/p&gt;

&lt;p&gt;Validate your schema before search engines silently decide to ignore it. That one habit catches more lost rich results than any other on-page check.&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build SEOScope, an on-page SEO auditor that validates structured data, finds broken links and scores your pages. It is free to try at &lt;a href="https://seoscope.dev" rel="noopener noreferrer"&gt;https://seoscope.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>marketing</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Why Your Emails Bounce (and How to Catch Bad Addresses Before You Hit Send)</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Wed, 24 Jun 2026 18:33:44 +0000</pubDate>
      <link>https://dev.to/toolkitonline/why-your-emails-bounce-and-how-to-catch-bad-addresses-before-you-hit-send-2eh6</link>
      <guid>https://dev.to/toolkitonline/why-your-emails-bounce-and-how-to-catch-bad-addresses-before-you-hit-send-2eh6</guid>
      <description>&lt;p&gt;Email bounces feel like a small annoyance until you realize they quietly damage every send that follows. This is a practical look at why lists go bad and how to validate them before a campaign, with a simple approach you can run on a single address or a whole CSV.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: one dirty send taxes all the others
&lt;/h2&gt;

&lt;p&gt;When you email an address that doesn't exist, the receiving server returns a hard bounce. A few of those is normal. But when bounces cluster, like right after you import a list or run a big campaign, mailbox providers (Gmail, Outlook, Yahoo) read it as a signal that you don't maintain your list. Their response is to filter more of your mail, including the messages going to real, engaged subscribers.&lt;/p&gt;

&lt;p&gt;So the cost of bad addresses isn't just the wasted send. It's a reputation hit that pushes your good mail toward spam folders for weeks. Newsletter operators see open rates collapse. SaaS founders watch onboarding emails miss the people who just signed up. E-commerce stores lose order confirmations to the void.&lt;/p&gt;

&lt;p&gt;The good news: most bad addresses are detectable before you send.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that break a list
&lt;/h2&gt;

&lt;p&gt;A typical list accumulates four kinds of bad addresses:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Bad syntax. Missing the @, a trailing dot, illegal characters. The easiest to catch.&lt;/li&gt;
&lt;li&gt;Typo'd domains. People fat-finger "gmial.com", "hotmial.com", "yahooo.com". The address is syntactically valid but the domain is wrong, so it bounces.&lt;/li&gt;
&lt;li&gt;Dead or non-receiving domains. The domain has no mail server (no MX record), so nothing can be delivered.&lt;/li&gt;
&lt;li&gt;Disposable / temporary inboxes. Sign-up throwaways that exist for ten minutes. They inflate your list and never engage.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to validate, step by step
&lt;/h2&gt;

&lt;p&gt;A solid pre-send check runs these stages in order, cheapest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Syntax check. Confirm the address is well-formed before doing anything network-related.&lt;/li&gt;
&lt;li&gt;Typo / domain heuristic. Compare the domain against common providers and flag near-misses like "gmial.com" so they can be corrected, not discarded.&lt;/li&gt;
&lt;li&gt;MX record probe. Look up the domain's mail servers. No MX record means no mailbox can receive mail there.&lt;/li&gt;
&lt;li&gt;Disposable check. Match the domain against known throwaway providers and flag it as risky.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to sketch the MX step yourself, the core lookup is just a few lines in Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;promises&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;dns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:dns&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;domainCanReceiveMail&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;email&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="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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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;mx&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;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolveMx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domain&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;mx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no MX record = cannot receive mail&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;domainCanReceiveMail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello@gmial.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That snippet covers one stage. A real validator layers syntax rules, a typo dictionary, the MX probe, and a disposable-domain list, then runs the whole thing across every row of your CSV so you can clean an entire list in one pass rather than checking addresses one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it a habit, not a fire drill
&lt;/h2&gt;

&lt;p&gt;The mistake most senders make is only cleaning a list after the bounces have already done damage. By then the reputation hit is in motion. Treat validation like a pre-flight check: scrub right before any large or important send, and re-scrub lists that have been sitting unused for months (addresses decay over time as people change jobs and abandon inboxes).&lt;/p&gt;

&lt;p&gt;If you'd rather not wire the stages together yourself, EmailGuard runs all four, syntax, typo detection, MX probing, and disposable flagging, and accepts either a single address or a CSV upload for bulk verification. It's built to be a faster, simpler way to do the pre-send scrub without standing up a whole pipeline.&lt;/p&gt;

&lt;p&gt;Either way, the principle holds: clean the list before you send, and you protect the deliverability of everything that comes after.&lt;/p&gt;







&lt;p&gt;&lt;em&gt;Full disclosure: I build EmailGuard, a fast email validation tool that checks syntax, MX records, typos and disposable addresses, in bulk from a CSV. It is free to try at &lt;a href="https://emailguard.dev" rel="noopener noreferrer"&gt;https://emailguard.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>marketing</category>
      <category>saas</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Color Contrast Failures: The Number One Accessibility Issue and How to Fix It</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Thu, 11 Jun 2026 12:40:08 +0000</pubDate>
      <link>https://dev.to/toolkitonline/color-contrast-failures-the-number-one-accessibility-issue-and-how-to-fix-it-35gc</link>
      <guid>https://dev.to/toolkitonline/color-contrast-failures-the-number-one-accessibility-issue-and-how-to-fix-it-35gc</guid>
      <description>&lt;p&gt;Color contrast failures are the single most common accessibility issue on the web. The WebAIM Million study found them on 83.6% of home pages tested. Yet they're also one of the easiest issues to fix — once you understand the rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WCAG Contrast Requirements
&lt;/h2&gt;

&lt;p&gt;WCAG 2.2 defines two success criteria for color contrast:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.4.3 Contrast (Minimum) — Level AA&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Normal text (&amp;lt; 18pt or &amp;lt; 14pt bold): minimum &lt;strong&gt;4.5:1&lt;/strong&gt; contrast ratio&lt;/li&gt;
&lt;li&gt;Large text (≥ 18pt or ≥ 14pt bold): minimum &lt;strong&gt;3:1&lt;/strong&gt; contrast ratio&lt;/li&gt;
&lt;li&gt;UI components and graphical objects: minimum &lt;strong&gt;3:1&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;1.4.6 Contrast (Enhanced) — Level AAA&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Normal text: &lt;strong&gt;7:1&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Large text: &lt;strong&gt;4.5:1&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The contrast ratio formula is &lt;code&gt;(L1 + 0.05) / (L2 + 0.05)&lt;/code&gt;, where L1 is the lighter colour's relative luminance and L2 is the darker one's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mathematics Behind Luminance
&lt;/h2&gt;

&lt;p&gt;Relative luminance isn't just brightness — it accounts for how the human eye perceives different wavelengths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;relativeLuminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bs&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="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="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;sRGB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&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;sRGB&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.04045&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;sRGB&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;12.92&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sRGB&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1.055&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.4&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="mf"&gt;0.2126&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;gs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0722&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;bs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;contrastRatio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hex2&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;lum1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relativeLuminance&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nf"&gt;hexToRgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex1&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;lum2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relativeLuminance&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nf"&gt;hexToRgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lighter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;darker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lum1&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;lum2&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lum1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lum2&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="nx"&gt;lum2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lum1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lighter&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&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="nx"&gt;darker&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&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;h2&gt;
  
  
  Common Failures and Fixes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Grey text on white backgrounds
&lt;/h3&gt;

&lt;p&gt;Light grey (#767676) on white is exactly 4.54:1 — barely passing. At #757575, it fails. Use #595959 or darker for safe normal text.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blue links on coloured backgrounds
&lt;/h3&gt;

&lt;p&gt;Many design systems use brand blue (#0066CC) as the link colour. On a dark navy background (#003366), that's only 2.6:1 — a clear failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Placeholder text
&lt;/h3&gt;

&lt;p&gt;Placeholder text is treated as normal text for contrast purposes. The typical grey placeholder on a white input (#B0B0B0 on #FFFFFF) is 2.5:1 — fails badly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* ❌ Fails WCAG */&lt;/span&gt;
&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;::placeholder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#B0B0B0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* ✅ Passes WCAG AA */&lt;/span&gt;
&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;::placeholder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#767676&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;h3&gt;
  
  
  Disabled states
&lt;/h3&gt;

&lt;p&gt;Disabled controls are exempt from contrast requirements — but only if they're genuinely disabled (not just visually styled as disabled).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tricky Edge Cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gradients&lt;/strong&gt;: WCAG requires the entire text background to meet contrast requirements. For text on a gradient, test at the lowest-contrast point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Text over images&lt;/strong&gt;: Image backgrounds change depending on the user's screen, browser rendering, and image loading. Safest approach: add a semi-transparent overlay or text shadow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero-text&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;text-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c"&gt;/* OR */&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5em&lt;/span&gt; &lt;span class="m"&gt;1em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Focus indicators&lt;/strong&gt;: WCAG 2.2 added Success Criterion 2.4.11 (Focus Appearance) — focus indicators must have at least 3:1 contrast against adjacent colours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating Detection
&lt;/h2&gt;

&lt;p&gt;Checking contrast manually across every combination of text and background in your app is impractical. Automated tools like &lt;a href="https://fixmyweb.dev" rel="noopener noreferrer"&gt;AccessiScan&lt;/a&gt; scan your entire page across 201 WCAG 2.2 checks — including contrast — and generate a prioritised report with specific failing elements identified.&lt;/p&gt;

&lt;p&gt;Start with Level AA compliance. The changes are usually small CSS tweaks with an outsized impact on usability for everyone, not just users with visual impairments.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>css</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>GDPR Audit Automation: 5 Compliance Checks You Are Probably Missing</title>
      <dc:creator>DevToolsmith</dc:creator>
      <pubDate>Tue, 09 Jun 2026 11:56:50 +0000</pubDate>
      <link>https://dev.to/toolkitonline/gdpr-audit-automation-5-compliance-checks-you-are-probably-missing-19ab</link>
      <guid>https://dev.to/toolkitonline/gdpr-audit-automation-5-compliance-checks-you-are-probably-missing-19ab</guid>
      <description>&lt;p&gt;GDPR has been enforceable since 2018, yet enforcement actions keep increasing year after year. The problem isn't that developers don't care — it's that most compliance checks happen once, at launch, and then get forgotten. Here are five critical GDPR requirements that slip through the cracks on most SaaS products.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Data Processing Register (ROPA)
&lt;/h2&gt;

&lt;p&gt;The GDPR requires all organisations processing personal data to maintain a Record of Processing Activities (Article 30). Most developers have never heard of it. Your ROPA must document:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What data you collect and why&lt;/li&gt;
&lt;li&gt;The legal basis for processing (consent, legitimate interest, contract)&lt;/li&gt;
&lt;li&gt;Data retention periods&lt;/li&gt;
&lt;li&gt;Third-party processors (AWS, Stripe, Mixpanel — every one)&lt;/li&gt;
&lt;li&gt;Cross-border data transfers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fine for not having one: up to €10M or 2% of global turnover.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Data Subject Request Automation
&lt;/h2&gt;

&lt;p&gt;Under GDPR, users have the right to access, rectify, erase, and port their data — within 30 days. Most SaaS products handle these manually (or ignore them entirely). At scale, this becomes unmanageable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Minimum viable DSR handler&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/dsr/erasure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&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;user&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;// Must delete from ALL systems — not just your main DB&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&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;analyticsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteUser&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;emailService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsubscribeAll&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;backups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleDataPurge&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="c1"&gt;// often forgotten&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;30&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;h2&gt;
  
  
  3. Legitimate Interest Assessment (LIA)
&lt;/h2&gt;

&lt;p&gt;"Legitimate interest" is the most used (and most abused) legal basis for data processing. Using it correctly requires a three-part balancing test: purpose test, necessity test, and balancing test. Using it incorrectly — for marketing without consent, for example — is a violation.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Cookie Consent That Actually Works
&lt;/h2&gt;

&lt;p&gt;A cookie banner that says "We use cookies" with a single OK button is not GDPR-compliant. Compliant consent requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Granular categories (functional, analytics, marketing)&lt;/li&gt;
&lt;li&gt;Equal ease of accepting vs rejecting&lt;/li&gt;
&lt;li&gt;No pre-ticked boxes&lt;/li&gt;
&lt;li&gt;Stored consent records with timestamp and version&lt;/li&gt;
&lt;li&gt;Re-consent when purposes change&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Vendor Due Diligence
&lt;/h2&gt;

&lt;p&gt;Every third-party service your app touches that handles personal data is a "data processor" under GDPR. You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A signed Data Processing Agreement (DPA) with each&lt;/li&gt;
&lt;li&gt;Documented transfers under Article 46 (SCCs for US vendors)&lt;/li&gt;
&lt;li&gt;A way to revoke access if they're breached&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Common oversight: using npm packages that phone home (analytics, error tracking, fonts) without documenting them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating the Audit
&lt;/h2&gt;

&lt;p&gt;Running these checks manually is error-prone and time-consuming. Tools like &lt;a href="https://complipilot.dev" rel="noopener noreferrer"&gt;CompliPilot&lt;/a&gt; automate 200+ compliance checks across GDPR, HIPAA, CCPA, and NIS2 — giving you a scored audit report in under 60 seconds, with specific remediation steps for each finding.&lt;/p&gt;

&lt;p&gt;The goal isn't perfect compliance overnight. It's knowing exactly where your gaps are so you can prioritise the highest-risk issues first.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>privacy</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
