<?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: Jibran Usman</title>
    <description>The latest articles on DEV Community by Jibran Usman (@jibranusman95).</description>
    <link>https://dev.to/jibranusman95</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%2F3965902%2F023010ee-a64d-4d53-aba0-1488285dc4d1.jpeg</url>
      <title>DEV Community: Jibran Usman</title>
      <link>https://dev.to/jibranusman95</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jibranusman95"/>
    <language>en</language>
    <item>
      <title>The webhook inbox pattern: stop writing this controller by hand</title>
      <dc:creator>Jibran Usman</dc:creator>
      <pubDate>Tue, 16 Jun 2026 18:33:10 +0000</pubDate>
      <link>https://dev.to/jibranusman95/the-webhook-inbox-pattern-stop-writing-this-controller-by-hand-17an</link>
      <guid>https://dev.to/jibranusman95/the-webhook-inbox-pattern-stop-writing-this-controller-by-hand-17an</guid>
      <description>&lt;p&gt;Every Rails app that takes Stripe webhooks has some version of this controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;
  &lt;span class="n"&gt;sig_header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HTTP_STRIPE_SIGNATURE"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="n"&gt;event&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Stripe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;construct_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig_header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"STRIPE_SECRET"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:ok&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;StripeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;stripe_id: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="no"&gt;StripeEvent&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="ss"&gt;stripe_id: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;HandleStripeEventJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:ok&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Stripe&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SignatureVerificationError&lt;/span&gt;
  &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:unauthorized&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RecordNotUnique&lt;/span&gt;
  &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:ok&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've written this in at least four different Rails apps. I've seen it copied from the same three blog posts. And I've had it break in production — twice.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with hand-rolling it
&lt;/h2&gt;

&lt;p&gt;It looks fine. It's not fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The race condition.&lt;/strong&gt; Stripe retries failed webhooks. Two retries can arrive within milliseconds of each other. Both hit &lt;code&gt;StripeEvent.exists?&lt;/code&gt; before either has committed a row. Both return false. Both create a row. Now your &lt;code&gt;customer.subscription.created&lt;/code&gt; handler has fired twice — you've created two subscriptions, charged the customer twice, sent two welcome emails.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;rescue ActiveRecord::RecordNotUnique&lt;/code&gt; at the bottom is the fix, but it's easy to miss, and it only works if you have a unique index on &lt;code&gt;stripe_id&lt;/code&gt;. Most people add the check but forget the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The missing replay.&lt;/strong&gt; Your handler has a bug. It's been failing silently for 6 hours. You've lost 40 events. You want to reprocess them. Your options are: query the raw payload from wherever you stored it, manually re-trigger the job for each one, and hope you got it right. There's no dashboard showing you what failed, no replay button, no error messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tight coupling.&lt;/strong&gt; The controller does too much — it verifies, deduplicates, stores, and enqueues, all inline. Every time you add a provider (Shopify, GitHub, Twilio), you copy-paste and adapt. It diverges immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  The inbox pattern
&lt;/h2&gt;

&lt;p&gt;The solution is well-known in backend circles. Store every incoming webhook immediately, then process asynchronously. Deduplication happens at the storage layer, not the application layer. Processing failures are recoverable because the original payload is always there.&lt;/p&gt;

&lt;p&gt;Every blog post about this teaches you to build it yourself. RailsConf 2023 had a whole workshop on it. AppSignal wrote about it. They all show the same ~50 lines and send you on your way.&lt;/p&gt;

&lt;p&gt;I got tired of writing those 50 lines. So I packaged them.&lt;/p&gt;




&lt;h2&gt;
  
  
  webhook_inbox
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle add webhook_inbox
rails generate webhook_inbox:install
rails db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator creates the &lt;code&gt;webhook_inbox_events&lt;/code&gt; table and an initializer stub.&lt;/p&gt;

&lt;p&gt;Wire up your controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StripeWebhooksController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;WebhookInbox&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Receiver&lt;/span&gt;
  &lt;span class="n"&gt;receive_from&lt;/span&gt; &lt;span class="ss"&gt;:stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;secret: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"STRIPE_WEBHOOK_SECRET"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="n"&gt;receive_webhook!&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register your handlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/initializers/webhook_inbox.rb&lt;/span&gt;
