<?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: jguillaumesio</title>
    <description>The latest articles on DEV Community by jguillaumesio (@jguillaumesio).</description>
    <link>https://dev.to/jguillaumesio</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%2F3987822%2Fe71b8496-6844-440c-be1f-2d92795d48aa.png</url>
      <title>DEV Community: jguillaumesio</title>
      <link>https://dev.to/jguillaumesio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jguillaumesio"/>
    <language>en</language>
    <item>
      <title>The cart timer expired while they were paying. Now what?</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Fri, 03 Jul 2026 12:02:39 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/the-cart-timer-expired-while-they-were-paying-now-what-25gh</link>
      <guid>https://dev.to/jguillaumesio/the-cart-timer-expired-while-they-were-paying-now-what-25gh</guid>
      <description>&lt;p&gt;Here is a bug report I have received in some form at three different companies. The customer opens the payment widget, enters their card, and hits pay. While the bank is doing its 3-D Secure dance, the cart's countdown timer hits zero. The frontend, doing exactly what it was told, tears down the session. The payment succeeds a second later against a cart that no longer exists. Now there is money in the account and no order attached to it.&lt;/p&gt;

&lt;p&gt;Every engineer who built that timer built it correctly. The countdown protects a limited resource: a concert seat, a hotel night, the last unit in stock. Holding it forever would let one abandoned tab starve every other customer. So you set a timer, and when it fires you release the hold. Clean, defensible, and completely blind to the fact that a real person had already committed their money.&lt;/p&gt;

&lt;p&gt;That blindness is the actual subject here. The countdown-expiry bug is small. The habit that produces it is not.&lt;/p&gt;




&lt;h2&gt;
  
  
  The timer answers a question nobody asked at that moment
&lt;/h2&gt;

&lt;p&gt;Step back and ask what the countdown is &lt;em&gt;for&lt;/em&gt;. It exists to resolve contention: two people want the same seat, and the hold decides who gets first refusal. That is the only job. It is a fairness mechanism between customers who have &lt;strong&gt;not&lt;/strong&gt; yet paid.&lt;/p&gt;

&lt;p&gt;The moment a customer enters the payment flow, the question the timer answers stops being relevant. Contention is over. This person is not browsing, not hesitating, not sitting on a tab they forgot about. They are actively handing you money for the exact thing the hold was protecting. Firing the timer now does not serve any other customer, because no other customer can be served: the seat is about to be sold. It only serves the abstraction.&lt;/p&gt;

&lt;p&gt;This is the tell. An engineer thinking in systems sees a timer that reached zero and a rule that says "release on zero". An engineer thinking about the product sees a person who did everything right and is about to be punished for the bank's latency. Same event, two completely different readings, and only one of them keeps the customer.&lt;/p&gt;




&lt;h2&gt;
  
  
  So, do you let them finish?
&lt;/h2&gt;

&lt;p&gt;Yes. Almost always, yes. If the payment authorizes, honor it.&lt;/p&gt;

&lt;p&gt;The reasoning is not sentimental. A successful authorization is the strongest possible signal of intent, far stronger than the "still holding" state the timer was guarding. Rejecting a payment you already accepted creates the worst outcome for everyone: you now have to refund, the customer sees a charge-then-refund cycle that reads as "this company is sketchy", and you have burned trust to enforce a rule that protected nobody. Forcing that refund by letting a timer kill a live payment is a self-inflicted wound.&lt;/p&gt;

&lt;p&gt;The one case where you genuinely cannot honor it is true oversell: while this customer was in 3-D Secure, the last unit actually went to someone who completed faster. That is a real conflict, not a timer artifact. Handle it as an inventory failure with an immediate, automatic reversal and an honest message, not as a "your time ran out" error. The customer's experience should be "we were one second too slow and here is your money back instantly", never "you were too slow".&lt;/p&gt;




&lt;h2&gt;
  
  
  The two follow-up questions
&lt;/h2&gt;

