{
  "@context": "https://mith.tech/patterns.json",
  "name": "MithTech Architecture Patterns",
  "description": "Integration and architecture patterns for ERP, commerce and accounting systems, each with when to use it, when not to, trade-offs, documented failure modes and ERPNext, Frappe and Medusa mappings.",
  "license": "https://creativecommons.org/licenses/by/4.0/",
  "source": "https://mith.tech/patterns",
  "count": 6,
  "patterns": [
    {
      "patternId": "point-to-point-vs-hub",
      "title": "Point-to-point vs a hub",
      "kind": "integration",
      "url": "https://mith.tech/patterns/point-to-point-vs-hub",
      "summary": "Point-to-point integration builds one connection for every pair of systems, up to n(n−1)/2 for n systems, while a hub gives each system one connection to a shared layer, so n systems need n.",
      "problem": "A business connects its store to its ERP, then the bank feed, then WhatsApp, then the courier, each time with a link built for just those two systems. Each link is mapped, run and watched on its own. When one system changes, someone has to find every link that touches it.",
      "whenToUse": [
        "Four or more systems exchange data, so direct links outnumber systems.",
        "Several systems need the same record, such as the customer or the item.",
        "Systems change on their own schedule and you want a change to touch one connection, not many."
      ],
      "whenNotToUse": [
        "You connect three systems or fewer. A separate hub saves nothing at three: three links either way.",
        "One system only ever feeds one other, and nothing else is coming.",
        "Nobody will run, watch and back up one more component. A hub with no owner is a new single point of failure."
      ],
      "tradeoffs": [
        {
          "gain": "A change in one system touches one connection.",
          "cost": "A new component to run, secure, upgrade and back up."
        },
        {
          "gain": "Adding a system adds one link, not up to n.",
          "cost": "A shared message format to agree on and keep current."
        },
        {
          "gain": "One place to log and replay what moved.",
          "cost": "If the hub is down, every link is down."
        }
      ],
      "failureModes": [
        {
          "mode": "The hub becomes the bottleneck.",
          "why": "Routing all messages through one broker \"can turn the Message Broker into a serious bottleneck\". Stateless brokers can run as several instances.",
          "evidence": "documented",
          "sources": [
            "eip-broker"
          ]
        },
        {
          "mode": "One hub grows into everything.",
          "why": "The same page names a broker that grows \"unmaintainable\" and offers splitting it into several as the answer.",
          "evidence": "documented",
          "sources": [
            "eip-broker"
          ]
        },
        {
          "mode": "At small scale the shared format costs more than it saves.",
          "why": "Two applications need 2 translators directly and 4 with a canonical model; three need 6 either way.",
          "evidence": "documented",
          "sources": [
            "eip-cdm"
          ]
        },
        {
          "mode": "A hub outage stops every link.",
          "why": "Every link passes through it.",
          "evidence": "derived",
          "sources": []
        }
      ],
      "systemMappings": {
        "erpnext": [
          {
            "feature": "REST API",
            "location": "/api/resource/:doctype and /api/resource/:doctype/:name",
            "note": "Other systems list, create, read, update and delete any document through one interface."
          },
          {
            "feature": "Webhook",
            "location": "Integrations › Webhook (doctype Webhook)",
            "note": "Sends a document's event to a URL: after insert, on update, on submit, on cancel, on trash or on change. Set per doctype and event."
          },
          {
            "feature": "Webhook Request Log",
            "location": "doctype",
            "note": "One row for every attempt, with the response."
          },
          {
            "feature": "Your ERP as the hub",
            "location": "the doctypes themselves (Customer, Item, Sales Order)",
            "note": "Other systems read and write the record instead of keeping a copy. This is the n−1 case in the counter."
          },
          {
            "feature": "A separate layer",
            "location": "n8n, or a custom Frappe app",
            "note": "n8n's ERPNext node has five document operations: create, retrieve, retrieve all, update and delete. Triggers come from ERPNext Webhooks."
          }
        ]
      },
      "relationships": [
        {
          "predicate": "usesConcept",
          "target": "customer"
        },
        {
          "predicate": "usesConcept",
          "target": "item"
        },
        {
          "predicate": "usesConcept",
          "target": "sales-order"
        },
        {
          "predicate": "pairsWith",
          "target": "idempotent-webhooks"
        }
      ],
      "relatedContent": {
        "docs": [
          "erpnext-implementation-guide/current-software-audit"
        ],
        "posts": [
          "erpnext-n8n-integration",
          "medusa-commerce-architecture",
          "erpnext-shopify-integration"
        ],
        "demos": [
          "order"
        ]
      },
      "interactive": {
        "kind": "simulator",
        "title": "Wire counter",
        "description": "Choose the systems you run. See the links with every pair connected, then through a hub."
      },
      "sources": [
        {
          "id": "eip-broker",
          "publisher": "Hohpe and Woolf, Enterprise Integration Patterns",
          "title": "Message Broker (© 2003, 2023)",
          "url": "https://www.enterpriseintegrationpatterns.com/patterns/messaging/MessageBroker.html",
          "accessed": "2026-10-13"
        },
        {
          "id": "eip-cdm",
          "publisher": "Hohpe and Woolf, Enterprise Integration Patterns",
          "title": "Canonical Data Model (© 2003, 2023)",
          "url": "https://www.enterpriseintegrationpatterns.com/patterns/messaging/CanonicalDataModel.html",
          "accessed": "2026-10-13"
        },
        {
          "id": "frappe-rest",
          "publisher": "Frappe",
          "title": "REST API (updated 2026-09-29)",
          "url": "https://docs.frappe.io/framework/user/en/api/rest",
          "accessed": "2026-10-13"
        },
        {
          "id": "frappe-webhook-events",
          "publisher": "Frappe",
          "title": "webhook/__init__.py run_webhooks, v15.123.0",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/integrations/doctype/webhook/__init__.py",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-webhook-v15",
          "publisher": "Frappe",
          "title": "webhook.py, v15.123.0, lines 158 to 196; v16.52.0 keeps the three tries and the 1 s and 4 s pauses",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/integrations/doctype/webhook/webhook.py",
          "accessed": "2026-10-14"
        },
        {
          "id": "n8n-erpnext",
          "publisher": "n8n docs",
          "title": "ERPNext node",
          "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.erpnext/",
          "accessed": "2026-10-13"
        }
      ],
      "provenance": {
        "publisher": "MithTech",
        "lastReviewed": "2026-10-14",
        "basis": "Checked against the named primary sources on the dates shown. Failure modes are documented behaviour, not results from MithTech client projects."
      }
    },
    {
      "patternId": "webhooks-vs-polling",
      "title": "Webhooks vs polling",
      "kind": "integration",
      "url": "https://mith.tech/patterns/webhooks-vs-polling",
      "summary": "A webhook sends one request for each change and arrives when the sender decides, while polling sends a fixed number of requests a day and waits up to one interval, so the cheaper choice depends on how many changes arrive against how many polls fit inside the delay you can accept.",
      "problem": "One system has to learn when another changed. It can wait to be told, with a webhook, or keep asking, with a poll. A webhook can arrive late, twice or never; a poll costs requests whether or not anything changed.",
      "whenToUse": [
        "Changes are rarer than the polls that would catch them: under 1,440 a day if a minute is the longest wait you accept.",
        "The source offers webhooks and can reach an address of yours.",
        "A change matters within seconds, not hours."
      ],
      "whenNotToUse": [
        "The source sends no webhooks, or cannot reach you: a desktop system, a private network.",
        "Changes come faster than you would poll and a wait of minutes is fine: one poll then collects many.",
        "A missed change would cost money. Keep a poll, alone or as the net beside webhooks."
      ],
      "tradeoffs": [
        {
          "gain": "A webhook sends one request per change and none when nothing changed.",
          "cost": "You run a public address, check each signature and handle repeats and retries."
        },
        {
          "gain": "The change arrives when the sender sends it, often within seconds.",
          "cost": "You do not choose the delay or the order, and the sender stops retrying after a fixed time."
        },
        {
          "gain": "A poll lets you choose the interval, the batch and the order.",
          "cost": "Every poll costs a request and a slice of the rate limit, whether or not anything changed."
        },
        {
          "gain": "A poll needs no public address and, with a saved checkpoint, does not skip a change it can see.",
          "cost": "It cannot see a deletion unless the source lists deletions, and two changes inside one interval look like one."
        }
      ],
      "failureModes": [
        {
          "mode": "Webhooks stop arriving and nothing says so.",
          "why": "Shopify says delivery isn't always guaranteed and deletes an API-created subscription after eight failures in a row. Razorpay disables a webhook that fails for 24 hours. Stripe retries for up to three days; after that only its List events API, which reaches back 30 days, still holds the event.",
          "evidence": "documented",
          "sources": [
            "shopify-webhooks",
            "shopify-dupes",
            "razorpay-bp",
            "stripe-undelivered"
          ]
        },
        {
          "mode": "Events arrive out of order.",
          "why": "Shopify, Stripe and Razorpay all say order is not guaranteed. Fetch the current record instead of trusting the order of arrival.",
          "evidence": "documented",
          "sources": [
            "shopify-webhooks",
            "stripe-webhooks",
            "razorpay-bp"
          ]
        },
        {
          "mode": "The poll runs less often than you set.",
          "why": "A Frappe scheduled job of type All runs every four minutes by default, and the Shopify connector's 5-minute setting is a minimum gap checked on those ticks. Version-16's scheduler wakes every four minutes unless scheduler_tick_interval is set; version-15's wakes every minute.",
          "evidence": "documented",
          "sources": [
            "frappe-sjt-v15",
            "frappe-scheduler-v16",
            "frappe-scheduler-v15",
            "ecom-scheduling"
          ]
        },
        {
          "mode": "The poll never runs.",
          "why": "Frappe queues no scheduled job while the scheduler is paused or disabled. Medusa runs scheduled jobs only on the worker, so a deployment with only a server instance runs none.",
          "evidence": "documented",
          "sources": [
            "frappe-scheduler-v15",
            "medusa-worker-mode",
            "medusa-sched-trouble"
          ]
        },
        {
          "mode": "A poll by modified date cannot see a deletion.",
          "why": "A deleted row is simply missing from the list. Frappe records each deletion in Deleted Document, so read that list as well.",
          "evidence": "documented",
          "sources": [
            "frappe-deleted-doc-v15"
          ]
        },
        {
          "mode": "A rate limit can count what you poll.",
          "why": "A Frappe site can cap server time per window with the rate_limit setting; a poll over the cap is refused with 429 and a Retry-After header. Polling every ten seconds is 8,640 requests a day even if nothing changes.",
          "evidence": "documented",
          "sources": [
            "frappe-ratelimit-v15"
          ]
        }
      ],
      "systemMappings": {
        "erpnext": [
          {
            "feature": "Send a change (webhook)",
            "location": "Integrations › Webhook",
            "note": "Sends a document's event to a URL after the save commits: after insert, on update, on submit, on cancel, on trash or on change. A condition can filter it. The pending send waits in the request's memory, then a background job."
          },
          {
            "feature": "Retries and log",
            "location": "Webhook Request Log",
            "note": "Three tries, five seconds each by default. A timeout is retried at once, other errors after one and four seconds. One log row per attempt."
          },
          {
            "feature": "Poll a doctype",
            "location": "GET /api/resource/:doctype",
            "note": "Filter on modified, sort on it and page through. The list returns 20 rows unless you ask for more, and modified is indexed on every doctype."
          },
          {
            "feature": "See deletions",
            "location": "Deleted Document",
            "note": "One row for each deleted document, with its JSON, unless the delete was permanent."
          },
          {
            "feature": "Poll from inside Frappe",
            "location": "Scheduled Job Type, frequency All",
            "note": "Runs every four minutes by default in version-15 and version-16. The scheduler wakes every minute in version-15 and every four minutes in version-16, unless scheduler_tick_interval is set."
          },
          {
            "feature": "Limit the calls",
            "location": "site config rate_limit",
            "note": "Off unless set. It counts server time, not requests. Over the limit: 429 with Retry-After."
          },
          {
            "feature": "Both together",
            "location": "Ecommerce Integrations › Shopify Setting",
            "note": "Orders arrive by Shopify webhooks; the connector saves the payload in an Ecommerce Integration Log, commits, then queues the job. Stock goes out on a schedule, only for items whose Bin changed since the last sync."
          }
        ],
        "medusa": [
          {
            "feature": "In Medusa",
            "location": "Event Module, subscribers, scheduled jobs",
            "note": "Subscribers react to events. The default Local module is for development; Redis is the production module and makes one attempt unless attempts is set. Scheduled jobs and subscribers run only on a worker instance."
          }
        ]
      },
      "relationships": [
        {
          "predicate": "usesConcept",
          "target": "sales-order"
        },
        {
          "predicate": "usesConcept",
          "target": "item"
        },
        {
          "predicate": "pairsWith",
          "target": "idempotent-webhooks"
        },
        {
          "predicate": "pairsWith",
          "target": "reconciliation-as-a-job"
        }
      ],
      "relatedContent": {
        "docs": [],
        "posts": [
          "erpnext-n8n-integration",
          "erpnext-shopify-integration",
          "erpnext-scheduler-process-not-found-docker"
        ],
        "demos": []
      },
      "interactive": {
        "kind": "simulator",
        "title": "Cost and delay plotter",
        "description": "Set the changes a day and the longest wait you accept. See the requests a day each way, the break-even and a verdict."
      },
      "sources": [
        {
          "id": "shopify-webhooks",
          "publisher": "Shopify",
          "title": "About webhooks: polling, ordering, \"Implement reconciliation jobs\"",
          "url": "https://shopify.dev/docs/apps/build/webhooks",
          "accessed": "2026-10-14"
        },
        {
          "id": "shopify-dupes",
          "publisher": "Shopify",
          "title": "Verify and ignore duplicate webhooks: retries and failures (the C07 URL /ignore-duplicates now redirects here)",
          "url": "https://shopify.dev/docs/apps/build/webhooks/verify-deliveries",
          "accessed": "2026-10-14"
        },
        {
          "id": "razorpay-bp",
          "publisher": "Razorpay",
          "title": "Webhook best practices: 24-hour retries, disable logic, \"you may poll our payment APIs as well\"",
          "url": "https://razorpay.com/docs/webhooks/best-practices/",
          "accessed": "2026-10-14"
        },
        {
          "id": "stripe-undelivered",
          "publisher": "Stripe",
          "title": "Process undelivered webhook events (List events, 30 days)",
          "url": "https://docs.stripe.com/webhooks/process-undelivered-events",
          "accessed": "2026-10-14"
        },
        {
          "id": "stripe-webhooks",
          "publisher": "Stripe",
          "title": "Receive Stripe events in your webhook endpoint",
          "url": "https://docs.stripe.com/webhooks",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-sjt-v15",
          "publisher": "Frappe",
          "title": "core/doctype/scheduled_job_type/scheduled_job_type.py L125, v15.123.0 (L126 in v16.52.0): All = every 4 minutes",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py#L125",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-scheduler-v16",
          "publisher": "Frappe",
          "title": "utils/scheduler.py L26, L78-91, L262-264, v16.52.0: wakes every 240 s unless scheduler_tick_interval is set",
          "url": "https://github.com/frappe/frappe/blob/v16.52.0/frappe/utils/scheduler.py#L262-L264",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-scheduler-v15",
          "publisher": "Frappe",
          "title": "utils/scheduler.py L99-100, L132-146, L247-248, v15.123.0: wakes every 60 s; nothing queued while paused or disabled",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/utils/scheduler.py#L247-L248",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-scheduling",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "controllers/scheduling.py L5-23 and hooks.py L139: a minimum gap, checked on each All tick",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/controllers/scheduling.py#L5-L23",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-worker-mode",
          "publisher": "Medusa",
          "title": "docs: Worker Mode of Medusa Instance (v2.21.2): the worker runs scheduled jobs and subscribers",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/www/apps/book/app/learn/production/worker-mode/page.mdx#L16",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-sched-trouble",
          "publisher": "Medusa",
          "title": "docs: scheduled job not running, deploy only a server instance (v2.21.2)",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/www/apps/resources/app/troubleshooting/_sections/scheduled-jobs/not-running-worker-mode.mdx#L15",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-deleted-doc-v15",
          "publisher": "Frappe",
          "title": "model/delete_doc.py L183-206, v15.123.0: each deleted document is written to Deleted Document",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/model/delete_doc.py#L183-L206",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-ratelimit-v15",
          "publisher": "Frappe",
          "title": "rate_limiter.py L16-20, L50, L70-102, v15.123.0: opt-in; counts server time; 429 with Retry-After",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/rate_limiter.py#L16-L102",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-webhook-queue-v15",
          "publisher": "Frappe",
          "title": "webhook/__init__.py L61-107, v15.123.0: the pending send waits in memory, then is queued after the commit",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/integrations/doctype/webhook/__init__.py#L61-L107",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-commit-v15",
          "publisher": "Frappe",
          "title": "database/database.py L1173-1184, v15.123.0: commit runs the after-commit hooks after SQL commit",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/database/database.py#L1173-L1184",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-webhook-send-v15",
          "publisher": "Frappe",
          "title": "webhook/webhook.py L158-221, v15.123.0: three tries, 5 s timeout, one log row per attempt",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/integrations/doctype/webhook/webhook.py#L158-L221",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-rest-list-v15",
          "publisher": "Frappe",
          "title": "api/v1.py L19-22, v15.123.0: the REST list returns 20 rows by default",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/api/v1.py#L19-L22",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-modified-index-v15",
          "publisher": "Frappe",
          "title": "database/mariadb/schema.py L35, v15.123.0: every doctype table has an index on modified",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/database/mariadb/schema.py#L35",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-shopify-constants",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "shopify/constants.py L11-17: the five Shopify order webhooks",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/shopify/constants.py#L11-L17",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-shopify-connection",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "shopify/connection.py L96-119 (version-15 3dc8eb81): check the HMAC, save a log row, then queue the job",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/shopify/connection.py#L96-L119",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-integration-log",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "ecommerce_integration_log.py L40-78: the log row is saved and committed",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py#L40-L78",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-inventory-controller",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "controllers/inventory.py L16-37: only Bins modified since the last sync",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/controllers/inventory.py#L16-L37",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-shopify-inventory",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "shopify/inventory.py L19-36, L43-67: ERPNext stock pushed to Shopify as an absolute value",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/shopify/inventory.py#L19-L67",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-event-docs",
          "publisher": "Medusa",
          "title": "docs: Event Module (v2.21.2): Local by default, Redis for production",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/www/apps/resources/app/infrastructure-modules/event/page.mdx#L25",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-event-redis",
          "publisher": "Medusa",
          "title": "event-bus-redis.ts L152-156, L219, L265-347, L477-505 (v2.21.2): attempts 1, staging list, TTL 600 s set again with every rpush (L283-294, so it counts from the last event staged; the comment at L288-291 says long-running workflows need a much higher TTL or none), release",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/packages/modules/event-bus-redis/src/services/event-bus-redis.ts#L265-L347",
          "accessed": "2026-10-14"
        }
      ],
      "provenance": {
        "publisher": "MithTech",
        "lastReviewed": "2026-10-14",
        "basis": "Checked against the named primary sources on the dates shown. Failure modes are documented behaviour, not results from MithTech client projects."
      }
    },
    {
      "patternId": "idempotent-webhooks",
      "title": "Idempotent webhooks",
      "kind": "integration",
      "url": "https://mith.tech/patterns/idempotent-webhooks",
      "summary": "An idempotent webhook handler records each event's unique id in the same transaction as its effect, so receiving the same event any number of times changes the books once.",
      "problem": "Webhook senders deliver at least once. A slow reply, a lost reply or an error makes the sender try again, so the same event can arrive twice, sometimes at the same moment. A handler that books a receipt every time it is called turns one payment into two.",
      "whenToUse": [
        "A webhook creates or changes money, stock or a document.",
        "The sender retries, as Razorpay, Stripe, Shopify and Frappe all do.",
        "Your handler can fail after doing part of its work."
      ],
      "whenNotToUse": [
        "The handler only sets a value, such as \"status = paid\". Repeating it changes nothing, so a key table adds cost, not safety.",
        "The webhook only sends a notification where a repeat is harmless, such as a chat message.",
        "Only a few events arrive a month and a person checks each one against the bank statement."
      ],
      "tradeoffs": [
        {
          "gain": "Any number of repeats books once.",
          "cost": "A table of event ids to keep, with a unique rule."
        },
        {
          "gain": "Retries become safe, so you can reply fast and work later.",
          "cost": "Ids must outlive the sender's longest retry window, and any manual resend."
        },
        {
          "gain": "A stop midway leaves nothing half done.",
          "cost": "The id row and the books must share one database, or you need a second safeguard."
        }
      ],
      "failureModes": [
        {
          "mode": "A slow handler causes the duplicate.",
          "why": "A Frappe Webhook waits 5 seconds by default and sends again at once after a timeout. Razorpay and Stripe say the same thing differently: reply fast, work later.",
          "evidence": "documented",
          "sources": [
            "frappe-webhook-v15",
            "razorpay-bp",
            "stripe-webhooks"
          ]
        },
        {
          "mode": "The retry window ends and the event is gone.",
          "why": "Frappe stops after three attempts, Shopify after eight tries over four hours, Razorpay after 24 hours, Stripe after three days in live mode. A person can resend from Stripe's dashboard for 15 days.",
          "evidence": "documented",
          "sources": [
            "frappe-webhook-v15",
            "shopify-dupes",
            "razorpay-bp",
            "stripe-webhooks"
          ]
        },
        {
          "mode": "Events arrive out of order.",
          "why": "Stripe does not guarantee order, and Razorpay says you may not always receive webhooks in order. A key stops repeats, not reordering: fetch the current record.",
          "evidence": "documented",
          "sources": [
            "stripe-webhooks",
            "razorpay-bp"
          ]
        },
        {
          "mode": "A retry carries a new key, so the key protects nothing.",
          "why": "Medusa issue 16292: a payment-capture retry sends a different idempotency key. Closed as not planned on 27 September 2026.",
          "evidence": "documented",
          "sources": [
            "medusa-16292"
          ]
        },
        {
          "mode": "Claiming the id first can lose the event.",
          "why": "If the id is saved before the work and the server stops, the retry sees the id and skips. Save the id and the work together. The sandbox shows it.",
          "evidence": "derived",
          "sources": []
        }
      ],
      "systemMappings": {
        "erpnext": [
          {
            "feature": "Webhook (when Frappe sends)",
            "location": "Integrations › Webhook",
            "note": "Sends in a background job after the save commits. Three attempts. Timeout 5 seconds unless you set one. After a read timeout the next attempt starts at once; after other errors it waits 1 second, then 4. One Webhook Request Log row per attempt. No delivery id in the request."
          },
          {
            "feature": "What it sends",
            "location": "Webhook › Webhook Data, or the JSON template",
            "note": "Add name and modified so the receiver has a key. Repeated triggers for the same document and webhook inside one transaction are merged."
          },
          {
            "feature": "Signature",
            "location": "header X-Frappe-Webhook-Signature when Enable Security is on",
            "note": "Base64 of an HMAC-SHA256 of the body. Check it before anything else."
          },
          {
            "feature": "Payment Entry reference",
            "location": "field reference_no, labelled \"Cheque/Reference No\"",
            "note": "Plain text with no unique rule. Put the gateway's payment id here and look it up before you book."
          },
          {
            "feature": "Allocation check",
            "location": "Payment Entry against an invoice",
            "note": "A receipt that allocates to an invoice is checked against the invoice's latest outstanding. A repeat is refused with \"has already been fully paid\" when nothing is left, and \"has already been partly paid\" when the outstanding has changed but is not zero. A receipt with no invoice rows skips the check."
          },
          {
            "feature": "A table keyed by event id",
            "location": "a new doctype with Auto Name field:event_id",
            "note": "Inserting a second row with the same id raises Duplicate Entry."
          },
          {
            "feature": "One transaction",
            "location": "a whitelisted POST request",
            "note": "Frappe commits the request when it ends cleanly and rolls it back if it raises. Put the id row and the receipt in the same request."
          },
          {
            "feature": "Gateway callbacks",
            "location": "Frappe Payments app, Integration Request",
            "note": "Logs each Razorpay payment as an Integration Request and moves it through Queued, Authorized and Completed."
          }
        ]
      },
      "relationships": [
        {
          "predicate": "usesConcept",
          "target": "payment"
        },
        {
          "predicate": "usesConcept",
          "target": "invoice"
        },
        {
          "predicate": "usesConcept",
          "target": "bank-statement"
        },
        {
          "predicate": "pairsWith",
          "target": "reconciliation-as-a-job"
        },
        {
          "predicate": "pairsWith",
          "target": "webhooks-vs-polling"
        }
      ],
      "relatedContent": {
        "docs": [
          "frappe-erpnext-architecture-guide/document-lifecycle"
        ],
        "posts": [
          "erpnext-n8n-integration",
          "medusa-workflow-compensation-boundary",
          "erpnext-payment-gateway-integration"
        ],
        "demos": []
      },
      "interactive": {
        "kind": "simulator",
        "title": "Duplicate-delivery sandbox",
        "description": "Fire one payment event at a handler more than once. Change the handler and fire it again."
      },
      "sources": [
        {
          "id": "frappe-webhook-v15",
          "publisher": "Frappe",
          "title": "webhook.py, v15.123.0, lines 158 to 196; v16.52.0 keeps the three tries and the 1 s and 4 s pauses",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/integrations/doctype/webhook/webhook.py",
          "accessed": "2026-10-14"
        },
        {
          "id": "razorpay-bp",
          "publisher": "Razorpay",
          "title": "Webhook best practices: 24-hour retries, disable logic, \"you may poll our payment APIs as well\"",
          "url": "https://razorpay.com/docs/webhooks/best-practices/",
          "accessed": "2026-10-14"
        },
        {
          "id": "stripe-webhooks",
          "publisher": "Stripe",
          "title": "Receive Stripe events in your webhook endpoint",
          "url": "https://docs.stripe.com/webhooks",
          "accessed": "2026-10-14"
        },
        {
          "id": "shopify-dupes",
          "publisher": "Shopify",
          "title": "Verify and ignore duplicate webhooks: retries and failures (the C07 URL /ignore-duplicates now redirects here)",
          "url": "https://shopify.dev/docs/apps/build/webhooks/verify-deliveries",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-16292",
          "publisher": "Medusa",
          "title": "Issue 16292",
          "url": "https://github.com/medusajs/medusa/issues/16292",
          "accessed": "2026-10-13"
        },
        {
          "id": "erpnext-payment-entry",
          "publisher": "ERPNext",
          "title": "payment_entry.json and payment_entry.py, v15.122.0",
          "url": "https://github.com/frappe/erpnext/tree/v15.122.0/erpnext/accounts/doctype/payment_entry",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-duplicate",
          "publisher": "Frappe",
          "title": "model/base_document.py db_insert and model/naming.py, v15.123.0",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/model/base_document.py",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-request-tx",
          "publisher": "Frappe",
          "title": "app.py application and sync_database, v15.123.0",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/app.py",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-payments-razorpay",
          "publisher": "Frappe Payments",
          "title": "razorpay_settings.py, frappe/payments commit 9885a6e5 (no tag exists), lines 371 to 417",
          "url": "https://github.com/frappe/payments/blob/9885a6e500104f1f160add47d55b370773e67a81/payments/payment_gateways/doctype/razorpay_settings/razorpay_settings.py",
          "accessed": "2026-10-14"
        }
      ],
      "provenance": {
        "publisher": "MithTech",
        "lastReviewed": "2026-10-14",
        "basis": "Checked against the named primary sources on the dates shown. Failure modes are documented behaviour, not results from MithTech client projects."
      }
    },
    {
      "patternId": "transactional-outbox",
      "title": "Transactional outbox",
      "kind": "integration",
      "url": "https://mith.tech/patterns/transactional-outbox",
      "summary": "A transactional outbox saves each outgoing event in a table of the same database, in the same transaction as the business change, and a separate relay publishes it afterwards, so the event exists if and only if the change was committed, at the cost of delivering at least once.",
      "problem": "A service must change its database and tell a message broker. The two cannot commit together. Publish after the commit and a crash in between loses the event; publish before it and a rollback leaves an event for something that never happened.",
      "whenToUse": [
        "A change in your database must always cause a message elsewhere: orders, stock, payments.",
        "The database and the broker cannot share one transaction, which is the usual case.",
        "You can make consumers handle a repeat, or you will."
      ],
      "whenNotToUse": [
        "Only your own database needs the change: a consumer can read the table, and no broker is involved.",
        "Losing the odd event is harmless, or a reconciliation job would find it anyway.",
        "Nobody will run and watch a relay. A relay that stops is a silent failure."
      ],
      "tradeoffs": [
        {
          "gain": "The event exists if and only if the change committed.",
          "cost": "A second table to write, read and clean up. Rows pile up when the relay is down."
        },
        {
          "gain": "A broker outage no longer stops orders.",
          "cost": "The event is late by the relay's interval: a scheduler tick or a polling period."
        },
        {
          "gain": "No two-phase commit, and any broker works.",
          "cost": "Delivery is at least once. The consumer must cope with a repeat."
        },
        {
          "gain": "Order per record can be kept by sequence.",
          "cost": "More than one relay can reorder, and one relay is one more thing to watch."
        }
      ],
      "failureModes": [
        {
          "mode": "The relay publishes twice.",
          "why": "It can stop after publishing and before it records that it did; on restart it publishes again. The consumer has to recognise the event id.",
          "evidence": "documented",
          "sources": [
            "ms-outbox",
            "aws-outbox",
            "debezium-outbox-blog"
          ]
        },
        {
          "mode": "The relay never runs, and nothing says so.",
          "why": "By default Frappe's Email Queue is sent only when the scheduler flushes it. With the scheduler paused or disabled, rows wait. An unsent row's age is the only sign, so alert on it.",
          "evidence": "documented",
          "sources": [
            "frappe-email-queue-v15",
            "frappe-hooks-v15",
            "frappe-scheduler-v15"
          ]
        },
        {
          "mode": "A relay that dies mid-send leaves a row stuck.",
          "why": "Frappe marks a row Sending before it sends, and puts rows stuck for 15 minutes back in line, up to a retry limit of three. Past it the row becomes Error and a person has to look.",
          "evidence": "documented",
          "sources": [
            "frappe-email-queue-v15",
            "frappe-email-queue-doctype-v15"
          ]
        },
        {
          "mode": "Frappe's Webhook is not an outbox.",
          "why": "The pending send waits in the request's memory until the commit, then a job is queued in Redis. The first database row is written after an attempt. A process that stops between the commit and the queue leaves no record that a webhook was owed.",
          "evidence": "derived",
          "sources": [
            "frappe-webhook-queue-v15",
            "frappe-bgjobs-v15",
            "frappe-webhook-send-v15"
          ]
        },
        {
          "mode": "Medusa v2 stages events in Redis, with a timer.",
          "why": "Events from a workflow wait in a Redis list and go to the queue when the workflow ends. By default the list is set to expire 10 minutes after the last event was staged. The code's own comment says a long-running workflow would need a much higher TTL, or none. If the release fails, Medusa cancels the workflow. Redis event jobs make one attempt unless you set attempts.",
          "evidence": "documented",
          "sources": [
            "medusa-event-redis",
            "medusa-workflow-export",
            "medusa-emit-event"
          ]
        },
        {
          "mode": "Events can arrive out of order.",
          "why": "Several relays, or a relay that retries one row, can deliver in a different order than the writes. AWS and microservices.io both list order as an issue. Keep a sequence per record.",
          "evidence": "documented",
          "sources": [
            "aws-outbox",
            "ms-outbox"
          ]
        }
      ],
      "systemMappings": {
        "erpnext": [
          {
            "feature": "An outbox you already run",
            "location": "Email Queue",
            "note": "For an ordinary message, frappe.sendmail saves a row in your transaction (delayed is the default). The scheduler sends rows that are Not Sent or Partially Sent, 500 at a time, locking each one. It marks Sending with a commit, records each recipient as sent, then marks Sent. An error puts the row back with retry plus one until the limit (3 by default), then Error."
          },
          {
            "feature": "The relay's recovery",
            "location": "retry_sending_emails",
            "note": "Rows stuck in Sending for more than 15 minutes go back to Not Sent or Partially Sent, or to Error after the limit."
          },
          {
            "feature": "When the relay runs",
            "location": "scheduler event All",
            "note": "flush and retry_sending_emails run on every All tick: four minutes by default. Nothing runs while the scheduler is off."
          },
          {
            "feature": "Not an outbox",
            "location": "Webhook",
            "note": "Pending sends sit in the request's memory, then a background job is queued in Redis after the commit. The first database row, Webhook Request Log, is written after an attempt."
          },
          {
            "feature": "Queue after the commit",
            "location": "frappe.enqueue(enqueue_after_commit=True)",
            "note": "Registers the enqueue to run after the commit. The queue is Redis, a different store from the database."
          },
          {
            "feature": "An inbox",
            "location": "Ecommerce Integration Log",
            "note": "The Shopify receiver saves the payload, commits, then queues the job."
          }
        ],
        "medusa": [
          {
            "feature": "Medusa v2",
            "location": "events from workflows",
            "note": "Staged in Redis, released when the workflow ends, cleared if it fails. The staging list expires 10 minutes after the last event was staged, by default. The Local Event Module is for development; Redis is the production module."
          },
          {
            "feature": "Medusa v1 did this",
            "location": "staged_job table",
            "note": "Events were saved in the caller's transaction. An enqueuer read the table every 3 seconds, published, then deleted the rows. Version 2 has no such table."
          }
        ]
      },
      "relationships": [
        {
          "predicate": "usesConcept",
          "target": "sales-order"
        },
        {
          "predicate": "usesConcept",
          "target": "invoice"
        },
        {
          "predicate": "pairsWith",
          "target": "idempotent-webhooks"
        },
        {
          "predicate": "pairsWith",
          "target": "reconciliation-as-a-job"
        }
      ],
      "relatedContent": {
        "docs": [],
        "posts": [
          "medusa-workflow-compensation-boundary",
          "erpnext-scheduler-process-not-found-docker"
        ],
        "demos": []
      },
      "interactive": {
        "kind": "simulator",
        "title": "Failure injector",
        "description": "Pick how the service saves and publishes, then stop the process after any step or take the broker down. See what the database, the broker and the consumer end up with."
      },
      "sources": [
        {
          "id": "ms-outbox",
          "publisher": "Chris Richardson, microservices.io",
          "title": "Pattern: Transactional outbox (© 2026)",
          "url": "https://microservices.io/patterns/data/transactional-outbox.html",
          "accessed": "2026-10-14"
        },
        {
          "id": "aws-outbox",
          "publisher": "AWS Prescriptive Guidance",
          "title": "Transactional outbox pattern",
          "url": "https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html",
          "accessed": "2026-10-14"
        },
        {
          "id": "debezium-outbox-blog",
          "publisher": "Gunnar Morling, Debezium",
          "title": "Reliable Microservices Data Exchange With the Outbox Pattern (19 Feb 2019)",
          "url": "https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-email-queue-v15",
          "publisher": "Frappe",
          "title": "email/queue.py L129-204, v15.123.0: flush and retry_sending_emails (v16.52.0: L129-208)",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/email/queue.py#L129-L204",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-hooks-v15",
          "publisher": "Frappe",
          "title": "hooks.py L243-246, v15.123.0: scheduler event All runs queue.flush and retry_sending_emails",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/hooks.py#L243-L246",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-scheduler-v15",
          "publisher": "Frappe",
          "title": "utils/scheduler.py L99-100, L132-146, L247-248, v15.123.0: wakes every 60 s; nothing queued while paused or disabled",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/utils/scheduler.py#L247-L248",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-email-queue-doctype-v15",
          "publisher": "Frappe",
          "title": "email/doctype/email_queue/email_queue.py L102-112, L179-210, L270-290, L509-510, v15.123.0",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/email/doctype/email_queue/email_queue.py#L270-L290",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-webhook-queue-v15",
          "publisher": "Frappe",
          "title": "webhook/__init__.py L61-107, v15.123.0: the pending send waits in memory, then is queued after the commit",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/integrations/doctype/webhook/__init__.py#L61-L107",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-bgjobs-v15",
          "publisher": "Frappe",
          "title": "utils/background_jobs.py L59-172, v15.123.0: enqueue and enqueue_after_commit (Redis queue)",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/utils/background_jobs.py#L59-L172",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-webhook-send-v15",
          "publisher": "Frappe",
          "title": "webhook/webhook.py L158-221, v15.123.0: three tries, 5 s timeout, one log row per attempt",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/integrations/doctype/webhook/webhook.py#L158-L221",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-event-redis",
          "publisher": "Medusa",
          "title": "event-bus-redis.ts L152-156, L219, L265-347, L477-505 (v2.21.2): attempts 1, staging list, TTL 600 s set again with every rpush (L283-294, so it counts from the last event staged; the comment at L288-291 says long-running workflows need a much higher TTL or none), release",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/packages/modules/event-bus-redis/src/services/event-bus-redis.ts#L265-L347",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-workflow-export",
          "publisher": "Medusa",
          "title": "workflows-sdk/src/helper/workflow-export.ts L590-665 (v2.21.2): release on success, clear on failure, cancel if release fails",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/packages/core/workflows-sdk/src/helper/workflow-export.ts#L590-L665",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-emit-event",
          "publisher": "Medusa",
          "title": "core-flows emit-event.ts L47-48, L96-133 (v2.21.2): emitted only after the workflow succeeds",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/packages/core/core-flows/src/common/steps/emit-event.ts#L47-L133",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-sendmail-v15",
          "publisher": "Frappe",
          "title": "frappe/__init__.py L688-820, v15.123.0: sendmail is delayed by default and saves an Email Queue row",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/__init__.py#L688-L820",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-sjt-v15",
          "publisher": "Frappe",
          "title": "core/doctype/scheduled_job_type/scheduled_job_type.py L125, v15.123.0 (L126 in v16.52.0): All = every 4 minutes",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py#L125",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-commit-v15",
          "publisher": "Frappe",
          "title": "database/database.py L1173-1184, v15.123.0: commit runs the after-commit hooks after SQL commit",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/database/database.py#L1173-L1184",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-shopify-connection",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "shopify/connection.py L96-119 (version-15 3dc8eb81): check the HMAC, save a log row, then queue the job",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/shopify/connection.py#L96-L119",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-integration-log",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "ecommerce_integration_log.py L40-78: the log row is saved and committed",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py#L40-L78",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-event-docs",
          "publisher": "Medusa",
          "title": "docs: Event Module (v2.21.2): Local by default, Redis for production",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/www/apps/resources/app/infrastructure-modules/event/page.mdx#L25",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-v1-event-bus",
          "publisher": "Medusa",
          "title": "v1.20.11 services/event-bus.ts L169-228 and models/staged-job.ts: events saved in the transaction, a 3 s enqueuer",
          "url": "https://github.com/medusajs/medusa/blob/v1.20.11/packages/medusa/src/services/event-bus.ts#L169-L228",
          "accessed": "2026-10-14"
        }
      ],
      "provenance": {
        "publisher": "MithTech",
        "lastReviewed": "2026-10-14",
        "basis": "Checked against the named primary sources on the dates shown. Failure modes are documented behaviour, not results from MithTech client projects."
      }
    },
    {
      "patternId": "eventual-consistency-in-stock",
      "title": "Eventual consistency in stock",
      "kind": "integration",
      "url": "https://mith.tech/patterns/eventual-consistency-in-stock",
      "summary": "Eventual consistency in stock means each sales channel holds its own copy of the count and learns of other channels' sales only after a sync lag, so two orders for the last unit can both be accepted unless one place decrements the stock when an order is taken.",
      "problem": "A shop sells the last unit online. Moments later the counter, a marketplace or another store sells it again, because their copy of the count has not caught up. Both customers have paid for one unit. The time between the first sale and the other copy updating is the inconsistency window.",
      "whenToUse": [
        "Stock is deep: the units in stock far outnumber the orders that arrive inside one sync lag.",
        "A channel must keep selling when the source is unreachable, such as a shop counter that is offline.",
        "A late cancel or a backorder costs less than a lost sale."
      ],
      "whenNotToUse": [
        "Units are few or one of a kind: two orders inside the lag can both take the last one.",
        "An oversell cannot be undone: a made-to-order or allocated item, or a customer you cannot refund quickly.",
        "You sell on one channel, or every channel already reads one inventory, as Medusa sales channels do through their linked stock locations."
      ],
      "tradeoffs": [
        {
          "gain": "Each channel sells without waiting for the others, even when one is down.",
          "cost": "Two channels can sell the same unit inside the sync lag."
        },
        {
          "gain": "A copy is cheap to read and to cache.",
          "cost": "It is only as fresh as the last sync. A shorter lag narrows the window but never closes it."
        },
        {
          "gain": "Splitting the units between channels cannot oversell.",
          "cost": "Units sit on the wrong channel and sales are lost until someone rebalances."
        },
        {
          "gain": "One count, reserved at the source, cannot oversell.",
          "cost": "Every order waits for the source, and a channel cannot sell if the source is down."
        }
      ],
      "failureModes": [
        {
          "mode": "A set overwrites a newer count.",
          "why": "Frappe's Shopify connector sets Shopify's quantity to ERPNext's actual minus reserved stock as read at the time of the push. A sale that ERPNext has not imported yet is missing from that number, so the set puts the older count back. Medusa's Admin route for a stock level also takes the quantity to set. Shopify's docs say its set mutation takes absolute values, can compare the stored quantity with the one you last read, and is meant for a system that acts as the source of truth; otherwise they point to the adjust mutation.",
          "evidence": "derived",
          "sources": [
            "ecom-shopify-inventory",
            "shopify-inventory-set",
            "medusa-admin-level"
          ]
        },
        {
          "mode": "Two lags stack.",
          "why": "A Shopify order reaches ERPNext by webhook and a background job. The stock goes back to Shopify only on the next scheduled push, which is off by default: every 5, 10, 15, 30 or 60 minutes, default 60, checked on the scheduler's four-minute ticks. The window is both together.",
          "evidence": "documented",
          "sources": [
            "ecom-shopify-constants",
            "ecom-shopify-setting",
            "ecom-scheduling",
            "frappe-sjt-v15"
          ]
        },
        {
          "mode": "ERPNext notices at delivery, not at the order.",
          "why": "A submitted Sales Order only raises the Bin's reserved quantity. The negative-stock check runs when a voucher posts to the Stock Ledger, such as a Delivery Note, and only while Allow Negative Stock is off.",
          "evidence": "documented",
          "sources": [
            "erpnext-sales-order-v15",
            "erpnext-delivery-note-v15",
            "erpnext-stock-ledger-v15"
          ]
        },
        {
          "mode": "Add to cart says yes; checkout says no.",
          "why": "Medusa checks stock when an item is added to a cart, which is a read. The reservation happens at checkout, under a lock, and re-checks inside its transaction. Stock gone in between fails the checkout.",
          "evidence": "documented",
          "sources": [
            "medusa-add-to-cart",
            "medusa-reserve-inventory",
            "medusa-inventory-module"
          ]
        },
        {
          "mode": "The lock only holds on one instance.",
          "why": "Medusa's docs say the Locking Module is what prevents overselling, and that its default in-memory provider is meant for a single instance. More than one instance needs another provider.",
          "evidence": "documented",
          "sources": [
            "medusa-locking-docs"
          ]
        },
        {
          "mode": "Syncing faster does not close the window.",
          "why": "Orders at the same moment both read their own copy before either sale can travel, whatever the lag. Only one place that decrements the stock can refuse the second. The simulator shows it. Vogels calls the time until every observer sees an update the inconsistency window.",
          "evidence": "derived",
          "sources": [
            "vogels-eventual"
          ]
        }
      ],
      "systemMappings": {
        "erpnext": [
          {
            "feature": "Where the count lives",
            "location": "Bin, Sales Order",
            "note": "Actual, reserved and projected quantity per item and warehouse. Projected is actual plus ordered, requested and planned, minus reserved. Submitting a Sales Order raises the reserved quantity and posts nothing to the Stock Ledger."
          },
          {
            "feature": "What refuses to go below zero",
            "location": "Stock Ledger Entry",
            "note": "A Delivery Note, a Sales Invoice that updates stock or a Stock Entry fails with \"N units of Item needed in Warehouse\", unless Allow Negative Stock is on (Stock Settings, default off, or per item). Reserved stock is subtracted first."
          },
          {
            "feature": "Hold units for an order",
            "location": "Stock Reservation Entry",
            "note": "Opt in under Stock Settings. Only against a Sales Order. Available to reserve is the balance minus open reservations, read with a row lock."
          },
          {
            "feature": "Push stock to a store",
            "location": "Ecommerce Integrations › Shopify Setting",
            "note": "Sends actual minus reserved quantity as an absolute value, only for Bins changed since the last sync. Off by default. Every 5, 10, 15, 30 or 60 minutes (default 60), checked on the four-minute scheduler ticks."
          },
          {
            "feature": "Take orders from a store",
            "location": "Shopify webhooks orders/create",
            "note": "Creates and submits a Sales Order and skips an order already imported."
          }
        ],
        "medusa": [
          {
            "feature": "Medusa: one inventory for many channels",
            "location": "Sales Channel linked to Stock Location",
            "note": "A channel's stock operations are scoped to its linked stock locations. Channels of one store share the same stock levels: no copy and no lag between them."
          },
          {
            "feature": "Medusa: check, then reserve",
            "location": "confirmInventoryStep, reserveInventoryStep",
            "note": "Add to cart confirms with a read. Checkout reserves under a lock keyed by inventory item and checks again. allow_backorder is off by default."
          },
          {
            "feature": "Medusa: set a stock level",
            "location": "POST /admin/inventory-items/:id/location-levels/:location_id",
            "note": "Takes the quantity to set. The delta method exists inside Medusa's own fulfilment and return workflows."
          }
        ]
      },
      "relationships": [
        {
          "predicate": "usesConcept",
          "target": "item"
        },
        {
          "predicate": "usesConcept",
          "target": "warehouse"
        },
        {
          "predicate": "usesConcept",
          "target": "delivery-note"
        },
        {
          "predicate": "pairsWith",
          "target": "reconciliation-as-a-job"
        },
        {
          "predicate": "pairsWith",
          "target": "transactional-outbox"
        }
      ],
      "relatedContent": {
        "docs": [],
        "posts": [
          "erpnext-shopify-integration",
          "erpnext-for-ecommerce",
          "medusa-multi-location-inventory-reservation",
          "erpnext-negative-stock"
        ],
        "demos": [
          "count"
        ]
      },
      "interactive": {
        "kind": "simulator",
        "title": "Oversell simulator",
        "description": "One unit, two channels, two orders. Choose how the channels stay in step, how far apart the orders are and how long a sale takes to reach the other channel."
      },
      "sources": [
        {
          "id": "ecom-shopify-inventory",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "shopify/inventory.py L19-36, L43-67: ERPNext stock pushed to Shopify as an absolute value",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/shopify/inventory.py#L19-L67",
          "accessed": "2026-10-14"
        },
        {
          "id": "shopify-inventory-set",
          "publisher": "Shopify",
          "title": "Admin GraphQL inventorySetQuantities, API 2026-10: absolute values, compare-and-set",
          "url": "https://shopify.dev/docs/api/admin-graphql/latest/mutations/inventorySetQuantities",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-admin-level",
          "publisher": "Medusa",
          "title": "api/admin/inventory-items/[id]/location-levels/[location_id]/route.ts L74-78 and validators.ts L105-110 (v2.21.2): the route sets stocked_quantity",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/packages/medusa/src/api/admin/inventory-items/validators.ts#L105-L110",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-shopify-constants",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "shopify/constants.py L11-17: the five Shopify order webhooks",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/shopify/constants.py#L11-L17",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-shopify-setting",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "shopify_setting.json: Update ERPNext stock levels to Shopify (off), Inventory Sync Frequency 5/10/15/30/60, default 60",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-scheduling",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "controllers/scheduling.py L5-23 and hooks.py L139: a minimum gap, checked on each All tick",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/controllers/scheduling.py#L5-L23",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-sjt-v15",
          "publisher": "Frappe",
          "title": "core/doctype/scheduled_job_type/scheduled_job_type.py L125, v15.123.0 (L126 in v16.52.0): All = every 4 minutes",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py#L125",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-sales-order-v15",
          "publisher": "ERPNext",
          "title": "selling/doctype/sales_order/sales_order.py L453-456, L561-584, v15.122.0: submit raises Bin reserved quantity",
          "url": "https://github.com/frappe/erpnext/blob/v15.122.0/erpnext/selling/doctype/sales_order/sales_order.py#L453-L456",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-delivery-note-v15",
          "publisher": "ERPNext",
          "title": "stock/doctype/delivery_note/delivery_note.py L509, L527, v15.122.0: submit posts to the Stock Ledger",
          "url": "https://github.com/frappe/erpnext/blob/v15.122.0/erpnext/stock/doctype/delivery_note/delivery_note.py#L509",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-stock-ledger-v15",
          "publisher": "ERPNext",
          "title": "stock/stock_ledger.py L1358-1376, L1883, L1913-1918, L2681-2686, v15.122.0: negative stock check",
          "url": "https://github.com/frappe/erpnext/blob/v15.122.0/erpnext/stock/stock_ledger.py#L1358-L1376",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-add-to-cart",
          "publisher": "Medusa",
          "title": "core-flows add-to-cart.ts L319 and confirm-inventory.ts L63-100 (v2.21.2): a read at add-to-cart",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/packages/core/core-flows/src/cart/steps/confirm-inventory.ts#L63-L100",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-reserve-inventory",
          "publisher": "Medusa",
          "title": "core-flows reserve-inventory.ts L61-98 and complete-cart.ts L362-366, L648-660 (v2.21.2): reserve under a lock",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/packages/core/core-flows/src/cart/steps/reserve-inventory.ts#L61-L98",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-inventory-module",
          "publisher": "Medusa",
          "title": "inventory-module.ts L173-192, L257-310 (v2.21.2): the reservation re-checks the quantity in its transaction",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/packages/modules/inventory/src/services/inventory-module.ts#L257-L310",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-locking-docs",
          "publisher": "Medusa",
          "title": "docs: Locking Module (v2.21.2): prevents overselling; in-memory default for a single instance",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/www/apps/resources/app/infrastructure-modules/locking/page.mdx#L134",
          "accessed": "2026-10-14"
        },
        {
          "id": "vogels-eventual",
          "publisher": "Werner Vogels",
          "title": "Eventually Consistent (19 Dec 2008; Communications of the ACM, Jan 2009): the inconsistency window",
          "url": "https://www.allthingsdistributed.com/2008/12/eventually_consistent.html",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-bin-v15",
          "publisher": "ERPNext",
          "title": "stock/doctype/bin/bin.py L65-75, v15.122.0: projected quantity",
          "url": "https://github.com/frappe/erpnext/blob/v15.122.0/erpnext/stock/doctype/bin/bin.py#L65-L75",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-stock-settings-v15",
          "publisher": "ERPNext",
          "title": "stock/doctype/stock_settings/stock_settings.json, v15.122.0: Allow Negative Stock, Enable Stock Reservation (both default off)",
          "url": "https://github.com/frappe/erpnext/blob/v15.122.0/erpnext/stock/doctype/stock_settings/stock_settings.json",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-sre-v15",
          "publisher": "ERPNext",
          "title": "stock/doctype/stock_reservation_entry/stock_reservation_entry.py L497-552, v15.122.0: opt-in, Sales Order only, row-locked",
          "url": "https://github.com/frappe/erpnext/blob/v15.122.0/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py#L497-L552",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-inventory-controller",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "controllers/inventory.py L16-37: only Bins modified since the last sync",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/controllers/inventory.py#L16-L37",
          "accessed": "2026-10-14"
        },
        {
          "id": "ecom-shopify-order",
          "publisher": "Frappe Ecommerce Integrations",
          "title": "shopify/order.py L38-40, L129: skip an imported order; submit the Sales Order",
          "url": "https://github.com/frappe/ecommerce_integrations/blob/3dc8eb818dba95ff5291dd32f54321562252cf95/ecommerce_integrations/shopify/order.py#L38-L129",
          "accessed": "2026-10-14"
        },
        {
          "id": "medusa-sales-channel-stock",
          "publisher": "Medusa",
          "title": "docs: variant inventory (v2.21.2): sales channels linked to stock locations; allow_backorder off by default",
          "url": "https://github.com/medusajs/medusa/blob/v2.21.2/www/apps/resources/app/commerce-modules/product/variant-inventory/page.mdx#L66-L78",
          "accessed": "2026-10-14"
        }
      ],
      "provenance": {
        "publisher": "MithTech",
        "lastReviewed": "2026-10-14",
        "basis": "Checked against the named primary sources on the dates shown. Failure modes are documented behaviour, not results from MithTech client projects."
      }
    },
    {
      "patternId": "reconciliation-as-a-job",
      "title": "Reconciliation as a job",
      "kind": "integration",
      "url": "https://mith.tech/patterns/reconciliation-as-a-job",
      "summary": "A reconciliation job compares what the books say with an outside record, such as a bank statement or a gateway settlement list, on a schedule, and leaves a short list of differences for people to resolve.",
      "problem": "A payment can be missing from the books because a webhook never arrived, doubled because it arrived twice, or short because of a fee. Retries end: a Frappe Webhook stops after three attempts, Shopify after eight tries over four hours, Razorpay after 24 hours, Stripe after three days. After that, only a comparison with the outside record can find the gap.",
      "whenToUse": [
        "Money is recorded in two places by different routes, such as a gateway and the ledger.",
        "A missed or doubled event would not be noticed until month-end.",
        "More entries arrive than a person can match by eye in one sitting."
      ],
      "whenNotToUse": [
        "You reconcile a few entries a month. ERPNext's manual Payment Reconciliation tool is enough.",
        "The cause is a handler that drops events every day. Fix the handler; the job is the net, not the repair.",
        "There is no outside record to compare with: no statement, no settlement list."
      ],
      "tradeoffs": [
        {
          "gain": "Misses and doubles surface within a day, not at month-end.",
          "cost": "The job needs the scheduler running; if it is off, nothing runs."
        },
        {
          "gain": "People work a short exception list, not the whole ledger.",
          "cost": "Matching rules can be wrong, and accepting weak matches hides errors."
        },
        {
          "gain": "It catches what retries cannot: events that never arrived.",
          "cost": "It only finds differences against a record that exists. No statement, no check."
        }
      ],
      "failureModes": [
        {
          "mode": "The sender stops sending.",
          "why": "Shopify deletes an API-configured webhook subscription after eight failures in a row, and says delivery \"isn't always guaranteed\". Events stop until someone re-creates it.",
          "evidence": "documented",
          "sources": [
            "shopify-dupes",
            "shopify-webhooks"
          ]
        },
        {
          "mode": "One request tries to carry the whole ledger.",
          "why": "ERPNext's Invoice Limit and Payment Limit default to 50, and the field says \"System will fetch all the entries if limit value is zero\". Large parties then time out; the background job works through queued documents instead.",
          "evidence": "documented",
          "sources": [
            "erpnext-pr",
            "erpnext-ppr"
          ]
        },
        {
          "mode": "The job never runs, and nothing says so.",
          "why": "When Auto Reconcile Payments is off, or the scheduler is inactive, the trigger shows a message and does nothing.",
          "evidence": "documented",
          "sources": [
            "erpnext-ppr"
          ]
        },
        {
          "mode": "\"Submitted\" is read as \"reconciled\".",
          "why": "ERPNext's docs: submitting queues the process and \"does not itself confirm that every entry was reconciled\". Read the status and the log.",
          "evidence": "documented",
          "sources": [
            "frappe-docs-ppr"
          ]
        },
        {
          "mode": "It matches the books to the books.",
          "why": "Process Payment Reconciliation allocates payments to invoices that already exist. It does not import or match bank transactions, so a payment the bank has and the books lack is invisible to it.",
          "evidence": "documented",
          "sources": [
            "frappe-docs-ppr"
          ]
        },
        {
          "mode": "The list is cleared instead of worked.",
          "why": "Forcing or auto-accepting weak matches finishes the reconciliation and leaves the difference, disguised. (Our glossary: Bank statement.)",
          "evidence": "derived",
          "sources": []
        }
      ],
      "systemMappings": {
        "erpnext": [
          {
            "feature": "Payment Reconciliation (manual)",
            "location": "Accounts › Payment Reconciliation",
            "note": "Allocates a party's unreconciled payments to its open invoices. Invoice Limit and Payment Limit default to 50; 0 fetches everything."
          },
          {
            "feature": "Process Payment Reconciliation",
            "location": "doctype",
            "note": "Queues the same allocation as a background job per party. Statuses: Queued, Running, Paused, Completed, Partially Reconciled, Failed, Cancelled."
          },
          {
            "feature": "Auto Reconcile Payments",
            "location": "Accounts Settings › Payments",
            "note": "Turns the queue on. The trigger runs every 15 minutes by default (1 to 59). Queue size is 5 by default (5 to 100). Jobs run on the long queue with a 3,600-second timeout. Nothing runs if the scheduler is inactive."
          },
          {
            "feature": "Bank Transaction and Bank Reconciliation Tool",
            "location": "doctypes",
            "note": "The statement side: bank lines matched to Payment Entries and invoices. Statuses: Pending, Settled, Unreconciled, Reconciled, Cancelled."
          },
          {
            "feature": "Bank Statement Import",
            "location": "doctype",
            "note": "Imports a statement file as new Bank Transactions."
          },
          {
            "feature": "The scheduler",
            "location": "bench scheduler",
            "note": "The job only runs when it does. Our scheduler check shows how to tell."
          }
        ]
      },
      "relationships": [
        {
          "predicate": "usesConcept",
          "target": "bank-statement"
        },
        {
          "predicate": "usesConcept",
          "target": "payment"
        },
        {
          "predicate": "usesConcept",
          "target": "general-ledger"
        },
        {
          "predicate": "pairsWith",
          "target": "idempotent-webhooks"
        },
        {
          "predicate": "pairsWith",
          "target": "eventual-consistency-in-stock"
        }
      ],
      "relatedContent": {
        "docs": [
          "frappe-erpnext-architecture-guide/scheduler-and-background-jobs"
        ],
        "posts": [
          "erpnext-payment-reconciliation-auto",
          "erpnext-bank-reconciliation",
          "erpnext-scheduler-process-not-found-docker"
        ],
        "demos": [
          "bank"
        ]
      },
      "interactive": {
        "kind": "simulator",
        "title": "Run the reconciliation job",
        "description": "A sample ledger and a sample statement with five planted gaps. Run the job and see what is left for a person."
      },
      "sources": [
        {
          "id": "shopify-dupes",
          "publisher": "Shopify",
          "title": "Verify and ignore duplicate webhooks: retries and failures (the C07 URL /ignore-duplicates now redirects here)",
          "url": "https://shopify.dev/docs/apps/build/webhooks/verify-deliveries",
          "accessed": "2026-10-14"
        },
        {
          "id": "shopify-webhooks",
          "publisher": "Shopify",
          "title": "About webhooks: polling, ordering, \"Implement reconciliation jobs\"",
          "url": "https://shopify.dev/docs/apps/build/webhooks",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-pr",
          "publisher": "ERPNext",
          "title": "payment_reconciliation.json, v15.122.0",
          "url": "https://github.com/frappe/erpnext/tree/v15.122.0/erpnext/accounts/doctype/payment_reconciliation",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-ppr",
          "publisher": "ERPNext",
          "title": "process_payment_reconciliation, v15.122.0",
          "url": "https://github.com/frappe/erpnext/tree/v15.122.0/erpnext/accounts/doctype/process_payment_reconciliation",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-docs-ppr",
          "publisher": "ERPNext docs",
          "title": "Process Payment Reconciliation Tool (updated 2026-08-14)",
          "url": "https://docs.frappe.io/erpnext/process-payment-reconciliation-tool",
          "accessed": "2026-10-13"
        },
        {
          "id": "erpnext-acc-settings",
          "publisher": "ERPNext",
          "title": "accounts_settings.json and accounts_settings.py, v15.122.0",
          "url": "https://github.com/frappe/erpnext/tree/v15.122.0/erpnext/accounts/doctype/accounts_settings",
          "accessed": "2026-10-14"
        },
        {
          "id": "erpnext-bank-txn",
          "publisher": "ERPNext",
          "title": "bank_transaction.json, v15.122.0",
          "url": "https://github.com/frappe/erpnext/tree/v15.122.0/erpnext/accounts/doctype/bank_transaction",
          "accessed": "2026-10-14"
        },
        {
          "id": "frappe-webhook-v15",
          "publisher": "Frappe",
          "title": "webhook.py, v15.123.0, lines 158 to 196; v16.52.0 keeps the three tries and the 1 s and 4 s pauses",
          "url": "https://github.com/frappe/frappe/blob/v15.123.0/frappe/integrations/doctype/webhook/webhook.py",
          "accessed": "2026-10-14"
        },
        {
          "id": "razorpay-bp",
          "publisher": "Razorpay",
          "title": "Webhook best practices: 24-hour retries, disable logic, \"you may poll our payment APIs as well\"",
          "url": "https://razorpay.com/docs/webhooks/best-practices/",
          "accessed": "2026-10-14"
        },
        {
          "id": "stripe-webhooks",
          "publisher": "Stripe",
          "title": "Receive Stripe events in your webhook endpoint",
          "url": "https://docs.stripe.com/webhooks",
          "accessed": "2026-10-14"
        }
      ],
      "provenance": {
        "publisher": "MithTech",
        "lastReviewed": "2026-10-14",
        "basis": "Checked against the named primary sources on the dates shown. Failure modes are documented behaviour, not results from MithTech client projects."
      }
    }
  ]
}