<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[BillionVerify]]></title><description><![CDATA[Verify emails in real-time with 99.9% accuracy. Stop bounces, protect your sender reputation, and reach real people.]]></description><link>https://billionverify.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69b0f830abc0d95001ca7a2d/dd7386e4-0152-40f6-8c4c-dcfcf31384db.png</url><title>BillionVerify</title><link>https://billionverify.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 09:41:23 GMT</lastBuildDate><atom:link href="https://billionverify.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What "Print-Ready" Actually Means: EPUB 3, Fixed PDFs and KDP Trim Math]]></title><description><![CDATA[EPUB 3 packages, reading PDFs and print-ready interiors explained: KDP trim and bleed math, cover wrap sizing, and a Python script to check page sizes]]></description><link>https://billionverify.hashnode.dev/what-print-ready-actually-means-epub-3-fixed-pdfs-and-kdp-trim-math</link><guid isPermaLink="true">https://billionverify.hashnode.dev/what-print-ready-actually-means-epub-3-fixed-pdfs-and-kdp-trim-math</guid><category><![CDATA[epub]]></category><category><![CDATA[pdf]]></category><category><![CDATA[publishing]]></category><category><![CDATA[AI]]></category><category><![CDATA[writing]]></category><dc:creator><![CDATA[Leo]]></dc:creator><pubDate>Sat, 24 Oct 2026 05:26:31 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69b0f830abc0d95001ca7a2d/b938106d-def9-4d5e-898e-b8f8145c937a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Writing the book is the part everyone talks about. Then you try to publish it and discover the second project: files. An EPUB that passes validation and reads well on a phone. A PDF for people who want fixed pages. A print interior at the exact size your printer expects, with margins that survive the trim. A cover in two different shapes.</p>
<p>Each of those has a spec, and each spec has a few numbers that will get your upload rejected if they're off. This post goes through them, with a small script to check the print interior yourself. At the end, it covers how Bookeen, the book creator we build, produces all of these from one manuscript.</p>
<h2>1. One manuscript, four files</h2>
<p>A finished book usually needs more than one edition, because readers use them differently:</p>
<table>
<thead>
<tr>
<th>File</th>
<th>Layout</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td>EPUB 3</td>
<td>Reflowable: text adapts to the screen</td>
<td>Novels, narrative guides, anything read on phones and e-readers</td>
</tr>
<tr>
<td>PDF for reading</td>
<td>Fixed pages</td>
<td>Reports, illustrated guides, printable exercises</td>
</tr>
<tr>
<td>Print PDF</td>
<td>Fixed pages at the printer's trim size</td>
<td>Paperbacks</td>
</tr>
<tr>
<td>DOCX</td>
<td>Editable</td>
<td>Editors, co-authors, last-mile revisions</td>
</tr>
</tbody></table>
<p>The rule that saves the most time: decide what the reader needs to control. Flowing text lets them change the type size and still reach the next sentence. A fixed page keeps a figure next to its caption, or an exercise next to its answer space. Many books deserve one edition of each, reviewed separately, because the page breaks won't match.</p>
<h2>2. EPUB 3: a package, not a document</h2>
<p>An EPUB is a ZIP container with a defined structure: XHTML content documents for the chapters, CSS, images and SVG, a package document that lists every resource and the reading order, and a navigation document for the table of contents. The <a href="https://www.w3.org/TR/epub-33/">W3C EPUB 3.3 Recommendation</a> describes it as "a distribution and interchange format for digital publications and documents."</p>
<p>Two things in that spec matter in practice.</p>
<p><strong>Reflowable and fixed layout are both EPUB.</strong> The extension doesn't tell you which one you have. A reflowable EPUB lets the reading app set line length and type size. A fixed-layout EPUB tries to keep a designed page. If your book has diagrams that must stay beside their text, test those pages before you commit the whole book to flowing text.</p>
<p><strong>Navigation is part of the file.</strong> The contents list is a real document inside the package, and every entry points at a location in a chapter. A tidy preview in your editor can hide an entry that lands in the wrong place. The only check that counts is opening the file in the reading app your readers use.</p>
<p>Validate the package before any store sees it. The W3C's EPUBCheck tool is the standard:</p>
<pre><code class="language-bash">java -jar epubcheck.jar my-book.epub
</code></pre>
<p>Then read one representative chapter in the target app at two type sizes, and follow a contents entry into the text. For Kindle, check the file in Kindle Previewer before you upload. For Apple Books, review it against Apple's EPUB delivery guide.</p>
<h2>3. Print PDFs: trim, bleed and margins</h2>
<p>Print is where the numbers get strict. Three terms:</p>
<ul>
<li><strong>Trim size</strong> is the finished page after the printer cuts the sheet. A common paperback trim is 6 × 9 inches.</li>
<li><strong>Bleed</strong> is extra area beyond the trim, for images or color that should run to the edge. The cut is never perfectly exact, so anything meant to touch the edge has to extend past it.</li>
<li><strong>Margins</strong> keep live text far enough from the cut, and from the binding, that nothing disappears.</li>
</ul>
<p>Amazon KDP's paperback rules make a good worked example. With bleed, KDP adds 0.125 inches at the outside edge and at both the top and bottom, so a 6 × 9 inch book uses 6.125 × 9.25 inch interior pages. The extra width is on the outside edge only, not the binding edge. Minimum top, bottom and outside margins are 0.25 inches without bleed or 0.375 inches with bleed, and the inside margin depends on page count. The details are in KDP's <a href="https://kdp.amazon.com/en_US/help/topic/GVBQ3CMEQW3W2VL6">trim, bleed and margins guide</a>. Other printers publish their own numbers, so treat these as KDP's, not universal.</p>
<p>Page size is the easiest thing to get wrong and the easiest to check. PDF page sizes are stored in points, at 72 points per inch. This script reads every page with <code>pypdf</code> and tells you whether the interior matches the trim, with or without bleed:</p>
<pre><code class="language-python"># pip install pypdf
import sys
from pypdf import PdfReader

PT_PER_INCH = 72

def check_interior(path, trim_w=6.0, trim_h=9.0, bleed=0.125, tol=0.01):
    no_bleed = (trim_w, trim_h)
    with_bleed = (trim_w + bleed, trim_h + 2 * bleed)  # outside edge + top + bottom
    problems = []

    for n, page in enumerate(PdfReader(path).pages, start=1):
        w = float(page.mediabox.width) / PT_PER_INCH
        h = float(page.mediabox.height) / PT_PER_INCH
        def close(size):
            return abs(w - size[0]) &lt; tol and abs(h - size[1]) &lt; tol
        if not (close(no_bleed) or close(with_bleed)):
            problems.append(f"page {n}: {w:.3f} x {h:.3f} in")

    if problems:
        print(f"{len(problems)} page(s) don't match {no_bleed} or {with_bleed}:")
        print("\n".join(problems[:20]))
    else:
        print("all pages match the trim size")

if __name__ == "__main__":
    check_interior(sys.argv[1])
</code></pre>
<p>Run it as <code>python check_interior.py interior.pdf</code>. A mix of bleed and no-bleed pages is also a problem: printers want one size for the whole interior, so the output listing pages of both sizes is worth a second look.</p>
<p>Page size is necessary, not sufficient. Fonts must be embedded, figures have to stay inside the safe area, and the inside margin has to survive binding. Text that looks centered on a loose sheet can vanish into the gutter of a bound book. Turn every page of the export, then order a proof. The proof is allowed to send you back to the manuscript. That's what it's for.</p>
<h2>4. Covers come in two shapes</h2>
<p>An ebook cover is a single front image. A portrait image of 1600 × 2560 pixels is a common target, a 1:1.6 ratio.</p>
<p>A print cover is a different object: one wide image that wraps around the book. From left to right it holds the back cover, the spine and the front cover, with bleed on all four outer edges. For a 6 × 9 inch paperback with 0.125 inch bleed:</p>
<pre><code class="language-text">width  = 0.125 + 6 (back) + spine + 6 (front) + 0.125 = 12.25 in + spine
height = 0.125 + 9 + 0.125                           = 9.25 in
</code></pre>
<p>The spine width depends on the page count and the paper, so take it from your printer's cover calculator after the interior is final. Add a chapter, and the spine changes, and so does the cover. That's one more reason to finish the interior before the cover.</p>
<p>The interior and the cover are almost always separate files. Don't put the cover spread at the front of the interior PDF.</p>
<h2>5. Generating all of it from one manuscript</h2>
<p>This is the part Bookeen automates. It's an AI book creator that takes a book from an idea to these files in one project.</p>
<ol>
<li><strong>Brief and outline.</strong> You start from a one-line idea, your notes or a draft you have the rights to. Bookeen writes a brief (reader, purpose, length) and an outline, and you edit both before any chapter is written.</li>
<li><strong>Chapters.</strong> It drafts every chapter from the approved outline. Templates cover how-to guides, lead magnets, workbooks, course companions, research reports and novels, plus a children's book preset for picture-book text.</li>
<li><strong>Revision.</strong> Ask for changes to the brief, a chapter or the tone. Regenerating a chapter costs half the credits of its first draft.</li>
<li><strong>Covers.</strong> It creates an ebook cover at 1600 × 2560 pixels and a print cover that lays out back cover, spine and front cover with 1/8-inch bleed.</li>
<li><strong>Export.</strong> You download EPUB 3, a PDF for reading, a 6 × 9 inch print-ready PDF, Word (DOCX), Markdown and the cover images. Exports don't use extra credits.</li>
</ol>
<p>The point of keeping it in one project is that the manuscript is the single source. Fix a sentence in chapter 4, and every export picks it up. You aren't copying text between a chat window, a word processor and a layout tool, and the editions can't drift apart. The <a href="https://bookeen.ai/book-formats/epub">EPUB guide</a> and the <a href="https://bookeen.ai/book-formats/print-pdf">print PDF guide</a> on our site go deeper on reviewing each file.</p>
<p>Bookeen doesn't upload books to stores. You export the files, run your own checks (EPUBCheck, the script above, Kindle Previewer, a printed proof), and publish wherever you choose, such as Amazon KDP or your own website. Follow each store's rules, including KDP's disclosure rules for AI-generated content.</p>
<h2>6. Pricing, in pages</h2>
<p>One credit covers about one page, roughly 400 English words or 650 Chinese characters. A 30,000-word guide is about 75 credits for its first draft.</p>
<ul>
<li><strong>Free start:</strong> 30 credits on sign-up, which don't expire. That's enough to generate and preview a short book. Downloading files needs a paid plan or a credit pack.</li>
<li><strong>Starter:</strong> $20 a month for 200 credits.</li>
<li><strong>Creator:</strong> $50 a month for 600 credits.</li>
<li><strong>Studio:</strong> $100 a month for 1,500 credits.</li>
</ul>
<p>Yearly plans save 20%, and one-time credit packs cost the same as a month. You own what you make, including the manuscript, cover and files, with full commercial rights. More on the <a href="https://bookeen.ai/about">about page</a>.</p>
<h2>7. Try it</h2>
<p>If you have a book idea that has been waiting on "the files part," <a href="https://bookeen.ai/signup">start a book on Bookeen</a> with your free credits, get the outline first, and run the exported print PDF through the script above before you trust any file, including ours. The <a href="https://bookeen.ai/">Bookeen home page</a> shows the book types it supports.</p>
]]></content:encoded></item><item><title><![CDATA[Product Videos as Code: Voiceover-Timed Storyboards Rendered with Remotion]]></title><description><![CDATA[How a product launch video gets built from screenshots: an LLM storyboard, voiceover-driven timing, Remotion scenes, and the math behind export.]]></description><link>https://billionverify.hashnode.dev/product-videos-as-code-voiceover-timed-storyboards-rendered-with-remotion</link><guid isPermaLink="true">https://billionverify.hashnode.dev/product-videos-as-code-voiceover-timed-storyboards-rendered-with-remotion</guid><category><![CDATA[video]]></category><category><![CDATA[Remotion]]></category><category><![CDATA[AI]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Leo]]></dc:creator><pubDate>Sat, 24 Oct 2026 05:24:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69b0f830abc0d95001ca7a2d/c5aaa63d-2a55-48ae-aceb-60405b8e7ba0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every feature launch wants a short video: one for the launch page, one for the changelog, a vertical cut for social. Most teams don't have a motion designer on call, so the video either waits until after the launch or doesn't happen.</p>
<p>AI video models look like the answer until you point one at a product. A generated clip will happily invent a dashboard that doesn't exist, misspell your feature name in the UI, and show a button where there isn't one. For a product video that's a dealbreaker. The interface on screen has to be your interface.</p>
<p>This post explains the approach we took at Potto AI: treat a product video as motion graphics rendered from code, with your screenshots as the scenes, an LLM writing the storyboard, and the voiceover deciding the timing.</p>
<h2>1. Motion graphics, not generated footage</h2>
<p>There are two ways to make a video with AI.</p>
<p><strong>Generate the pixels.</strong> A video model produces every frame. It's great for abstract visuals and mood. It's bad at anything that has to be literally correct: text, numbers, UI states, logos.</p>
<p><strong>Generate the plan, render the pixels with code.</strong> A language model writes a structured plan: what each scene shows, what it says, how long it lasts. A renderer turns that plan into frames from real assets: your screenshots, your copy, your colors, charts drawn from your numbers. Nothing on screen is invented, because every visual element comes from an input you provided or from a template.</p>
<p>Potto uses the second approach. Videos are animated scenes built from screenshots, text, charts and illustrations, with music and optional narration and captions. Potto doesn't generate filmed footage or digital presenters. If a video needs a real person on camera, that's a recording, not a render.</p>
<h2>2. The pipeline</h2>
<p>A product launch video in Potto goes through five steps.</p>
<ol>
<li><strong>Brief.</strong> You describe the video or paste a link, and attach up to 8 screenshots, documents, audio files or web links. PDF, PPTX and DOCX files work. Video clips are not accepted as inputs.</li>
<li><strong>Outline.</strong> Claude Opus 5.5 reads the request and writes an outline: the message, the sequence of screens and a visual system.</li>
<li><strong>Storyboard.</strong> Each screen gets one job, its on-screen text, its voiceover line, its assets and a duration. You can read and edit every line before anything renders.</li>
<li><strong>Render.</strong> Remotion scene components put each screen in motion, with AI-generated illustrations, voiceover, captions that highlight each word as it's spoken, and music from an original library.</li>
<li><strong>Edit.</strong> You change scenes by chatting. An edit re-renders the screens it touches, not the whole video.</li>
</ol>
<p>A storyboard screen looks like this. It's from a video we made about our own site:</p>
<pre><code class="language-text">0:11 – 0:18 · Screen 3 · 8s
Voiceover:    First, motion graphics videos: add screenshots or a topic,
              review the storyboard, export an MP4.
