<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Feezan Khattak</title>
    <description>The latest articles on DEV Community by Feezan Khattak (@feezan_khattak).</description>
    <link>https://dev.to/feezan_khattak</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4122820%2F5f19fab6-d709-445a-940c-e5e026178bb7.png</url>
      <title>DEV Community: Feezan Khattak</title>
      <link>https://dev.to/feezan_khattak</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/feezan_khattak"/>
    <language>en</language>
    <item>
      <title>You Might Not Need a Vector Database</title>
      <dc:creator>Feezan Khattak</dc:creator>
      <pubDate>Wed, 30 Sep 2026 16:25:14 +0000</pubDate>
      <link>https://dev.to/feezan_khattak/you-might-not-need-a-vector-database-3pof</link>
      <guid>https://dev.to/feezan_khattak/you-might-not-need-a-vector-database-3pof</guid>
      <description>&lt;p&gt;A RAG pipeline is a lot of parts: a chunker, an embedding model, a vector database, a retriever, usually a reranker, and an eval harness to tell you when retrieval quietly got worse.&lt;/p&gt;

&lt;p&gt;Cache-augmented generation (CAG) deletes all of it. You put the &lt;strong&gt;entire&lt;/strong&gt; knowledge base in the prompt, cache it at the provider, and ask your question. No retrieval step, so no retrieval mistakes.&lt;/p&gt;

&lt;p&gt;That sounds like a stunt until you do the arithmetic. Then it turns into a straightforward rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CAG = the whole corpus in the prompt + prompt caching.&lt;/strong&gt; With a hosted API, that's the whole implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule: cache the corpus when it's less than ~10× what you'd retrieve.&lt;/strong&gt; Cached input costs about 0.1× normal input, so 10× the tokens costs roughly the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real constraint is the cache TTL, not the context window.&lt;/strong&gt; Entries expire in minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sparse traffic makes CAG the most expensive option&lt;/strong&gt;, not the cheapest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep RAG&lt;/strong&gt; for big corpora, fast-changing content, per-user permissions, and provenance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What CAG actually is
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://arxiv.org/abs/2412.15605" rel="noopener noreferrer"&gt;paper that named it&lt;/a&gt; — &lt;em&gt;Don't Do RAG: When Cache-Augmented Generation is All You Need for Knowledge Tasks&lt;/em&gt; — preloads the documents into the model's context, computes the KV cache once, and answers queries against that. No retriever at query time.&lt;/p&gt;

&lt;p&gt;Running your own model, you hold that cache in GPU memory. On a hosted API you get the same effect from &lt;strong&gt;prompt caching&lt;/strong&gt;: the provider stores the processed prefix and charges a fraction to reuse it.&lt;/p&gt;

&lt;p&gt;So CAG isn't a framework you install. It's a prompt layout:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Put the whole corpus at the &lt;strong&gt;front&lt;/strong&gt;, in a fixed byte-for-byte order.&lt;/li&gt;
&lt;li&gt;Mark the end of it as a cache breakpoint.&lt;/li&gt;
&lt;li&gt;Put the question &lt;strong&gt;after&lt;/strong&gt; the breakpoint, where it changes freely.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not the same as caching responses.&lt;/strong&gt; Response caching stores the &lt;em&gt;answer&lt;/em&gt; so an identical question skips the model. Prompt caching stores the &lt;em&gt;processed input&lt;/em&gt; so a &lt;em&gt;different&lt;/em&gt; question over the same documents skips re-reading them. They compose well — response cache in front, prompt cache behind.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The arithmetic
&lt;/h2&gt;

&lt;p&gt;Cached input tokens cost roughly &lt;strong&gt;0.1×&lt;/strong&gt; normal input tokens — that holds for &lt;a href="https://docs.claude.com/en/docs/build-with-claude/prompt-caching" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt; and, on current models, &lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;. Writing the cache costs about &lt;strong&gt;1.25×&lt;/strong&gt;, once.&lt;/p&gt;

&lt;p&gt;That one number gives you the decision. Let &lt;strong&gt;C&lt;/strong&gt; be your whole corpus in tokens and &lt;strong&gt;R&lt;/strong&gt; the tokens you'd send per query with retrieval:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caching the corpus costs about the same as retrieving when C ≈ 10 × R.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Below that, CAG is cheaper per query &lt;em&gt;and&lt;/em&gt; has no vector database to run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Worked through at $5 per million input tokens (so cached reads around $0.50 per million):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Tokens per query&lt;/th&gt;
&lt;th&gt;Cost per query&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No cache, whole 200K corpus every time&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;$1.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;CAG&lt;/strong&gt;, 200K corpus cached&lt;/td&gt;
&lt;td&gt;200,000 cached&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;RAG&lt;/strong&gt;, 6K of retrieved chunks&lt;/td&gt;
&lt;td&gt;6,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.03&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;CAG&lt;/strong&gt;, 20K corpus cached&lt;/td&gt;
&lt;td&gt;20,000 cached&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.01&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 200K tokens, retrieval still wins on tokens. At 20K — a product manual, an API reference, a policy handbook — caching the lot is cheaper than retrieving part of it, and you delete the entire pipeline.&lt;/p&gt;

&lt;p&gt;And the table leaves out everything RAG costs besides tokens: a vector database to run, an embedding model called on every write and every query, a chunking strategy to tune, a reranker, and an eval harness to catch silent regressions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TTL is the real constraint
&lt;/h2&gt;

&lt;p&gt;Everyone asks whether the corpus &lt;em&gt;fits&lt;/em&gt;. Current flagship models take a million tokens; most corpora that matter fit. The question that actually decides it is &lt;strong&gt;how often you ask&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Cache lifetime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;5 minutes by default, 1-hour TTL at 2× the write price. Every read refreshes the timer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI (GPT-5.6+)&lt;/td&gt;
&lt;td&gt;A prefix stays eligible for about 30 minutes after its last use.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini&lt;/td&gt;
&lt;td&gt;Implicit caching on by default for 2.5 and newer.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the break-even is about traffic shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gap between queries sharing the corpus&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Under 5 minutes&lt;/td&gt;
&lt;td&gt;Every query refreshes the entry. Pay the write once, read cheaply forever. &lt;strong&gt;CAG shines.&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5–60 minutes&lt;/td&gt;
&lt;td&gt;Use a longer TTL or re-warm on a schedule. The doubled write price needs a few reads to pay off.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hours apart&lt;/td&gt;
&lt;td&gt;Every query is a cold write at 1.25–2× full price. &lt;strong&gt;CAG is the most expensive option available.&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A trap worth knowing:&lt;/strong&gt; every provider has a minimum cacheable prefix, and below it caching is skipped with &lt;strong&gt;no error&lt;/strong&gt; — you just quietly pay full price forever. Anthropic's is model-dependent (512–4096 tokens), OpenAI's is 1,024 on GPT-5.6+, Gemini needs 2,048–4,096. Check the usage fields on the response, not the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layout in Spring Boot
&lt;/h2&gt;

&lt;p&gt;Corpus in the system prompt with a cache breakpoint; question in the user message, after it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HandbookAnswerService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AnthropicClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AnthropicOkHttpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromEnv&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Built once at startup, in a fixed order. Any byte that changes here&lt;/span&gt;
    &lt;span class="c1"&gt;// invalidates the cache for every request that follows.&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;corpus&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;MessageCreateParams&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MessageCreateParams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"claude-opus-5"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;maxTokens&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4096L&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;systemOfTextBlockParams&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                        &lt;span class="nc"&gt;TextBlockParam&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corpus&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cacheControl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CacheControlEphemeral&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CacheControlEphemeral&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Ttl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TTL_1H&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                                        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addUserMessage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// after the breakpoint - varies freely&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;Message&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// The only proof that caching is working.&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache write={} read={} uncached={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;cacheCreationInputTokens&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;cacheReadInputTokens&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;inputTokens&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details do most of the work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The corpus never varies.&lt;/strong&gt; Caching is a prefix match on exact bytes. A timestamp, a reordered map, a &lt;code&gt;"user: Feezan"&lt;/code&gt; line — any of those changes the prefix and every request after it misses. Sort deterministically, interpolate nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The question sits after the cached block.&lt;/strong&gt; Putting the breakpoint at the very end is the common mistake: each unique question writes its own entry that nothing ever reads, so you pay the write premium on every call.&lt;/p&gt;