&lt;p&gt;Once you accept that a live payment should win, two design questions follow, and they are where most implementations quietly go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you re-lock the offer right after they paid?&lt;/strong&gt; No. This one trips people up because the instinct is to keep the state machine consistent: the hold expired, so on the way out we should re-acquire it. But re-locking something the customer already bought is nonsense. They own it now. Payment is the terminal state, not another step that needs its slot reserved. If your code path re-locks after capture, you have a state machine that does not know the transaction is over, and that will produce its own class of bugs (double-holds, phantom availability) down the line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you run a separate timer for the final step?&lt;/strong&gt; Yes, and this is the actual fix. The browsing countdown and the payment window are two different clocks measuring two different things. The cart timer manages contention while the customer decides. The moment they commit to paying, you switch to a &lt;em&gt;payment grace window&lt;/em&gt;: a separate, more generous timer whose only job is to give the authorization time to resolve. Bank redirects, 3-D Secure, wallet confirmations, slow networks: a 30-second cart hold that felt urgent while browsing is absurd once someone is staring at their banking app's confirmation screen. Give the final step its own clock, sized for how long a real payment actually takes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Make expiry server-authoritative
&lt;/h2&gt;

&lt;p&gt;The bug at the top of this article only reaches production when the timer lives in the browser and the browser is allowed to decide the session is dead. A frontend countdown is a display. It should never be the thing that releases inventory.&lt;/p&gt;

&lt;p&gt;Put the authority on the server. The hold has an expiry timestamp the server owns. The frontend counts down for the human, but the decision to release is a server-side check that runs when it actually matters: when someone else tries to grab the seat, or when the payment webhook arrives. When the capture webhook lands, the server compares it against the hold and the inventory, and decides. A visual timer hitting zero is not an event that should mutate anything. It is a hint to the user, nothing more.&lt;/p&gt;

&lt;p&gt;That single architectural move, holds expire on the server, the frontend only displays, eliminates the entire category. The countdown can hit zero and reset to "processing your payment" without ever touching the underlying reservation, because the reservation was never the frontend's to release.&lt;/p&gt;




&lt;h2&gt;
  
  
  The habit behind the bug
&lt;/h2&gt;

&lt;p&gt;None of the above is hard. The reason it ships broken so often is not technical difficulty, it is that the timer was built as a pure systems problem. Resource, contention, TTL, release. Every line of that is correct and the sum of it fails a customer, because the model never included the customer's experience of time. To the system, zero is zero. To the person, they were three-quarters of the way through paying.&lt;/p&gt;

&lt;p&gt;This is the engineer-versus-product gap, and it does not show up in the happy path. It shows up in exactly these seams: what happens when two clocks disagree, when the network is slow, when the user does the right thing at the wrong instant. Those edge cases are where the product actually lives, and they are the first thing a systems-only mindset drops, because from inside the system they look like correctly handled states, not like a human being told "too late" for something they already did.&lt;/p&gt;

&lt;p&gt;The fix is not "care more". It is to make one question part of the definition of done for anything user-facing: &lt;em&gt;what does the person on the other end experience when this fires?&lt;/em&gt; Ask it of the timer and you invent the grace window on your own. Skip it and you ship a state machine that is right about everything except the one thing it was for.&lt;/p&gt;




&lt;h2&gt;
  
  
  The rule I follow now
&lt;/h2&gt;

&lt;p&gt;Any timer, lock, or expiry that a user can be actively working against needs two things: a server that owns the real deadline, and an answer to "what happens to someone mid-action when this fires". A countdown that can cancel a payment already in flight has neither. Give the final step its own clock, keep the authority on the server, and let a committed customer finish. The abstraction does not need protecting. The person does.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/checkout-timer-expired-mid-payment/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>architecture</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Multiple accounts in Claude Code: the complete setup</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Thu, 25 Jun 2026 19:54:15 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/multiple-accounts-in-claude-code-the-complete-setup-10ek</link>
      <guid>https://dev.to/jguillaumesio/multiple-accounts-in-claude-code-the-complete-setup-10ek</guid>
      <description>&lt;p&gt;You have a personal Claude Code subscription and a work one. Or you freelance for two clients who each provide their own API key. Or you want a sandboxed account for experiments that won't pollute your main config.&lt;/p&gt;

&lt;p&gt;Whatever the reason, Claude Code doesn't ship with a built-in "switch account" command. But the architecture makes it straightforward: everything lives in a single config directory, and you can redirect it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Claude Code stores state
&lt;/h2&gt;