Viewers see:  The first view of potto.ai, captured from the link, in a browser
              frame on a dark background in the site's colors, labeled
              "Motion graphics videos", with the voiceover captioned underneath.
On-screen:    Motion graphics videos
Assets:       first view captured from the link; voiceover, captions and music;
              logo from the link
</code></pre>
<p>When you paste a link, the brand name, colors, fonts and logo are pulled from that page, and its first view is captured at 1440×900 for the screens that need it. A brand kit can pin four colors, one of three fonts and your logo for the opening and closing.</p>
<h2>3. Why the voiceover sets the timing</h2>
<p>The most important design decision is small: <strong>the voiceover line is written first, and each screen stays up exactly as long as its line takes to speak.</strong></p>
<p>If you time scenes first and fit narration afterward, you get one of two failures. Either the narrator rushes to finish before the cut, or the screen sits there in silence. Driving durations from the audio removes both, and it makes edits local. Rewrite one line, and only that screen's duration changes.</p>
<p>Here's a minimal version of the idea in Remotion. This isn't Potto's source code, just the shape of it: a storyboard array where each screen carries its measured voiceover duration, mapped onto <code>&lt;Sequence&gt;</code> blocks.</p>
<pre><code class="language-tsx">import { AbsoluteFill, Audio, Img, Sequence, staticFile, useVideoConfig } from "remotion";

type Screen = {
  image: string;        // a screenshot you supplied
  onScreen: string;     // the caption or headline
  voiceover: string;    // path to the narration clip for this screen
  seconds: number;      // measured length of that narration clip
};

export const LaunchVideo: React.FC&lt;{ screens: Screen[] }&gt; = ({ screens }) =&gt; {
  const { fps } = useVideoConfig();
  let from = 0;

  return (
    &lt;AbsoluteFill style={{ backgroundColor: "#0b0b0f" }}&gt;
      {screens.map((s, i) =&gt; {
        const durationInFrames = Math.ceil(s.seconds * fps);
        const start = from;
        from += durationInFrames;
        return (
          &lt;Sequence key={i} from={start} durationInFrames={durationInFrames}&gt;
            &lt;Img src={staticFile(s.image)} style={{ width: "80%", margin: "auto" }} /&gt;
            &lt;div style={{ position: "absolute", bottom: 80, width: "100%", textAlign: "center",
                          color: "white", fontSize: 56 }}&gt;
              {s.onScreen}
            &lt;/div&gt;
            &lt;Audio src={staticFile(s.voiceover)} /&gt;
          &lt;/Sequence&gt;
        );
      })}
    &lt;/AbsoluteFill&gt;
  );
};
</code></pre>
<p>At 30 fps, a 30-second video is 900 frames, and the total length falls out of the narration instead of being forced. A real pipeline adds a lot on top: focus crops that zoom to the control that changed, cursor highlights, word-level caption timing, transitions, music ducking under the voice. The structure stays the same.</p>
<p>One more property follows from rendering with code: the browser preview and the exported MP4 use the same Remotion composition. What you approve in the preview is what you download.</p>
<h2>4. Writing a brief that survives the storyboard</h2>
<p>Since the storyboard is generated from your inputs, the quality of the inputs sets the ceiling. A few rules from the videos we've seen work:</p>
<ul>
<li><strong>Pick one message.</strong> "Bulk export is now one click" beats a tour of every menu. Three well-chosen screenshots tell a clearer story than ten.</li>
<li><strong>Capture the screens in the state you want shown.</strong> Each screenshot becomes a scene, so the interface in the video is the interface in your image. Crop to the control that changed, especially for 9:16, where a full desktop screenshot becomes unreadable.</li>
<li><strong>Put exact names, dates and numbers in the brief.</strong> The model won't guess your plan names or limits correctly, and you don't want it to.</li>
<li><strong>Check claims before transitions.</strong> Read the outline and storyboard against your source first. Motion polish is easy to fix later; a wrong claim in narration isn't.</li>
<li><strong>Say when a feature depends on a plan or a rollout,</strong> so the video doesn't imply everyone has it today.</li>
</ul>
<h2>5. The output and the credit math</h2>
<p>Videos export as 1080p MP4 at 30 fps, in 16:9, 9:16 or 1:1. Product launch and explainer videos target 15, 30, 45, 60 or 90 seconds. The target is a budget, since narration and revisions can change the final length. Narration and captions work in 32 languages, so one storyboard can serve several markets.</p>
<p>Credits are only spent at export. Outlines, storyboards, chat edits and previews use none. The published rates:</p>
<ul>
<li>5 credits per second of video</li>
<li>about 1 credit per 188 characters of narration</li>
<li>23 credits per AI illustration</li>
</ul>
<p>That's easy to estimate before you start:</p>
<pre><code class="language-js">function estimateExportCredits({ seconds, narrationChars = 0, illustrations = 0 }) {
  return 5 * seconds + Math.ceil(narrationChars / 188) + 23 * illustrations;
}

estimateExportCredits({ seconds: 30 });                          // 150, silent product demo
estimateExportCredits({ seconds: 60, narrationChars: 900, illustrations: 3 }); // 300 + 5 + 69 = 374
</code></pre>
<p>The project shows the exact quote before you confirm. To start a video, your balance needs to cover the export of the chosen length before narration and illustrations, for example 75 credits for 15 seconds. Starting itself spends nothing, and a failed render isn't charged.</p>
<p>New accounts get 100 credits, and a daily check-in adds 5 to 25 credits a day over a 7-day cycle. Plans are Lite at $20 a month for 2,000 credits, Pro at $50 for 5,000 and Max at $100 for 15,000, or $192, $480 and $960 a year. One-time packs cost the same as a month. Credits stay valid for 12 months from the day they're added. Full details are on the <a href="https://potto.ai/pricing">pricing page</a>.</p>
<h2>6. If you'd rather run it in your own agent</h2>
<p>Some teams want the render loop on their own machine, inside Claude Code or Codex. Every video in the Potto gallery is backed by an agent skill: a <code>SKILL.md</code> with the full prompt, scripts and asset rules. The skills build on Remotion, so you need Bun, Python 3 and FFmpeg installed, and they run in Claude Code, Codex or any agent that reads <code>SKILL.md</code>. Installing one means copying its one-line install prompt into your agent or downloading the ZIP.</p>
<p>Free skills need a free account. Premium skills come with any paid plan, or cost $5 each to keep. The catalog is on the <a href="https://potto.ai/skills">skills page</a>, filterable by style such as Launch, Explainer and Product Screens.</p>
<h2>7. Try it on your next release</h2>
<p>Take the screenshots from your latest release and open the <a href="https://potto.ai/product-video">product launch video maker</a>. Read the storyboard it writes, fix any line that doesn't match what shipped, and watch the preview. You only spend credits if you export. For narrated videos built from notes or an article, start from the <a href="https://potto.ai/explainer-video">explainer video maker</a> instead, and the <a href="https://potto.ai/claude-video">Claude video page</a> shows a full brief-to-render walkthrough. The <a href="https://potto.ai/">Potto AI home page</a> lists every video type and the image tools.</p>
]]></content:encoded></item><item><title><![CDATA[Feeding Video to an LLM: Timestamped Transcripts over REST and MCP]]></title><description><![CDATA[Turn YouTube, TikTok, Instagram, LinkedIn videos into timestamped text for an LLM: caption-first extraction, async ASR jobs, batches and MCP setup.]]></description><link>https://billionverify.hashnode.dev/feeding-video-to-an-llm-timestamped-transcripts-over-rest-and-mcp</link><guid isPermaLink="true">https://billionverify.hashnode.dev/feeding-video-to-an-llm-timestamped-transcripts-over-rest-and-mcp</guid><category><![CDATA[api]]></category><category><![CDATA[mcp]]></category><category><![CDATA[youtube]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[speech to text]]></category><dc:creator><![CDATA[Leo]]></dc:creator><pubDate>Sat, 24 Oct 2026 05:19:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69b0f830abc0d95001ca7a2d/1113e485-1d36-4330-8875-6b92ec550d93.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Models read text. Most of what people want them to read lately is video: a conference talk, a product demo, a competitor's channel, a podcast clip someone posted on X. You can't grep an MP4, you can't quote it, and you can't fit three hours of it into a prompt.</p>
<p>So the first step of almost every "summarize this video" or "research this channel" feature is the same: turn the video into text, keep the timestamps, and hand the text to the model in pieces it can handle.</p>
<p>This post walks through how that step works, where it gets awkward, and how to do it with the <a href="http://transcript.im">transcript.im</a> API and MCP server, which is what we build.</p>
<h2>1. Where the text comes from</h2>
<p>There are two sources of text for a video, and a good pipeline uses both.</p>
<p><strong>Existing captions.</strong> Many YouTube videos carry a caption track, either uploaded by the creator or generated by the platform. TikTok and LinkedIn videos often have one too. Reading that track is the cheapest way to get text, and the timestamps are already aligned to the speech.</p>
<p><strong>Speech recognition (ASR).</strong> When there is no caption track, you have to transcribe the audio. That means fetching the media, running a speech model and aligning the output into timed segments. It takes longer and has to run as a background job, because a long recording shouldn't hold an HTTP request open.</p>
<p><a href="http://transcript.im">transcript.im</a> does it in that order: captions first, AI transcription only when they're missing. Instagram Reels and X videos have no caption track to read, so they always go to ASR. An uploaded file also goes straight to ASR.</p>
<p>The consequence for your code is that one request has two possible shapes of answer: the transcript right away, or a job id to come back for.</p>
<h2>2. Requesting a transcript</h2>
<p>The REST API lives at <code>https://api.transcript.im</code>. Authenticate with an API key, either as <code>Authorization: Bearer &lt;key&gt;</code> or as an <code>X-API-Key</code> header. Keys are created in your account and use scopes, so a key with only the <code>transcripts</code> scope can extract but can't start batches.</p>
<pre><code class="language-bash">curl -X POST https://api.transcript.im/v1/transcript \
  -H "X-API-Key: $TRANSCRIPT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.youtube.com/watch?v=VIDEO_ID",
    "language": "en,asr",
    "format": "json",
    "includeTimestamp": true,
    "sendMetadata": true
  }'
