<?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>A practical playbook for running a remote dev team</title>
      <dc:creator>jguillaumesio</dc:creator>
      <pubDate>Thu, 09 Jul 2026 16:00:34 +0000</pubDate>
      <link>https://dev.to/jguillaumesio/a-practical-playbook-for-running-a-remote-dev-team-p3a</link>
      <guid>https://dev.to/jguillaumesio/a-practical-playbook-for-running-a-remote-dev-team-p3a</guid>
      <description>&lt;p&gt;We are eight people, fully remote: one lead, four developers, two product (one of them a designer), and one QA. We build a multi-tenant product: one shared codebase, plus features specific to individual tenants.&lt;/p&gt;

&lt;p&gt;For a while it did not work. Bugs reached production regularly. We picked up work ad-hoc. Nobody had a clear picture of what was shipping next. And one QA cannot cover four developers on a codebase where a change for one client can quietly break another.&lt;/p&gt;

&lt;p&gt;Then a new lead arrived. He did not rewrite our process from a book. He introduced five ceremonies, one at a time, each one aimed at a specific thing that was hurting us. This is the playbook: what each ceremony is, exactly how we run it, and what changed.&lt;/p&gt;

&lt;p&gt;One honest caveat up front: we do not run fixed-length sprints. When I say "sprint review" below, read it as "release review." We run regular ceremonies over a continuous flow of work, not textbook Scrum. I think that is fine, and I will come back to it at the end.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ship fewer bugs: put quality in the ticket, not just in QA
&lt;/h2&gt;

&lt;p&gt;The worst bugs we shipped were per-tenant: a change for one client broke another. You cannot hire your way out of that fast enough. The only thing that scales is moving quality earlier, before a line of code is written. The industry name is "shift left," and the numbers are blunt: ambiguous or incomplete requirements are blamed for an estimated 50 to 60% of production defects, and a bug caught at the requirements stage costs a fraction of the same bug caught in production.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Requirements-phase defects (ambiguous acceptance criteria, missing edge cases, contradictory business rules) account for an estimated 50 to 60% of production defects, and teams that shift QA left reduce defect costs dramatically compared to teams that catch bugs in production.&lt;/p&gt;

&lt;p&gt;Source: synthesised from IBM's Systems Sciences Institute data and Capgemini's &lt;a href="https://www.practicalengineering.management/p/leading-qa-shift-left-transformation-740" rel="noopener noreferrer"&gt;World Quality Report&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So we changed what a ticket must contain before anyone picks it up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every ticket gets acceptance criteria before it is estimated.&lt;/strong&gt; Not a vague title. A concrete list of what must be true for the ticket to be done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a ticket is unclear, it gets an investigation first.&lt;/strong&gt; We do not estimate something nobody understands. A short spike answers the open questions, and only then do we size it. This alone killed a class of "we did not realise it also touched X" bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The multi-tenant rule: every ticket names the blast radius.&lt;/strong&gt; Because our worst failures were cross-tenant, each ticket answers one question explicitly: does this touch shared code or a single tenant, and if shared, which tenants need checking? That one line turns an invisible risk into a checklist item.&lt;/p&gt;

&lt;p&gt;Here is a template we paste into every non-trivial ticket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Acceptance criteria&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Given &lt;span class="nt"&gt;&amp;lt;state&amp;gt;&lt;/span&gt;, when &lt;span class="nt"&gt;&amp;lt;action&amp;gt;&lt;/span&gt;, then &lt;span class="nt"&gt;&amp;lt;result&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Negative case: given &lt;span class="nt"&gt;&amp;lt;bad&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;, then &lt;span class="nt"&gt;&amp;lt;handled&lt;/span&gt; &lt;span class="na"&gt;how&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## Blast radius&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Touches: [ ] shared core   [ ] single tenant
&lt;span class="p"&gt;-&lt;/span&gt; If shared, tenants to verify: &lt;span class="nt"&gt;&amp;lt;list&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## How to test&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nt"&gt;&amp;lt;steps&lt;/span&gt; &lt;span class="na"&gt;QA&lt;/span&gt; &lt;span class="na"&gt;can&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt; &lt;span class="na"&gt;without&lt;/span&gt; &lt;span class="na"&gt;asking&lt;/span&gt; &lt;span class="na"&gt;us&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the part we have not fully solved, and I would rather be honest about it. Today the &lt;strong&gt;ticket creator&lt;/strong&gt; writes the acceptance criteria, and we developers write the "how to test" notes. That works until it does not: sometimes those notes are thin, the ticket gets marked ready for release, and it should not have been. The root cause is not who typed the notes. It is that the &lt;strong&gt;author is effectively self-certifying&lt;/strong&gt;. The person who wrote the code is the last person who should be the gate that says "this is releasable."&lt;/p&gt;

