{"id":14919,"date":"2026-09-26T17:30:00","date_gmt":"2026-09-26T12:00:00","guid":{"rendered":"https:\/\/www.allerin.com\/blog\/?p=14919"},"modified":"2026-09-25T11:19:10","modified_gmt":"2026-09-25T05:49:10","slug":"rails-webhook-acceptance-recovery","status":"publish","type":"post","link":"https:\/\/www.allerin.com\/blog\/rails-webhook-acceptance-recovery\/","title":{"rendered":"When should a Rails webhook return a successful response?"},"content":{"rendered":"<style>.allerin-rails-question{max-width:100%;overflow-wrap:anywhere}.allerin-rails-question h2{scroll-margin-top:125px}.allerin-rails-question nav{border-left:3px solid #17665b;padding:10px 18px;margin:24px 0}.allerin-rails-question nav ol{padding-left:22px}.allerin-rails-question nav a{display:inline-block}.allerin-rails-question code{white-space:normal;overflow-wrap:anywhere}.allerin-rails-question pre{max-width:100%;overflow:auto}.allerin-rails-question pre code{white-space:pre;overflow-wrap:normal}.allerin-rails-question .table-wrap{max-width:100%;overflow-x:auto;margin:22px 0}.allerin-rails-question table{width:100%;border-collapse:collapse;line-height:1.5}.allerin-rails-question th,.allerin-rails-question td{border:1px solid #d1d9d5;padding:10px;text-align:left;vertical-align:top}.allerin-rails-question th{background:#edf2ef}.allerin-rails-question[data-question=\"q05\"] th:nth-child(-n+2),.allerin-rails-question[data-question=\"q05\"] td:nth-child(-n+2){white-space:nowrap;overflow-wrap:normal}.allerin-rails-question .experiment-download{margin-top:28px;border-top:1px solid #d1d9d5;padding-top:18px;font-size:0.93em}<\/style>\n<div class=\"allerin-rails-question\" data-question=\"q04\">\n<nav aria-label=\"In this article\"><strong>In this article<\/strong><\/p>\n<ol>\n<li><a href=\"#separate-receipt-from-completion\">Separate Rails webhook acceptance from completion<\/a><\/li>\n<li><a href=\"#what-the-local-check-demonstrated\">What the local check demonstrated<\/a><\/li>\n<li><a href=\"#choose-the-recovery-contract-explicitly\">Choose the recovery contract explicitly<\/a><\/li>\n<\/ol>\n<\/nav>\n<p>A successful HTTP response from a Rails webhook tells a sender that the endpoint accepted its event. It does not tell your support team whether the associated work finished. If the handler acknowledges an event while its only remaining copy sits in a volatile queue, losing that queue can leave nothing to retry locally.<\/p>\n<p>A useful acceptance rule is to acknowledge after the application has durably recorded enough trusted information to recover the work. Processing can then happen separately. The record needs an owner, a recovery path and an observable unfinished state. A table that nobody revisits is not a recovery mechanism.<\/p>\n<h2 id=\"separate-receipt-from-completion\">Separate Rails webhook acceptance from completion<\/h2>\n<p><a href=\"https:\/\/docs.stripe.com\/webhooks\" target=\"_blank\" rel=\"noopener\">Stripe&#8217;s webhook documentation<\/a> recommends a prompt successful response before complex processing, documents duplicate deliveries and does not guarantee event order. Manual resends can overlap automatic retries. Those properties make a receiver&#8217;s acceptance decision distinct from the business operation it starts.<\/p>\n<p>For an actual Stripe endpoint, verify the signature against the unmodified request body using the supported integration path. Restrict accepted event types and versions, and establish the account context from trusted configuration. Do this before treating a payload as work. Do not use an event&#8217;s creation timestamp as a general ordering or deduplication key.<\/p>\n<p>The application also needs to distinguish a receipt identifier from the resource being updated. Two different event IDs may refer to the same invoice. Deduplicating deliveries does not, by itself, establish the right invoice state.<\/p>\n<h2 id=\"what-the-local-check-demonstrated\">What the local check demonstrated<\/h2>\n<p>The <a href=\"#download-experiments\">accompanying experiment<\/a> and <a href=\"#download-experiments\">evidence record<\/a> used Ruby 3.3.3, Active Record 8.1.3.1, Rack 3.2.7 and SQLite through the sqlite3 2.9.5 gem. It is a component fixture, not a deployed Rails endpoint. Its signing scheme is deliberately named a lab protocol; it does not implement Stripe&#8217;s signature format or exercise Stripe delivery.<\/p>\n<p>The unsafe receiver returned 200 after adding an event to an in-memory list. Clearing that list left no receipt and no local projection. The corrected receiver persisted a pending receipt before returning 202. An injected queue error then left recoverable work in the database. A separate recovery pass found that receipt and completed the local update.<\/p>\n<div class=\"table-wrap\" tabindex=\"0\" role=\"region\" aria-label=\"Experiment comparison table\">\n<table>\n<thead>\n<tr>\n<th>Injected condition<\/th>\n<th>Observed result in the fixture<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Queue hint lost or unavailable<\/td>\n<td>Pending database receipt remained discoverable<\/td>\n<\/tr>\n<tr>\n<td>Repeated event and repeated processing<\/td>\n<td>One receipt and one resource projection remained<\/td>\n<\/tr>\n<tr>\n<td>Interruption before the local transaction committed<\/td>\n<td>Projection rolled back; receipt remained pending<\/td>\n<\/tr>\n<tr>\n<td>Old event delivered after a newer one<\/td>\n<td>The fake authoritative resource supplied the current state<\/td>\n<\/tr>\n<tr>\n<td>Resource unavailable<\/td>\n<td>Processing failed visibly with the receipt still pending<\/td>\n<\/tr>\n<tr>\n<td>Invalid signature or stale signed request<\/td>\n<td>Request was rejected before storage<\/td>\n<\/tr>\n<tr>\n<td>Same event ID with a different payload<\/td>\n<td>Receiver returned a conflict instead of silently discarding it<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<p>The ten checks passed with 34 assertions on 20 September 2026. The interruption is an injected exception, not a killed operating-system process. Reopening a database connection demonstrated persisted recovery state; it did not test storage corruption or host failure.<\/p>\n<h2 id=\"choose-the-recovery-contract-explicitly\">Choose the recovery contract explicitly<\/h2>\n<p>The fixture treats queue entries as hints. Its database receipt is the durable work list. That choice requires an independently operated sweep of pending records and a policy for poison events, retention, access, alerting and retries. None of those operational responsibilities disappears because the endpoint returns quickly.<\/p>\n<p><a href=\"https:\/\/guides.rubyonrails.org\/v8.1\/active_job_basics.html#transactional-integrity-on-jobs\" target=\"_blank\" rel=\"noopener\">Active Job&#8217;s transaction guidance<\/a> explains why backend and database arrangement matter. A queue in another database cannot simply inherit the application&#8217;s transaction. Confirm the actual adapter and deployment before copying an enqueue pattern.<\/p>\n<p>Here, the resource projection and processed marker share one local transaction. An external payment, email or accounting write would cross another boundary and require its own idempotency and reconciliation design. This example therefore makes no universal exactly-once claim. Its late-event test is sequential against a fixed fake resource. Separate receipts do not lock that resource together, so the fixture does not prevent regression from concurrent stale reads. It also does not justify holding a database lock during a slow network request.<\/p>\n<p>For an existing application, define one receiver-to-result path and agree what accepted, pending, completed and failed mean. That produces a useful <a href=\"https:\/\/www.allerin.com\/services\/ruby-on-rails\">Rails integration delivery scope<\/a>: implement the receipt and recovery behavior, execute the failure cases, and hand over the operating responsibilities along with the code.<\/p>\n<p><em>Prepared and executed with AI assistance using synthetic inputs. Independent AI reviews are recorded separately from the byline owner\u2019s approval; no human engineering sign-off is claimed.<\/em><\/p>\n<section class=\"experiment-download\" aria-labelledby=\"download-experiments\">\n<h2 id=\"download-experiments\">Download the experiment<\/h2>\n<p><a href=\"https:\/\/www.allerin.com\/blog\/wp-content\/uploads\/2026\/09\/allerin-rails-synthetic-experiments-2026-09-20.zip\">Download the runnable Rails examples and evidence summary (ZIP, 36 KB)<\/a>. This article\u2019s example is in <code>q04\/<\/code>; the shared setup and reproduction instructions are in <code>README.md<\/code>. The package contains eight synthetic examples, checked on 20 September 2026. The recorded runtime, provider fakes and limits are documented inside.<\/p>\n<\/section>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A tested local receipt-and-recovery example separates webhook acceptance from completed processing, with explicit retry and concurrency limits.<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","_links_to":"","_links_to_target":""},"categories":[2037],"tags":[2077,2061,20],"class_list":["post-14919","post","type-post","status-publish","format-standard","hentry","category-ruby-on-rails","tag-rails-integrations","tag-rails-testing","tag-ruby-on-rails"],"_links":{"self":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14919","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/comments?post=14919"}],"version-history":[{"count":4,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14919\/revisions"}],"predecessor-version":[{"id":14959,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14919\/revisions\/14959"}],"wp:attachment":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/media?parent=14919"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/categories?post=14919"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/tags?post=14919"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}