</code></pre>
<p>The fields that matter:</p>
<ul>
<li><code>url</code> takes a full link, a short link, or a bare YouTube video id. The platform is detected for you: YouTube, TikTok, Instagram, LinkedIn, X and Facebook. If you already know them, <code>platform</code> plus <code>externalId</code> works too.</li>
<li><code>language</code> is a priority list, not a single value. <code>de,en,asr</code> means "German captions, else English captions, else transcribe the audio." <code>asr-&lt;code&gt;</code> asks for transcription in a specific language. The default is <code>auto</code>.</li>
<li><code>format</code> is <code>json</code> for timestamped segments or <code>text</code> for plain text you can paste.</li>
<li><code>sendMetadata</code> adds title, channel, author and publish date when they're available.</li>
</ul>
<p>When captions match your list, you get <code>200</code> with the transcript in the body. Each segment has <code>text</code>, <code>start</code> and <code>duration</code>, in seconds:</p>
<pre><code class="language-json">{
  "status": "success",
  "format": "json",
  "cached": false,
  "language": "en",
  "metadataStatus": "complete",
  "transcript": {
    "platform": "youtube",
    "externalId": "VIDEO_ID",
    "lang": "en",
    "source": "youtube_transcript_api",
    "title": "…",
    "segments": [
      { "text": "Today we're looking at…", "start": 0.0, "duration": 3.2 },
      { "text": "The first thing to notice…", "start": 3.2, "duration": 4.1 }
    ]
  }
}
</code></pre>
<p>When the request has to go to ASR, you get <code>202</code> with a <code>jobId</code> and a <code>stage</code> such as <code>asr_queued</code> instead. A transcript that was extracted before is served from the cache, so asking for the same video twice doesn't start a second job.</p>
<p>One more endpoint is useful before you commit: <code>GET /v1/transcript/info</code> resolves a source and lists the caption languages it can serve.</p>
<h2>3. Handling the async path</h2>
<p>Here's a small JavaScript client that covers both answers. It polls <code>GET /v1/transcript/job/{id}</code> until the job finishes.</p>
<pre><code class="language-js">const API = "https://api.transcript.im";
const headers = {
  "X-API-Key": process.env.TRANSCRIPT_API_KEY,
  "Content-Type": "application/json",
};
const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));

export async function getTranscript(url, language = "auto") {
  const res = await fetch(`${API}/v1/transcript`, {
    method: "POST",
    headers,
    body: JSON.stringify({ url, language, format: "json" }),
  });

  if (res.status === 200) return (await res.json()).transcript;
  if (res.status !== 202) {
    throw new Error(`transcript request failed: ${res.status} ${await res.text()}`);
  }

  const { jobId } = await res.json();
  for (;;) {
    await sleep(3000);
    const job = await (await fetch(`${API}/v1/transcript/job/${jobId}`, { headers })).json();
    // A failed job still returns HTTP 200, so branch on the status field.
    if (job.status === "success") return job.transcript;
    if (job.status === "failed") throw new Error(`${job.error.code}: ${job.error.message}`);
  }
}
</code></pre>
<p>The comment is the part people miss: the job endpoint answers <code>200</code> even when the job failed, so you have to read <code>status</code>. A failed job carries an <code>error.code</code> you can act on, such as <code>NO_SPEECH</code>, <code>MEMBERS_ONLY</code>, <code>AGE_RESTRICTED</code> or <code>REGION_BLOCKED</code>. If you'd rather not poll, <code>GET /v1/transcript/job/{id}/events</code> streams progress as server-sent events.</p>
<p>Rate limits come back as <code>429</code> with a <code>Retry-After</code> header, and successful responses carry <code>X-RateLimit-*</code> headers, so a client can back off properly.</p>
<h2>4. Turning segments into model-sized, citeable chunks</h2>
<p>Raw segments are short, often a few words each. Before sending them to a model, group them into chunks and keep the start time of each chunk. That gives you two things: chunks small enough to embed or summarize, and a timestamp the model can cite.</p>
<pre><code class="language-js">export function chunkSegments(segments, maxChars = 2000) {
  const chunks = [];
  let current = { start: 0, text: "" };

  for (const seg of segments) {
    if (current.text &amp;&amp; current.text.length + seg.text.length &gt; maxChars) {
      chunks.push(current);
      current = { start: seg.start, text: "" };
    }
    if (!current.text) current.start = seg.start;
    current.text += (current.text ? " " : "") + seg.text;
  }
  if (current.text) chunks.push(current);
  return chunks;
}

const stamp = (s) =&gt;
  `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;

// For YouTube, every chunk can link back to the exact moment.
const cite = (videoId, start) =&gt;
  `https://www.youtube.com/watch?v=${videoId}&amp;t=${Math.floor(start)}s`;
</code></pre>
<p>Prompt the model with <code>[12:40] …text…</code> lines and ask it to quote the bracketed time when it makes a claim. Your UI can turn those back into links with <code>cite()</code>. That's the difference between "the speaker said something about pricing" and a link a reader can check.</p>
<h2>5. Whole channels and playlists</h2>
<p>One video at a time doesn't scale to "what has this channel said about X over the last year?" For that, <code>POST /v1/batch</code> takes one of three body shapes, and the shape picks the mode:</p>
<pre><code class="language-bash">curl -X POST https://api.transcript.im/v1/batch \
  -H "X-API-Key: $TRANSCRIPT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"channel": "@somechannel", "language": "en", "limit": 50}'
