<?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: Abhishek</title>
    <description>The latest articles on DEV Community by Abhishek (@abhishekvoid).</description>
    <link>https://dev.to/abhishekvoid</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%2F1274540%2F7a7ab7c0-e0db-43ec-9df5-25e2d6042e50.jpeg</url>
      <title>DEV Community: Abhishek</title>
      <link>https://dev.to/abhishekvoid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abhishekvoid"/>
    <language>en</language>
    <item>
      <title>Replacing GitHub PATs with Ephemeral Installation Tokens</title>
      <dc:creator>Abhishek</dc:creator>
      <pubDate>Sun, 12 Jul 2026 07:24:31 +0000</pubDate>
      <link>https://dev.to/abhishekvoid/replacing-github-pats-with-ephemeral-installation-tokens-46na</link>
      <guid>https://dev.to/abhishekvoid/replacing-github-pats-with-ephemeral-installation-tokens-46na</guid>
      <description>&lt;h1&gt;
  
  
  Replacing GitHub PATs with Ephemeral Installation Tokens
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Give an AI agent GitHub access without giving it a standing credential.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Every AI agent I build eventually needs to touch GitHub open an issue, push a branch, comment on a PR. And every guide, every example repo, every quickstart solves that the same way: generate a personal access token, paste it into &lt;code&gt;.env&lt;/code&gt;,move on. I did it too, for a long time, and it always felt wrong.&lt;/p&gt;

&lt;p&gt;A classic PAT is a &lt;em&gt;standing&lt;/em&gt; credential. It's long-lived valid until you remember to revoke it. &lt;br&gt;
It's broadly scoped it carries your whole account's reach, not the one thing the agent needs right now. And it's ambient it sits in the process environment where any code path can read it, where a stack trace can log it, and where a prompt-injected agent can be talked into exfiltrating it. Fine grained PATs help at the margins per-repo, optional expiry but they're still a credential a developer scopes by hand, pastes into an environment, and leaves lying there for weeks.&lt;/p&gt;

&lt;p&gt;Broker isn't competing with them; it's replacing the pattern of&lt;br&gt;
putting any long-lived credential where an agent can read it. The shape of the problem doesn't change: the agent is holding power it isn't using.&lt;/p&gt;


&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;GitHub already has a better primitive, and almost nobody uses it for agents.&lt;/p&gt;

&lt;p&gt;A GitHub &lt;strong&gt;App&lt;/strong&gt; doesn't authenticate with a token you store. It authenticates by signing a short JWT with a private key, then exchanging that JWT for an &lt;strong&gt;installation access token&lt;/strong&gt; scoped to exactly the repositories and permissions you granted the App, and expiring on GitHub's side in about an hour. Nothing to rotate. Nothing broad. Nothing that outlives the moment.&lt;/p&gt;

&lt;p&gt;The reason people reach for a PAT anyway is ergonomics: the JWT dance, the App registration, the installation IDs it's more moving parts than "paste a token."&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Broker&lt;/strong&gt;, a small capability broker that makes installation tokens the &lt;em&gt;default&lt;/em&gt; path for an agent. The agent asks for an action; Broker checks policy, mints a token at the instant of use, spends it on one call, and logs the whole lifecycle. The agent never sees or stores a credential.&lt;/p&gt;


&lt;h2&gt;
  
  
  The demo
&lt;/h2&gt;

&lt;p&gt;Here is the entire thesis in one screenshot. An LLM, wired to Broker over MCP, asked it to create an issue in a repository that isn't on the allowlist:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffwrxejl6vjri76z4jo64.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffwrxejl6vjri76z4jo64.png" alt=" " width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;An LLM asked Broker to create an issue in a non-allowlisted repo. Broker refused.&lt;br&gt;
No token was ever minted.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That last sentence is the whole point. In the PAT model, the agent already holds the credential policy is something you hope the agent respects, or something you bolt on after the fact. Here, the credential &lt;em&gt;does not exist yet&lt;/em&gt; when the decision is made. &lt;/p&gt;

&lt;p&gt;Policy runs first. A denial isn't "the agent was told no"; it's "there was never anything to say no with." The token for a forbidden action is never minted, never sent, never logged because Broker never reaches the mint step. &lt;/p&gt;

&lt;p&gt;The deny path is cheaper and safer than the allow path, which is exactly the property you want in a security boundary.&lt;/p&gt;