&lt;p&gt;Log those usage fields from day one. If the write count is large on &lt;em&gt;every&lt;/em&gt; call, something upstream is rewriting your prefix — and this failure is invisible, because requests still succeed and only the bill changes. Assert on it in a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  When RAG still wins
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The corpus doesn't fit, or barely fits.&lt;/strong&gt; Above a few hundred thousand tokens you're re-reading everything for every question, and long-context accuracy isn't free either — measure it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It changes constantly.&lt;/strong&gt; One edited document changes the prefix and rewrites the whole cache at the write premium. A handbook revised weekly is ideal; a ticket system is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different users see different documents.&lt;/strong&gt; Per-user filtering means a separate cached prefix per permission set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need provenance.&lt;/strong&gt; RAG hands you the chunks, so you can cite them. With everything in context, you're trusting the model to cite honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volume is large and steady.&lt;/strong&gt; At high volume, retrieval's smaller prompt wins again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The hybrid is usually the answer at scale:&lt;/strong&gt; cache the stable material (schemas, policies, API reference, instructions) as a fixed prefix, and retrieve only the volatile part after the breakpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RAG is a solution to a size problem.&lt;/strong&gt; If your knowledge base isn't actually that big, you can skip the pipeline, cache the whole thing, and spend the time you saved on answer quality instead of retriever tuning.&lt;/p&gt;

&lt;p&gt;Count your corpus before you build. If it's under about ten times what your retriever would send per query, try the boring version first.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about backend engineering and payments at &lt;a href="https://feezankhattak.com" rel="noopener noreferrer"&gt;feezankhattak.com&lt;/a&gt;. The &lt;a href="https://feezankhattak.com/blog/rag-vs-cag" rel="noopener noreferrer"&gt;longer version of this post&lt;/a&gt; has the full gotchas list, and I build free in-browser developer tools — no sign-up, nothing uploaded.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>springboot</category>
      <category>programming</category>
    </item>
    <item>
      <title>If Your Spring AI Streams Die After 60 Seconds, It's Not Your Network</title>
      <dc:creator>Feezan Khattak</dc:creator>
      <pubDate>Tue, 29 Sep 2026 03:24:33 +0000</pubDate>
      <link>https://dev.to/feezan_khattak/if-your-spring-ai-streams-die-after-60-seconds-its-not-your-network-1bap</link>
      <guid>https://dev.to/feezan_khattak/if-your-spring-ai-streams-die-after-60-seconds-its-not-your-network-1bap</guid>
      <description>&lt;p&gt;If you're on Spring AI 2.0.1 and your streaming calls die after about a minute with &lt;code&gt;OpenAIIoException: Stream failed&lt;/code&gt;, stop debugging your proxy.&lt;/p&gt;

&lt;p&gt;Since 2.0.1, &lt;strong&gt;every request carried a hard-coded 60-second per-call timeout that overrode whatever you configured.&lt;/strong&gt; &lt;code&gt;spring.ai.openai.timeout&lt;/code&gt; and &lt;code&gt;spring.ai.openai.chat.timeout&lt;/code&gt; were ignored. Any streaming turn longer than a minute was killed, no matter what you set.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://spring.io/blog/2026/09/25/spring-ai-2-1-0-M1-available-now" rel="noopener noreferrer"&gt;Spring AI 2.1.0-M1&lt;/a&gt;, released 25 September 2026, fixes it. That alone is worth the ten minutes — but the release also lands the change that the next two versions are built on.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 60-second timeout regression is fixed.&lt;/strong&gt; Your configured timeout is honoured again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message parts&lt;/strong&gt;: messages are now an ordered list of typed parts, so reasoning, tool calls and media round-trip in the order the model produced them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI Responses endpoint&lt;/strong&gt;: one property. Needed if you want tool calling &lt;em&gt;and&lt;/em&gt; reasoning effort on a current OpenAI flagship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;VectorStore.upsert()&lt;/code&gt;&lt;/strong&gt;: write embeddings you computed somewhere else, with stable ids that make re-runs safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a milestone.&lt;/strong&gt; APIs can change, and it's built against Spring Boot 4.2.0-M2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fix, first
&lt;/h2&gt;

&lt;p&gt;The generic lesson is one I keep relearning in payments work: &lt;strong&gt;the shortest timeout in the chain wins, and it's usually one you didn't set.&lt;/strong&gt; A load balancer, a gateway, an SDK default — or, here, a framework regression.&lt;/p&gt;

&lt;p&gt;When a call dies at a suspiciously round number of seconds, that's rarely a coincidence. 60 seconds, 29 seconds, 30 seconds: those are configured limits, not network weather.&lt;/p&gt;

&lt;p&gt;Three other fixes in the same release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenAI strict-mode tool schemas are backfilled with &lt;code&gt;additionalProperties: false&lt;/code&gt; at every object level, which fixes strict mode for MCP or hand-written schemas that didn't come from Spring AI's own generator.&lt;/li&gt;
&lt;li&gt;Bedrock messages carrying media but no text no longer send an empty text block, which the Converse API rejected with a 400.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TextReader&lt;/code&gt; closes its stream instead of leaking a file descriptor per document.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Message parts: the actual headline
&lt;/h2&gt;

&lt;p&gt;Until now, a Spring AI message was &lt;strong&gt;text plus side lists&lt;/strong&gt; for tool calls and media. That can't represent what current models return: reasoning interleaved with tool calls, text between two images, or provider-specific blocks that have to go back verbatim next turn.&lt;/p&gt;

&lt;p&gt;Now &lt;code&gt;AssistantMessage&lt;/code&gt;, &lt;code&gt;UserMessage&lt;/code&gt; and &lt;code&gt;ToolResponseMessage&lt;/code&gt; hold an ordered list of &lt;code&gt;MessagePart&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TextPart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;plain text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ReasoningPart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the model's reasoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ToolCallPart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a requested tool call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ToolResultPart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the result you sent back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MediaPart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;an image or file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UnknownPart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raw JSON for a block the adapter doesn't model yet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;UserMessage&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UserMessage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;part&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TextPart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"What changed between these two charts?"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;part&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaPart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;firstChart&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;part&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaPart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secondChart&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;AssistantMessage&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatModel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;call&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Prompt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;)).&lt;/span&gt;&lt;span class="na"&gt;getResult&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getOutput&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ReasoningPart&lt;/span&gt; &lt;span class="n"&gt;reasoning&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getReasoning&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Reasoning: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;reasoning&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getText&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than they look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;OpaquePayload&lt;/code&gt;&lt;/strong&gt; on reasoning and tool-call parts holds things that must be replayed unchanged — an Anthropic thinking signature, a Gemini thought signature. Without a defined home for those, a framework either drops them (and the model loses its train of thought mid tool loop) or grows a provider-specific hack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;UnknownPart&lt;/code&gt;&lt;/strong&gt; keeps the raw JSON of blocks the adapter doesn't understand yet. Nothing is silently dropped when a provider ships a new block type faster than the framework models it.&lt;/p&gt;

&lt;p&gt;Your existing code keeps working: &lt;code&gt;getText()&lt;/code&gt;, &lt;code&gt;getMedia()&lt;/code&gt; and &lt;code&gt;getToolCalls()&lt;/code&gt; are now views over the parts, and the old constructors still produce the legacy order. Streaming subscribers reading &lt;code&gt;getText()&lt;/code&gt; see the same deltas as before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two caveats the release is explicit about:&lt;/strong&gt; only the new &lt;code&gt;OpenAiResponsesChatModel&lt;/code&gt; produces and consumes parts natively in M1 (the rest get refactored in RC1), and chat memory repositories don't persist parts yet — so &lt;strong&gt;only &lt;code&gt;InMemoryChatMemoryRepository&lt;/code&gt; preserves reasoning across turns.&lt;/strong&gt; With a persistent repository the conversation works, but the model reasons from scratch each turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OpenAI Responses endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# chat-completions (default) | responses
&lt;/span&gt;&lt;span class="py"&gt;spring.ai.openai.chat.api&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;responses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of your &lt;code&gt;spring.ai.openai&lt;/code&gt; config stays where it is.&lt;/p&gt;