</code></pre>
<p>Swap the body for the other two modes:</p>
<pre><code class="language-json">{ "urls": ["https://www.tiktok.com/@user/video/123", "https://youtu.be/VIDEO_ID"], "language": "auto" }
{ "playlist": "https://www.youtube.com/playlist?list=PLAYLIST_ID", "language": "en" }
</code></pre>
<p>A URL list can mix platforms. A playlist takes an id or a URL, and a channel takes an id, an <code>@handle</code> or a URL.</p>
<p>The response returns a <code>batch.id</code> with counts for <code>total</code>, <code>pending</code>, <code>success</code> and <code>failed</code>. Read progress with <code>GET /v1/batch/{batchId}</code>, which pages items with a <code>nextPageToken</code>. One failed link is marked on its own and doesn't sink the rest, and <code>/retry</code> re-runs the failures. When it's done, <code>GET /v1/batch/{batchId}/export?format=json</code> gives you the whole set. Other formats are <code>txt</code>, <code>csv</code>, <code>srt</code>, <code>vtt</code> and <code>zip</code>.</p>
<p>For picking what to transcribe, the API also has YouTube discovery endpoints under <code>/v1/youtube</code>: search, video details, channels, a channel's uploads, playlist items and caption metadata. The full contract is published as an OpenAPI document, linked from the <a href="https://transcript.im/docs">API docs</a>.</p>
<h2>6. Skipping the HTTP code: the MCP server</h2>
<p>If the caller is an AI client rather than your backend, connect it to the MCP server at <code>https://mcp.transcript.im/mcp</code> instead. It's a remote HTTP endpoint that takes the same API key, as a Bearer token or an <code>x-api-key</code> header, and forwards it to the REST API. Nothing runs on your machine.</p>
<p>For Cursor and other editors that read <code>mcpServers</code>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "transcript": {
      "url": "https://mcp.transcript.im/mcp",
      "headers": { "Authorization": "Bearer &lt;API_KEY&gt;" }
    }
  }
}
</code></pre>
<p>In Claude, add the same URL as a custom connector. In ChatGPT, add an MCP connector and choose API key authentication.</p>
<p>The server exposes 11 tools: <code>get_transcript</code>, <code>get_transcript_info</code>, <code>create_batch</code>, <code>get_batch</code>, <code>export_batch</code>, <code>search_video</code>, <code>get_channel</code>, <code>list_channel_video</code>, <code>list_playlist_item</code>, <code>list_caption</code> and <code>get_usage</code>. There are deliberately no AI tools in the list. Your client already runs a model, so the server only supplies data.</p>
<p>The detail that matters for agents is chunking. A two-hour recording can be far more text than you want in a single tool result, so <code>get_transcript</code> returns long transcripts in bounded chunks. Each chunk reports <code>truncated: true</code> and an opaque <code>nextPageToken</code>, and the agent calls the tool again with only that token to read the next one. The agent can answer a question about the opening without pulling the whole transcript into context.</p>
<p>If your agent runtime reads <code>SKILL.md</code> files (Claude Code, Cursor, Codex and others), there are also <a href="https://transcript.im/skills">agent skills</a> that use the MCP server when it's configured and fall back to the REST API when it isn't. The workflow skills cover research briefs, meeting notes, lecture notes and repurposing a video into posts, each with source citations.</p>
<h2>7. Plans and limits</h2>
<p>API access is part of the Pro and Max plans.</p>
<ul>
<li><strong>Free:</strong> $0. 3 transcripts per UTC day, 50 AI credits and 30 transcription minutes a month, no video length limit.</li>
<li><strong>Lite:</strong> $10 a month or $96 a year, 200 credits a month.</li>
<li><strong>Pro:</strong> $20 a month or $192 a year, 1,000 credits a month, batches of up to 20 videos, API access.</li>
<li><strong>Max:</strong> $50 a month or $480 a year, 4,000 credits a month, batches of up to 100 videos, API access.</li>
</ul>
<p>There's no per-video length limit on any plan. A long video uses more of your allowance instead of being refused. Details are on the <a href="https://transcript.im/pricing">pricing page</a>, and the endpoints are described on the <a href="https://transcript.im/api">API overview</a> and the <a href="https://transcript.im/mcp">MCP page</a>.</p>
<h2>8. Try it without code first</h2>
<p>Before you write the client, paste one of the videos you care about into <a href="https://transcript.im/">transcript.im</a> and look at the output: the timestamps, the language it picked, and whether it came from captions or from transcription. Then decide how big your chunks should be. The API returns the same transcript, so what you see there is what your model will get.</p>
]]></content:encoded></item><item><title><![CDATA[How to Verify an Email Address Without Sending One (and Use It at Signup)]]></title><description><![CDATA[How email verification works without sending mail: SMTP RCPT checks, catch-all domains, unknown results, and a Node.js signup guard you can copy.]]></description><link>https://billionverify.hashnode.dev/how-to-verify-an-email-address-without-sending-one-and-use-it-at-signup</link><guid isPermaLink="true">https://billionverify.hashnode.dev/how-to-verify-an-email-address-without-sending-one-and-use-it-at-signup</guid><category><![CDATA[email]]></category><category><![CDATA[api]]></category><category><![CDATA[mcp]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Leo]]></dc:creator><pubDate>Sat, 24 Oct 2026 04:05:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69b0f830abc0d95001ca7a2d/2d728bbf-3b29-4951-9d9b-3341ae7b0598.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A regex tells you an email address looks right. It can't tell you whether anyone will ever read mail sent to it. <code>jane@gmial.com</code> passes most patterns. So does <code>test123@mailinator.com</code>, and so does a former employee's address at a company that closed the mailbox last year.</p>
<p>Those addresses cost you later. Hard bounces hurt your sender reputation, throwaway inboxes inflate your signup numbers, and the person who mistyped their address never gets the welcome email.</p>
<p>This post covers how mailbox verification works without sending a message, the edge cases that make it harder than it looks, and how to wire it into a signup handler and a list-cleaning job. The code uses the BillionVerify API, which is the service we build, but the decision logic carries over to any verifier.</p>
<h2>1. What a verifier checks, in order</h2>
<p>A verification runs through four layers. Each one can end the check early.</p>
<ol>
<li><strong>Syntax.</strong> Can the address be parsed at all? A malformed local part or domain fails here with the reason <code>invalid_syntax</code>.</li>
<li><strong>DNS.</strong> Does the domain exist, and does it publish MX records? A domain with no MX records (<code>no_mx_records</code>) or with a null MX record as defined in RFC 7505 (<code>null_mx</code>) can't receive mail.</li>
<li><strong>SMTP.</strong> The verifier connects to the domain's mail server and starts an ordinary SMTP conversation: <code>EHLO</code>, <code>MAIL FROM</code>, then <code>RCPT TO:&lt;jane@company.com&gt;</code>. The server's reply to <code>RCPT TO</code> says whether it would accept mail for that mailbox. A <code>250</code> means yes. A <code>550</code> with "user unknown" means no. The verifier closes the connection before <code>DATA</code>, so no message is ever sent.</li>
<li><strong>Risk flags.</strong> Is the domain a temporary-inbox provider? Is the local part a shared role such as <code>info@</code> or <code>support@</code>?</li>
</ol>
<p>That's the theory. Three things make it messy in practice.</p>
<p><strong>Catch-all domains.</strong> Some mail servers answer <code>250</code> to every recipient, real or not. If you trust that reply, every address on the domain looks valid. The fix is to probe a random address on the same domain first. If <code>x8k2q9-random@company.com</code> is accepted too, the server accepts everything, and the real mailbox can't be confirmed. BillionVerify reports these as <code>catchall</code> with a reason such as <code>catch_all_domain</code>, <code>m365_internal_relay</code> (Microsoft 365 set up as an internal relay) or <code>gateway_accept_all</code> (a security gateway in accept-all mode). They are not counted as valid.</p>
<p><strong>Providers that don't answer probes.</strong> Yahoo, AOL, <a href="http://Outlook.com">Outlook.com</a> and Hotmail block SMTP verification, so they are checked through provider-specific account lookups, with reasons like <code>yahoo_api_verified</code> or <code>microsoft_api_not_deliverable</code>. Apple iCloud, Proton and Tuta allow no reliable check at all. Their addresses always come back <code>unknown</code> with the reason <code>smtp_unverifiable</code>.</p>
<p><strong>Temporary failures.</strong> Greylisting, rate limits on the receiving side and DNS timeouts also produce <code>unknown</code>, with reasons that tell you whether a retry makes sense: <code>smtp_greylisted</code>, <code>smtp_rate_limited</code> and <code>dns_timeout</code> are retryable, <code>smtp_blocked</code> is not.</p>
<p>The lesson for your code: there are more than two outcomes, and <code>unknown</code> is a normal answer, not an error.</p>
<h2>2. The statuses you get back</h2>
<table>
<thead>
<tr>
<th>status</th>
<th>What it means</th>
<th>Typical action</th>
</tr>
</thead>
<tbody><tr>
<td><code>valid</code></td>
<td>The mailbox exists and accepts mail</td>
<td>Accept</td>
</tr>
<tr>
<td><code>invalid</code></td>
<td>The server rejected the mailbox, or the domain can't receive mail</td>
<td>Reject or ask for a correction</td>
</tr>
<tr>
<td><code>catchall</code></td>
<td>The domain accepts every address, so this mailbox can't be confirmed</td>
<td>Accept, but watch bounces</td>
</tr>
<tr>
<td><code>role</code></td>
<td>A shared mailbox such as <code>info@</code></td>
<td>Usually deliverable, often lower engagement</td>
</tr>
<tr>
<td><code>disposable</code></td>
<td>A temporary-inbox provider</td>
<td>Usually reject at signup</td>
</tr>
<tr>
<td><code>risky</code></td>
<td>Deliverable, but the domain has a confirmed abuse history</td>
<td>Remove from lists, review at signup</td>
</tr>
<tr>
<td><code>unknown</code></td>
<td>The server gave no reliable answer</td>
<td>Accept, verify later or by confirmation email</td>
</tr>
</tbody></table>
<p>Each result also carries a <code>score</code> from 0.0 to 1.0. Read it together with the status rather than on its own. The full list of reason codes is in the <a href="https://billionverify.com/docs/verification-reasons">verification reasons reference</a>.</p>
<p>Billing follows the same lines. A <code>valid</code>, <code>invalid</code>, <code>catchall</code> or <code>role</code> result uses 1 credit. <code>unknown</code>, <code>disposable</code> and badly formatted addresses are free, and so is checking the same address again within 24 hours.</p>
<h2>3. Checking one address</h2>
<p><code>POST /verify/single</code> checks one address synchronously. Authenticate with the key from your dashboard in the <code>BV-API-KEY</code> header. <code>Authorization: Bearer &lt;key&gt;</code> also works.</p>
<pre><code class="language-bash">curl -X POST https://api.billionverify.com/v1/verify/single \
  -H "BV-API-KEY: $BV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"jane@company.com","check_smtp":true}'
</code></pre>
<p><code>check_smtp</code> defaults to <code>false</code>. Without it, the request skips the live SMTP step and answers from syntax and DNS checks. For signup forms you want it on. A second flag, <code>force_refresh</code>, skips cached results and is always billed, so leave it off unless you need a fresh probe.</p>
<p>A trimmed response:</p>
<pre><code class="language-json">{
  "success": true,
  "code": "0",
  "message": "Success",
  "data": {
    "email": "jane@company.com",
    "status": "valid",
    "score": 0.95,
    "is_deliverable": true,
    "is_disposable": false,
    "is_catchall": false,
    "is_role": false,
    "is_free": false,
    "domain": "company.com",
    "mx_records": ["mail.company.com"],
    "smtp_check": true,
    "reason": "smtp_deliverable",
    "credits_used": 1
  }
}
</code></pre>
<p>Errors use the same envelope with <code>success: false</code>. Two codes matter in production: HTTP 402 with code <code>4020</code> when your credits run out, and HTTP 429 with code <code>4290</code> when you hit a rate limit.</p>
<h2>4. A signup guard in Node.js</h2>
<p>The goal at signup is narrow: stop addresses that will certainly fail, and never block a real person because a third-party call had a bad moment. That second rule shapes the code. Every path that isn't a clear "no" lets the user through.</p>
<pre><code class="language-js">const BV_BASE = "https://api.billionverify.com/v1";

export async function checkSignupEmail(email, { timeoutMs }) {
  let res;
  try {
    res = await fetch(`${BV_BASE}/verify/single`, {
      method: "POST",
      headers: {
        "BV-API-KEY": process.env.BV_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ email, check_smtp: true }),
      signal: AbortSignal.timeout(timeoutMs),
    });
  } catch {
    // Network error or our own timeout: let the user in, verify later.
    return { action: "allow", flag: "unverified" };
  }

  const body = await res.json().catch(() =&gt; null);
  if (!res.ok || !body?.success) {
    // 402 (code 4020) = out of credits, 429 (code 4290) = rate limited.
    // Neither is the user's fault, so fail open and alert yourself.
    console.warn("email verification failed", res.status, body?.code);
    return { action: "allow", flag: "unverified" };
  }

  const { status, reason } = body.data;
  switch (status) {
    case "valid":
      return { action: "allow" };
    case "invalid":
      return {
        action: "reject",
        message: "We can't deliver mail to this address. Please check it for typos.",
      };
    case "disposable":
      return { action: "reject", message: "Please use a permanent email address." };
    case "risky":
      return { action: "review", flag: status, reason };
    default:
      // catchall, role, unknown: allow, but keep the flag for later.
      return { action: "allow", flag: status, reason };
  }
}
</code></pre>
<p>Use it in a route handler and pass a timeout that suits your form:</p>
<pre><code class="language-js">const verdict = await checkSignupEmail(req.body.email, {
  timeoutMs: config.emailVerifyTimeoutMs,
});
if (verdict.action === "reject") {
  return res.status(422).json({ field: "email", message: verdict.message });
}
await createUser({ ...req.body, emailFlag: verdict.flag ?? null });
</code></pre>
<p>A few choices worth explaining:</p>
<ul>
<li><code>invalid</code> <strong>is the only hard block on deliverability.</strong> A typo like <code>gmial.com</code> usually lands here, because the domain has no MX records or the mailbox doesn't exist. Show the message next to the field so the person can fix it.</li>
<li><code>disposable</code> <strong>is a product decision.</strong> Blocking temporary inboxes fits most SaaS trials. A newsletter might accept them.</li>
<li><code>catchall</code> <strong>and</strong> <code>unknown</code> <strong>are allowed.</strong> Many company domains are catch-all, and every iCloud address is <code>unknown</code>. Blocking them would block real customers. Store the flag, and let your confirmation email do the final check.</li>
<li><strong>Failures are allowed.</strong> If the call times out or you run out of credits, signups keep working. Alert on the warning log, not on lost users.</li>
</ul>
<h2>5. Cleaning a list you already have</h2>
<p>For small batches, <code>POST /verify/bulk</code> takes up to 50 addresses per request and answers synchronously. Here's a Python loop that walks a longer list in chunks of 50:</p>
<pre><code class="language-python">import os
import requests

BASE = "https://api.billionverify.com/v1"
HEADERS = {"BV-API-KEY": os.environ["BV_API_KEY"]}

def verify_in_chunks(emails, size=50):
    for i in range(0, len(emails), size):
        r = requests.post(
            f"{BASE}/verify/bulk",
            headers=HEADERS,
            json={"emails": emails[i:i + size], "check_smtp": True},
        )
        r.raise_for_status()
        yield from r.json()["data"]["results"]

keep = [r["email"] for r in verify_in_chunks(my_emails) if r["status"] == "valid"]
</code></pre>
<p>For a real list, upload the file instead. <code>POST /verify/file</code> takes a CSV, TXT or XLSX file as <code>multipart/form-data</code> and runs asynchronously:</p>
<pre><code class="language-bash"># 1. Upload. The response includes a task_id.
curl -X POST https://api.billionverify.com/v1/verify/file \
  -H "BV-API-KEY: $BV_API_KEY" \
  -F file=@customers.csv \
  -F email_column=email \
  -F check_smtp=true