&lt;p&gt;Broker speaks the Model Context Protocol, which is how Claude Desktop and a growing number of agent frameworks discover and call external tools. That's what makes the demo work end-to-end: the LLM saw &lt;code&gt;github_create_issue&lt;/code&gt; in Broker's tool catalog, chose to call it with structured parameters, and got back either a capability object or a policy denial. The interception point isn't code the developer had to write. It's the tool boundary itself.&lt;/p&gt;


&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The whole thing is three small files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;broker/policy.py&lt;/code&gt; is one function.&lt;/strong&gt; It takes an action and a repo and returns a&lt;br&gt;
decision — allow or deny with a reason. That's it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;ALLOWED_ACTION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not permitted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;ALLOWED_REPO&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;repo &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not in allowlist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PolicyDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matches allowlist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs &lt;em&gt;before&lt;/em&gt; any credential exists. Wrong repo or wrong action, and the request dies here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;broker/github.py&lt;/code&gt; does the JWT → installation-token exchange.&lt;/strong&gt; It signs an&lt;br&gt;
&lt;code&gt;RS256&lt;/code&gt; JWT with the App private key (a nine-minute expiry, per GitHub's ceiling),&lt;br&gt;
POSTs it to &lt;code&gt;/app/installations/{id}/access_tokens&lt;/code&gt;, and wraps the returned token in&lt;br&gt;
a &lt;code&gt;Capability&lt;/code&gt;. The raw token goes into a private field and never leaves the object callers get a public view with the token stripped out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;broker/audit.py&lt;/code&gt; is &lt;code&gt;json.dumps&lt;/code&gt; to a file.&lt;/strong&gt; Every stage the policy decision, the mint, the use and its outcome appends one timestamped JSON line to &lt;code&gt;audit.jsonl&lt;/code&gt;. Tokens are never written. You get a replayable, grep-able trail of who asked for what and what happened.&lt;/p&gt;

&lt;p&gt;The difference is easiest to see in what the agent actually holds. With a PAT:&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;# .env — sits here for the life of the project&lt;/span&gt;
&lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ghp_R8sNq...   &lt;span class="c"&gt;# your whole account. no expiry. readable by any code path.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Broker, the only thing the agent ever receives is the public capability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CAP-6AEB2D42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"issues:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ttl_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3598&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-11T08:34:05Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACTIVE"&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;No secret in there. Just a receipt.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;PAT in &lt;code&gt;.env&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Broker capability&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;If it leaks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full account access&lt;/td&gt;
&lt;td&gt;An already-expiring, single-scope token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Whole account&lt;/td&gt;
&lt;td&gt;One action (&lt;code&gt;issues:write&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lifetime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Months, until revoked&lt;/td&gt;
&lt;td&gt;~1 hour, server-enforced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sits in the environment&lt;/td&gt;
&lt;td&gt;Never stored; minted per call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;I want to be precise about what this v0.1 is and isn't, because the gaps matter more than the pitch.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.pem&lt;/code&gt; is still a real secret. Broker moves the standing credential from the agent's environment to the App's private key a single, high value key that lives outside the repo and is git-ignored along with &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;audit.jsonl&lt;/code&gt;. That's a better key to have to protect, but it's still a key.&lt;/p&gt;

&lt;p&gt;"Spend once" is a &lt;em&gt;discipline&lt;/em&gt;, not an enforcement. Broker mints a token and uses it for exactly one call but the token it mints is a real GitHub installation token with a server-side expiry of roughly an hour. Broker does no local revocation, so within that window the token would technically still work if it leaked between mint and use. True oneshot, immediately-revoked credentials are future work.&lt;/p&gt;

&lt;p&gt;And the policy engine is deliberately tiny: a single action against a single-repo allowlist. There's no per-caller policy, no rate limiting, no multi-tenant story yet. Those are extension points, not shipped features.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Two directions. First, other providers — the same pattern maps cleanly onto &lt;strong&gt;AWS STS&lt;/strong&gt; (&lt;code&gt;AssumeRole&lt;/code&gt; for short-lived, scoped credentials), and the provider layer is&lt;br&gt;
where that plugs in. Second, the awkward reality that some services only offer long-lived keys: those can be wrapped behind the same policy-and-audit layer, so even a legacy secret is spent through a broker instead of handed to the agent.&lt;/p&gt;




&lt;p&gt;The repo is open source and MIT-licensed:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/Abhishekvoid/broker-mcp" rel="noopener noreferrer"&gt;github.com/Abhishekvoid/broker-mcp&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
It's small enough to read in one sitting — kick the tires and tell me where it breaks.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>agents</category>
      <category>programming</category>
    </item>
    <item>
      <title>A Successful Payment That Never Became a Booking: Building a Fault-Tolerant Payment Pipeline</title>
      <dc:creator>Abhishek</dc:creator>
      <pubDate>Wed, 17 Jun 2026 08:25:41 +0000</pubDate>
      <link>https://dev.to/abhishekvoid/a-successful-payment-that-never-became-a-booking-building-a-fault-tolerant-payment-pipeline-4ioj</link>
      <guid>https://dev.to/abhishekvoid/a-successful-payment-that-never-became-a-booking-building-a-fault-tolerant-payment-pipeline-4ioj</guid>
      <description>&lt;h1&gt;
  
  
  A Successful Payment That Never Became a Booking
&lt;/h1&gt;

&lt;p&gt;A customer completed their payment.&lt;/p&gt;

&lt;p&gt;Razorpay showed &lt;strong&gt;Payment Successful&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The webhook returned &lt;strong&gt;HTTP 200&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The payment was &lt;strong&gt;captured&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And the booking sat there spinning on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Still confirming...&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing had errored.&lt;/p&gt;

&lt;p&gt;No exception.&lt;/p&gt;

&lt;p&gt;No failed request.&lt;/p&gt;

&lt;p&gt;No alert.&lt;/p&gt;

&lt;p&gt;By every individual metric, the system was healthy.&lt;/p&gt;

&lt;p&gt;Yet the one thing that mattered hadn't happened.&lt;/p&gt;

&lt;p&gt;The customer had paid.&lt;/p&gt;

&lt;p&gt;The creator had no booking.&lt;/p&gt;

&lt;p&gt;This is the story of that bug, the investigation behind it, and the architecture we built so it can never happen again.&lt;/p&gt;

&lt;p&gt;The lesson underneath all of it is deceptively simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Accepting money is easy. Guaranteeing that every successful payment becomes a confirmed booking is the actual problem.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  What CreatorOS Is, and Why This Matters
&lt;/h1&gt;

&lt;p&gt;CreatorOS is a booking and payments platform for independent creators, coaches, tutors, consultants, and freelancers.&lt;/p&gt;

&lt;p&gt;A typical flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
   ↓
Select Slot
   ↓
Pay via UPI
   ↓
Booking Confirmed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Money moves through &lt;strong&gt;Razorpay&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Bookings live in our database.&lt;/p&gt;

&lt;p&gt;When payments are part of the product, &lt;strong&gt;"mostly works" is not good enough&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A payment that vanishes into a stuck booking isn't just a bug.&lt;/p&gt;

&lt;p&gt;It's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A customer who paid and received nothing&lt;/li&gt;
&lt;li&gt;A creator who appears unreliable&lt;/li&gt;
&lt;li&gt;A loss of trust&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And trust is the entire business.&lt;/p&gt;

&lt;p&gt;The goal was never:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Process payments.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The real goal was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every captured payment must eventually become a confirmed booking.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  The Naive Architecture (What Most Tutorials Teach)
&lt;/h1&gt;

&lt;p&gt;Most payment tutorials teach something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Razorpay]
    B[Webhook]
    C[Update Booking]

    A --&amp;gt; B
    B --&amp;gt; C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The webhook receives an event and immediately updates the booking.&lt;/p&gt;

&lt;p&gt;Simple.&lt;/p&gt;

&lt;p&gt;Clean.&lt;/p&gt;

&lt;p&gt;Dangerous.&lt;/p&gt;

&lt;p&gt;This architecture quietly carries every failure mode that matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Webhook retries&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Duplicate deliveries&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Partial failures&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Race conditions&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No recovery mechanism&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moment business logic lives inside a webhook handler, correctness becomes dependent on delivery success.&lt;/p&gt;

&lt;p&gt;That is a fragile system.&lt;/p&gt;




&lt;h1&gt;
  
  
  Moving To Event Sourcing
&lt;/h1&gt;

&lt;p&gt;The first major architectural decision was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Webhooks should record events, not perform business logic.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of updating bookings directly, we introduced an event ledger.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Razorpay Webhook]
    B[payment_events]
    C[Processor]
    D[Bookings]

    A --&amp;gt; B
    B --&amp;gt; C
    C --&amp;gt; D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tables became the foundation of the system:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;payment_orders&lt;/td&gt;