&lt;span class="no"&gt;WebhookInbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"customer.subscription.created"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="no"&gt;CreateSubscriptionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parsed_payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"invoice.payment_failed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="no"&gt;NotifyPaymentFailedJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parsed_payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. &lt;code&gt;receive_webhook!&lt;/code&gt; runs the full pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verifies the Stripe signature → &lt;code&gt;401&lt;/code&gt; on failure&lt;/li&gt;
&lt;li&gt;Inserts the event into the DB — the unique constraint on &lt;code&gt;[provider, event_id]&lt;/code&gt; is the deduplication mechanism, not application code&lt;/li&gt;
&lt;li&gt;Enqueues &lt;code&gt;WebhookInbox::ProcessJob&lt;/code&gt; via ActiveJob&lt;/li&gt;
&lt;li&gt;Responds &lt;code&gt;200 OK&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The race condition is gone. The DB unique constraint is enforced at the transaction level. Two simultaneous identical deliveries can't both succeed — one will hit &lt;code&gt;ActiveRecord::RecordNotUnique&lt;/code&gt; and return &lt;code&gt;200&lt;/code&gt; silently. No double processing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the DB constraint, not application code
&lt;/h2&gt;

&lt;p&gt;The critical design decision is putting deduplication in the database, not the controller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:webhook_inbox_events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:event_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;unique: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Application-level checks (&lt;code&gt;exists?&lt;/code&gt; before &lt;code&gt;create!&lt;/code&gt;) have a TOCTOU (time-of-check-time-of-use) race: two processes can both read false from &lt;code&gt;exists?&lt;/code&gt; before either has written. Database constraints don't have this problem — they're enforced atomically at the transaction level by the DB engine.&lt;/p&gt;

&lt;p&gt;The constraint works on SQLite, PostgreSQL, and MySQL. No Redis, no distributed locks, no external services.&lt;/p&gt;




&lt;h2&gt;
  
  
  Replay and visibility
&lt;/h2&gt;

&lt;p&gt;Every event is stored with its full payload, status, attempt count, and any error message from failures.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;WebhookInbox&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:retry!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;WebhookInbox&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for_provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:stripe&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;event_type: &lt;/span&gt;&lt;span class="s2"&gt;"invoice.payment_failed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;        &lt;span class="c1"&gt;# =&amp;gt; "failed"&lt;/span&gt;
&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error_message&lt;/span&gt; &lt;span class="c1"&gt;# =&amp;gt; "RuntimeError: customer not found\n  app/jobs/..."&lt;/span&gt;
&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attempts&lt;/span&gt;      &lt;span class="c1"&gt;# =&amp;gt; 3&lt;/span&gt;
&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retry!&lt;/span&gt;        &lt;span class="c1"&gt;# → re-enqueues the handler job&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mount the dashboard in &lt;code&gt;config/routes.rb&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;mount&lt;/span&gt; &lt;span class="no"&gt;WebhookInbox&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/webhook_inbox"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard shows all events with status badges, full JSON payload, and a replay button per event. When something goes wrong at 2am, you want to see exactly what failed and reprocess it from a browser — not dig through logs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# spec/rails_helper.rb&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"webhook_inbox/rspec"&lt;/span&gt;

&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="s2"&gt;"Stripe billing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;type: :request&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"creates a subscription on webhook"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;deliver_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"customer.subscription.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;object: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="s2"&gt;"sub_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;customer: &lt;/span&gt;&lt;span class="s2"&gt;"cus_456"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;perform_enqueued_jobs&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;stripe_id: &lt;/span&gt;&lt;span class="s2"&gt;"sub_123"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be_active&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"ignores duplicate deliveries"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;times&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;deliver_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"customer.subscription.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="ss"&gt;event_id: &lt;/span&gt;&lt;span class="s2"&gt;"evt_fixed_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;WebhookInbox&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deliver_webhook&lt;/code&gt; signs the request with HMAC-SHA256 the same way Stripe does. The signature passes real verification — no mocking of the signature check, no bypassing the controller logic.&lt;/p&gt;