&lt;p&gt;The reason isn't novelty, it's correctness: &lt;strong&gt;from GPT-5.4 onward, Chat Completions doesn't support tool calling combined with a reasoning effort other than &lt;code&gt;none&lt;/code&gt;.&lt;/strong&gt; Responses does. Building an agent on a current OpenAI flagship? That's the endpoint.&lt;/p&gt;

&lt;p&gt;It's built on message parts, so encrypted reasoning rides in a &lt;code&gt;ReasoningPart&lt;/code&gt; and goes back verbatim across tool calls — the model keeps reasoning through the loop. It's deliberately stateless (every call sends the whole &lt;code&gt;Prompt&lt;/code&gt;), so &lt;code&gt;ChatMemory&lt;/code&gt;, advisors and RAG behave exactly as before. &lt;code&gt;HostedTool&lt;/code&gt; switches on OpenAI's server-side tools: web search, file search, code interpreter, remote MCP, image generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-computed embeddings
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;VectorStore.add()&lt;/code&gt; always computed embeddings with the store's own model. That's wrong whenever you already have vectors — a batch API ran overnight at a discount, another team owns the pipeline, a multimodal model embedded an image, or you're migrating from a system that exports text and vectors together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;...;&lt;/span&gt; &lt;span class="c1"&gt;// computed elsewhere&lt;/span&gt;
&lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"8f14e45f-ceea-467a-9a3e-5b1c2d6f7a90"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"some text"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"source"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"manual"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;vectorStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EmbeddedDocument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name is the feature. &lt;strong&gt;Writing the same id again replaces the row&lt;/strong&gt;, so an ingestion job with stable ids can be re-run after a failure instead of duplicating everything — the difference between an idempotent pipeline and a nightly job nobody dares restart.&lt;/p&gt;