# 2. Poll until status is "completed".
curl https://api.billionverify.com/v1/verify/file/$TASK_ID \
  -H "BV-API-KEY: $BV_API_KEY"

# 3. Download only the rows you want to keep.
curl -L -o keep.csv \
  "https://api.billionverify.com/v1/verify/file/$TASK_ID/results?valid=true&amp;catchall=true" \
  -H "BV-API-KEY: $BV_API_KEY"
</code></pre>
<p>Two details trip people up. The results endpoint answers with a <code>307</code> redirect to the result file, so <code>curl</code> needs <code>-L</code>, or you save an empty body. And the status filters are combined with OR, so <code>valid=true&amp;catchall=true</code> returns both groups. <code>preserve_original</code> defaults to <code>true</code>, which keeps your original columns next to the verification fields. The status call also accepts a long-polling <code>timeout</code> parameter, and the <a href="https://billionverify.com/docs/api-reference">API reference</a> covers webhooks if you'd rather be notified when a job finishes.</p>
<p>To check your balance before a big job, call <code>GET /credits</code>. It returns <code>credits_balance</code> along with what you've used.</p>
<h2>6. Letting an AI agent do it</h2>
<p>If you work in Claude Code, Cursor or another MCP client, you can skip the HTTP code. BillionVerify runs a hosted MCP server at <code>https://mcp.billionverify.com/mcp</code> over Streamable HTTP. It signs you in with OAuth 2.1, so no API key ends up in a config file or a URL.</p>
<p>In Claude Code:</p>
<pre><code class="language-bash">claude mcp add --transport http billionverify https://mcp.billionverify.com/mcp
</code></pre>
<p>The server exposes 11 tools. The ones you'll use most are <code>verify_single_email</code>, <code>verify_batch_emails</code> (up to 50 addresses), <code>get_account_balance</code>, <code>get_task_status</code> for file jobs you uploaded in the dashboard, and <code>get_download_url</code> for a filtered result link. Then you ask in plain words: "Verify these 30 leads and tell me which ones will bounce."</p>
<img src="https://cdn.hashnode.com/uploads/covers/69b0f830abc0d95001ca7a2d/d4fa5f3d-5151-4d21-82cf-a3269a108d2e.png" alt="" style="display:block;margin:0 auto" />

<p>In the Claude apps, BillionVerify is also listed in the connector directory, so you can connect it without any config. The <a href="https://billionverify.com/blog/billionverify-claude-connector">connector guide</a> walks through it. Setup for other clients is in the <a href="https://billionverify.com/docs/ai-guides/mcp">MCP docs</a>. If your client only supports local stdio servers with API keys, use the REST API above instead.</p>
<h2>7. What it costs</h2>
<ul>
<li><strong>Free:</strong> 20 credits every day you log in, up to 600 a month, with no credit card.</li>
<li><strong>Credit packs:</strong> from $20 for 20,000 credits.</li>
<li><strong>Subscriptions:</strong> from $16 a month for 20,000 credits a month, or $96 a year for 240,000.</li>
<li><strong>Credits never expire,</strong> whether you bought them or earned them on the free plan.</li>
</ul>
<p>Remember that only definite answers are billed: <code>unknown</code>, <code>disposable</code> and badly formatted addresses cost nothing. Current plans are on the <a href="https://billionverify.com/pricing">pricing page</a>.</p>
<h2>8. Try it on your own form</h2>
<p>Want to see what your signup form would have caught? Paste a few addresses from last week's signups into the <a href="https://billionverify.com/email-verifier">free email verifier</a>, which works without an account. When you're ready to call the API, <a href="https://billionverify.com/auth/sign-up">create a free account</a> and grab a key from the dashboard. Your daily free credits are enough to test the signup guard above end to end.</p>
]]></content:encoded></item><item><title><![CDATA[3 Critical Pitfalls in Signup Form Validation (And How to Fix Them)]]></title><description><![CDATA[I was reviewing the user database at a SaaS company where I'd just started consulting. The product had grown to 8,000 active users, and they were…]]></description><link>https://billionverify.hashnode.dev/3-critical-pitfalls-in-signup-form-validation-and-how-to-fix-them</link><guid isPermaLink="true">https://billionverify.hashnode.dev/3-critical-pitfalls-in-signup-form-validation-and-how-to-fix-them</guid><dc:creator><![CDATA[Leo]]></dc:creator><pubDate>Thu, 12 Mar 2026 06:49:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69b0f830abc0d95001ca7a2d/6d51fa35-916b-447c-91a4-8fbd3958081b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I was reviewing the user database at a SaaS company where I'd just started consulting. The product had grown to 8,000 active users, and they were struggling with a mysterious problem: their email marketing campaigns were bouncing at 6.2%, just above the critical 5% threshold where ISPs start treating them like spammers. When I dug deeper, I found something worse than bad emails—I found patterns of deliberate negligence in their validation strategy.</p>
<p>One user had signed up with <code>john@gmail.com</code> but the database contained <code>john@gmai1.com</code> (with a "1" instead of an "l"). Another had registered using <code>temp@10minutemail.com</code>, a famous disposable email service. Still others had used catch-all addresses like <code>noreply@company.com</code> that would accept any email sent to them, trapping the company's transactional messages in a black hole. When I asked the engineering team why these weren't caught, they gave me variations of the same answer: "we validate with regex on the frontend," or "we validate after signup," or "we don't check for disposable emails."</p>
<p>That's when I realized signup form validation is much more complex than most developers think. It's not just about regex patterns or timing—it's about understanding exactly when and how to validate, and what factors to check for. Today, I'll walk you through the three critical pitfalls that are probably costing your product right now.</p>
<h2>The First Pitfall: Trusting Frontend Validation Alone</h2>
<p>Your frontend validation probably looks something like this. You've got a React component with a form, and you validate emails with a regex pattern before the user can submit:</p>
<pre><code class="language-jsx">import React, { useState } from 'react';

function SignupForm() {
  const [email, setEmail] = useState('');
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);

  // This is what most developers rely on
  const validateEmailFormat = (email) =&gt; {
    // RFC 5322 simplified regex (but still incomplete)
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  };

  const handleChange = (e) =&gt; {
    const value = e.target.value;
    setEmail(value);

    // Clear error when user starts typing
    if (errors.email) {
      setErrors({ ...errors, email: null });
    }
  };

  const handleSubmit = async (e) =&gt; {
    e.preventDefault();
    const newErrors = {};

    // Frontend validation
    if (!email) {
      newErrors.email = 'Email is required';
    } else if (!validateEmailFormat(email)) {
      newErrors.email = 'Invalid email format';
    }

    if (Object.keys(newErrors).length &gt; 0) {
      setErrors(newErrors);
      return;
    }

    // If we get here, the email "looks valid" to us
    setSubmitting(true);

    try {
      // Send to backend
      const response = await fetch('/api/signup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password: 'placeholder' })
      });

      if (response.ok) {
        alert('Signup successful!');
      } else {
        const data = await response.json();
        setErrors({ email: data.error || 'Signup failed' });
      }
    } catch (error) {
      setErrors({ email: 'Network error occurred' });
    } finally {
      setSubmitting(false);
    }
  };

  return (
    &lt;form onSubmit={handleSubmit}&gt;
      &lt;div&gt;
        &lt;label htmlFor="email"&gt;Email Address&lt;/label&gt;
        &lt;input
          id="email"
          type="email"
          value={email}
          onChange={handleChange}
          placeholder="you@example.com"
          disabled={submitting}
        /&gt;
        {errors.email &amp;&amp; &lt;span className="error"&gt;{errors.email}&lt;/span&gt;}
      &lt;/div&gt;
      &lt;button type="submit" disabled={submitting}&gt;
        {submitting ? 'Creating account...' : 'Sign up'}
      &lt;/button&gt;
    &lt;/form&gt;
  );
}