&lt;td&gt;Provider truth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;payment_events&lt;/td&gt;
&lt;td&gt;Immutable event ledger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bookings&lt;/td&gt;
&lt;td&gt;Business truth&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This design gives us:&lt;/p&gt;

&lt;h3&gt;
  
  
  Durability
&lt;/h3&gt;

&lt;p&gt;Events are safely stored before processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auditability
&lt;/h3&gt;

&lt;p&gt;Every state transition can be traced.&lt;/p&gt;

&lt;h3&gt;
  
  
  Replayability
&lt;/h3&gt;

&lt;p&gt;Reprocessing means replaying events.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idempotency
&lt;/h3&gt;

&lt;p&gt;Duplicate deliveries become harmless.&lt;/p&gt;

&lt;p&gt;The webhook now has one responsibility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Verify Signature
      ↓
Store Event
      ↓
Return 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing else.&lt;/p&gt;




&lt;h1&gt;
  
  
  Two State Machines, Kept Separate
&lt;/h1&gt;

&lt;p&gt;One subtle but critical decision:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Payment state and booking state are not the same thing.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Treating them as identical creates hidden bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payment State Machine
&lt;/h2&gt;

&lt;p&gt;This reflects provider truth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; Created
    Created --&amp;gt; Authorized
    Authorized --&amp;gt; Captured
    Authorized --&amp;gt; Failed
    Created --&amp;gt; Failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Booking State Machine