&lt;h2&gt;
  
  
  What about stripe_event?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/integrallis/stripe_event" rel="noopener noreferrer"&gt;stripe_event&lt;/a&gt; is a great event router — 14.5M downloads — and it's not what webhook_inbox replaces. stripe_event routes events to handlers. It has no storage, no deduplication, no replay.&lt;/p&gt;

&lt;p&gt;If you use stripe_event, webhook_inbox sits underneath it as the storage and dedup layer. They're complementary.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more thing: body rewinding
&lt;/h2&gt;

&lt;p&gt;One sharp edge that burned me during development: Rails middleware can consume &lt;code&gt;request.body&lt;/code&gt; before your controller sees it. If you read the body for signature verification, it's gone by the time you try to read it again for storage.&lt;/p&gt;

&lt;p&gt;The fix is explicit: &lt;code&gt;request.body.rewind&lt;/code&gt; before every read. The gem handles this internally — &lt;code&gt;read_request_body&lt;/code&gt; rewinds before reading, and the raw body is passed explicitly to the provider adapter rather than re-reading from the request. It's boring plumbing but it has to be right.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle add webhook_inbox
rails generate webhook_inbox:install
rails db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub: &lt;a href="https://github.com/jibranusman95/webhook_inbox" rel="noopener noreferrer"&gt;jibranusman95/webhook_inbox&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you hit something weird or have a use case I haven't thought of, open an issue. I read them all.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why WebMock Stubs Lie (And What To Do About It)</title>
      <dc:creator>Jibran Usman</dc:creator>
      <pubDate>Wed, 03 Jun 2026 06:50:25 +0000</pubDate>
      <link>https://dev.to/jibranusman95/why-webmock-stubs-lie-and-what-to-do-about-it-4990</link>
      <guid>https://dev.to/jibranusman95/why-webmock-stubs-lie-and-what-to-do-about-it-4990</guid>
      <description>&lt;p&gt;You've written the test. WebMock is set up. The stub returns 200. Everything is green.&lt;/p&gt;

&lt;p&gt;Then Stripe ships a breaking change, your code sends a malformed request body, and you find out in production.&lt;/p&gt;

&lt;p&gt;Your tests lied to you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem With Stubs
&lt;/h2&gt;

&lt;p&gt;WebMock is excellent at what it does. But what it does is intercept HTTP calls and return whatever you told it to return — regardless of whether your request was valid.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;stub_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"https://api.stripe.com/v1/charges"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;body: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="s2"&gt;"ch_123"&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This stub will return 200 whether you send:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A perfectly formed request&lt;/li&gt;
&lt;li&gt;A request missing the required &lt;code&gt;amount&lt;/code&gt; field&lt;/li&gt;
&lt;li&gt;A request with a completely wrong content type&lt;/li&gt;
&lt;li&gt;Nothing at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You're not testing that your integration works. You're testing that your code calls a URL.&lt;/p&gt;




&lt;h2&gt;
  
  
  VCR Cassettes Are Worse
&lt;/h2&gt;

&lt;p&gt;VCR records real HTTP interactions and replays them. That sounds great until:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cassettes go stale.&lt;/strong&gt; The API changes, your cassette keeps replaying the old response forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diffs are unreadable.&lt;/strong&gt; Cassette files are YAML blobs that nobody wants to review in a PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-recording is painful.&lt;/strong&gt; You need real credentials, a real network, and hope the API behaves the same way twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel tests break.&lt;/strong&gt; Cassettes aren't built for concurrent access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You end up with a test suite that passes in CI and quietly lies about your production behaviour for months.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Helps: A Real Server
&lt;/h2&gt;

&lt;p&gt;What if your test spun up an actual HTTP server — one that could validate your requests, enforce contracts, and simulate real failure modes?&lt;/p&gt;