&lt;p&gt;Where I think this goes next is a lighter version of what the industry calls the "Three Amigos": product, QA, and a developer shaping the criteria together, product owning the "what," QA owning "what could break," dev owning "what it touches." We do not run that ceremony yet. But even a lightweight Definition of Ready would help, and the release gate should be someone other than the author, QA when possible, a peer when QA is swamped.&lt;/p&gt;

&lt;p&gt;Here is the checklist we are converging on, tick it against your own next ticket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Acceptance criteria written and understood before estimation&lt;/li&gt;
&lt;li&gt;Blast radius named: shared core or single tenant, and which tenants to verify&lt;/li&gt;
&lt;li&gt;"How to test" steps a non-author can follow without asking questions&lt;/li&gt;
&lt;li&gt;Release gate is someone other than the author&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The regression net: the same cases, tested before every release
&lt;/h3&gt;

&lt;p&gt;Acceptance criteria stop new bugs. They do nothing for the old feature that silently breaks when you touch shared code. For a multi-tenant product that is the most dangerous failure of all, and it needs its own guard: a fixed set of checks that runs before every release, not per ticket.&lt;/p&gt;

&lt;p&gt;There are two acceptable versions of this, and you should be climbing from the second toward the first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automated end-to-end tests&lt;/strong&gt; covering the critical paths: login, the core workflow, billing, and the per-tenant features that diverge from the shared core. Green suite or no release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At minimum, a written list of must-pass scenarios&lt;/strong&gt;, one set per important client, that QA runs before every release. Nothing ships until that list is green.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The multi-tenant rule applies here too: the list must include at least one scenario for every tenant whose behaviour differs from the shared core, because that is exactly where a "harmless" change detonates. And the list is a ratchet: the moment a regression slips through, its scenario gets added, so the same bug can never reach production twice.&lt;/p&gt;

&lt;p&gt;This is the difference between "we tested the thing we changed" and "we proved we did not break the things we did not touch." Only the second one keeps a multi-tenant product stable as it grows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed anyway:&lt;/strong&gt; more tickets are genuinely ready for release, because more eyes hit them before they get there. The remaining gap is the self-certification one above, and naming it is the first step to closing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retro: a Miro board, and votes that force focus
&lt;/h2&gt;

&lt;p&gt;At the end of each release we open a Miro board with four columns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What is good&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What we learned&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What is not going well&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What we would want&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everyone adds notes, privately, at the same time. Writing before talking is the whole trick: it avoids the loudest voice setting the agenda and it avoids groupthink. Then everyone gets a small number of votes (we use three) to spend on the topics they most want to discuss. We only discuss the top-voted items. The rest are logged, not lost.&lt;/p&gt;

&lt;p&gt;The retro is where every other ceremony on this page actually got tuned. It is also the ceremony most likely to become theatre. The single most common way retros fail is not the format, it is the follow-through:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The reason most retrospectives fail is not the meeting itself, it is what happens (or does not happen) afterwards. Action items get discussed, then forgotten by the next sprint.&lt;/p&gt;

&lt;p&gt;Source: Easy Agile, &lt;a href="https://www.easyagile.com/blog/improve-sprint-retrospective-action-items" rel="noopener noreferrer"&gt;Why Retrospectives Fail&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is exactly where we are weakest, so I will not pretend otherwise. We discuss the top items and we track them loosely, but we do not assign a named owner or turn each one into a Jira ticket. So some drift, and the same complaint resurfaces a few retros later. The fix we know we need is boring: two or three action items maximum, each with an owner and a ticket, revisited at the start of the next retro. An action with no owner did not really happen. We are not there yet, and writing it down here is partly to shame ourselves into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Planning poker: the size is disposable
&lt;/h2&gt;

&lt;p&gt;We size tickets with &lt;a href="https://www.planitpoker.com/" rel="noopener noreferrer"&gt;planITpoker&lt;/a&gt;, but with one deliberate change: we do not vote in numbers. We vote in t-shirt sizes, XS, S, M, L, XL. Numbers invite false precision (is it really a 5 and not an 8?) and, worse, they invite someone to add them up into a velocity number and wave it around. Sizes resist both. "This is a Large" starts a conversation. "This is 8 points" starts a spreadsheet.&lt;/p&gt;

&lt;p&gt;Everyone votes at the same time, hidden, then we reveal together. The blind reveal is the point: it kills anchoring, so a junior does not just echo the senior's card.&lt;/p&gt;

&lt;p&gt;A wide spread, S next to XL, is not a failure of the estimate, that is the estimate doing its job. When the sizes disagree, someone knows something the others do not, a hidden dependency, an edge case, a tenant that behaves differently. The conversation that follows is the actual deliverable.&lt;/p&gt;

&lt;p&gt;I want to be fair to the critics here, because they are the reason we dropped numbers in the first place. There is a loud, credible backlash against story points:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I don't like story points because they give a false sense of precision, and the moment management starts treating them as a productivity metric, they are weaponised against the very developers who produce them.&lt;/p&gt;