&lt;/h2&gt;

&lt;p&gt;This reflects business truth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; Pending

    Pending --&amp;gt; PaymentPending
    PaymentPending --&amp;gt; Confirmed
    PaymentPending --&amp;gt; Cancelled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A payment being &lt;strong&gt;captured&lt;/strong&gt; is merely an input into a booking becoming &lt;strong&gt;confirmed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;They are related.&lt;/p&gt;

&lt;p&gt;They are not identical.&lt;/p&gt;

&lt;p&gt;Keeping them separate makes reconciliation possible.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Incident
&lt;/h1&gt;

&lt;p&gt;Then it happened.&lt;/p&gt;

&lt;p&gt;A customer paid.&lt;/p&gt;

&lt;p&gt;Razorpay showed success.&lt;/p&gt;

&lt;p&gt;The webhook arrived.&lt;/p&gt;

&lt;p&gt;The event existed in the database.&lt;/p&gt;

&lt;p&gt;And the booking remained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;payment_pending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first query told the story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payment_events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;payment.captured    false
order.paid          false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The events existed.&lt;/p&gt;

&lt;p&gt;They simply had not been processed.&lt;/p&gt;

&lt;p&gt;Everything looked healthy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Razorpay worked&lt;/li&gt;
&lt;li&gt;Webhook worked&lt;/li&gt;
&lt;li&gt;Database worked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yet bookings did not.&lt;/p&gt;




&lt;h1&gt;
  
  
  The Investigation
&lt;/h1&gt;

&lt;p&gt;We traced a single booking through the system.&lt;/p&gt;

&lt;p&gt;Using its correlation ID we followed the trail across:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;payment_events&lt;/li&gt;
&lt;li&gt;payment_orders&lt;/li&gt;
&lt;li&gt;bookings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every row looked correct.&lt;/p&gt;

&lt;p&gt;Except for one field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;processed = false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeated everywhere.&lt;/p&gt;

&lt;p&gt;So we manually invoked the processor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer xxx"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
https://creator-os.vercel.app/api/cron/process-events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"processed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&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;Immediately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Events became processed&lt;/li&gt;
&lt;li&gt;Orders became captured&lt;/li&gt;
&lt;li&gt;Bookings became confirmed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The processor wasn't broken.&lt;/p&gt;

&lt;p&gt;It worked perfectly.&lt;/p&gt;

&lt;p&gt;The moment it ran.&lt;/p&gt;




&lt;h1&gt;
  
  
  Root Cause: The Scheduler That Didn't Exist
&lt;/h1&gt;

&lt;p&gt;The investigation ultimately revealed something surprisingly simple.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook
   ↓
Event Stored
   ↓
Processor Exists
   ↓
Never Runs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody was invoking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;processPendingEvents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The processor was healthy.&lt;/p&gt;

&lt;p&gt;The webhook was healthy.&lt;/p&gt;

&lt;p&gt;The database was healthy.&lt;/p&gt;

&lt;p&gt;The scheduler was missing.&lt;/p&gt;

&lt;p&gt;This is the hidden cost of event-driven architecture.&lt;/p&gt;

&lt;p&gt;Decoupling ingestion from processing is the right design.&lt;/p&gt;

&lt;p&gt;But it creates a new dependency:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Something must reliably trigger processing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that trigger, events accumulate forever.&lt;/p&gt;




&lt;h1&gt;
  
  
  Building Recovery Mechanisms
&lt;/h1&gt;

&lt;p&gt;Adding a cron job wasn't enough.&lt;/p&gt;

&lt;p&gt;The goal was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Even if a webhook fails, the system must eventually recover.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Scheduled Processing
&lt;/h2&gt;

