Magento integration: methods, editions and what it costs
indexation: WAIVER unset
Your store takes an order. Someone in accounts retypes it into another system an hour later, or a nightly job picks it up, or nobody picks it up and the warehouse hears about it by email. Every Magento integration comes down to one of three transports: a file dropped on a schedule, a poll on cron, or an event pushed the moment it fires. Which of the three you can use depends on the edition you are running, and that is the question this page settles before it hands you to the page for your system.
Start here: find your system, then leave this page
Find the row that matches what you are connecting. A row with a page behind it has its field-level detail on that page. A row that points back here names the block on this page that answers it. The marketplace row is the exception: the marketplace publishes the field contract and you build to it.
| WHAT MOVES | DIRECTION | TRANSPORT THAT USUALLY FITS | WHERE THE DETAIL LIVES | |
|---|---|---|---|---|
| ERP | Orders out; stock, price and invoice status back | Both ways | Event push out, seconds; scheduled pull back at whatever interval you set | Magento ERP integration, then Magento NetSuite integration or Magento SAP integration if that is the system |
| ACCOUNTING | Invoices, credit memos, payment status | Mostly out of Magento | Event push, seconds, or a nightly file at the batch window if the receiver only reads files | Here. The credit memo row of the entity map |
| 3PL AND SHIPPING | Orders to the warehouse; tracking and stock levels back | Both ways | Event push out, seconds; callback or poll back at the poll interval | Here. Out-of-order delivery |
| PIM | Product data, attributes, media | Into Magento | Scheduled import at the batch window, or the PIM writing into the Magento REST API on its own schedule | Here. The payload on the wire, for inbound token scoping |
| CRM | Customers, order history, support context | Both ways | Event push out of Magento, seconds | Here. The customer row of the entity map |
| POS | Stock, orders and customers across channels | Both ways | Event push out, seconds; callback or scheduled poll back at that interval | Here. The stock source row of the entity map |
| MARKETPLACE | Listings and prices out, orders in | Both ways | The marketplace's own API, at whatever latency its schedule allows | The marketplace's developer docs publish the field contract. Three transports, on this page, for the choice itself |
If the ERP row is yours, Magento ERP integration answers the questions common to every ERP build, and Magento NetSuite integration or Magento SAP integration takes it down to the field names and keys that one vendor insists on.
The ERP row has three pages behind it because it varies more from one vendor to the next than any other row, down to which field the receiver expects you to key on. POS is the hardest row on the list, because stock is authoritative in two places at once and whichever side you nominate as the source of truth is the wrong one in the other system. CRM is the most forgiving: a late customer record leaves somebody looking at a stale screen, where a late invoice is a wrong document in a ledger.
Everything else runs on the same three transports. Dynamics 365, Sage, QuickBooks, HubSpot, Klaviyo, ShipStation, 3PL platforms, CRM platforms and POS systems will generally accept an inbound HTTP call, so the transport section below is what decides your build. Only the field names change.
Three transports, and what each one costs to run
Use event push if the receiving system can accept an inbound HTTP call and you care about the record arriving in seconds. Use a scheduled poll if it cannot accept one. Use a flat file if it can do neither, which is still the right answer more often than this industry likes to admit.
| FLAT-FILE DROP | SCHEDULED API POLL | EVENT PUSH | |
|---|---|---|---|
| HOW THE RECORD MOVES | CSV or XML written to SFTP or a watched folder on a schedule | cron asks the Magento REST API what changed since the last run | Magento fires on the event and posts the record to your endpoint |
| LATENCY | The batch window | The poll interval, by definition | Seconds |
| WHAT IT COSTS TO OPERATE | Cheap to build, expensive to watch | Wasted requests. The store answers every poll whether or not anything changed | An endpoint you own, monitored, with a retry path behind it |
| HOW IT FAILS | Quietly. A malformed row, a half-written file, a folder nobody is watching | Missed windows, clock drift, and a changed-since cursor that is wrong after any outage | A delivery the receiver processed, whose response never made it back |
| WHEN IT IS THE RIGHT ANSWER | Low volume, tolerant of a batch window, a receiver that only speaks files | The receiver can only pull, or you have no public endpoint to receive on | Real-time requirement, high volume, or a receiver that takes inbound calls |
Scheduled poll
[ receiver ] --- cron fires every N minutes ---> [ Magento REST API ]
<-- what changed since T? ----------
<-- 0 rows, 0 rows, 0 rows, 1 order
Event push
[ Magento ] --- sales_order_place_after -------> [ queue ]
--- POST the record ---------------> [ your endpoint ]
|
200 fast, store it,
process afterward
|
v
[ any external system ]In one line: a poll asks Magento for changes on a timer and mostly gets nothing back, while a push hands the order over as it is placed, tries again if the endpoint does not answer, and depends on the receiver spotting a repeat.
Flat-file drop
A nightly CSV to a watched folder is a legitimate architecture, and it runs unattended for years in stores that never think about it. What it does badly is fail. One row in tonight's file carries a comma inside a product name, the receiver imports every other row, and nobody finds out until somebody goes looking for that one order. Build it when the receiver genuinely cannot do anything else, and pair it with a job that reconciles counts in both directions rather than a job that only reports the errors it managed to catch.
Scheduled API poll
A poll is a cron entry on the receiving side asking the Magento REST API what changed. Its latency is its interval, and no configuration improves on that: whatever you set the cron to is how late every record is. The part that bites is the cursor. Whatever value you store as the last successful run has to survive a failed run, a partial run and a clock change, and the usual bug is a cursor that advanced on a run that never finished, which silently skips a window of orders forever.
Poll anyway when the receiving system can only initiate outbound calls, or when you have nowhere public to receive a delivery. Those constraints are real and common, and a working poll beats an elegant push you cannot deploy.
Event push
Magento dispatches sales_order_place_after when an order is placed. Something observes it, builds a payload, and posts it to a URL you control. The record lands in seconds instead of at the top of the hour.
The objection is that push moves the hard part rather than removing it, and that is fair. You now own a receiver that has to be up, has to answer fast, and has to behave correctly when the same delivery arrives twice. That is a real cost, and it is smaller than the cost of a poll whose floor is its own interval and whose cursor is wrong after every outage. If the line between a request-response API and a delivered event is still fuzzy, the long version is in webhook vs API.
Which edition are you on, and what can it actually do?
Two mechanisms decide this, and they are not interchangeable. Adobe I/O Events is the asynchronous one: Commerce emits the event and carries on, and your subscriber deals with it when it arrives. Native webhooks is the synchronous one: Commerce calls out in the middle of an operation and waits for the answer, so the answer can change or refuse what the platform was about to do. Both belong to Adobe Commerce. Adobe's installation prerequisites for I/O Events state that Magento Open Source is not supported, and neither mechanism has a configuration path on an Open Source instance.
Do not take the install position from an article, this one included. Run composer show and look for magento/commerce-webhooks and magento/commerce-eventing. The webhooks package is an explicit Composer install. The eventing package is loaded for you on recent releases and a Composer install on earlier ones. Your own output answers this in a second.
| ADOBE COMMERCE | MAGENTO OPEN SOURCE | |
|---|---|---|
| NATIVE WEBHOOKS, SYNCHRONOUS, SO THE ANSWER CAN ALTER OR REJECT THE OPERATION IN FLIGHT | A Commerce package, installed with Composer. Do not assume a stock instance has it | Not available |
| ADOBE I/O EVENTS, ASYNCHRONOUS DELIVERY TO A SUBSCRIBER | A Commerce package, either already loaded or installed with Composer depending on the release | Not supported, and Adobe's installation prerequisites say that in those words |
| OBSERVERS ON CORE EVENTS, SALES_ORDER_PLACE_AFTER AMONG THEM | Core | Core |
| MAGENTO MESSAGE QUEUE, DRAINED BY BIN/MAGENTO QUEUE:CONSUMERS:START | Available | Available |
| ASYNCHRONOUS AND BULK WEB API AT /ASYNC/BULK/V1/ | Available | Available |
What Open Source has is the same core dispatches plus the plumbing that makes a delivery survive a bad afternoon, assembled by you rather than configured. sales_order_place_after is core in both editions, and an observer on it is code running inside the order-placement request, synchronously, which is the part that gets described backwards. The asynchrony comes from the Magento message queue sitting behind the observer: the observer publishes, bin/magento queue:consumers:start drains, and by the time the shopper sees a confirmation page the delivery attempt is a separate concern. Inbound traffic has the matching option, which is to post to /async/bulk/V1/ and take an immediate acknowledgment rather than hold a connection open for a batch that will time out.
So an Open Source store can get an order to a receiving system in seconds. What it cannot do is stand inside a Commerce operation and veto it.
The entity map: what a Magento order becomes on the other side
The shape below holds for almost any receiving system. The third column is where the work is, because every entry in it is a per-system answer somebody has to go and get before the build is scoped.
| USUAL COUNTERPART IN THE RECEIVING SYSTEM | WHERE THE ASSUMPTION BREAKS | |
|---|---|---|
| CUSTOMER | Customer, business partner, debtor, or an accounts receivable account | The widest naming spread in this table. Some receivers file the shopper as a receivable rather than a person, which changes what you are allowed to update on a repeat order |
| CUSTOMER GROUP | Price list, price level, or customer category | Only carries meaning if the receiver prices by group. Some do not, in which case the value is stored and unused |
| PRODUCT | Item, material, or article | Which system owns the item master, and whether the receiver's unit of measure matches the Magento one. If it sells by case and Magento sells by each, every quantity on every order is wrong by a multiple |
| ORDER | Sales order | Whether the receiver accepts an order for a customer it has never seen. Some create the record on the fly, some reject the whole document |
| ORDER LINE | Sales order line | Line-level discounts. Some receivers have no line discount concept and expect a net unit price, so you either send net or send the header discount and reconcile later |
| INVOICE | Accounts receivable invoice | Usually derived from the order it belongs to rather than created independently, so an invoice that arrives before its order has nowhere to attach |
| SHIPMENT | Delivery, fulfillment, or goods issue | Tracking data travels the other way, so this row needs a return path as well as an outbound one |
| CREDIT MEMO | Credit note, sometimes preceded by a return authorization | Whether the authorization step is mandatory is a per-system answer, and getting it wrong leaves refunds stranded on the storefront with no matching document |
| STOCK SOURCE | Warehouse, location, or plant | Whether the receiver nets availability per location or only in total. If it only totals, multi-source inventory has nowhere to land |
| STORE VIEW | Usually nothing | Receiving systems generally have no store view concept, so it lands in a user-defined field or it is dropped and nothing downstream can tell two storefronts apart. That is also what makes an order number unsafe as an idempotency key on its own, which the idempotency block below picks up |
Order of operations decides whether a new shopper's first order lands or bounces. The customer record has to exist before the order that references it, and the product before the line that sells it. Event push carries no ordering guarantee of its own, so the first time a brand new shopper buys a brand new SKU, a naive integration posts an order referencing two records the receiver has never heard of, and the receiver rejects the document. Decide now whether that order queues and retries until its dependencies land, or fails loudly to somebody who will act on it. Both are defensible.
The field-level version of this table, down to which key you write and what it is called, is per system. The NetSuite and SAP pages in the routing table carry theirs.
Which events fire, and what the payload looks like on the wire
Before the payload, the list. The module curates ten events, picked around what a receiving system has to be told rather than around everything Magento happens to dispatch. Check the moment you care about is on it before you plan a build around it.
| MAGENTO EVENT IT SUBSCRIBES TO | WHY YOU WOULD TURN IT ON | |
|---|---|---|
| ORDER PLACED | sales_order_place_after | Where nearly every build starts. An order exists and something downstream has to be told |
| ORDER UPDATED | sales_order_save_after | Computes order.status_changed and order.previous_status, so the receiver reacts to a transition rather than diffing two payloads itself |
| SHIPMENT CREATED | sales_order_shipment_save_after | Goods left the building. Feeds a warehouse acknowledgment loop or a customer notification |
| INVOICE CREATED | sales_order_invoice_save_after | What an accounting system posts against, which is the invoice and not the order it came from |
| CREDIT MEMO CREATED | sales_order_creditmemo_save_after | The reverse flow, and the one most projects find out they skipped on the day of the first refund |
| CUSTOMER REGISTERED | customer_register_success | The CRM gets the account at signup instead of waiting for a first order to conjure one |
| CUSTOMER UPDATED | customer_save_after_data_object | Contact and address changes, with the previous version of the record sitting alongside the new one |
| PRODUCT SAVED | catalog_product_save_after | Catalog edits out to a PIM, a marketplace listing or a search index |
| PRODUCT DELETED | catalog_product_delete_after | The record is snapshotted when the event is observed, so the delete arrives carrying the product instead of an id pointing at nothing |
| STOCK LEVEL CHANGED | cataloginventory_stock_item_save_after | Carries stock_item together with product.sku and product.id, so a receiver keyed either way can find its row |
Two of those rows have a detail worth knowing before you build on them. Customer Updated hands over orig_customer next to the saved record, which is what makes a before-and-after comparison possible on the receiving side, and it is empty when the event is a create rather than an edit. And the snapshot in the Product Deleted row is not special-case behavior: every payload reflects the entity as it stood at the moment the event fired, not as it stands when the delivery finally lands. On a delete, that is the difference between a usable message and an id with nothing behind it.
If the moment you want is not among the ten, any Magento event can be subscribed by name. That sits behind an Advanced flag that ships switched off, so pointing a webhook at an arbitrary core event is a decision somebody made rather than a checkbox somebody found. The subscriptions themselves live under System > OCM Labs > Webhooks.
Read the lifecycle before you build on sales_order_place_after. The dispatch lives in Magento\Sales\Model\Order::place(), which fires the event and returns without persisting anything, so at that moment the order has no database row and the per-item item_id values are unassigned. increment_id is the exception. The sequence hands it out during quote-to-order conversion, which puts it in place before the event fires. That is not trivia: it is why the payload keys on increment_id and why increment_id is the value you hand the receiver to dedupe with.
The block below is the module's default Order Placed template, rendered. Note what it is not, because this is where most people's mental model is wrong: it is not a serialized order object. The default emits a curated subset, an envelope of two fields wrapping an order that carries four values and its line items, on the reasoning that almost every receiver wants the order number, the money, the customer and what was bought, and anything past that is a template edit rather than a parsing problem. The field names and the structure below are the shipped default. The values are illustrative. Every variable available to a template, and the two JSON modifiers that decide whether a value arrives with its own quotes, are listed in the payload template documentation.
{
"event": "sales_order_place_after",
"triggered_at": "2026-07-31 14:07:52",
"order": {
"increment_id": "000002418",
"grand_total": 2579.35,
"currency": "USD",
"customer_email": "receiving@example.com",
"items": [
{
"sku": "VLV-BFY-4IN",
"name": "4 in. lug-style butterfly valve, EPDM seat",
"qty": 6,
"row_total": 1734.00
},
{
"sku": "GSK-SPW-150",
"name": "Spiral-wound gasket, 150 lb, 4 in.",
"qty": 24,
"row_total": 588.00
}
]
}
}Two things about that block. grand_total and qty arrive unquoted because they are already numeric, while customer_email, sku and name are strings. And the two fields a developer usually goes looking for are not missing from the body, they were never in the body. X-Webhook-Signature carries sha256= followed by an HMAC-SHA256 of the exact bytes of the request body, computed with that webhook's secret, and it is omitted entirely when no secret is configured. X-Webhook-Delivery-Id carries a UUID that the original send and every retry of it share. X-Webhook-Event, X-Webhook-Id and X-Webhook-Attempt name the event, the subscription and which attempt you are looking at.
The receiving end has four jobs and their order is not negotiable. Verify the signature before you act on the delivery, and verify it with a constant-time comparison, hash_equals() in PHP rather than ==, because a plain comparison returns at the first byte that differs and quietly reports how much of a forged signature was right. An endpoint that creates financial documents for whoever finds the URL is a worse incident than a missing order, and the mechanics are spelled out under signature verification. Second, dedupe on the delivery id, and do it after the signature check rather than before, so an unverified request never gets to touch your dedupe table. Third, answer quickly. Fourth, write the delivery somewhere durable and do the work afterward. A receiver that opens a transaction against the ERP before it responds will time out on its first busy afternoon, and a timeout on a delivery that already succeeded is how you end up with two of everything.
Traffic in the other direction, into Magento, authenticates with an integration token or with OAuth-based API access, and the ACL on the integration record scopes both. Scope them down. An integration that reads stock and writes shipment tracking has no business holding a token that can also read customer records.
Where does a Magento integration break in production?
None of the following is a defect in a particular product. They are properties of moving records between two systems that fail independently, and they show up on every build that runs long enough.
Retries
Retry on 5xx. Do not retry a plain 4xx, because a deterministic rejection returns the same rejection the second time. Carve out 429. A receiver at its concurrency ceiling answers 429 during exactly the flash-sale hour when you need the orders to land, so 429 means back off and try again rather than stop. A blanket rule against retrying 4xx will drop orders.
Now the numbers, which are the part worth writing into your runbook. The module computes each delay as retry_base_delay * 2^(failed_attempt - 1), ships with a base of 60 seconds, and caps at three attempts counting the first send. Fail once and it waits a minute. Fail again and it waits two. Fail a third time and there is no fourth. The whole ladder is over in about three minutes, which is shorter than most people picture when they hear exponential backoff, and it is deliberately short. A cron sweeps for due retries once a minute, so treat each interval as a floor rather than a stopwatch. The base and the cap both live at Stores > Configuration > OCM Labs > Webhooks > Delivery. One failure class skips the ladder entirely: a template that will not render is deterministic, so re-rendering it twice more produces the same error, and the module does not spend the attempts.
Then it stops, and this is the part to design against rather than discover. The delivery is marked Failed in the Delivery Log, which records every attempt with its status, HTTP code, duration and both bodies. There is no dead-letter queue behind it, no alert, and nothing that replays it later on your behalf. Delivery is at-least-once for as long as the attempts last, and after that it is a row in a log that somebody has to read. Which settles a question this page would otherwise be giving you as advice: reconciliation belongs to the receiver, not to the transport, and that is a property of the product rather than an opinion about how you should build. The full behavior, including what each log status means, is documented under retries and the Delivery Log.
Duplicate delivery
Assume every event can arrive twice, and not rarely. The routine case is a receiver that did the work and then failed to get its response back before the sender gave up waiting, which means the sender is correct to try again and the receiver is correct that it has already finished. Both sides behaved properly and you now have two sales orders.
Idempotency
Give the receiver a key so it can tell a repeat from a new record, and make it increment_id. It is the value the default template emits, it is assigned before the order is saved, it never changes afterward, and it is what a warehouse person reads out on the phone. On the receiving side, write it into whatever the external reference field is called, then query that field before you create anything. If the receiving system has no such field, you keep the mapping table yourself, and you write down that you are doing it, because the next person will not guess.
One caveat, and it is the store view row of the map above arriving to collect. increment_id is unique within a store's own sequence, not across the installation, so two storefronts on one Magento instance will each produce an order 000002418 sooner or later. There are two ways out of that, and they are different architectures rather than a matter of taste. Scope the subscription: a webhook can be limited to named store views, and an event that fires in a store view outside that list is never delivered, so one subscription per storefront hands each receiver a key space nobody else writes into. Or leave the subscription on all stores and send the store with the order, using {$meta.store_id} in the template so the receiver keys on the pair. Take the first when each storefront has its own receiving system or its own company file, the second when one receiver takes everything. A single-store merchant can skip the choice today. A merchant who launches a second storefront next year cannot, and retrofitting a compound key into a receiver already holding a million rows keyed the short way is not an afternoon's work.
The ERP is down mid-checkout
The customer sees nothing, and that has to be a design decision rather than luck. Order placement must never depend on the receiving system being reachable, because if it does then an ERP maintenance window is a storefront outage. The synchronous mechanism from the edition table is how a store ends up in that position: a webhook that runs inside order placement and waits for a reply needs an explicit decision about what happens when the reply never comes, where an observer that publishes to the queue gets that decision for free.
So the event queues, the customer completes checkout, and the delivery retries against something that is not answering. Be clear-eyed about what a retry ladder does and does not cover here: three minutes of attempts does not outlast a four-hour maintenance window, so everything placed during the outage ends up Failed in the log rather than waiting patiently to land. That is the correct trade. A transport that held deliveries open indefinitely would be a transport that hides an outage from you. Replaying the window afterward is a reconciliation task you plan for, and knowing the exact size of the window is what makes it plannable. Two properties of that queue are worth understanding before the maintenance window rather than during it. Publishing is decoupled from delivery by design, so a stalled receiver applies no backpressure to the storefront, which is why checkout survives and also why queue depth is the only place the problem is visible. And if you run parallel consumers to keep up with volume, you have given up ordering, which is why out-of-order delivery below is routine rather than exotic.
The consumer that quietly died
bin/magento queue:consumers:start is a process, and unsupervised processes stop. Magento will not tell you. Messages keep arriving in the queue, no process takes them out, and the first signal is usually somebody in operations asking why yesterday's orders never reached the ERP. Put the consumers under a supervisor and alert on depth and age rather than on liveness.
Out-of-order delivery
The shipment event can reach the receiver before the order event it belongs to. In a transactional system that is not a curiosity, it is a document with no parent. The receiver either buffers it until the parent shows up or rejects it and lets the retry sort it out. Pick one deliberately and write it down, because the default behavior of a hand-built receiver is to raise an exception and stop.
Rate limits and the flash sale
A flash sale is not more traffic spread out, it is a burst of events inside a few minutes, and a receiver that copes at a steady rate starts answering 429 at a burst. Inbound, /async/bulk/V1/ is the mechanism built for it: submit the batch and take the acknowledgment instead of holding a connection open per record. Read that acknowledgment for what it is, though, which is a receipt for accepting the work rather than a report that the work is done. The batch only lands if a consumer is draining the queue, so the depth and age alerting above is what turns the acknowledgment into a guarantee. Outbound, the queue and the retry policy are the only things standing between a burst and a pile of dropped deliveries.
What this costs, and what the module does not replace
Set the recurring cost against the one-time cost, and do the multiplication rather than gesturing at it.
A subscription connector publishes three monthly tiers: $99, $249 and $699. Multiply each by 36 months and three years of that connector is $3,564, $8,964 or $25,164, before anybody has written a line of mapping logic. Custom middleware prices by the year rather than by the project. Built and supported in-house, ours costs $18,000 annually, all-in, with no hosting or on-call line sitting underneath it. That is $18,000 after year one and $54,000 across the same 36 months, against the $8,964 the middle connector tier reaches over that stretch. Read it as our own cost of ownership rather than a quote or a going rate.
The OCM Labs Magento 2 Webhooks module is $250, paid once, with no renewal to multiply out. It installs with Composer and runs on Magento 2.4.7 to 2.4.8 with PHP 8.2 to 8.4, on a Luma or a Hyva storefront, which is the qualifying question most people ask before they get to the money.
That comparison is only honest with the catch attached. The module moves the transport. It does not move the receiver. Somebody still builds the endpoint that accepts the delivery, writes the field mapping, holds the credentials, and picks up the phone when the receiving system starts rejecting documents at two in the morning. If you have a developer and somewhere to receive, the arithmetic above is real and the transport genuinely is the cheap part. If you have neither, a subscription connector that comes with both halves is the better purchase.
If you have read this far without clicking through to a system page, you are choosing an approach rather than a vendor. Of the three transports, one does not make anything wait on a timer, and on the Magento side that sender is what the OCM Labs Magento 2 Webhooks module is for. Look at it once you know which system is receiving, because the receiver decides the payload, and the payload is most of what is left to build.
✦ QUESTIONS MERCHANTS ASK