&lt;p&gt;Source: paraphrasing Tomasz Lakomy, &lt;a href="https://dev.to/tlakomy/why-i-don-t-like-story-point-driven-estimates-28h7"&gt;Why I don't like story-point-driven estimates&lt;/a&gt;, and Scott Logic, &lt;a href="https://blog.scottlogic.com/2024/07/05/story-points-are-wasting-time.html" rel="noopener noreferrer"&gt;Story points are wasting time&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They are right about the failure mode. Points become poison the instant a manager turns them into a burndown scoreboard. T-shirt sizes are our defence: there is nothing to sum, nothing to chart, nothing to weaponise. &lt;strong&gt;The size is disposable, the discussion is the deliverable.&lt;/strong&gt; We use the vote to trigger a conversation, and then we mostly forget the letter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The daily: coordination, not a status report
&lt;/h2&gt;

&lt;p&gt;Fifteen minutes, same time every day, everyone on camera. Here is what it is for, and what it is not.&lt;/p&gt;

&lt;p&gt;It is not a status report to the lead. The dailies people rightly hate are the ones where each person recites yesterday's tasks to a manager who writes them down. If that is all your daily is, it should have been an async written update, a Slack message. The critics are right about that. Keep the synchronous time for the blockers. So that is what each person actually covers: what is blocking me, and what I am about to touch, especially if it is shared code. Not a recital of yesterday's tickets.&lt;/p&gt;

&lt;p&gt;Being fully remote, we felt the pull toward async written standups, and for pure status they would be better. But we kept the live daily for one reason it does well: &lt;strong&gt;coordination and surfacing blockers in real time.&lt;/strong&gt; It is where "I am about to touch the shared billing code" meets "wait, so am I." That collision does not happen in a threaded update. So the rule is: keep it short, no solving problems in the meeting, take the deep dives to a breakout right after.&lt;/p&gt;

&lt;p&gt;Less ad-hoc drift is one of the payoffs, not the biggest but a real one: because we sync every morning, fewer people quietly start random work, and everyone has a rough idea of what is being picked up. The bigger win is still the collisions we avoid by saying out loud what we are about to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sprint review: shared visibility, not a demo
&lt;/h2&gt;

&lt;p&gt;Call it a release review in our case, since we do not run fixed sprints. The trap everyone falls into is treating this as a demo: a parade of every task, performed for an audience. Ours has no external audience at all, no clients, just the developers, product, and QA, which is exactly why we can optimise it for shared understanding instead of performance. Scrum's own guidance pushes back on the demo trap too:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Sprint Review is not a demo. It is a working session to inspect the outcome and figure out what to do next. If it is a one-way presentation, you are doing it wrong.&lt;/p&gt;

&lt;p&gt;Source: Scrum.org, &lt;a href="https://www.scrum.org/resources/blog/sprint-review-much-more-just-demo" rel="noopener noreferrer"&gt;Sprint Review: Much More Than Just A Demo&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For us the real value is not the demo at all, it is shared visibility. We walk the whole internal team through what is going into the next release and what is queued behind it. Three things fall out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everyone learns the whole product.&lt;/strong&gt; Because we discuss many tickets, including ones we will not personally pick up, more people understand more of the system. New joiners ramp faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer PRs stuck in the review queue.&lt;/strong&gt; When everyone already has context on a change, reviews are quick instead of archaeological.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We catch duplicates and collisions.&lt;/strong&gt; Discussing tickets together is when someone says "that is the same as the thing I did last month" or "those two will conflict."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do keep a separate slot for real demos: a Friday session you sign up for to show something you shipped. Honestly, it is our weakest ceremony. It tends to run long, not many people sign up, and half the time the interesting work never gets shown. The visibility review above is what earns its keep. The demo is still looking for its format.&lt;/p&gt;

&lt;p&gt;None of this is clever. The reason shared visibility beats a polished demo is that the demo optimises for looking productive to outsiders, while the review optimises for the team actually knowing what is happening. The first is theatre. The second is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;None of these five ceremonies is novel. You have heard of all of them. The thing worth copying is not the ceremonies, it is that each one was introduced to fix a specific, named pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bugs in prod → &lt;strong&gt;acceptance criteria and blast radius in the ticket.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Ad-hoc chaos → &lt;strong&gt;the daily as coordination.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Estimates nobody trusted → &lt;strong&gt;blind planning poker in t-shirt sizes, with the size thrown away.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;No shared picture → &lt;strong&gt;the review as visibility, not demo.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Everything drifting → &lt;strong&gt;the retro, with owned action items.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do not do textbook Scrum. No fixed sprints, no formal Three Amigos, and one honest gap where authors still self-certify their own releases. That is fine. The goal was never to run the ceremonies correctly. It was to stop shipping bugs and start knowing what the team is doing. Pick the pain you actually have, and add the one ceremony that targets it. Then run the retro, and let it tell you what to add next.&lt;/p&gt;




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

</description>
      <category>productivity</category>
      <category>agile</category>
      <category>teamwork</category>
      <category>remote</category>
    </item>
    <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>