&lt;p&gt;That's what &lt;a href="https://github.com/jibranusman95/http_decoy" rel="noopener noreferrer"&gt;http_decoy&lt;/a&gt; does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;HttpDecoy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:payments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="s2"&gt;"https://payments.example.com"&lt;/span&gt;

  &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="s2"&gt;"/charge"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;requires_body&lt;/span&gt; &lt;span class="ss"&gt;:amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:currency&lt;/span&gt;
    &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="s2"&gt;"ch_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="s2"&gt;"/charge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;scenario: :decline&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;402&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="s2"&gt;"card_declined"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a real WEBrick server running on a random port inside your test process. Your code makes actual HTTP calls to it. WebMock integration is automatic — no config needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  In Your RSpec Tests
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt; &lt;span class="no"&gt;PaymentsService&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;HttpDecoy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:payments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rspec_helpers&lt;/span&gt;

  &lt;span class="n"&gt;fake_server&lt;/span&gt; &lt;span class="ss"&gt;:payments&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"charges the card"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;PaymentsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;amount: &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;currency: &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be_success&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"handles declines gracefully"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;with_scenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:decline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;PaymentsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;amount: &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;currency: &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;be_declined&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"validates the request body"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;PaymentsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;amount: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;raise_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;PaymentsService&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;InvalidRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what changed: the server validates that &lt;code&gt;amount&lt;/code&gt; and &lt;code&gt;currency&lt;/code&gt; are present. If your code doesn't send them, the request fails — exactly like the real API would.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Catches That WebMock Doesn't
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;WebMock&lt;/th&gt;
&lt;th&gt;http_decoy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing required field&lt;/td&gt;
&lt;td&gt;Passes silently&lt;/td&gt;
&lt;td&gt;Fails the request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong content type&lt;/td&gt;
&lt;td&gt;Passes silently&lt;/td&gt;
&lt;td&gt;Configurable validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale response format&lt;/td&gt;
&lt;td&gt;Invisible&lt;/td&gt;
&lt;td&gt;Update the fake, tests break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network timeout simulation&lt;/td&gt;
&lt;td&gt;Awkward&lt;/td&gt;
&lt;td&gt;&lt;code&gt;raise_error Net::ReadTimeout&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scenario switching&lt;/td&gt;
&lt;td&gt;Multiple stubs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;with_scenario(:decline)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request log inspection&lt;/td&gt;
&lt;td&gt;Not built in&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;have_received_request&lt;/code&gt; matcher&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Scenario Switching
&lt;/h2&gt;

&lt;p&gt;Testing sad paths with WebMock means redefining stubs inside individual tests — messy and hard to reuse. With http_decoy, scenarios are first-class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;HttpDecoy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:payments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="s2"&gt;"https://payments.example.com"&lt;/span&gt;

  &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="s2"&gt;"/charge"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="s2"&gt;"ch_123"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="s2"&gt;"/charge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;scenario: :rate_limited&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="s2"&gt;"rate_limit_exceeded"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="s2"&gt;"/charge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;scenario: :server_error&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="s2"&gt;"internal_server_error"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# In tests:&lt;/span&gt;
&lt;span class="n"&gt;with_scenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:rate_limited&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;PaymentsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;with_scenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:server_error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;PaymentsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Inspecting Requests
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="s2"&gt;"sends the correct currency"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;PaymentsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;amount: &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;currency: &lt;/span&gt;&lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payments_server&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;have_received_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/charge"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;including&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"currency"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"usd"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't checking that your code built the right hash. It's checking what actually went over the wire.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gem &lt;span class="nb"&gt;install &lt;/span&gt;http_decoy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Gemfile&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"http_decoy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;group: :test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works with WebMock out of the box. No additional config for existing test suites.&lt;/p&gt;

&lt;p&gt;Full docs and examples: &lt;a href="https://github.com/jibranusman95/http_decoy" rel="noopener noreferrer"&gt;https://github.com/jibranusman95/http_decoy&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;WebMock is the right tool for unit tests where you want fast, isolated behaviour. But for integration tests that touch real service boundaries, you want a server that enforces contracts — not a stub that returns whatever you asked it to.&lt;/p&gt;

&lt;p&gt;Your cassettes are lying to you. Ship a fake that tells the truth.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built this? Feedback welcome in the comments or open an issue on GitHub.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>testing</category>
      <category>rails</category>
      <category>cicd</category>
    </item>
  </channel>
</rss>