&lt;p&gt;Claude Code keeps all its state in &lt;code&gt;~/.claude&lt;/code&gt; by default. That includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Authentication tokens&lt;/li&gt;
&lt;li&gt;Project-level settings (&lt;code&gt;.claude/&lt;/code&gt; inside each repo)&lt;/li&gt;
&lt;li&gt;Conversation history&lt;/li&gt;
&lt;li&gt;Memory files&lt;/li&gt;
&lt;li&gt;MCP server configs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key insight: the &lt;strong&gt;&lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt;&lt;/strong&gt; environment variable overrides where Claude Code looks for all of this. Point it somewhere else, and you get a completely independent instance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Basic setup: shell aliases
&lt;/h2&gt;

&lt;p&gt;Create one config directory per account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.claude-personal
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.claude-work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find your Claude binary path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;which claude
&lt;span class="c"&gt;# e.g. /Users/you/.nvm/versions/node/v22.15.0/bin/claude&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add aliases to your &lt;code&gt;~/.zshrc&lt;/code&gt; (or &lt;code&gt;~/.bashrc&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;claude-personal&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'CLAUDE_CONFIG_DIR=~/.claude-personal claude'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-work&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'CLAUDE_CONFIG_DIR=~/.claude-work claude'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload your shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then authenticate each one separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude-personal   &lt;span class="c"&gt;# run /login inside the session&lt;/span&gt;
claude-work       &lt;span class="c"&gt;# run /login inside the session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each alias now opens Claude Code with its own auth, memory, and settings.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the alias approach misses
&lt;/h2&gt;

&lt;p&gt;The alias trick works, but it has gaps that will bite you in production use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 1: NVM version upgrades break absolute paths
&lt;/h3&gt;

&lt;p&gt;If you hardcode the binary path in your alias (as many guides suggest), upgrading Node via NVM silently breaks it. Use just &lt;code&gt;claude&lt;/code&gt; instead of the absolute path, and let &lt;code&gt;$PATH&lt;/code&gt; resolve it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# fragile&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-work&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'CLAUDE_CONFIG_DIR=~/.claude-work /Users/you/.nvm/versions/node/v22.15.0/bin/claude'&lt;/span&gt;

&lt;span class="c"&gt;# resilient&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-work&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'CLAUDE_CONFIG_DIR=~/.claude-work claude'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Problem 2: project-level config leaks between accounts
&lt;/h3&gt;

&lt;p&gt;Claude Code creates a &lt;code&gt;.claude/&lt;/code&gt; directory inside your project repo. That directory stores project settings, &lt;code&gt;CLAUDE.md&lt;/code&gt;, and &lt;code&gt;settings.json&lt;/code&gt;. These are shared across all your aliases because they live in the repo, not in the config dir.&lt;/p&gt;

&lt;p&gt;This means your work account and personal account see the same project-level instructions. That's usually fine, but if you need different MCP servers or permissions per account per project, you'll need to handle it differently (see the wrapper script section below).&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 3: hooks and MCP servers are per-config-dir
&lt;/h3&gt;

&lt;p&gt;If you've configured custom hooks or MCP servers in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, those won't exist in your new config directories. You'll need to copy or symlink the parts you want shared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# share hooks across accounts&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; ~/.claude/settings.json ~/.claude-work/settings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, if you want different hooks per account, copy and customize:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; ~/.claude/settings.json ~/.claude-work/settings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Problem 4: memory doesn't transfer
&lt;/h3&gt;

&lt;p&gt;Each config directory has its own memory system. Your personal account won't remember what your work account learned. If you use memory-heavy workflows, that isolation is sometimes a feature, sometimes a problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Better approach: a wrapper script
&lt;/h2&gt;

&lt;p&gt;Instead of simple aliases, a small wrapper gives you account switching with validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Save as ~/bin/claude-switch and chmod +x&lt;/span&gt;

&lt;span class="nv"&gt;ACCOUNT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?Usage:&lt;span class="p"&gt; claude-switch &amp;lt;account-name&amp;gt; [claude args...]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;shift

&lt;/span&gt;&lt;span class="nv"&gt;CONFIG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude-&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG_DIR&lt;/span&gt;&lt;span class="s2"&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;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Account '&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT&lt;/span&gt;&lt;span class="s2"&gt;' not found. Available accounts:"&lt;/span&gt;
  &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; ~/.claude-&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s|.*/.claude-||'&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CONFIG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;exec &lt;/span&gt;claude &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude-switch work