&lt;p&gt;A scheduler drives several independent jobs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/5 * * * *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  process-events
&lt;/h3&gt;

&lt;p&gt;Processes payment events.&lt;/p&gt;

&lt;h3&gt;
  
  
  reconcile
&lt;/h3&gt;

&lt;p&gt;Queries provider truth and recovers missed webhooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  integrity
&lt;/h3&gt;

&lt;p&gt;Validates system invariants.&lt;/p&gt;




&lt;h2&gt;
  
  
  Idempotency Everywhere
&lt;/h2&gt;

&lt;p&gt;Retries should never create incorrect state.&lt;/p&gt;

&lt;p&gt;The processor claims work using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payment_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This guarantees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple workers are safe&lt;/li&gt;
&lt;li&gt;Retries are safe&lt;/li&gt;
&lt;li&gt;Duplicate deliveries are safe&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether one processor runs or one hundred, results remain correct.&lt;/p&gt;




&lt;h1&gt;
  
  
  Proving It With CI
&lt;/h1&gt;

&lt;p&gt;A payment system you cannot test is a payment system you cannot trust.&lt;/p&gt;

&lt;p&gt;We built CI that spins up a real Postgres instance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD

    A[GitHub Actions]
    B[Postgres Container]
    C[Test Suite]
    D[117 Passing Tests]

    A --&amp;gt; B
    B --&amp;gt; C
    C --&amp;gt; D
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Duplicate webhook deliveries&lt;/li&gt;
&lt;li&gt;Event replay&lt;/li&gt;
&lt;li&gt;Concurrent processors&lt;/li&gt;
&lt;li&gt;Reconciliation recovery&lt;/li&gt;
&lt;li&gt;Booking confirmation invariants&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not just the happy path.&lt;/p&gt;

&lt;p&gt;The guarantees.&lt;/p&gt;




&lt;h1&gt;
  
  
  Final Production Architecture
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdu3le4ehc73ukh7ohe7c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdu3le4ehc73ukh7ohe7c.png" alt=" " width="800" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The architecture follows a simple principle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhooks record&lt;/li&gt;
&lt;li&gt;Events persist&lt;/li&gt;
&lt;li&gt;Processors transform&lt;/li&gt;
&lt;li&gt;Reconciliation guarantees&lt;/li&gt;
&lt;li&gt;Schedulers trigger&lt;/li&gt;
&lt;li&gt;Idempotency protects&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  What It Taught Us
&lt;/h1&gt;

&lt;h2&gt;
  
  
  A Successful Payment Is Not The Same Thing As A Successful Booking
&lt;/h2&gt;

&lt;p&gt;They are different facts.&lt;/p&gt;

&lt;p&gt;The gap between them is where customers get hurt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Webhooks Should Record Events, Not Perform Business Logic
&lt;/h2&gt;

&lt;p&gt;Business logic belongs in processors.&lt;/p&gt;

&lt;p&gt;Webhooks should be fast, durable, and boring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Every Critical Workflow Needs A Recovery Path
&lt;/h2&gt;

&lt;p&gt;If your system only works when every webhook arrives on time:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It doesn't actually work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It just hasn't failed yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Schedulers Are Production Infrastructure
&lt;/h2&gt;

&lt;p&gt;A queue that nobody drains is just a place where work waits forever.&lt;/p&gt;

&lt;p&gt;The trigger is as important as the processor itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reliability Is A Feature
&lt;/h2&gt;

&lt;p&gt;Customers never notice reliability when it exists.&lt;/p&gt;

&lt;p&gt;They immediately notice when it doesn't.&lt;/p&gt;




&lt;h1&gt;
  
  
  Closing Thoughts
&lt;/h1&gt;

&lt;p&gt;The hardest part of payments isn't collecting money.&lt;/p&gt;

&lt;p&gt;Stripe, Razorpay, PayPal, and countless providers already solved that problem.&lt;/p&gt;

&lt;p&gt;The hard part is guaranteeing that successful payments eventually become business outcomes.&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Durable events&lt;/li&gt;
&lt;li&gt;Idempotent processing&lt;/li&gt;
&lt;li&gt;Reconciliation&lt;/li&gt;
&lt;li&gt;Recovery paths&lt;/li&gt;
&lt;li&gt;Operational visibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The day we stopped thinking about payments as API calls and started thinking about them as distributed systems was the day the architecture became reliable.&lt;/p&gt;

&lt;p&gt;And that reliability is ultimately what customers pay for.&lt;/p&gt;

</description>
      <category>security</category>
      <category>performance</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