export default SignupForm;
</code></pre>
<p>This looks fine to a user. The regex catches obviously broken addresses like <code>notanemail</code> or <code>test@</code>, and the form feels responsive. But here's what happens when you test it with real data:</p>
<pre><code class="language-plaintext">test@tempmail.com              ✓ Passes (but it's a disposable service)
user@gmail.com.xyz             ✓ Passes (domain doesn't exist)
john@company.com               ✓ Passes (but might accept ANY address—it's catch-all)
admin@example.com              ✓ Passes (but it's a spamtrap)
contact@spam-list.io           ✓ Passes (but will damage your reputation)
</code></pre>
<p>The problem is that regex validation only checks syntax. It never touches reality. It doesn't contact the mail server, doesn't check if the domain exists, doesn't verify that the mailbox will actually accept mail. Your 95% regex-validated emails might include 8-10% that are legitimately problematic.</p>
<p>Here's the real cost: let's say you have 10,000 users and 8% have problematic emails. You send them your weekly digest. That's 800 bounces. Do it weekly for 4 weeks, and you've sent 3,200 emails that will bounce. Your sending reputation drops. ISPs watch this behavior. After a few weeks, Gmail starts filtering your entire domain to spam. Now your legitimate users don't receive your emails either, even though those addresses are perfectly valid.</p>
<p>I've calculated the cost at one company: they were losing roughly $15,000 per month in undelivered transactional emails (password resets, billing notifications) that users didn't receive because their sender reputation was poisoned by 8% bad data. That's the cost of trusting regex alone.</p>
<h2>The Second Pitfall: Validating Emails After Signup</h2>
<p>Once you realize frontend validation isn't enough, the natural instinct is to add backend validation. Many teams do this, but they make a timing mistake: they validate <em>after</em> the user has already been created in the database.</p>
<p>Here's how this typically looks:</p>
<pre><code class="language-javascript">// Node.js / Express backend
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();

app.use(express.json());

// Database connection (pseudocode)
const db = require('./database');

// Simple validation function
function isValidEmailFormat(email) {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(email);
}

// The signup endpoint
app.post('/api/signup', async (req, res) =&gt; {
  const { email, password } = req.body;

  try {
    // Validate format (frontend probably did this too)
    if (!isValidEmailFormat(email)) {
      return res.status(400).json({ error: 'Invalid email format' });
    }

    // Check if user already exists
    const existingUser = await db.users.findOne({ email });
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered' });
    }

    // Hash the password
    const hashedPassword = await bcrypt.hash(password, 10);

    // Create the user in the database
    // This is the critical moment—we're committing to this email
    const user = await db.users.create({
      email,
      password: hashedPassword,
      createdAt: new Date(),
      verified: false
    });

    // Only NOW do we send a verification email
    // But at this point, we don't know if the email actually exists
    await sendVerificationEmail(email);

    res.status(201).json({
      message: 'Account created. Check your email for verification link.',
      userId: user.id
    });

  } catch (error) {
    console.error('Signup error:', error);
    res.status(500).json({ error: 'Signup failed' });
  }
});

// This runs hours or days later
async function sendVerificationEmail(email) {
  // The user never receives this because:
  // - Email format was wrong (typo like gmai1.com)
  // - Domain is disposable and inaccessible
  // - Domain is a spamtrap
  // But we already created the account!

  const verificationToken = generateToken();
  await db.verificationTokens.create({ email, token: verificationToken });

  return sendEmail({
    to: email,
    subject: 'Verify Your Email',
    html: `Click here to verify: https://example.com/verify?token=${verificationToken}`
  });
}
</code></pre>
<p>The problem with this approach is subtle but costly. You've created the user account <em>before</em> knowing whether the email is legitimate. This means:</p>
<ol>
<li><p><strong>Your database is polluted with bad emails.</strong> You now have 10,000 users but maybe 800 of them have invalid emails. Cleaning this up later is expensive.</p>
</li>
<li><p><strong>Verification emails don't reach the user.</strong> They sign up, click the verification link... except they never receive the email because it's a disposable address or spamtrap. They think your system is broken.</p>
</li>
<li><p><strong>You've already paid for storage and computing.</strong> Every invalid email in your database is a record you'll need to handle, validate, or clean up later.</p>
</li>
<li><p><strong>Your metrics are misleading.</strong> You report 10,000 signups, but only 9,200 are real. This makes it hard to understand your actual product-market fit.</p>
</li>
</ol>
<p>The timing matters enormously. If you validate <em>after</em> signup, you're working with incomplete information. The user might never complete email verification. They might give up. They might have a bad experience and abandon your product.</p>
<h2>The Third Pitfall: Not Checking for Disposable Emails and Spam Traps</h2>
<p>This is the most insidious problem because it silently degrades your deliverability over time. You validate that an email <em>exists</em>, but you don't check whether it's legitimate—whether it's a real person you want in your system.</p>
<p>Disposable email services like <code>10minutemail.com</code>, <code>tempmail.com</code>, and <code>guerrillamail.com</code> are designed specifically to be temporary. Users sign up for them when they don't want to give you a real email address. Spam traps are even worse—they're email addresses that don't belong to real people but are monitored by ISPs to catch senders who validate against outdated or unreliable lists.</p>
<p>Here's what the impact looks like over time. Let's build a scenario where you're not checking for these:</p>
<pre><code class="language-python"># Python backend with Flask
from flask import Flask, request, jsonify
import os
from datetime import datetime, timedelta
import smtplib

app = Flask(__name__)

# Simple email format check (same problem as before)
def validate_email_format(email):
    import re
    pattern = r'^[^\s@]+@[^\s@]+\.[^\s@]+$'
    return re.match(pattern, email) is not None

# Database connection
class Database:
    def __init__(self):
        # Pseudocode—in reality this would be SQLAlchemy or similar
        self.users = []

    def create_user(self, email, password_hash):
        user = {
            'id': len(self.users) + 1,
            'email': email,
            'password': password_hash,
            'created_at': datetime.now(),
            'verified': False
        }
        self.users.append(user)
        return user

db = Database()

@app.route('/api/signup', methods=['POST'])
def signup():
    data = request.json
    email = data.get('email', '').strip().lower()
    password = data.get('password', '')

    # Validation step 1: Format check only
    if not validate_email_format(email):
        return jsonify({'error': 'Invalid email format'}), 400

    # Validation step 2: Check if exists (but we're about to create it)
    existing = next((u for u in db.users if u['email'] == email), None)
    if existing:
        return jsonify({'error': 'Email already registered'}), 409

    # Create user immediately
    import hashlib
    password_hash = hashlib.sha256(password.encode()).hexdigest()
    user = db.create_user(email, password_hash)

    # Send verification email
    send_verification_email(email)

    return jsonify({
        'message': 'Account created. Verify your email to continue.',
        'user_id': user['id']
    }), 201

def send_verification_email(email):
    # This function will send to:
    # - temp@tempmail.com (temporary, user will lose access)
    # - admin@fake-domain.com (spam trap, damages reputation)
    # - catch-all@company.com (goes nowhere meaningful)
    # But we don't check for any of these

    verification_token = 'fake_token_' + os.urandom(16).hex()

    # Log that we're sending (we're not actually sending in this example)
    print(f'Verification email sent to {email}')
    print(f'  Token: {verification_token}')
    print(f'  Will reach user: {predict_email_reachability(email)}')

def predict_email_reachability(email):
    """
    This function is pseudocode to show what we're missing.
    A real implementation would check:
    - Is this a known disposable service?
    - Is this a spam trap?
    - Does the domain accept all addresses (catch-all)?
    - Is this a role-based email?
    """

    disposable_domains = [
        'tempmail.com', '10minutemail.com', 'guerrillamail.com',
        'mailinator.com', 'temp-mail.org', 'throwaway.email'
    ]

    spam_trap_domains = [
        'fake-domain.com', 'nonexistent-company.xyz', 'spamtrap.io'
    ]

    domain = email.split('@')[1]

    if domain in disposable_domains:
        return False  # Won't reach user
    if domain in spam_trap_domains:
        return False  # Will damage reputation

    return True  # Probably fine (wrong!)

# Example of what gets into your database
if __name__ == '__main__':
    test_signups = [
        ('john@gmail.com', 'password123'),           # Real
        ('temp@tempmail.com', 'password456'),        # Disposable
        ('admin@fake-domain.com', 'password789'),    # Spam trap
        ('bot@company.com', 'password000'),          # Role account
    ]

    for email, password in test_signups:
        print(f'\nSignup: {email}')
        data = {'email': email, 'password': password}
        # This would call signup() in a real app
        reachability = predict_email_reachability(email)
        print(f'  Can reach user: {reachability}')
        print(f'  But we created account anyway!')
</code></pre>
<p>Here's the damage this causes. Let's trace what happens over time:</p>
<p><strong>Month 1:</strong> You have 1,000 users. You don't realize that 80 of them used disposable emails and 15 used spam trap addresses. You start sending transactional emails (order confirmations, password resets).</p>
<p><strong>Month 2:</strong> Your bounce rate is 8%. The disposable users have lost access (those services delete inboxes after hours). The spam traps are flagging you as a problematic sender. Some ISPs start watching your reputation.</p>
<p><strong>Month 3:</strong> You send a marketing campaign to your 1,000 users. 800 go to inboxes, 150 go to spam folder, 50 bounce. Your metrics look bad.</p>
<p><strong>Month 4:</strong> Gmail, Yahoo, and Outlook have reduced trust in your sender reputation. Even emails to real users are being filtered to spam more aggressively. Your product launches a feature and tries to notify users—only 60% receive the notification because of reputation damage.</p>
<p><strong>The cost:</strong> If you'd validated properly at signup, that 8% bad data would have been 0%. You'd have 920 real users instead of 1,000 fake ones, but your email deliverability would be clean. That difference compounds—after a year, it could mean the difference between a successful email channel and a blocked one.</p>
<h2>The Right Way: Real-Time Validation at Signup</h2>
<p>Here's how to fix all three pitfalls in one solution. We'll validate the email in real-time during signup, before creating the user account, and check for disposable addresses and spam traps.</p>
<p>First, the frontend stays simple and just collects the email. Don't do fancy validation there:</p>
<pre><code class="language-jsx">import React, { useState } from 'react';

function SignupForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [success, setSuccess] = useState(false);

  const handleSubmit = async (e) =&gt; {
    e.preventDefault();
    setError(null);
    setLoading(true);

    try {
      // Send to backend for real validation
      const response = await fetch('/api/signup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });

      const data = await response.json();

      if (!response.ok) {
        // Backend will tell us if email is invalid, disposable, spam trap, etc.
        setError(data.error || 'Signup failed');
        return;
      }

      setSuccess(true);
      setEmail('');
      setPassword('');

    } catch (error) {
      setError('Network error. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    &lt;form onSubmit={handleSubmit}&gt;
      &lt;div&gt;
        &lt;label htmlFor="email"&gt;Email Address&lt;/label&gt;
        &lt;input
          id="email"
          type="email"
          value={email}
          onChange={(e) =&gt; setEmail(e.target.value)}
          placeholder="you@example.com"
          required
          disabled={loading}
        /&gt;
      &lt;/div&gt;

      &lt;div&gt;
        &lt;label htmlFor="password"&gt;Password&lt;/label&gt;
        &lt;input
          id="password"
          type="password"
          value={password}
          onChange={(e) =&gt; setPassword(e.target.value)}
          required
          disabled={loading}
        /&gt;
      &lt;/div&gt;

      {error &amp;&amp; &lt;div className="error"&gt;{error}&lt;/div&gt;}
      {success &amp;&amp; &lt;div className="success"&gt;Account created! Check your email.&lt;/div&gt;}

      &lt;button type="submit" disabled={loading}&gt;
        {loading ? 'Creating account...' : 'Sign up'}
      &lt;/button&gt;
    &lt;/form&gt;
  );
}

export default SignupForm;
</code></pre>
<p>Notice how the frontend is dumb—it just sends the email to the backend. The real magic happens there. The backend is where we use BillionVerify to do real validation:</p>
<pre><code class="language-javascript">// Node.js / Express backend with real email validation
const express = require('express');
const https = require('https');
const bcrypt = require('bcrypt');
const app = express();

app.use(express.json());

// Database pseudocode
const db = require('./database');

class BillionVerifyClient {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  async verify(email) {
    return new Promise((resolve, reject) =&gt; {
      const requestBody = JSON.stringify({
        email: email,
        check_smtp: true
      });

      const options = {
        hostname: 'api.billionverify.com',
        path: '/v1/verify/single',
        method: 'POST',
        headers: {
          'BV-API-KEY': this.apiKey,
          'Content-Type': 'application/json',
          'Content-Length': Buffer.byteLength(requestBody),
          'User-Agent': 'MyApp-Signup/1.0',
          'Connection': 'keep-alive'
        },
        timeout: 5000
      };

      const req = https.request(options, (res) =&gt; {
        let data = '';

        res.on('data', (chunk) =&gt; {
          data += chunk;
        });

        res.on('end', () =&gt; {
          try {
            const result = JSON.parse(data);
            resolve(result);
          } catch (error) {
            reject(new Error('Invalid API response'));
          }
        });
      });

      req.on('error', reject);
      req.on('timeout', () =&gt; {
        req.destroy();
        reject(new Error('Validation timeout'));
      });

      req.write(requestBody);
      req.end();
    });
  }
}

const verifier = new BillionVerifyClient(process.env.BILLIONVERIFY_API_KEY);

// The signup endpoint—now with real validation
app.post('/api/signup', async (req, res) =&gt; {
  const { email, password } = req.body;

  try {
    // Step 1: Basic format check (just to catch obviously broken input)
    if (!email || !email.includes('@')) {
      return res.status(400).json({ error: 'Invalid email format' });
    }

    // Step 2: Check if already registered
    const existingUser = await db.users.findOne({ email: email.toLowerCase() });
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered' });
    }

    // Step 3: REAL VALIDATION via BillionVerify
    // This is the critical difference—we validate BEFORE creating the account
    let validationResult;
    try {
      validationResult = await verifier.verify(email);
    } catch (error) {
      console.error('Validation service error:', error);
      // Fail closed: if we can't validate, reject the signup
      // This protects your reputation
      return res.status(503).json({
        error: 'Email validation service temporarily unavailable. Try again later.'
      });
    }

    // Step 4: Apply business rules based on validation result
    if (validationResult.status !== 'valid') {
      return res.status(400).json({
        error: 'This email address does not exist or is not valid'
      });
    }

    if (validationResult.is_disposable) {
      return res.status(400).json({
        error: 'Please use a permanent email address, not a temporary one'
      });
    }

    if (validationResult.is_spam_trap) {
      // This is a security issue—someone might be testing our system
      console.warn(`Spam trap signup attempt: ${email}`);
      return res.status(400).json({
        error: 'This email address cannot be used'
      });
    }

    // Step 5: Only NOW create the user account
    // We know the email is real, permanent, and not a spam trap
    const hashedPassword = await bcrypt.hash(password, 10);

    const user = await db.users.create({
      email: email.toLowerCase(),
      password: hashedPassword,
      createdAt: new Date(),
      verified: false,
      validationData: {
        // Store validation data for future reference
        isValid: true,
        isCatchAll: validationResult.is_catch_all,
        isRoleAccount: validationResult.is_role_account,
        validatedAt: new Date()
      }
    });

    // Step 6: Send verification email to an address we trust
    // The user can now actually receive and verify the email
    await sendVerificationEmail(email, user.id);

    res.status(201).json({
      message: 'Account created successfully! Check your email to verify.',
      userId: user.id
    });

  } catch (error) {
    console.error('Signup error:', error);
    res.status(500).json({ error: 'Signup failed. Please try again.' });
  }
});

async function sendVerificationEmail(email, userId) {
  // In a real app, this would use nodemailer or a service like SendGrid
  const token = generateVerificationToken(userId);

  // Store token in database
  await db.verificationTokens.create({
    userId,
    email,
    token,
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
  });

  // Send email (pseudocode)
  console.log(`Verification email sent to ${email}`);
  console.log(`Link: https://example.com/verify?token=${token}`);
}

function generateVerificationToken(userId) {
  const crypto = require('crypto');
  return crypto.randomBytes(32).toString('hex');
}

app.listen(3000, () =&gt; console.log('Server running on port 3000'));
</code></pre>
<p>This is the complete fix. Notice the key difference: we validate the email <em>before</em> creating the user account, and we check not just for existence but also for disposable addresses and spam traps. If any of those checks fail, the user never gets created, and your database stays clean.</p>
<h2>Why This Actually Matters for Your Business</h2>
<p>Let me put a number on this. Let's say you're a SaaS product with a $99/month plan and 1,000 paying customers. Your churn rate is 5% per month (pretty standard for SaaS). At 1,000 paying customers, you need 50 new signups per day just to stay flat. If you're growing, you need more.</p>
<p>With bad signup validation, maybe 8% of your 50 daily signups are invalid. That's 4 users per day, 120 per month, 1,440 per year who seem to convert but actually don't. They never verify their email, never use the product, never pay. You're spending money on onboarding emails that never arrive, support tickets from users who think your system is broken, and reputation damage from bounces.</p>
<p>With proper validation, all 50 of those signups are real. Your verified user rate goes from 92% to 100%. Your email deliverability stays clean. Your support tickets from signup issues drop to nearly zero.</p>
<p>It's not just about preventing bad data—it's about ensuring every user who signs up is actually a user who can use your product.</p>
<h2>Getting Started Today</h2>
<p>The code examples above are complete and ready to use. Here's your implementation checklist:</p>
<p>First, sign up for BillionVerify at <a href="https://billionverify.com/auth/sign-up">https://billionverify.com/auth/sign-up</a>. You'll get 100 free credits to test with, no credit card required. Grab your API key and set it as an environment variable.</p>
<p>Then, integrate the Node.js code from the backend example into your signup endpoint. Test it with various emails: real ones, disposable ones, spam traps. You'll see immediately how it catches problems that regex would miss.</p>
<p>Once you're confident it's working, deploy it to production. Your bounce rates will drop. Your user database will be cleaner. Your email reputation will improve.</p>
<p>For detailed API documentation and additional options like batch validation, visit <a href="https://billionverify.com/docs">https://billionverify.com/docs</a>.</p>
<p>The difference between regex validation and real validation might seem small, but it compounds over time. Start today, and your future self will thank you.</p>
]]></content:encoded></item><item><title><![CDATA[Real-Time Email Validation: From API Call to Production Deployment]]></title><description><![CDATA[I was working on a SaaS product that sent welcome emails to new users. Everything seemed fine until our infrastructure team noticed something alarming: our…]]></description><link>https://billionverify.hashnode.dev/real-time-email-validation-from-api-call-to-production-deployment</link><guid isPermaLink="true">https://billionverify.hashnode.dev/real-time-email-validation-from-api-call-to-production-deployment</guid><dc:creator><![CDATA[Leo]]></dc:creator><pubDate>Wed, 11 Mar 2026 05:11:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69b0f830abc0d95001ca7a2d/9117e864-8b3e-4f0b-ad99-25085bfb152e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Real-Time Email Validation: From API Call to Production Deployment</h1>
<p>I was working on a SaaS product that sent welcome emails to new users. Everything seemed fine until our infrastructure team noticed something alarming: our bounce rate had climbed to 8.7%. They warned me that ISPs flag senders with bounce rates above 5% as spam, and we were heading toward the spam folder on Gmail and Outlook.</p>
<p>When we investigated, we discovered the root cause. Our signup form validated emails with a simple regex pattern, and we were accepting everything from disposable email services to typos that looked syntactically correct. One user had signed up with <code>test@gmai1.com</code> (with a "1" instead of "l"), and we sent three welcome emails to a non-existent address before giving up. Multiply that by thousands of users, and you've poisoned your sender reputation.</p>
<p>That's when I learned that real email validation is far more complex than checking format. Today, I'll show you how to build a production-grade validation system that catches these problems before they destroy your deliverability.</p>
<h2>Why Regex Validation Is Just The Beginning</h2>
<p>Let's start with why your current approach probably isn't working. Most developers rely on regex patterns like this:</p>
<pre><code class="language-javascript">const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function isValidEmail(email) {
  return emailRegex.test(email);
}

// Test some addresses
console.log(isValidEmail("john@example.com"));      // true
console.log(isValidEmail("test@tempmail.com"));     // true (WRONG!)
console.log(isValidEmail("user@typo.cmo"));         // true (WRONG!)
console.log(isValidEmail("info@non-existent.xyz")); // true (WRONG!)
</code></pre>
<p>This regex is only checking syntax—whether the string looks like an email address. It doesn't care if the domain actually exists, if the mailbox accepts mail, or whether you're inviting a spam bot into your system.</p>
<p>The real damage happens slowly. Each invalid email you send out contributes to your bounce rate. When your bounce rate climbs past 5%, ISPs start treating you like a spammer. Some Gmail accounts stop receiving your emails entirely. Your support team gets flooded with users saying they never received your welcome email. It's a cascading failure that's expensive to fix.</p>
<p>I ran the numbers at my company: by the time we fixed the problem, we'd wasted roughly $12,000 on email delivery services sending to dead addresses, and we'd lost an estimated 40 sign-ups from users who assumed we had a broken system when they didn't receive confirmations.</p>
<p>The solution is to validate emails properly before you ever send them. This means checking not just format, but also whether the address actually exists and whether it's legitimate.</p>
<h2>Building a Real Email Validator With BillionVerify</h2>
<p>Let me show you how to build a proper validator using the BillionVerify API. We'll start with Node.js, then move to Python. The key difference from a regex checker is that we're actually hitting an API that validates against real mail servers.</p>
<p>Here's the basic approach: when a user enters their email, we send it to BillionVerify, which performs SMTP validation against the recipient's mail server. Within milliseconds, we get back detailed information about whether the email is valid, whether it's a disposable address, whether it's a catch-all account, and whether it's a known spam trap.</p>
<p>Let's build this step by step. First, we'll create a simple wrapper around the BillionVerify API that handles the HTTP request and processes the response:</p>
<pre><code class="language-javascript">const https = require('https');

class BillionVerifyValidator {
  constructor(apiKey) {
    this.apiKey = apiKey;
    this.baseUrl = 'api.billionverify.com';
    this.requestTimeout = 5000; // 5 seconds
  }

  validateEmail(email) {
    return new Promise((resolve, reject) =&gt; {
      // Build the JSON request body
      const requestBody = JSON.stringify({
        email: email,
        check_smtp: true
      });

      // Configure HTTPS request
      const options = {
        hostname: this.baseUrl,
        path: '/v1/verify/single',
        method: 'POST',
        headers: {
          'BV-API-KEY': this.apiKey,
          'Content-Type': 'application/json',
          'Content-Length': Buffer.byteLength(requestBody),
          'User-Agent': 'MyApp-EmailValidator/1.0',
          'Connection': 'keep-alive'
        }
      };

      // Make the request with a timeout
      const req = https.request(options, (res) =&gt; {
        let responseData = '';

        // Accumulate response chunks
        res.on('data', (chunk) =&gt; {
          responseData += chunk;
        });

        // Process complete response
        res.on('end', () =&gt; {
          try {
            const result = JSON.parse(responseData);

            // Validate response structure
            if (!result.hasOwnProperty('status')) {
              reject(new Error('Invalid API response: missing status field'));
              return;
            }

            resolve(result);
          } catch (error) {
            reject(new Error(`Failed to parse API response: ${error.message}`));
          }
        });
      });

      // Handle request errors
      req.on('error', (error) =&gt; {
        reject(new Error(`API request failed: ${error.message}`));
      });

      // Handle timeout
      req.on('timeout', () =&gt; {
        req.destroy();
        reject(new Error('API request timed out after 5 seconds'));
      });

      // Set timeout, send request body, and finish
      req.setTimeout(this.requestTimeout);
      req.write(requestBody);
      req.end();
    });
  }
}

// Now let's use this validator in a realistic scenario
async function validateUserSignup(userEmail) {
  try {
    const validator = new BillionVerifyValidator(process.env.BILLIONVERIFY_API_KEY);
    const result = await validator.validateEmail(userEmail);

    console.log(`Validation result for ${userEmail}:`);
    console.log(`  Status: ${result.status}`);
    console.log(`  Disposable: ${result.is_disposable}`);
    console.log(`  Catch-all: ${result.is_catch_all}`);
    console.log(`  Spam trap: ${result.is_spam_trap}`);
    console.log(`  Role account: ${result.is_role_account}`);

    // Business logic: decide whether to accept this email
    if (result.status === 'valid' &amp;&amp; !result.is_disposable &amp;&amp; !result.is_spam_trap) {
      return { accepted: true, message: 'Email validated successfully' };
    } else if (result.is_disposable) {
      return { accepted: false, message: 'Please use a permanent email address, not a temporary one' };
    } else if (result.is_spam_trap) {
      return { accepted: false, message: 'This email address cannot be used' };
    } else {
      return { accepted: false, message: 'This email address appears to be invalid' };
    }
  } catch (error) {
    console.error(`Validation error: ${error.message}`);
    // In production, you might have a fallback strategy here
    return { accepted: false, message: 'Email validation service temporarily unavailable' };
  }
}

// Example usage
validateUserSignup('john@example.com').then(result =&gt; {
  console.log(result);
});
</code></pre>
<p>Notice how we're handling several important things here that go beyond the basic API call. We're validating the response structure, handling timeouts gracefully, catching errors properly, and then applying business logic to decide whether to accept the email. The response from BillionVerify includes multiple flags—<code>is_disposable</code>, <code>is_spam_trap</code>, <code>is_catch_all</code>—and as the developer, you decide how strictly to enforce these rules for your use case.</p>
<h2>The Python Approach: A More Structured Implementation</h2>
<p>Now let's look at how you'd implement the same logic in Python, but with a slightly different approach that takes advantage of Python's strengths. Python's <code>requests</code> library makes HTTP calls simpler, and we can use type hints and classes to make the code more maintainable:</p>
<pre><code class="language-python">import requests
import os
import json
from typing import Dict, Optional, Tuple
from datetime import datetime

class EmailValidator:
    """
    Validates email addresses using the BillionVerify API.

    This validator checks not just email format, but also whether the
    email address actually exists and whether it's legitimate (not disposable,
    not a spam trap, etc.).
    """

    def __init__(self, api_key: str):
        self.api_key = api_key
        self.base_url = 'https://api.billionverify.com/v1'
        # Use a session for connection pooling (more efficient)
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'MyApp-EmailValidator/1.0'
        })

    def validate(self, email: str) -&gt; Dict:
        """
        Validate a single email address.

        Makes a request to the BillionVerify API and returns detailed
        validation information including whether the email is valid,
        disposable, a catch-all, etc.

        Args:
            email: The email address to validate

        Returns:
            Dictionary containing:
            - status: 'valid', 'invalid', 'unknown', 'undeliverable'
            - is_disposable: True if it's a temporary email service
            - is_catch_all: True if the domain accepts all addresses
            - is_spam_trap: True if it's a known spam trap
            - is_role_account: True if it's a generic account (info@, admin@, etc.)

        Raises:
            ConnectionError: If the API request fails
            ValueError: If the response is malformed
        """

        try:
            # Make the API request
            response = self.session.get(
                f'{self.base_url}/verify',
                params={'email': email, 'key': self.api_key},
                timeout=5
            )

            # Check HTTP status code
            if response.status_code == 401:
                raise ValueError('Invalid API key')
            elif response.status_code == 429:
                raise ConnectionError('Rate limited: too many requests')
            elif response.status_code &gt;= 500:
                raise ConnectionError('BillionVerify API server error')

            response.raise_for_status()

            # Parse and return the JSON response
            result = response.json()
            return result

        except requests.exceptions.Timeout:
            raise ConnectionError('API request timed out after 5 seconds')
        except requests.exceptions.ConnectionError as e:
            raise ConnectionError(f'Failed to connect to API: {str(e)}')
        except json.JSONDecodeError:
            raise ValueError('API returned invalid JSON')

    def should_accept_for_signup(self, email: str) -&gt; Tuple[bool, Optional[str]]:
        """
        Determines whether an email should be accepted for user signup.

        This is where you apply your business rules. Different applications
        might have different policies. For example:
        - A free tier might accept disposable emails, but a business tier might not
        - Some apps might allow catch-all addresses, others might reject them
        - Most should reject known spam traps

        Args:
            email: Email address to evaluate

        Returns:
            Tuple of (accepted: bool, reason: Optional[str])
            If accepted is False, reason contains why the email was rejected
        """

        try:
            result = self.validate(email)
        except Exception as e:
            # If validation fails, we have a choice: fail open or fail closed
            # Failing open means accepting the user anyway
            # Failing closed means rejecting the user
            # This depends on your product requirements
            print(f'Warning: validation error for {email}: {str(e)}')
            return False, 'Email validation service temporarily unavailable'

        # Check the validation result
        if result['status'] != 'valid':
            return False, 'This email address does not exist'

        if result.get('is_spam_trap'):
            # Never accept spam traps
            return False, 'This email address is not valid'

        if result.get('is_disposable'):
            # For this application, we require permanent email addresses
            return False, 'Please use a permanent email address, not a temporary one'

        # All checks passed
        return True, None

    def validate_batch(self, emails: list) -&gt; Dict[str, Dict]:
        """
        Validate multiple email addresses.

        This is useful for cleaning up email lists before a mailing campaign.
        Note that this makes one API call per email, so for large lists
        you might want to implement batching or caching.

        Args:
            emails: List of email addresses

        Returns:
            Dictionary mapping email -&gt; validation result
        """
        results = {}
        for email in emails:
            try:
                results[email] = self.validate(email)
            except Exception as e:
                results[email] = {'error': str(e), 'status': 'error'}
        return results