claude-switch personal &lt;span class="nt"&gt;--resume&lt;/span&gt;
claude-switch work &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"fix the login bug"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This passes all arguments through, so flags like &lt;code&gt;--resume&lt;/code&gt;, &lt;code&gt;--print&lt;/code&gt;, and &lt;code&gt;-p&lt;/code&gt; all work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Per-project account defaults
&lt;/h2&gt;

&lt;p&gt;If a specific repo should always use a specific account, you can set it in a &lt;code&gt;.envrc&lt;/code&gt; file (if you use &lt;a href="https://direnv.net" rel="noopener noreferrer"&gt;direnv&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /path/to/work-project/.envrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CONFIG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude-work"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every time you &lt;code&gt;cd&lt;/code&gt; into that project and run &lt;code&gt;claude&lt;/code&gt;, it automatically uses the work account. No alias needed.&lt;/p&gt;

&lt;p&gt;Without direnv, you can add it to the project's shell history or a local &lt;code&gt;.env&lt;/code&gt; file that your shell sources.&lt;/p&gt;




&lt;h2&gt;
  
  
  API key accounts vs OAuth accounts
&lt;/h2&gt;

&lt;p&gt;There are two authentication modes in Claude Code, and they interact differently with multi-account setups:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth (default):&lt;/strong&gt; You run &lt;code&gt;/login&lt;/code&gt; and authenticate through Anthropic's web flow. The token is stored in the config directory. This is what most people use with Claude Pro/Max subscriptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API key:&lt;/strong&gt; You set &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; as an environment variable. This bypasses the config directory's auth entirely.&lt;/p&gt;

&lt;p&gt;For API key setups, you don't even need separate config directories for auth. You can just switch the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;claude-client-a&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ANTHROPIC_API_KEY=$CLIENT_A_KEY claude'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-client-b&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ANTHROPIC_API_KEY=$CLIENT_B_KEY claude'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But you'll still want separate config directories if you need isolated memory, hooks, or MCP servers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Combining both: API key + config isolation
&lt;/h2&gt;

&lt;p&gt;The most robust setup for freelancers or consultants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLIENT_A_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sk-ant-..."&lt;/span&gt;  &lt;span class="c"&gt;# or source from a secrets manager&lt;/span&gt;

&lt;span class="nb"&gt;alias &lt;/span&gt;claude-client-a&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ANTHROPIC_API_KEY=$CLIENT_A_KEY CLAUDE_CONFIG_DIR=~/.claude-client-a claude'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;claude-client-b&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'ANTHROPIC_API_KEY=$CLIENT_B_KEY CLAUDE_CONFIG_DIR=~/.claude-client-b claude'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each client gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Their own API key (billing goes to the right place)&lt;/li&gt;
&lt;li&gt;Their own memory (client context stays separate)&lt;/li&gt;
&lt;li&gt;Their own MCP servers (different clients, different tools)&lt;/li&gt;
&lt;li&gt;Their own hooks (different code review standards, different workflows)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Running accounts simultaneously
&lt;/h2&gt;

&lt;p&gt;You can run multiple Claude Code instances in parallel, each in its own terminal tab. The config directories are independent, so there's no locking or conflict.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Terminal 1&lt;/span&gt;
claude-work

&lt;span class="c"&gt;# Terminal 2&lt;/span&gt;
claude-personal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sessions run concurrently without interference. This is useful when you're waiting on a long task in one account and want to work on something else in another.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want&lt;/th&gt;
&lt;th&gt;What to set&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Different auth&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different API key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different memory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different MCP servers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; + custom &lt;code&gt;settings.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-project default&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.envrc&lt;/code&gt; with &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All of the above&lt;/td&gt;
&lt;td&gt;Combined alias with both env vars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Going further: multiple Claude Desktop instances
&lt;/h2&gt;

&lt;p&gt;Everything above covers Claude Code (the CLI). If you also use the &lt;strong&gt;Claude desktop app&lt;/strong&gt; and want two instances running side by side with different accounts, the approach is different: you need to duplicate the app itself.&lt;/p&gt;

&lt;p&gt;On macOS, &lt;a href="https://www.parallels.com/products/toolbox/" rel="noopener noreferrer"&gt;Parallels Toolbox&lt;/a&gt; can create an "app shortcut" that acts as a second copy of Claude. Each copy maintains its own login session, so you can run your work account in one window and your personal account in another, without logging in and out. &lt;a href="https://youtu.be/IF1qZmCLWFk" rel="noopener noreferrer"&gt;This walkthrough&lt;/a&gt; shows the full setup.&lt;/p&gt;

&lt;p&gt;The process: open Parallels Toolbox, create an app shortcut pointing to Claude, give it a distinct name (like "Claude Work"), approve it in macOS security settings, and log in with your second account. Both instances live in your dock and run independently.&lt;/p&gt;

&lt;p&gt;This pairs well with the CLI multi-account setup: use &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; aliases for terminal work, and Parallels app shortcuts for the desktop GUI.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I actually use
&lt;/h2&gt;

&lt;p&gt;Two config directories: &lt;code&gt;~/.claude-personal&lt;/code&gt; for my own projects, &lt;code&gt;~/.claude-work&lt;/code&gt; for client work. Direnv handles the switching per project, so I just type &lt;code&gt;claude&lt;/code&gt; and it picks the right account. I symlink &lt;code&gt;settings.json&lt;/code&gt; from my personal config to the work one because I want the same hooks everywhere, but memory stays separate.&lt;/p&gt;

&lt;p&gt;The total setup took five minutes. The part that took longest was realizing I needed to re-run &lt;code&gt;/login&lt;/code&gt; in each config directory after creating it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/claude-code-multiple-accounts/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Cloudflare under the hood: how it works and how attackers try to get around it</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Mon, 22 Jun 2026 10:21:35 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/cloudflare-under-the-hood-how-it-works-and-how-attackers-try-to-get-around-it-3740</link>
      <guid>https://dev.to/jguillaumesio/cloudflare-under-the-hood-how-it-works-and-how-attackers-try-to-get-around-it-3740</guid>
      <description>&lt;h2&gt;
  
  
  1. What Cloudflare actually is
&lt;/h2&gt;

&lt;p&gt;Cloudflare is not a reverse proxy running on one server somewhere. It is a globally distributed edge network with over 300 points of presence (PoPs). When you put your domain behind Cloudflare, you are routing all traffic through that network before it ever reaches your server&lt;/p&gt;

&lt;p&gt;The mechanism is anycast routing. Cloudflare announces the same IP address from every PoP simultaneously. When a user sends a request to your site, BGP routing automatically directs it to the closest PoP, not to your origin server. From there, Cloudflare decides what to do with it&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User in Tokyo
   |
   | (anycast routes to nearest PoP)
   v
Cloudflare Tokyo PoP
   |-- cached? → serve from edge, origin never touched
   |-- blocked? → return 403, origin never touched
   |-- challenge? → run Turnstile, origin never touched
   |-- clean? → forward to origin, return response
   v
Your origin server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TLS termination happens at the edge PoP, not at your origin. Cloudflare holds the certificate, decrypts the request, inspects it, then re-encrypts it for the leg to your origin (assuming SSL between Cloudflare and origin is enabled, which it should be)&lt;/p&gt;

&lt;p&gt;This is why Cloudflare can inspect HTTPS traffic for WAF rules without a man-in-the-middle attack: you are explicitly delegating that decryption to them&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The layers between a request and your server
&lt;/h2&gt;

&lt;p&gt;A request arriving at a Cloudflare PoP passes through several decision layers in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DDoS mitigation&lt;/strong&gt; runs first. Volumetric floods are absorbed at the network layer. HTTP floods are identified by rate, pattern, and reputation&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IP reputation and geofencing&lt;/strong&gt; checks the source IP against Cloudflare's threat database. IPs from known botnets, Tor exit nodes, or datacenter ranges are scored&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WAF&lt;/strong&gt; inspects the HTTP layer: headers, path, query params, body. Cloudflare maintains a managed ruleset covering OWASP Top 10 plus known CVEs&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bot management&lt;/strong&gt; (Turnstile is the visible part) assigns each request a bot score from 1 to 99. Score 1 is almost certainly a bot. Score 99 is almost certainly human&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache&lt;/strong&gt; is the last layer before origin. If the response is cacheable and a fresh copy exists at the PoP, Cloudflare serves it without touching your server&lt;/p&gt;




&lt;h2&gt;
  
  
  3. How Turnstile works
&lt;/h2&gt;

&lt;p&gt;Turnstile is Cloudflare's CAPTCHA replacement. Unlike reCAPTCHA v2, it has no image challenge: the goal is to verify a visitor is human without making them solve anything visible&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The widget loads a JS challenge from Cloudflare's edge.&lt;/strong&gt; The script is different per request, not a static file you can analyze once&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The script collects passive signals:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timing: how long did each JS operation take? Headless browsers running at full CPU speed have suspiciously uniform timing.&lt;/li&gt;
&lt;li&gt;Interaction: did the mouse move before the form was submitted? Did keystrokes have natural delays?&lt;/li&gt;
&lt;li&gt;Browser fingerprint: canvas rendering, WebGL renderer, installed fonts, audio context output.&lt;/li&gt;
&lt;li&gt;Environment: is &lt;code&gt;navigator.webdriver&lt;/code&gt; exposed? Are dev tools open?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Cloudflare runs those signals through a model&lt;/strong&gt; trained on billions of requests and issues a signed token if the request looks human&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Your backend verifies the token&lt;/strong&gt; against Cloudflare's siteverify API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;https://challenges.cloudflare.com/turnstile/v&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="err"&gt;/siteverify&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"secret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-secret-key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"token-from-widget"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your backend does not make this call, the protection is entirely client-side and trivially bypassed by skipping the form submission step&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Finding the origin server behind Cloudflare
&lt;/h2&gt;

&lt;p&gt;If an attacker finds your origin IP, they can bypass Cloudflare entirely by sending requests directly to that IP. Your WAF, DDoS protection, and Turnstile all disappear&lt;/p&gt;

&lt;p&gt;Here are the techniques commonly used, in order of how often they succeed&lt;/p&gt;

&lt;h3&gt;
  
  
  SSL certificate history
&lt;/h3&gt;

&lt;p&gt;Before you put a domain behind Cloudflare, it had a certificate issued directly to the origin. Certificate transparency logs are public and record every certificate ever issued:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://crt.sh/?q=example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the origin IP appeared in a certificate before Cloudflare was enabled, it is in the log forever&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS history
&lt;/h3&gt;

&lt;p&gt;Before Cloudflare, your A record pointed directly to your origin. Those records are archived by SecurityTrails, DNSDumpster, and ViewDNS.info, often with timestamps showing exactly when you switched&lt;/p&gt;

&lt;h3&gt;
  
  
  Subdomains not behind Cloudflare
&lt;/h3&gt;

&lt;p&gt;Many teams proxy &lt;code&gt;www&lt;/code&gt; and the apex but leave other subdomains with a grey cloud (not proxied) by accident:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ftp.example.com&lt;/code&gt;: legacy, often points to origin&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dev.example.com&lt;/code&gt;, &lt;code&gt;staging.example.com&lt;/code&gt;: forgotten&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;api.example.com&lt;/code&gt;: sometimes bypasses the proxy for latency reasons&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A subdomain enumeration pass reveals which subdomains resolve to a non-Cloudflare IP&lt;/p&gt;

&lt;h3&gt;
  
  
  MX records
&lt;/h3&gt;

&lt;p&gt;Mail servers cannot be proxied through Cloudflare. Your MX record points directly to a mail server, often on the same IP block as your web server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig MX example.com        &lt;span class="c"&gt;# → mail.example.com&lt;/span&gt;
dig A mail.example.com    &lt;span class="c"&gt;# → 203.0.113.42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  SPF records
&lt;/h3&gt;

&lt;p&gt;SPF records list every IP authorized to send email on your behalf. They often include your origin server or hosting provider's IP range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig TXT example.com
&lt;span class="c"&gt;# v=spf1 ip4:203.0.113.0/24 include:sendgrid.net ~all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Shodan + certificate fingerprint
&lt;/h3&gt;

&lt;p&gt;If your origin uses a Cloudflare origin certificate, its fingerprint is the same regardless of how it is accessed. Shodan and Censys index TLS certificates across the entire IPv4 space: search for your cert fingerprint to find the raw IP&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Bypassing Turnstile
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Solving services
&lt;/h3&gt;

&lt;p&gt;2captcha, Anti-Captcha, and CapSolver use human workers who run a real browser session and return the token. This works but is slow (seconds per token) and costs money per solve. Practical at low volume, expensive at scale&lt;/p&gt;

&lt;h3&gt;
  
  
  Headless browser spoofing
&lt;/h3&gt;

&lt;p&gt;Playwright and Puppeteer combined with stealth plugins patch the detectable properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;navigator.webdriver&lt;/code&gt; set to &lt;code&gt;undefined&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Spoofed canvas fingerprint&lt;/li&gt;
&lt;li&gt;Realistic mouse movement and keystroke timing&lt;/li&gt;
&lt;li&gt;Full Chrome user agent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A well-configured headless browser can pass Turnstile at a reasonable rate. Cloudflare's model is continuously updated, but it is an ongoing arms race&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually stops most bots
&lt;/h3&gt;

&lt;p&gt;The visible Turnstile widget is not the main defense. Cloudflare's bot score from network-level signals (IP reputation, ASN, request rate, TLS fingerprint) catches far more traffic than the JS challenge does. A request from AWS Lambda with a clean User-Agent still has a datacenter ASN: that alone raises the bot score before any JS runs&lt;/p&gt;

&lt;p&gt;Turnstile alone, validated client-side only, is weak. The combination of network scoring plus behavioral analysis is what makes the system effective&lt;/p&gt;




&lt;h2&gt;
  
  
  6. How to actually protect your origin
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use Cloudflare Tunnel.&lt;/strong&gt; This is the only approach that fully hides your origin IP. &lt;code&gt;cloudflared&lt;/code&gt; opens an outbound connection from your server to Cloudflare's network. No open inbound ports, no IP to find.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared tunnel create my-tunnel
cloudflared tunnel route dns my-tunnel example.com
cloudflared tunnel run my-tunnel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;If you cannot use Tunnel, firewall your origin to Cloudflare IPs only.&lt;/strong&gt; Cloudflare publishes its full IP range at &lt;code&gt;cloudflare.com/ips-v4&lt;/code&gt;. Allow only those ranges on 80 and 443. Drop everything else&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy every subdomain.&lt;/strong&gt; Audit your DNS records. Every subdomain that should be proxied must have the orange cloud enabled. Grey-cloud records pointing to your origin are a bypass by design&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep mail on a separate IP.&lt;/strong&gt; Your mail server should not share an IP or IP block with your web server&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate Turnstile server-side, always.&lt;/strong&gt; The token must be verified by your backend on every form submission&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your certificate history now.&lt;/strong&gt; Run your domain through &lt;code&gt;crt.sh&lt;/code&gt; and SecurityTrails. If your old origin IP is visible, either move to a new IP (and use Tunnel going forward) or rely entirely on the firewall approach&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/cloudflare-under-the-hood/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>infrastructure</category>
      <category>bypass</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Stop refunding payments you should never have charged</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Tue, 16 Jun 2026 18:33:51 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/stop-refunding-payments-you-should-never-have-charged-4d7m</link>
      <guid>https://dev.to/jguillaumesio/stop-refunding-payments-you-should-never-have-charged-4d7m</guid>
      <description>&lt;p&gt;I once shipped a checkout that charged the card the instant the customer hit pay, then ran the order validation afterward. Stock check, address validation, a fraud heuristic, a third-party availability call. When any of those failed, the code did the obvious thing: it refunded the payment&lt;/p&gt;

&lt;p&gt;It worked. It also generated a steady trickle of confused, angry emails. "Why did you charge me 89 euros and then refund it three days later?" To the customer, a charge followed by a refund does not read as "we caught a problem". It reads as "this company is sketchy and now my money is stuck in limbo for a week"&lt;/p&gt;

&lt;p&gt;The fix was one property I had been ignoring for years: &lt;code&gt;capture_method: manual&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The charge-then-refund antipattern
&lt;/h2&gt;

&lt;p&gt;Here is the flow almost every tutorial teaches, and the one I had shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eur&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;payment_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentMethodId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// money has now left the customer's account&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;validateOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;payment_intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Order validation failed after charge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is the comment. By the time you run &lt;code&gt;validateOrder&lt;/code&gt;, the money is gone. The charge has hit the customer's statement, your statement, and in cross-border cases an FX conversion. If validation fails, you are not undoing a mistake, you are issuing a second financial event that has to settle on its own timeline&lt;/p&gt;

&lt;p&gt;That has real costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The customer sees a charge and a refund, days apart, and loses trust&lt;/li&gt;
&lt;li&gt;Refunds can take 5 to 10 business days to land back on the customer's statement&lt;/li&gt;
&lt;li&gt;You may not get every fee back, and FX swings mean the refund amount can differ from the charge&lt;/li&gt;
&lt;li&gt;A pattern of charges-then-refunds is exactly what card networks flag as a risk signal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You did everything to be honest and it still looks bad. The flow is the problem, not your intentions&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorize, then capture
&lt;/h2&gt;

&lt;p&gt;Cards have always supported a two-step model: &lt;strong&gt;authorization&lt;/strong&gt; places a hold on the funds without moving them, and &lt;strong&gt;capture&lt;/strong&gt; actually moves the money. Hotels and car rental companies have used this forever. The hold sits on the customer's card, the money never leaves until you decide it should, and if you never capture, the hold simply expires&lt;/p&gt;

&lt;p&gt;Stripe exposes this with one property on the PaymentIntent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eur&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;payment_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;paymentMethodId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;capture_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;confirm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// funds are AUTHORIZED, not captured, nothing has moved yet&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;validateOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cancel&lt;/code&gt; releases the hold. No charge ever appeared, so there is no refund to explain. The customer sees a pending authorization that quietly drops off. You moved the risky business logic to where it belongs: between the authorization and the capture, while the money is still reversible without a trace&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get for free
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partial capture.&lt;/strong&gt; You can capture less than you authorized. Authorize 89 euros for a cart, discover one item is out of stock, capture 64 euros and the rest of the hold releases automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;amount_to_capture&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A real validation window.&lt;/strong&gt; Stripe holds an uncaptured authorization for about 7 days. That is plenty of time for a synchronous stock check, and enough for some asynchronous flows too. If you never capture, you never charged&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A clean audit trail.&lt;/strong&gt; "Authorized, then cancelled" is a single coherent story in your logs and in the customer's mind. "Charged, then refunded" is two events that have to be reconciled, and that reconciliation is where money quietly goes missing&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not Stripe-specific
&lt;/h2&gt;

&lt;p&gt;The property name changes, but every serious payment provider exposes the same authorize-then-capture model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe&lt;/strong&gt;: &lt;code&gt;capture_method: 'manual'&lt;/code&gt; on the PaymentIntent, then &lt;code&gt;paymentIntents.capture()&lt;/code&gt; or &lt;code&gt;paymentIntents.cancel()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adyen&lt;/strong&gt;: set a manual capture delay, then call &lt;code&gt;/payments&lt;/code&gt; to authorize and &lt;code&gt;/payments/{id}/captures&lt;/code&gt; to capture&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Braintree&lt;/strong&gt;: pass &lt;code&gt;submitForSettlement: false&lt;/code&gt; to &lt;code&gt;transaction.sale&lt;/code&gt;, then &lt;code&gt;transaction.submitForSettlement(id)&lt;/code&gt; later&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PayPal&lt;/strong&gt;: create the order with &lt;code&gt;intent: 'AUTHORIZE'&lt;/code&gt;, then capture the authorization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The vocabulary differs, the shape is identical: get a hold, do your work, settle or release&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should not use it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instant digital goods.&lt;/strong&gt; If you deliver the moment payment succeeds and there is nothing to validate, the extra round trip buys you nothing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscriptions and recurring billing.&lt;/strong&gt; These run on their own automated capture flow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation slower than the hold.&lt;/strong&gt; If your checks can take longer than 7 days, the authorization will expire before you capture&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Methods that do not support it.&lt;/strong&gt; Some non-card payment methods do not offer separate auth and capture, confirm support before you build around it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;If there is any business logic that can fail &lt;em&gt;after&lt;/em&gt; the customer has paid but &lt;em&gt;before&lt;/em&gt; you are willing to keep their money, that logic belongs between an authorization and a capture&lt;/p&gt;

&lt;p&gt;The charge-then-refund flow is the default in almost every example online, which is exactly why it ends up in production. One property moves the risky work to the right side of the money, and the angry emails stop&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jguillaumesio.com/blog/stripe-manual-capture/" rel="noopener noreferrer"&gt;jguillaumesio.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