&lt;p&gt;Supported here: &lt;strong&gt;pgvector, Redis, Elasticsearch, Qdrant.&lt;/strong&gt; Other stores throw until they opt in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you take it?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Take it now if&lt;/th&gt;
&lt;th&gt;Wait for GA if&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;You're hitting the 60-second stream timeout&lt;/td&gt;
&lt;td&gt;2.0.x is stable for you with no symptoms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need tool calling &lt;strong&gt;and&lt;/strong&gt; reasoning effort on a current OpenAI model&lt;/td&gt;
&lt;td&gt;You need reasoning persisted across turns (RC1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You want to write vectors computed elsewhere&lt;/td&gt;
&lt;td&gt;Your vector store isn't one of the four&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You can move to Spring Boot 4.2&lt;/td&gt;
&lt;td&gt;You're pinned to an earlier Boot line&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What RC1 is building on this
&lt;/h2&gt;

&lt;p&gt;Message parts are the foundation for most of what's next: parts across all providers, session management moving into core (with repositories that persist the full part structure, so reasoning finally survives persistent storage), and MCP 2026-07-28 support. Spring AI Agents ships as a separate experimental project in November 2026, planned to merge into Spring AI 3.0 mid-2027.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The one thing to do after reading this:&lt;/strong&gt; grep your logs for &lt;code&gt;Stream failed&lt;/code&gt;. If it's there, you've just found out why.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I write about backend engineering and payments at &lt;a href="https://feezankhattak.com" rel="noopener noreferrer"&gt;feezankhattak.com&lt;/a&gt; — the &lt;a href="https://feezankhattak.com/blog/spring-ai-2-1-0-m1" rel="noopener noreferrer"&gt;longer version of this post&lt;/a&gt; has the full gotchas list, and I build free in-browser developer tools, no sign-up and nothing uploaded.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>There's a New AI Model That Can't Write a Single Word</title>
      <dc:creator>Feezan Khattak</dc:creator>
      <pubDate>Sat, 26 Sep 2026 15:42:59 +0000</pubDate>
      <link>https://dev.to/feezan_khattak/theres-a-new-ai-model-that-cant-write-a-single-word-4j0l</link>
      <guid>https://dev.to/feezan_khattak/theres-a-new-ai-model-that-cant-write-a-single-word-4j0l</guid>
      <description>&lt;p&gt;Count the LLM calls in your backend that end in a parser.&lt;/p&gt;

&lt;p&gt;Not the ones that write an email or summarise a document — the ones where you asked a chat model a question whose answer is a &lt;code&gt;boolean&lt;/code&gt;, a label, or a number between 1 and 5. Then you wrote a prompt template, a JSON schema, a parser, and a retry for the time it replied &lt;em&gt;"Sure! Here's my assessment:"&lt;/em&gt; instead of &lt;code&gt;{"urgent": true}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" rel="noopener noreferrer"&gt;Jev&lt;/a&gt;&lt;/strong&gt;, released by TypeSafe AI on 15 September 2026, deletes that whole layer — by refusing to generate text at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Jev answers &lt;strong&gt;typed questions&lt;/strong&gt;: yes/no, pick-one, score-on-a-rubric. No prose, ever.&lt;/li&gt;
&lt;li&gt;Every answer comes back with a &lt;strong&gt;probability&lt;/strong&gt;, and most with a &lt;strong&gt;confidence&lt;/strong&gt; as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No prompt template, no schema, no parser, no repair step.&lt;/strong&gt; The shape is the request.&lt;/li&gt;
&lt;li&gt;TypeSafe quotes &lt;strong&gt;$0.042 per million input tokens, free output, 70–500 ms&lt;/strong&gt;. Cheap enough to check &lt;em&gt;every&lt;/em&gt; expensive call instead of sampling.&lt;/li&gt;
&lt;li&gt;It does &lt;strong&gt;not&lt;/strong&gt; replace your chat model. It decides what to do with what the chat model produced.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What a "System One model" is
&lt;/h2&gt;

&lt;p&gt;The name refers to fast, intuitive thinking as opposed to slow deliberation. TypeSafe describes the target as "a judgment a knowledgeable person makes in a second given the right context."&lt;/p&gt;

&lt;p&gt;Three things matter to a backend engineer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It isn't autoregressive.&lt;/strong&gt; No token-by-token generation, which is why answers arrive in a few hundred milliseconds instead of seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output is type-constrained.&lt;/strong&gt; You don't ask for JSON and hope. The type is part of the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answers carry calibrated probabilities&lt;/strong&gt;, not just values.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trade is absolute: it gives up string generation entirely. It cannot write you a sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The entire API surface
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primitive&lt;/th&gt;
&lt;th&gt;You ask&lt;/th&gt;
&lt;th&gt;You get back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Noul&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a yes/no question&lt;/td&gt;
&lt;td&gt;a truth value in &lt;code&gt;[0, 1]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Choice&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pick one label&lt;/td&gt;
&lt;td&gt;the label, a probability per option, and a confidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Score&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;place on an ordered rubric&lt;/td&gt;
&lt;td&gt;a continuous value, the legend, per-level probabilities, and a confidence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's it. Three shapes.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Noul&lt;/code&gt; has no separate confidence because the value &lt;em&gt;is&lt;/em&gt; the certainty — &lt;code&gt;0.5&lt;/code&gt; means undecided. A &lt;code&gt;Score&lt;/code&gt; is continuous, so &lt;code&gt;1.1&lt;/code&gt; on a three-level rubric means "just past the middle level", which is what makes a threshold like &lt;code&gt;2.0&lt;/code&gt; mean something.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Spring Boot
&lt;/h2&gt;

&lt;p&gt;There's a &lt;a href="https://spring.io/blog/2026/09/21/spring-ai-typesafe-structured-judgment/" rel="noopener noreferrer"&gt;Spring AI Community integration&lt;/a&gt;, version 0.1.0 on Maven Central:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springaicommunity&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-ai-starter-typesafe&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;version&amp;gt;&lt;/span&gt;0.1.0&lt;span class="nt"&gt;&amp;lt;/version&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;TYPESAFE_API_KEY&lt;/code&gt;, and auto-configuration gives you a &lt;code&gt;TypeSafeClient&lt;/code&gt;. Here it is triaging a payment complaint — the kind of ticket I spend my working life near:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;SystemOneResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;typeSafeClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;systemOne&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Customer says: my card was charged twice for order 4417 but only one order shows up."&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"duplicate_charge"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Noul&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Is the customer reporting being charged more than once for one purchase?"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;whenTrue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The customer describes multiple charges for a single order"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;whenFalse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The customer describes a single charge, or a different problem"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="s"&gt;"queue"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Choice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Which queue should handle this?"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;option&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payments-ops"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Duplicate charges, stuck payments, reconciliation mismatches"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;option&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"disputes"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;     &lt;span class="s"&gt;"Chargebacks, evidence submission, representments"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;option&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;      &lt;span class="s"&gt;"Invoices, refunds, subscription changes"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
            &lt;span class="s"&gt;"urgency"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Score&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"How urgent is this for the customer?"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"Can wait"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Needs attention today"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Money is missing right now"&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;

&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;duplicate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;noulValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"duplicate_charge"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;choiceValue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"queue"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;certainty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"queue"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three questions, three typed answers, one call, no parsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write real option descriptions.&lt;/strong&gt; The Spring post measured the difference: the same ticket with bare labels instead of described options dropped confidence from &lt;strong&gt;0.82 to 0.60&lt;/strong&gt;. The descriptions are how the model learns what your labels mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea worth stealing: confidence is a routing decision
&lt;/h2&gt;

&lt;p&gt;This is the part that changes how you write the calling code, even if you never use Jev.&lt;/p&gt;

&lt;p&gt;The answer tells you &lt;strong&gt;what&lt;/strong&gt;. The confidence tells you &lt;strong&gt;whether to act on it unattended&lt;/strong&gt;. One number can't carry both — which is exactly why the classic "rate this 1–5" prompt fails: one catastrophic flaw gets averaged into a middling score.&lt;/p&gt;

&lt;p&gt;So you get three branches, not two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;certainty&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;humanReview&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// undecided is not "wrong"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duplicate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;investigateDuplicate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// confident and true&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Not unattended" becomes a first-class outcome instead of an error case. Anyone who has built a fraud queue or a dispute workflow already recognises this shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and latency — and who is claiming what
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TypeSafe&lt;/td&gt;
&lt;td&gt;$0.042 / MTok input, output free ("too cheap to meter")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeSafe&lt;/td&gt;
&lt;td&gt;70–500 ms end to end; 40–200× faster, 40–400× cheaper than frontier LLMs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeSafe's cookbook&lt;/td&gt;
&lt;td&gt;a 14-question call at &lt;strong&gt;$0.000043 and 111 ms&lt;/strong&gt; vs ~$0.0018 / 1.8 s for a small chat model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring blog author, measured on a laptop&lt;/td&gt;
&lt;td&gt;median &lt;strong&gt;275 ms&lt;/strong&gt; for one question, &lt;strong&gt;310 ms&lt;/strong&gt; for three&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The multipliers are vendor benchmarks on tasks the vendor chose. Treat them as an order of magnitude, not as a line in a business case — but note that two extra questions cost the Spring author 35 ms, because everything is answered against one read of the state.&lt;/p&gt;

&lt;p&gt;The real consequence isn't saving money on calls you already make. It's that at thousandths of a cent, a check can run in front of &lt;strong&gt;every&lt;/strong&gt; expensive call rather than on a sample: a gate on a model cascade, a relevance filter before a long-context generation, a verification pass on output you currently ship unchecked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that will bite you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No streaming.&lt;/strong&gt; Nothing is generated token by token, so there is nothing to stream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State must be a string, object, array or null.&lt;/strong&gt; A bare number or boolean returns a &lt;strong&gt;422&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-document work is one call per document.&lt;/strong&gt; Reranking a top-20 list is twenty calls — filter first, rank the survivors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Choice&lt;/code&gt; always names a winner&lt;/strong&gt;, because the probabilities sum to one. If "none of these" is a real answer, ask it as a separate &lt;code&gt;Noul&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Early access, US West Coast.&lt;/strong&gt; That's a real latency budget if you serve from elsewhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What a typed output does &lt;em&gt;not&lt;/em&gt; fix
&lt;/h2&gt;

&lt;p&gt;A typed answer can't be malformed. That genuinely kills a class of bug: the parse failure, the apology instead of an answer, the &lt;code&gt;"yes, but..."&lt;/code&gt; where you expected a boolean.&lt;/p&gt;

&lt;p&gt;It does not make the answer right. A calibrated probability is still a probability. In payments, where I work, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep it out of the authorization path.&lt;/strong&gt; A decision that moves money needs a deterministic rule and an audit trail, not a model's 0.91.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything you must justify to a regulator or card scheme&lt;/strong&gt; needs a reason. "The model scored it 0.87" is not one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threshold choice is a product decision.&lt;/strong&gt; Where you set the confidence floor decides how much work lands on a human.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this leaves us
&lt;/h2&gt;

&lt;p&gt;Most backends have been using one tool for two jobs, because there was only one tool. Chat models write. System One models decide.&lt;/p&gt;

&lt;p&gt;Go and look for the calls in your codebase that end in a parser. That's the list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about payments and backend engineering at &lt;a href="https://feezankhattak.com" rel="noopener noreferrer"&gt;feezankhattak.com&lt;/a&gt;. The &lt;a href="https://feezankhattak.com/blog/jev-system-one-models-java" rel="noopener noreferrer"&gt;longer version of this post&lt;/a&gt; covers the guardrail and reranking integrations, and I build free in-browser tools for backend developers — no sign-up, nothing uploaded.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Your Payment Webhook Handler Probably Processes Duplicates</title>
      <dc:creator>Feezan Khattak</dc:creator>
      <pubDate>Sat, 19 Sep 2026 05:53:22 +0000</pubDate>
      <link>https://dev.to/feezan_khattak/your-payment-webhook-handler-probably-processes-duplicates-3eno</link>
      <guid>https://dev.to/feezan_khattak/your-payment-webhook-handler-probably-processes-duplicates-3eno</guid>
      <description>&lt;p&gt;The customer's browser reaches your success page and calls your API: "the payment worked, please ship my order."&lt;/p&gt;

&lt;p&gt;Anyone can make that call. With curl. For free.&lt;/p&gt;

&lt;p&gt;The fix everyone knows is "use webhooks." The part fewer people get right is what happens next: webhooks arrive &lt;strong&gt;more than once&lt;/strong&gt;, &lt;strong&gt;out of order&lt;/strong&gt;, and the most common duplicate check in Spring Boot doesn't catch duplicates at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Never fulfil an order because the browser said so. Only a &lt;strong&gt;verified webhook&lt;/strong&gt; can tell you money moved.&lt;/li&gt;
&lt;li&gt;Verify the signature over the &lt;strong&gt;raw request body&lt;/strong&gt;, before parsing.&lt;/li&gt;
&lt;li&gt;Deduplicate with &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; — &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;save()&lt;/code&gt; and catch the exception. In Spring Data JPA that version quietly does nothing.&lt;/li&gt;
&lt;li&gt;One change can produce &lt;strong&gt;two events with different ids&lt;/strong&gt;. Make applying a change twice harmless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store, then return 200.&lt;/strong&gt; Do the slow work later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The browser can't be trusted — even when it's honest
&lt;/h2&gt;

&lt;p&gt;Two separate problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's forgeable.&lt;/strong&gt; &lt;code&gt;POST /orders/123/confirm&lt;/code&gt; can be sent by anyone who reads your JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's unreliable.&lt;/strong&gt; The customer closes the tab, loses signal on the redirect, or finishes 3-D Secure and the callback never lands. They've paid, and you never heard about it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The browser can &lt;em&gt;hint&lt;/em&gt; — show a spinner, an optimistic message. It must never be what triggers fulfilment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the signature over raw bytes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/webhooks/stripe"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;                        &lt;span class="c1"&gt;// raw String, not a DTO&lt;/span&gt;
        &lt;span class="nd"&gt;@RequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Stripe-Signature"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;constructEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webhookSecret&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SignatureVerificationException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rejected webhook with bad signature"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;badRequest&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature covers the &lt;strong&gt;exact bytes&lt;/strong&gt; the provider sent. Bind the body to a DTO first and you've lost them — re-serialising produces different JSON (key order, whitespace, number formatting), verification fails, and someone "temporarily" turns it off. &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe's docs&lt;/a&gt; say it plainly: any change to the raw body makes verification fail.&lt;/p&gt;

&lt;p&gt;The signature also covers a timestamp, which stops a captured request being replayed later. Stripe's libraries reject anything older than &lt;strong&gt;five minutes&lt;/strong&gt; by default. Keep your server clock synced, and never set the tolerance to &lt;code&gt;0&lt;/code&gt; — that disables the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The duplicate check that doesn't check
&lt;/h2&gt;

&lt;p&gt;Delivery is &lt;strong&gt;at-least-once&lt;/strong&gt;. You'll get the same event again after a timeout, after your 500, and occasionally for no visible reason. So you record processed event ids — and this is what most of us write first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;processedEvents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAndFlush&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProcessedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataIntegrityViolationException&lt;/span&gt; &lt;span class="n"&gt;duplicate&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// already processed&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;applyEffects&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks right. It fails in two different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It never sees the duplicate.&lt;/strong&gt; &lt;code&gt;ProcessedEvent&lt;/code&gt;'s id is the provider's event id — assigned by you, never &lt;code&gt;null&lt;/code&gt;. With no &lt;code&gt;@Version&lt;/code&gt; field, Spring Data can't tell it's new, so &lt;a href="https://docs.spring.io/spring-data/jpa/reference/jpa/entity-persistence.html" rel="noopener noreferrer"&gt;&lt;code&gt;save()&lt;/code&gt; calls &lt;code&gt;merge()&lt;/code&gt; instead of &lt;code&gt;persist()&lt;/code&gt;&lt;/a&gt;. Merge loads the existing row and &lt;em&gt;updates&lt;/em&gt; it. No exception, and the repeated event gets processed again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. When it does throw, you can't continue.&lt;/strong&gt; Two deliveries racing each other can both reach the insert, and one does get the duplicate-key error. But that exception passes through the repository's own transactional proxy, which marks your transaction rollback-only — and PostgreSQL has already aborted the transaction anyway. You catch it and return normally, and Spring throws &lt;code&gt;UnexpectedRollbackException&lt;/code&gt; at commit. That's a 500, which the provider retries — for up to three days, in Stripe's case.&lt;/p&gt;

&lt;h3&gt;
  
  
  What works: let the database answer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;processed_webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processed_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ProcessedEventRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Repository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProcessedEvent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// 1 = we claimed it, 0 = already processed. Never throws on a duplicate.&lt;/span&gt;
    &lt;span class="nd"&gt;@Modifying&lt;/span&gt;
    &lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""
            INSERT INTO processed_webhook_events (event_id, processed_at)
            VALUES (:eventId, now())
            ON CONFLICT (event_id) DO NOTHING
            """&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nativeQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;markProcessed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"eventId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processedEvents&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markProcessed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// already processed&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;applyEffects&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// same transaction: if this throws, the claim rolls back too&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things this gets right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates are a normal result, not an error.&lt;/strong&gt; The transaction stays usable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent deliveries are safe.&lt;/strong&gt; The second insert waits for the first transaction to finish, then inserts nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half-processed events aren't marked done.&lt;/strong&gt; The claim commits or rolls back &lt;em&gt;with&lt;/em&gt; the effects, so a failure gets retried properly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I checked the SQL behaviour for this post on PostgreSQL (via PGlite): the first delivery claims one row, a duplicate returns zero with no error and leaves the transaction usable, a plain &lt;code&gt;INSERT&lt;/code&gt; duplicate aborts the transaction, and a rolled-back claim is processed again on retry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event ids aren't the whole story
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe notes&lt;/a&gt; that it sometimes generates &lt;strong&gt;two separate event objects&lt;/strong&gt; for the same change — with different ids. Your id table won't catch those. Which leads to the more robust idea...&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle events as target states, not steps
&lt;/h2&gt;

&lt;p&gt;Events don't arrive in order either. Stripe's own example: creating a subscription can produce &lt;code&gt;customer.subscription.created&lt;/code&gt;, &lt;code&gt;invoice.created&lt;/code&gt;, &lt;code&gt;invoice.paid&lt;/code&gt; and &lt;code&gt;charge.created&lt;/code&gt; in any order. And don't sort by the event's &lt;code&gt;created&lt;/code&gt; field — it's in whole seconds, so events can tie.&lt;/p&gt;

&lt;p&gt;So a &lt;code&gt;succeeded&lt;/code&gt; event should mean "this payment is now captured", not "advance one step". And guard against moving backwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;PaymentState&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;INITIATED&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;REQUIRES_ACTION&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;AUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;CAPTURED&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="no"&gt;REFUNDED&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// every constant must declare one - the compiler enforces it&lt;/span&gt;

    &lt;span class="nc"&gt;PaymentState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;applyState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentState&lt;/span&gt; &lt;span class="n"&gt;incoming&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;incoming&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getState&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// stale, out of order, or a repeat - ignore it&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;incoming&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small choices matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rank lives on the enum, not in a &lt;code&gt;Map&lt;/code&gt;.&lt;/strong&gt; A map returns &lt;code&gt;null&lt;/code&gt; for a state someone adds next year, and the unboxing throws inside your webhook handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repeats become harmless.&lt;/strong&gt; A second &lt;code&gt;CAPTURED&lt;/code&gt; is ignored whatever its event id — which covers the two-events-one-change case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure states don't fit on a line: a failed payment can still succeed when the customer tries another card. For those, fetch the object's current state from the provider's API. The API is the source of truth; events are notifications.&lt;/p&gt;

&lt;h2&gt;
  
  
  Return 200 fast — but only after storing
&lt;/h2&gt;

&lt;p&gt;Providers treat a slow response as a failure and retry. Do fulfilment, emails and invoices inline, and you've built your own retry storm.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/webhooks/stripe"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                   &lt;span class="nd"&gt;@RequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Stripe-Signature"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// fast&lt;/span&gt;
    &lt;span class="n"&gt;inbox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// INSERT ... ON CONFLICT (event_id) DO NOTHING&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// ack - a duplicate still gets a 200&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then process the inbox asynchronously, using the &lt;code&gt;markProcessed&lt;/code&gt; claim above.&lt;/p&gt;

&lt;p&gt;The status code cuts both ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Return a &lt;strong&gt;500&lt;/strong&gt; on a transient problem and you get a free retry.&lt;/li&gt;
&lt;li&gt;Return a &lt;strong&gt;200&lt;/strong&gt; on an event you &lt;em&gt;didn't&lt;/em&gt; store and it's gone for good — the provider thinks it was delivered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: acknowledge once the event is &lt;strong&gt;durably stored&lt;/strong&gt;, not once it's fully processed. And make the inbox insert conflict-safe too, or a duplicate delivery hits the primary key and gets a 500.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Nothing fulfils on a browser-side success signal&lt;/li&gt;
&lt;li&gt;[ ] Webhook body bound as a raw &lt;code&gt;String&lt;/code&gt;, verified before parsing&lt;/li&gt;
&lt;li&gt;[ ] Signature timestamp tolerance left on (not &lt;code&gt;0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;[ ] Event ids claimed with &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt;, in the same transaction as the effects&lt;/li&gt;
&lt;li&gt;[ ] No &lt;code&gt;save()&lt;/code&gt;-and-catch for dedupe&lt;/li&gt;
&lt;li&gt;[ ] Handlers set a target state and ignore regressions&lt;/li&gt;
&lt;li&gt;[ ] Store, then 200; process asynchronously; duplicates still get a 200&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;livemode&lt;/code&gt; checked, so test events can't touch production&lt;/li&gt;
&lt;li&gt;[ ] Amounts and order ids checked against your own records&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Your provider's webhook is the only party that can tell you money moved.&lt;/strong&gt; Verify it, expect it twice, expect it out of order — and let the database, not an exception handler, decide what's a duplicate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 3 of a series on building payment systems as a backend engineer. The &lt;a href="https://feezankhattak.com/blog/payment-webhooks-never-trust-the-client" rel="noopener noreferrer"&gt;full version on my site&lt;/a&gt; also covers validating event amounts against your own records. If you're debugging signatures right now, there's a free &lt;a href="https://feezankhattak.com/tools/webhook-signature-verifier" rel="noopener noreferrer"&gt;webhook signature verifier&lt;/a&gt; that runs entirely in your browser — nothing is uploaded.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>security</category>
      <category>backend</category>
    </item>
    <item>
      <title>Your Payment Timed Out. Was the Customer Charged?</title>
      <dc:creator>Feezan Khattak</dc:creator>
      <pubDate>Tue, 15 Sep 2026 17:28:23 +0000</pubDate>
      <link>https://dev.to/feezan_khattak/your-payment-timed-out-was-the-customer-charged-151h</link>
      <guid>https://dev.to/feezan_khattak/your-payment-timed-out-was-the-customer-charged-151h</guid>
      <description>&lt;p&gt;Every other payment failure tells you something.&lt;/p&gt;

&lt;p&gt;A decline is an answer. A validation error is your bug. A refused connection means nothing happened.&lt;/p&gt;

&lt;p&gt;A timeout means &lt;strong&gt;you don't know.&lt;/strong&gt; The charge may have succeeded, failed, or never arrived — and one of those outcomes has the customer's money.&lt;/p&gt;

&lt;p&gt;Most integrations treat a timeout as a failure. That's a guess. It's wrong often enough to generate a steady stream of "I was charged but my order failed" support tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;read&lt;/strong&gt; timeout is ambiguous. Never mark the payment &lt;code&gt;FAILED&lt;/code&gt; because of one.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;connect&lt;/strong&gt; failure is different: nothing was sent, so it's safe to record as failed and retry.&lt;/li&gt;
&lt;li&gt;You need an explicit &lt;code&gt;UNKNOWN&lt;/code&gt; state, or your code is forced to guess.&lt;/li&gt;
&lt;li&gt;Resolve unknowns in layers — and &lt;strong&gt;"not found" is not "failed"&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The shortest timeout in your request chain wins, and it's usually one you forgot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What can be true after a timeout
&lt;/h2&gt;

&lt;p&gt;You can't tell these apart from your side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;th&gt;Customer charged?&lt;/th&gt;
&lt;th&gt;Naive code records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request never left your network&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;failed ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrived, issuer declined&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;failed ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrived, issuer &lt;strong&gt;approved&lt;/strong&gt;, response lost&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;failed ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still in flight when you gave up&lt;/td&gt;
&lt;td&gt;Possibly, a moment later&lt;/td&gt;
&lt;td&gt;failed ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the expensive one: the customer sees the charge, your system has no record of it, and they contact support or dispute it. The fourth is worse — the charge completes &lt;em&gt;after&lt;/em&gt; you've told them it failed, so they try again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't manufacture your own timeouts
&lt;/h2&gt;

&lt;p&gt;Before handling ambiguity better, stop creating it. Authorization has one genuinely slow step — the issuer's fraud check — and if your read timeout is shorter than that, you're turning successful payments into ambiguous ones yourself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HttpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;connectTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// can't reach them: fail fast, safe to retry&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;HttpRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HttpRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;// read: generous, above realistic p99&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;POST&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short connect timeout.&lt;/strong&gt; If the connection never opens, nothing was sent. Unambiguous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long read timeout.&lt;/strong&gt; Once the request is out, giving up early buys you nothing. The payment keeps processing at the other end whether you're listening or not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shortest timeout in the chain wins
&lt;/h2&gt;

&lt;p&gt;Your client's 30 seconds is irrelevant if something in front of it gives up sooner. Check every hop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Load balancer or ingress idle timeout&lt;/li&gt;
&lt;li&gt;Reverse proxy (&lt;code&gt;proxy_read_timeout&lt;/code&gt; in nginx)&lt;/li&gt;
&lt;li&gt;Service mesh&lt;/li&gt;
&lt;li&gt;API gateway&lt;/li&gt;
&lt;li&gt;Your framework's own request timeout&lt;/li&gt;
&lt;li&gt;The browser, for synchronous user-facing requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A real example: your service waits 30 seconds for the payment provider, but it sits behind an &lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-execution-service-limits-table.html" rel="noopener noreferrer"&gt;AWS API Gateway REST API, whose integration timeout defaults to 29 seconds&lt;/a&gt;. A payment that takes 31 seconds becomes a 504 for your caller — while the charge completes behind it. If the caller retries, you're one step away from a double charge.&lt;/p&gt;

&lt;p&gt;It's also a strong argument for making payment initiation asynchronous, so no user-facing timeout can interrupt it.&lt;/p&gt;

&lt;h2&gt;
  
  
  UNKNOWN is a required state
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;PaymentState&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;INITIATED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;AUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;CAPTURED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;DECLINED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// a real answer from the issuer&lt;/span&gt;
    &lt;span class="no"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// we know it didn't happen&lt;/span&gt;
    &lt;span class="no"&gt;UNKNOWN&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// we genuinely don't know - must be resolved&lt;/span&gt;
    &lt;span class="no"&gt;REFUNDED&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DECLINED&lt;/code&gt; and &lt;code&gt;FAILED&lt;/code&gt; are knowledge. &lt;code&gt;UNKNOWN&lt;/code&gt; is the honest absence of it — and it's what lets you defer the decision instead of guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ChargeResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;storedRequest&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getIdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;approved&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="no"&gt;AUTHORIZED&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="no"&gt;DECLINED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpConnectTimeoutException&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;ConnectException&lt;/span&gt; &lt;span class="n"&gt;nothingSent&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The connection was never established, so nothing was sent.&lt;/span&gt;
    &lt;span class="c1"&gt;// Not ambiguous: record it, and it's safe to retry.&lt;/span&gt;
    &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IOException&lt;/span&gt; &lt;span class="n"&gt;ambiguous&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Sent, then lost - including a read timeout. We do NOT know.&lt;/span&gt;
    &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setState&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UNKNOWN&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payment {} ambiguous, key={}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getIdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order of those &lt;code&gt;catch&lt;/code&gt; blocks matters: &lt;code&gt;HttpConnectTimeoutException&lt;/code&gt; is a subclass of &lt;code&gt;HttpTimeoutException&lt;/code&gt;, which is an &lt;code&gt;IOException&lt;/code&gt;, so the specific case has to come first. This structure was compiled on Java 21 and checked against real sockets — a refused connection and a connect timeout land in &lt;code&gt;FAILED&lt;/code&gt;, a server that accepts the request and never replies lands in &lt;code&gt;UNKNOWN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you use a provider's SDK rather than a raw &lt;code&gt;HttpClient&lt;/code&gt;, it wraps these in its own exception types, so check which ones it throws. The split is what matters: &lt;strong&gt;"couldn't connect" is an answer; "sent and didn't hear back" isn't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And make the customer-facing message match what you actually know: &lt;em&gt;"We're confirming your payment"&lt;/em&gt; — never &lt;em&gt;"Payment failed, please try again,"&lt;/em&gt; which invites the duplicate charge you're trying to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resolving unknowns — and the mistake that fails real payments
&lt;/h2&gt;

&lt;p&gt;The provider knows what happened. But &lt;em&gt;how&lt;/em&gt; you ask matters, because the obvious way to ask can give you a confident wrong answer.&lt;/p&gt;

&lt;p&gt;Resolve in layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks.&lt;/strong&gt; Most unknowns resolve themselves when the provider sends the success or failure event. The sweep job below is for the ones that don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay the identical request with the same idempotency key&lt;/strong&gt; while the provider still remembers it. You get the saved result of the original — or, if it never executed, it runs now, exactly once. &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;Stripe keeps keys for at least 24 hours&lt;/a&gt;, returns the saved result even for a stored 500, and rejects a replay whose parameters differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A strongly consistent lookup&lt;/strong&gt; — by the provider's own ID if you got one, or a list call filtered by customer and time window, matched on your reference in metadata.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trap is treating &lt;strong&gt;"not found" as "never happened"&lt;/strong&gt;. Some providers don't offer a lookup by idempotency key at all, and search endpoints are often eventually consistent. &lt;a href="https://docs.stripe.com/search" rel="noopener noreferrer"&gt;Stripe's docs say not to use its Search API right after a payment&lt;/a&gt;: new data is normally searchable within a minute, and it can take longer during an outage. A resolver that marks a payment &lt;code&gt;FAILED&lt;/code&gt; because a search came back empty will, sooner or later, fail a payment that succeeded.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30_000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;resolveUnknownPayments&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;graceCutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;minus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByStateAndUpdatedAtBefore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UNKNOWN&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;graceCutoff&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChargeResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isPresent&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;adopt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;            &lt;span class="c1"&gt;// definitive: approved or declined&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isUnknownLongerThan&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;escalate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// a human or reconciliation takes over&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// A lookup error is not an answer. Leave it UNKNOWN.&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"could not resolve payment {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChargeResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Replay only well inside the key's retention window, and only if the&lt;/span&gt;
    &lt;span class="c1"&gt;// customer's intent still stands (order not cancelled in the meantime).&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFirstAttemptAt&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;minus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderStillWantsPayment&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ChargeResult&lt;/span&gt; &lt;span class="n"&gt;replay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;storedRequest&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getIdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;replay&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isDefinitive&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;replay&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Strongly consistent lookup - never a search endpoint.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByReference&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomerRef&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getReference&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFirstAttemptAt&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grace period from the last attempt&lt;/strong&gt; (&lt;code&gt;updatedAt&lt;/code&gt;, not &lt;code&gt;createdAt&lt;/code&gt;), so you don't resolve a payment underneath a retry that's still running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing becomes &lt;code&gt;FAILED&lt;/code&gt; because it wasn't found.&lt;/strong&gt; Only a definitive answer changes the state. Everything else stays &lt;code&gt;UNKNOWN&lt;/code&gt; and escalates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A lookup error stays &lt;code&gt;UNKNOWN&lt;/code&gt;.&lt;/strong&gt; Never let a resolver bug downgrade a payment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persist the key &lt;em&gt;and&lt;/em&gt; the exact request&lt;/strong&gt; before the first call. A replay needs both.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Alert on age, not just errors
&lt;/h2&gt;

&lt;p&gt;A few &lt;code&gt;UNKNOWN&lt;/code&gt; payments are normal. What matters is the shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count rising&lt;/strong&gt; — provider degradation, or your timeouts are too tight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Age rising&lt;/strong&gt; — your resolver is broken. This one is invisible on error dashboards, because nothing throws: payments just pile up in limbo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alert on the &lt;strong&gt;age of the oldest unresolved payment&lt;/strong&gt;. If anything has been &lt;code&gt;UNKNOWN&lt;/code&gt; for more than a few minutes, someone should know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrying after a timeout
&lt;/h2&gt;

&lt;p&gt;Safe under one condition: &lt;strong&gt;the same idempotency key.&lt;/strong&gt; With the original key, a retry completes the original operation or returns its saved result. With a new key, you've authorized a second charge.&lt;/p&gt;

&lt;p&gt;And the key only protects you while the provider remembers it. Retry after the retention window and it's a brand-new request — which is why you also need the database guard from &lt;a href="https://dev.to/feezan_khattak/your-payment-code-can-still-charge-twice-even-with-idempotency-keys-2hk1"&gt;part 1 of this series&lt;/a&gt;, where a payment stuck in &lt;code&gt;UNKNOWN&lt;/code&gt; blocks a second attempt at the database level.&lt;/p&gt;

&lt;p&gt;Cap retries at two or three. Hammering a struggling provider is how a slowdown becomes an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Read timeouts never write &lt;code&gt;FAILED&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Connect failures handled separately from read timeouts&lt;/li&gt;
&lt;li&gt;[ ] An explicit &lt;code&gt;UNKNOWN&lt;/code&gt; state&lt;/li&gt;
&lt;li&gt;[ ] Read timeout above your realistic p99, connect timeout short&lt;/li&gt;
&lt;li&gt;[ ] Every timeout in the chain audited — gateway, proxy, load balancer, client&lt;/li&gt;
&lt;li&gt;[ ] Idempotency key and exact request persisted before the call&lt;/li&gt;
&lt;li&gt;[ ] Resolution in layers: webhooks → same-key replay → strongly consistent lookup&lt;/li&gt;
&lt;li&gt;[ ] "Not found" stays &lt;code&gt;UNKNOWN&lt;/code&gt;; no search endpoints in the resolver&lt;/li&gt;
&lt;li&gt;[ ] An alert on the age of the oldest &lt;code&gt;UNKNOWN&lt;/code&gt; payment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A timeout is a question, not an answer.&lt;/strong&gt; Code that treats it as an answer is wrong a meaningful fraction of the time — and always in the direction that costs the customer money.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 2 of a series on building payment systems as a backend engineer. The &lt;a href="https://feezankhattak.com/blog/payment-timeout-handling" rel="noopener noreferrer"&gt;full version on my site&lt;/a&gt; goes deeper on the resolver. I also build free, in-browser tools for payment engineers — including a &lt;a href="https://feezankhattak.com/tools/decline-code-lookup" rel="noopener noreferrer"&gt;card decline code lookup&lt;/a&gt; and a &lt;a href="https://feezankhattak.com/tools/webhook-signature-verifier" rel="noopener noreferrer"&gt;webhook signature verifier&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>backend</category>
      <category>microservices</category>
    </item>
    <item>
      <title>Your Payment Code Can Still Charge Twice (Even With Idempotency Keys)</title>
      <dc:creator>Feezan Khattak</dc:creator>
      <pubDate>Sun, 13 Sep 2026 05:55:46 +0000</pubDate>
      <link>https://dev.to/feezan_khattak/your-payment-code-can-still-charge-twice-even-with-idempotency-keys-2hk1</link>
      <guid>https://dev.to/feezan_khattak/your-payment-code-can-still-charge-twice-even-with-idempotency-keys-2hk1</guid>
      <description>&lt;p&gt;A double charge is the payment bug customers actually notice. Two lines on a bank statement, an angry support ticket, and if it's handled badly, a dispute — which costs you a fee whether you win or not.&lt;/p&gt;

&lt;p&gt;Most engineers reach for &lt;strong&gt;idempotency keys&lt;/strong&gt;, and they should. But idempotency keys stop &lt;em&gt;duplicate requests&lt;/em&gt;. They don't stop your own system making &lt;em&gt;two legitimately different requests&lt;/em&gt; for the same order. That second category is where double charges that survive code review come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Idempotency keys stop &lt;strong&gt;duplicate requests&lt;/strong&gt;, not &lt;strong&gt;duplicate intents&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Disabling the Pay button is a courtesy, &lt;strong&gt;not a control&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Most remaining causes are &lt;strong&gt;races&lt;/strong&gt;, and read-then-write checks lose races.&lt;/li&gt;
&lt;li&gt;The reliable fix is a &lt;strong&gt;partial unique index&lt;/strong&gt; — and &lt;em&gt;which states it covers&lt;/em&gt; decides whether it works at all.&lt;/li&gt;
&lt;li&gt;Never hold a database transaction open across the call to your payment provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Eight ways a customer gets charged twice
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Customer double-clicks Pay&lt;/td&gt;
&lt;td&gt;UI guard + server idempotency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Retry sent with &lt;strong&gt;no&lt;/strong&gt; idempotency key&lt;/td&gt;
&lt;td&gt;Always send one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Retry sent with a &lt;strong&gt;new&lt;/strong&gt; key&lt;/td&gt;
&lt;td&gt;Generate before attempt 1, persist it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Two browser tabs / two devices&lt;/td&gt;
&lt;td&gt;Server-side unique constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Mobile app retries on resume&lt;/td&gt;
&lt;td&gt;Key survives app restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Webhook handler charges again&lt;/td&gt;
&lt;td&gt;Handlers record state, never charge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Two concurrent requests race a check&lt;/td&gt;
&lt;td&gt;DB constraint, not &lt;code&gt;if (exists)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Dunning job races a manual retry&lt;/td&gt;
&lt;td&gt;Guard scoped to the billing period&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Causes 1–3 and 5 are solved by doing idempotency properly. &lt;strong&gt;4, 6, 7 and 8 are not&lt;/strong&gt; — they're your system charging twice with two different, valid requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race that careful-looking code loses
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WRONG: two concurrent requests can both pass this check&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;amountMinor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByOrderIdAndStateIn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;SUCCESSFUL_STATES&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AlreadyPaidException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;doCharge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amountMinor&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// both threads arrive here&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both requests run &lt;code&gt;existsBy...&lt;/code&gt; before either commits. Both see nothing. Both charge. You'll never see it in local testing; you will see it during a retry storm or a double-tap on a slow mobile connection.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;synchronized&lt;/code&gt; doesn't save you either — two instances behind a load balancer don't share a lock. &lt;strong&gt;Only the database can win this race.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The index that looks right and isn't
&lt;/h2&gt;

&lt;p&gt;The obvious fix is a partial unique index allowing one successful payment per order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Looks right. Doesn't prevent the double charge.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ux_payments_order_successful&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AUTHORIZED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CAPTURED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SETTLED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walk through the race with it in place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request A inserts its payment as &lt;code&gt;INITIATED&lt;/code&gt;. The index ignores that state.&lt;/li&gt;
&lt;li&gt;Request B inserts its payment as &lt;code&gt;INITIATED&lt;/code&gt;. Also ignored — &lt;strong&gt;both inserts succeed&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Both call the payment provider. &lt;strong&gt;Both charges go through.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Both try to mark themselves &lt;code&gt;AUTHORIZED&lt;/code&gt;. Only now does the constraint fire — on the second one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The index stopped you &lt;em&gt;recording&lt;/em&gt; a double charge. It didn't stop you &lt;em&gt;making&lt;/em&gt; one. The customer's card was already charged twice by step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index that actually works
&lt;/h2&gt;

&lt;p&gt;Invert it. Cover every state &lt;strong&gt;except&lt;/strong&gt; the ones where no money is held:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- At most one payment per order that is in flight OR holds money.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ux_payments_order_active&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DECLINED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FAILED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'VOIDED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'REFUNDED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now request B fails &lt;strong&gt;at the INSERT&lt;/strong&gt;, before anything leaves your system. And you get two properties for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A payment stuck in &lt;code&gt;UNKNOWN&lt;/code&gt; (a timeout — the money may or may not have moved) &lt;strong&gt;blocks a second attempt until it's resolved&lt;/strong&gt;. That's exactly the situation behind most real-world double charges.&lt;/li&gt;
&lt;li&gt;A genuinely &lt;strong&gt;declined&lt;/strong&gt; payment doesn't block a retry, so the customer can try another card, and failed attempts stay in the table as history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both indexes were tested against PostgreSQL for this post: the first accepts both &lt;code&gt;INITIATED&lt;/code&gt; rows, the second rejects the second one at insert, blocks on &lt;code&gt;UNKNOWN&lt;/code&gt;, and still allows a retry after &lt;code&gt;DECLINED&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it up in Spring
&lt;/h2&gt;

&lt;p&gt;The losing request needs to fail &lt;em&gt;inside&lt;/em&gt; your code, before the provider call, and the provider call must not run inside a transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;amountMinor&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Payment&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Short transaction: the INSERT is where the losing request fails.&lt;/span&gt;
        &lt;span class="c1"&gt;// saveAndFlush makes the constraint fire here, not later at commit.&lt;/span&gt;
        &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAndFlush&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initiated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amountMinor&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataIntegrityViolationException&lt;/span&gt; &lt;span class="n"&gt;alreadyInFlight&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Lost the race, and no provider call was made.&lt;/span&gt;
        &lt;span class="c1"&gt;// Return the payment that won instead of a 500.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findActiveByOrderId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;alreadyInFlight&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// External call runs with no database transaction held open.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;saveAndFlush&lt;/code&gt;, not &lt;code&gt;save&lt;/code&gt;.&lt;/strong&gt; With a plain &lt;code&gt;save&lt;/code&gt;, Hibernate may defer the insert until commit — so the violation surfaces somewhere your &lt;code&gt;catch&lt;/code&gt; isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;TransactionTemplate&lt;/code&gt; (&lt;code&gt;tx&lt;/code&gt;), not &lt;code&gt;@Transactional&lt;/code&gt; on a private helper.&lt;/strong&gt; Spring's transaction proxy doesn't intercept a class calling its own methods, so the annotation would silently do nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No transaction around &lt;code&gt;execute()&lt;/code&gt;.&lt;/strong&gt; Holding one open across a multi-second HTTP call pins a connection and a row lock for the whole duration. Under load, that alone takes you down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Never charge from a webhook handler
&lt;/h2&gt;

&lt;p&gt;Webhooks are delivered &lt;strong&gt;at least once&lt;/strong&gt;, so duplicates are guaranteed, not hypothetical. A handler that initiates a charge will eventually charge twice.&lt;/p&gt;

&lt;p&gt;Handlers should only &lt;strong&gt;record&lt;/strong&gt; what already happened. If an event genuinely needs to trigger a charge, enqueue a job that goes through the same guarded path as above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recurring billing needs a period-scoped guard
&lt;/h2&gt;

&lt;p&gt;Cause 8 is subtle. A dunning job retries March's failed renewal. Meanwhile, support manually retries it. Both correctly use &lt;em&gt;fresh&lt;/em&gt; idempotency keys — the provider's key retention expired days ago — so keys can't protect you. Two charges for one month.&lt;/p&gt;

&lt;p&gt;The guard has to live in your data, scoped to the &lt;strong&gt;billing period&lt;/strong&gt;, with the same index shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ux_sub_payments_period_active&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;subscription_payments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscription_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DECLINED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'FAILED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'VOIDED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'REFUNDED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Idempotency keys protect a request. Your schema protects a business outcome.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When it happens anyway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Refund first, investigate second.&lt;/strong&gt; A fast refund usually prevents a dispute, and a dispute costs you a fee regardless of outcome.&lt;/p&gt;

&lt;p&gt;Then run this on a schedule — with the index in place it should always return zero rows, which makes it an excellent canary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'AUTHORIZED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CAPTURED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SETTLED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it ever returns a row, either the index is missing in some environment or something is writing around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Partial unique index per order covering &lt;strong&gt;in-flight&lt;/strong&gt; states, not just successful ones&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;saveAndFlush&lt;/code&gt; in its own short transaction, violation caught there&lt;/li&gt;
&lt;li&gt;[ ] No transaction held open across the provider call&lt;/li&gt;
&lt;li&gt;[ ] Webhook handlers record state and never charge&lt;/li&gt;
&lt;li&gt;[ ] Recurring payments guarded per billing period&lt;/li&gt;
&lt;li&gt;[ ] Duplicate-charge canary query running on a schedule&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This is part 1 of a series on building payment systems as a backend engineer. The &lt;a href="https://feezankhattak.com/blog/prevent-double-charge" rel="noopener noreferrer"&gt;full version on my site&lt;/a&gt; adds the attempt-log schema for diagnosing the double charges that do slip through. I also build free, in-browser &lt;a href="https://feezankhattak.com/tools" rel="noopener noreferrer"&gt;payment engineering tools&lt;/a&gt; — including a &lt;a href="https://feezankhattak.com/tools/decline-code-lookup" rel="noopener noreferrer"&gt;card decline code lookup&lt;/a&gt; and a &lt;a href="https://feezankhattak.com/tools/reconciliation-diff" rel="noopener noreferrer"&gt;settlement reconciliation diff&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>backend</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