# Example: Signup flow
def handle_user_signup(email: str, password: str) -&gt; Tuple[bool, str]:
    """
    Example signup function that uses email validation.
    """

    validator = EmailValidator(os.getenv('BILLIONVERIFY_API_KEY'))

    # Validate the email address
    accepted, reason = validator.should_accept_for_signup(email)

    if not accepted:
        return False, reason

    # If we get here, the email is valid and meets our requirements
    # Now proceed with account creation
    print(f'Email {email} validated. Creating user account...')

    # (Rest of signup logic would go here)
    return True, 'Account created successfully'


# Example usage
if __name__ == '__main__':
    # Test the validator
    test_emails = [
        'john@example.com',
        'test@tempmail.com',
        'admin@mycompany.com',
        'user@unregistered-domain.fake'
    ]

    validator = EmailValidator(os.getenv('BILLIONVERIFY_API_KEY'))

    for email in test_emails:
        accepted, reason = validator.should_accept_for_signup(email)
        print(f'\n{email}:')
        print(f'  Accepted: {accepted}')
        if reason:
            print(f'  Reason: {reason}')
</code></pre>
<p>This Python implementation is more structured than the Node.js version. We're using type hints to make the code self-documenting, we're building business logic directly into the validator class, and we're handling different types of errors separately. Notice how the <code>should_accept_for_signup</code> method encapsulates all the business rules—you can easily modify what gets accepted by changing the logic there.</p>
<h2>Handling Real-World Scenarios: Caching and Performance</h2>
<p>The approach above works, but if you're processing thousands of emails, you'll hit performance problems and API rate limits. That's why we need caching. The key insight is that the validation result for an email address is stable—if <a href="mailto:john@example.com">john@example.com</a> is valid today, it will be valid tomorrow. So we can cache results and only validate new addresses.</p>
<p>Here's how to implement intelligent caching with Redis:</p>
<pre><code class="language-python">import redis
import hashlib
import json
from typing import Dict, Optional

class CachedEmailValidator(EmailValidator):
    """
    Extended validator that caches results using Redis.

    This dramatically improves performance for applications that see
    duplicate email validations (which is most of them—many users reuse
    the same company email addresses).
    """

    def __init__(self, api_key: str, redis_host: str = 'localhost', redis_port: int = 6379):
        super().__init__(api_key)
        self.redis = redis.Redis(host=redis_host, port=redis_port, decode_responses=True)
        self.cache_ttl = 86400 * 7  # Cache for 7 days

    def _get_cache_key(self, email: str) -&gt; str:
        """Generate a consistent cache key for an email address."""
        # Use MD5 hash to keep keys reasonably short
        email_hash = hashlib.md5(email.lower().encode()).hexdigest()
        return f'email_validation:{email_hash}'

    def validate(self, email: str) -&gt; Dict:
        """
        Validate email, using cache when available.

        This override checks Redis cache first. If we have a recent result,
        we return it immediately without calling the API. If not, we call
        the API and cache the result for future lookups.
        """

        cache_key = self._get_cache_key(email)

        # Check cache first
        cached_result = self.redis.get(cache_key)
        if cached_result:
            result = json.loads(cached_result)
            result['cached'] = True  # Mark this as coming from cache
            return result

        # Cache miss: call the API
        result = super().validate(email)

        # Store in cache with TTL
        self.redis.setex(cache_key, self.cache_ttl, json.dumps(result))
        result['cached'] = False

        return result


# Example: Validating a signup form with caching
class SignupService:
    """Service that handles user signups with cached email validation."""

    def __init__(self, api_key: str):
        # This validator will cache results automatically
        self.validator = CachedEmailValidator(api_key)

    def register_user(self, email: str, password: str, name: str) -&gt; Dict:
        """
        Register a new user with full validation.

        The first time someone with this email address signs up, we call
        the API. If someone else with the same email tries to sign up later
        that same week, we use the cached result—much faster.
        """

        # Validate email (may use cache)
        result = self.validator.validate(email)

        # Check if we should accept this email
        if result['status'] != 'valid':
            return {
                'success': False,
                'error': 'Email address does not exist',
                'field': 'email'
            }

        if result.get('is_disposable'):
            return {
                'success': False,
                'error': 'Please use a permanent email address',
                'field': 'email'
            }

        # Email is valid, proceed with user creation
        # (In real code, you'd save to database here)

        return {
            'success': True,
            'user_id': '12345',
            'email': email,
            'name': name,
            'validation_cached': result.get('cached', False)
        }


# Performance comparison example
if __name__ == '__main__':
    import time

    service = SignupService(os.getenv('BILLIONVERIFY_API_KEY'))

    # First signup: will hit the API
    print('First signup (no cache):')
    start = time.time()
    result1 = service.register_user('alice@example.com', 'password123', 'Alice')
    elapsed1 = time.time() - start
    print(f'  Completed in {elapsed1:.3f} seconds')
    print(f'  Cached: {result1.get("validation_cached", False)}')

    # Second signup with same email: will use cache
    print('\nSecond signup (same email, cached):')
    start = time.time()
    result2 = service.register_user('alice@example.com', 'different_password', 'Alice Smith')
    elapsed2 = time.time() - start
    print(f'  Completed in {elapsed2:.3f} seconds')
    print(f'  Cached: {result2.get("validation_cached", False)}')
    print(f'  Speedup: {elapsed1/elapsed2:.1f}x faster')
</code></pre>
<p>The caching approach is crucial for production systems. In my experience, about 70-80% of email validations can be served from cache, which means the average response time drops from 300ms to under 10ms. For a signup flow, that's the difference between feeling responsive and feeling sluggish.</p>
<h2>Why This Matters in Production</h2>
<p>Let me walk you through what actually happens when you skip proper email validation. I've seen this play out at several companies:</p>
<p><strong>The Scenario:</strong> Your product sends transactional emails (welcome emails, password resets, notifications). You have 50,000 users. Unbeknownst to you, about 8% of those emails are invalid—a mix of typos, disposable addresses, and spam traps.</p>
<p><strong>Week 1:</strong> You send out a feature announcement email. 4,000 of your 50,000 emails bounce. Your email provider logs this.</p>
<p><strong>Week 2:</strong> You send another batch. Same problem. Your mail server's IP reputation starts to decline.</p>
<p><strong>Week 3:</strong> Gmail and Outlook flag your emails as spam. Not all of them—just a percentage at first. But it's growing. Your bounce rate is now 8%, well above the 5% safety threshold.</p>
<p><strong>Week 4:</strong> Major ISPs start filtering your emails to the spam folder by default. Legitimate users stop receiving your emails. They think your product is broken. Support tickets flood in. You've poisoned your sender reputation.</p>
<p><strong>The Fix:</strong> Validate emails before you add them to your list. That 8% becomes 0-1% bounces. Your sender reputation stays clean. Emails arrive reliably in the inbox. Users have a good experience.</p>
<p>The BillionVerify API does this validation in milliseconds. It's real-time, accurate, and with proper caching, it's also cost-effective.</p>
<h2>Getting Started</h2>
<p>Ready to build this into your product? Here's what you need to do:</p>
<p>First, sign up for BillionVerify at <a href="https://billionverify.com/auth/sign-up">https://billionverify.com/auth/sign-up</a>. You'll get 100 free credits to test with, no credit card required.</p>
<p>Then grab your API key from the dashboard and set it as an environment variable:</p>
<pre><code class="language-bash">export BILLIONVERIFY_API_KEY="your_api_key_here"
</code></pre>
<p>Now take one of the code examples above (choose Node.js or Python depending on your stack) and integrate it into your signup flow. Start by just logging the results—don't reject emails yet. This way you can see what your current user base looks like. After a week, you'll have good data on how many disposable addresses, catch-all domains, and spam traps your users are signing up with.</p>
<p>Once you see the data, you can decide which rules to enforce. Most applications reject spam traps immediately. Many reject disposable emails. Some allow catch-all domains, others don't. It depends on your business model.</p>
<p>The code examples in this article are complete and ready to use. Copy them, modify the business logic to match your requirements, and deploy. Your bounce rates will thank you.</p>
<p>Check out the full API documentation at <a href="https://billionverify.com/docs">https://billionverify.com/docs</a> for details on all the fields in the response and advanced options like batch validation and list cleaning.</p>
<p>Have questions about implementing this in your stack? Drop them in the comments and I'll help you troubleshoot.</p>
]]></content:encoded></item></channel></rss>