Magento 2, sold by Adobe as Adobe Commerce and given away as Magento Open Source, is the platform of choice for mid market and enterprise merchants who want to own their stack. In India it shows up under agencies and system integrators, for brands with complex catalogues, multiple storefronts or B2B price lists, and for anyone who outgrew Shopify's customisation limits. Every install is a separate server with its own extensions, so like WooCommerce the operational risk sits with the store rather than with a platform. Unlike WooCommerce, the API surface is genuinely enterprise grade: a service contract for every module, search criteria on every list endpoint, and an asynchronous bulk lane backed by a message queue. The pain is the authentication, which is OAuth 1.0a in an era of bearer tokens, and the complete absence of a hosted webhook service in the open source edition.
At a glance
What it is
Magento was an independent open source commerce platform, bought by Adobe in 2018. There are now three things wearing the name and they differ in ways that matter for a connector.
Magento Open Source is the free, self hosted edition, currently on the 2.4 line. It has the full REST and GraphQL API but none of the commercial features: no RMA module, no B2B module, no staging, no Adobe I/O Events unless the modules are installed.
Adobe Commerce is the licensed edition, self hosted or on Adobe's managed cloud platform (PaaS). It adds RMA, B2B, customer segments, Adobe I/O Events and the Fastly CDN layer on cloud.
Adobe Commerce as a Cloud Service is the newer SaaS deployment. Base URLs change to https://<server>.api.commerce.adobe.com/<tenant-id>/..., the store view moves from the URL into a Store header whose value is all, default or a store view code, and authentication switches to Adobe Identity Management Service rather than store issued tokens. Anything below that says "store admin creates an integration" applies to the self hosted and PaaS editions.
Magento 1 is long dead and its API is not covered here. If a brand is on Magento 1, treat it as having no supported API.
API access
Nothing to apply for on self hosted editions: whoever administers the store can issue credentials in minutes. Two things to establish during onboarding: the base URL including the store view code, and which edition and version the store runs, because the answer decides whether returns, B2B prices and events exist at all. GET /rest/V1/modules lists the installed modules if the credential has the right permission, which is a quick way to detect RMA, MSI and the eventing modules.
URL shape on self hosted and PaaS:
https://<host>/rest/<store-view-code>/V1/<resource> https://<host>/rest/all/V1/<resource> https://<host>/rest/V1/<resource>
all targets the global scope, which is what we want for order and stock reads. Omitting the code entirely resolves to the default store view. Prices, product names and status are store view scoped, so a multi storefront merchant needs one read per store view for catalogue data and only one for orders.
The API reference is generated per install rather than published as one fixed document: Adobe publishes a static reference and the store itself serves a schema at /rest/<store-view-code>/schema?services=all (Swagger) and a SOAP WSDL. Generating the schema from the merchant's own install is the only reliable way to know what a given store exposes, since every extension can add routes.
There are no official first party client SDKs for the admin REST API in the way Shopify ships them. Integrations are usually hand rolled with a generic OAuth 1.0a library, or use community packages.
Authentication
Four documented mechanisms. For a server to server connector, only the first two are relevant.
Integration with OAuth 1.0a
- An admin goes to System, Extensions, Integrations, Add New Integration, names it, sets the Callback URL and Identity link URL, and chooses the API resources it may touch on the API tab. Resource permissions are per module, for example
Magento_Sales::sales,Magento_Catalog::products,Magento_InventoryApi::source. - On Save and Activate, Commerce generates a consumer key and consumer secret. Completing the handshake through the callback yields an access token and access token secret; an admin can also simply reveal all four values in the integration screen.
- Every request is signed. The signature method must be
HMAC-SHA256, and the signing key is the consumer secret and the token secret joined with an ampersand. TheAuthorizationheader carriesoauth_consumer_key,oauth_nonce,oauth_signature_method,oauth_signature,oauth_timestamp,oauth_tokenandoauth_version.
GET /rest/all/V1/orders?searchCriteria[pageSize]=1 HTTP/1.1 Host: store.example.in Authorization: OAuth oauth_consumer_key="9k2p...", oauth_nonce="a7f3c1d9e2", oauth_signature_method="HMAC-SHA256", oauth_timestamp="1790000000", oauth_token="b41c...", oauth_version="1.0", oauth_signature="Zk3h%2FQ1m..."
The credentials never expire. Revocation is the admin deactivating the integration.
Many older integrations send the integration access token as a plain Authorization: Bearer <token> and it works. Recent 2.4 releases gate that behind a configuration switch under Stores, Configuration, Services, OAuth, Consumer Settings, and newer installs default it to off. Test both on the merchant's actual install before committing to one; medium confidence on the exact version and default.
Admin and customer bearer tokens
curl -X POST "https://store.example.in/rest/V1/integration/admin/token" \
-H "Content-Type: application/json" \
-d '{"username":"integration_user","password":"S3cure-Passw0rd"}'
The response is a bare quoted string, not an object:
"eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ.eyJ1aWQiOjUsInV0eXBpZCI6MiwiaWF0IjoxNzkwMDAwMDAwfQ.0mFQqk1vWiqHMvUYRkJ3QhU3B6Xy5DGZq4bWQkq0Xw8"
Used as Authorization: Bearer <token>. The customer equivalent is POST /V1/integration/customer/token with an email and password, and it only sees that customer's own data. Documented default lifetimes are 4 hours for an admin token and 1 hour for a customer token, both configurable at Stores, Settings, Configuration, Services, OAuth, Access Token Expiration.
The catch is two factor authentication. Admin accounts are required to authenticate with a 2FA provider, so POST /V1/integration/admin/token alone will not produce a usable token on a stock 2.4 install; the documented flow adds a provider call such as POST /V1/tfa/provider/google/authenticate. In practice merchants either exempt the integration user from 2FA or, better, we use an integration rather than an admin token. For an unattended connector, use the integration.
Multi account: key the credential store on (base URL, consumer key, access token) for integrations, or (base URL, admin username) with a refresh job for bearer tokens. Nothing about the token tells us which store it belongs to, so the base URL is the tenant key.
Objects we can read
Every list endpoint takes the same searchCriteria query syntax. Filters are grouped: filters inside one filter_groups index are ORed, separate groups are ANDed. Condition types documented: eq, neq, like, nlike, in, nin, gt, gteq, lt, lteq, from, to, finset, nfinset, moreq, null, notnull. Paging is searchCriteria[pageSize] and searchCriteria[currentPage], sorting is searchCriteria[sortOrders][0][field] and [direction]. Every list response is { "items": [...], "search_criteria": {...}, "total_count": N }.
An incremental order pull:
GET /rest/all/V1/orders ?searchCriteria[filter_groups][0][filters][0][field]=updated_at &searchCriteria[filter_groups][0][filters][0][value]=2026-09-20 00:00:00 &searchCriteria[filter_groups][0][filters][0][condition_type]=gt &searchCriteria[sortOrders][0][field]=updated_at &searchCriteria[sortOrders][0][direction]=ASC &searchCriteria[pageSize]=100 &searchCriteria[currentPage]=1
Dates in filters are the database format YYYY-MM-DD HH:MM:SS in UTC, not ISO 8601 with a T.
Orders
GET /V1/orders for the list, GET /V1/orders/:id for one by entity id. There is no lookup by increment id except as a filter. Order state and status are two different fields: state is the machine lifecycle (new, pending_payment, processing, complete, closed, canceled, holded, payment_review) and status is a merchant editable label mapped onto a state, so a store can have a status called awaiting_pickup sitting inside state processing. Map from state and keep status as a string.
{
"items": [
{
"entity_id": 7241,
"increment_id": "000000512",
"state": "processing",
"status": "processing",
"store_id": 1,
"created_at": "2026-09-18 19:28:02",
"updated_at": "2026-09-19 04:02:10",
"customer_id": 26,
"customer_email": "priya@example.com",
"customer_firstname": "Priya",
"customer_is_guest": 0,
"order_currency_code": "INR",
"base_currency_code": "INR",
"subtotal": 2249.00,
"subtotal_incl_tax": 2698.82,
"shipping_amount": 0,
"shipping_incl_tax": 0,
"tax_amount": 449.82,
"discount_amount": -250.00,
"grand_total": 2698.82,
"base_grand_total": 2698.82,
"total_qty_ordered": 2,
"total_invoiced": 2698.82,
"total_refunded": null,
"items": [
{
"item_id": 15302,
"parent_item_id": null,
"product_id": 93,
"product_type": "configurable",
"sku": "CPO-500",
"name": "Cold Pressed Coconut Oil 500 ml",
"qty_ordered": 2,
"qty_shipped": 0,
"qty_invoiced": 2,
"qty_refunded": 0,
"qty_canceled": 0,
"price": 1124.50,
"price_incl_tax": 1349.41,
"row_total": 2249.00,
"row_total_incl_tax": 2698.82,
"tax_amount": 449.82,
"discount_amount": 250.00
}
],
"billing_address": {
"entity_id": 14481,
"address_type": "billing",
"firstname": "Priya",
"lastname": "Menon",
"street": ["14 Carter Road", "Flat 302"],
"city": "Mumbai",
"region": "Maharashtra",
"region_code": "MH",
"postcode": "400050",
"country_id": "IN",
"telephone": "9876543210",
"email": "priya@example.com"
},
"payment": {
"entity_id": 7241,
"method": "razorpay",
"amount_ordered": 2698.82,
"amount_paid": 2698.82,
"last_trans_id": "pay_QK7M2ZPLTabcd",
"additional_information": ["Razorpay"]
},
"extension_attributes": {
"shipping_assignments": [
{
"shipping": {
"address": {
"address_type": "shipping",
"firstname": "Priya",
"street": ["14 Carter Road", "Flat 302"],
"city": "Mumbai",
"region_code": "MH",
"postcode": "400050",
"country_id": "IN",
"telephone": "9876543210"
},
"method": "flatrate_flatrate",
"total": { "base_shipping_amount": 0, "shipping_amount": 0 }
},
"items": [{ "item_id": 15302 }],
"stock_id": 1
}
],
"payment_additional_info": [{ "key": "method_title", "value": "Razorpay" }]
}
}
],
"search_criteria": { "page_size": 100, "current_page": 1 },
"total_count": 3184
}
The shipping address is not a top level field. It lives at extension_attributes.shipping_assignments[0].shipping.address, which is the single most common thing an integration gets wrong. Addresses, email and telephone are PII and are returned unmasked.
Order items
Embedded in the order as items[], and separately at GET /V1/orders/items and GET /V1/orders/items/:id with the same search criteria syntax. Configurable products produce two rows: the parent, carrying the price, and a child with parent_item_id set, carrying the real simple product SKU. Deduplicate on parent_item_id or the quantities will double.
Products and listings
GET /V1/products with search criteria; GET /V1/products/:sku for one. Core fields are thin and everything else arrives as custom_attributes, an array of {attribute_code, value}, because the catalogue is EAV. Expect description, meta_title, url_key, special_price, image and every merchant defined attribute to live there.
{
"id": 93,
"sku": "CPO-500",
"name": "Cold Pressed Coconut Oil 500 ml",
"attribute_set_id": 4,
"price": 1249.50,
"status": 1,
"visibility": 4,
"type_id": "simple",
"created_at": "2025-03-04 10:00:00",
"updated_at": "2026-09-17 11:41:02",
"weight": 0.55,
"extension_attributes": {
"website_ids": [1],
"category_links": [{ "position": 0, "category_id": "9" }],
"stock_item": {
"item_id": 93,
"qty": 92,
"is_in_stock": true,
"manage_stock": true,
"min_qty": 0,
"backorders": 0,
"use_config_manage_stock": true
}
},
"media_gallery_entries": [
{ "id": 792, "media_type": "image", "label": "", "position": 1, "file": "/c/p/cpo-500.jpg", "types": ["image", "small_image", "thumbnail"] }
],
"custom_attributes": [
{ "attribute_code": "url_key", "value": "cold-pressed-coconut-oil-500-ml" },
{ "attribute_code": "special_price", "value": "1124.5000" },
{ "attribute_code": "tax_class_id", "value": "2" },
{ "attribute_code": "size", "value": "500 ml" }
]
}
status is 1 for enabled and 2 for disabled. visibility is 1 not visible, 2 catalog, 3 search, 4 catalog and search. A product can be enabled and invisible, which for our purposes is not a live listing. The product URL is not returned; build it from url_key and the store's base URL plus the URL suffix, or read the rewrite from GET /V1/products/:sku extension data per store view.
Inventory
Two systems coexist. The legacy single source registry is still there and still used by stores that never switched:
GET /V1/stockItems/:productSkureturns the legacy stock item, the same object embedded inextension_attributes.stock_item.GET /V1/stockStatuses/:productSkureturns the derived status.GET /V1/stockItems/lowStock/lists items under a threshold.
Multi Source Inventory, standard since 2.3, is the one to use where present. Routes confirmed from the module definition:
GET /V1/inventory/sourcesand/V1/inventory/sources/:sourceCode: physical sources, ourlocations.GET /V1/inventory/stocksand/V1/inventory/stocks/:stockId: virtual aggregations of sources mapped to sales channels.GET /V1/inventory/stock-source-links: which sources feed which stock.GET /V1/inventory/source-items: quantity per SKU per source, with search criteria.GET /V1/inventory/get-product-salable-quantity/:sku/:stockId: the salable quantity, which is on hand minus reservations.GET /V1/inventory/is-product-salable/:sku/:stockIdand/V1/inventory/is-product-salable-for-requested-qty/:sku/:stockId/:requestedQty.GET /V1/inventory/get-sources-assigned-to-stock-ordered-by-priority/:stockId.
{
"items": [
{ "sku": "CPO-500", "source_code": "bhiwandi_fc", "quantity": 104, "status": 1 },
{ "sku": "CPO-500", "source_code": "default", "quantity": 0, "status": 0 }
],
"search_criteria": { "page_size": 200, "current_page": 1 },
"total_count": 2
}
quantity on a source item is physical stock at that source. The difference between it and the salable quantity is the reservation, which MSI keeps in its own table and exposes only through the salable quantity endpoint. Read both if we want quantity_available and quantity_reserved.
Shipments and tracking
GET /V1/shipments and GET /V1/shipment/:id. A shipment belongs to an order, contains the shipped items and carries a tracks array, which is where the AWB lives. Labels, where the carrier module supports them, come from GET /V1/shipment/:id/label.
{
"entity_id": 3110,
"increment_id": "000000487",
"order_id": 7241,
"total_qty": 2,
"created_at": "2026-09-18 12:40:00",
"items": [
{ "entity_id": 6220, "order_item_id": 15302, "sku": "CPO-500", "name": "Cold Pressed Coconut Oil 500 ml", "qty": 2, "weight": 0.55 }
],
"tracks": [
{
"entity_id": 902,
"order_id": 7241,
"parent_id": 3110,
"track_number": "1234567890123",
"title": "Delhivery",
"carrier_code": "custom",
"created_at": "2026-09-18 12:41:10"
}
],
"packages": [],
"comments": []
}
There is no scan event history. Magento stores the tracking number and the carrier label, nothing more, so shipments.events[] has to come from the carrier connector. carrier_code is very often custom, with the real carrier name only in title, so normalise on title.
Returns and cancellations
A cancellation is POST /V1/orders/:id/cancel, and cancelled orders carry state: canceled with qty_canceled on the items.
Returns proper (RMA) are an Adobe Commerce feature, not present in Magento Open Source. The commercial edition adds return endpoints under /V1/returns, and their exact shape could not be verified here because the Commerce modules are not in the public repository. Mark RMA support as low confidence and detect it per store with GET /V1/modules.
What every edition does have is the credit memo, which is the money record of a return: GET /V1/creditmemos, GET /V1/creditmemo/:id, with POST /V1/order/:orderId/refund and POST /V1/invoice/:invoiceId/refund to create one offline or through the gateway.
{
"entity_id": 1840,
"increment_id": "000000203",
"order_id": 7241,
"state": 2,
"created_at": "2026-09-21 20:07:11",
"subtotal": 1124.50,
"tax_amount": 202.41,
"shipping_amount": 0,
"adjustment": 0,
"grand_total": 1326.91,
"items": [
{ "entity_id": 4411, "order_item_id": 15302, "sku": "CPO-500", "qty": 1, "price": 1124.50, "row_total": 1124.50, "tax_amount": 202.41 }
]
}
Payments and settlements
Order level payment is the payment object on the order plus GET /V1/transactions, which lists gateway transactions with txn_id, txn_type (authorization, capture, refund, void, order) and additional_information. Invoices, at GET /V1/invoices, are the accounting document rather than a money movement.
There is no settlement or payout object, because Magento never holds the money. Fees and payouts come from the gateway, matched on payment.last_trans_id.
Customers
GET /V1/customers/search with search criteria, GET /V1/customers/:id, and GET /V1/customers/me for a customer token. Fields: id, group_id, created_at, updated_at, created_in, email, firstname, lastname, store_id, website_id, addresses[], disable_auto_group_change, plus custom_attributes. Guest orders create no customer record; customer_is_guest on the order is the flag, and customer_email is the only identity. All PII, unmasked.
Locations
MSI sources are the locations: GET /V1/inventory/sources returns source_code, name, enabled, description, latitude, longitude, country_id, region, city, street, postcode, contact_name, email, phone and use_default_carrier_config. On a pre MSI store there is exactly one implicit location and the store address in configuration is the best we can do.
Writing back: listings, price and stock
The price endpoints are the right tool for a pricing sync: they take an array, are store scoped through store_id (0 is the default or global scope), and return an array of failed items rather than failing the whole call. The generic PUT /V1/products/:sku is slow, because it saves the whole EAV entity and reindexes, and it is easy to wipe an attribute by omitting it in some versions. Prefer the narrow endpoints.
Stock, MSI form:
{
"sourceItems": [
{ "sku": "CPO-500", "source_code": "bhiwandi_fc", "quantity": 80, "status": 1 },
{ "sku": "CPO-250", "source_code": "bhiwandi_fc", "quantity": 0, "status": 0 }
]
}
status is 1 for in stock and 0 for out of stock, and it is independent of quantity: a source item can hold 0 and still be flagged in stock if the merchant allows backorders. Quantities are absolute, not deltas, and there is no compare and set.
Asynchronous and bulk lanes
Two variants of the same idea, both requiring message queue consumers to be running on the store (bin/magento queue:consumers:start async.operations.all). If consumers are not running, requests are accepted and nothing ever happens, which is a failure mode worth alerting on.
- Asynchronous single:
POST|PUT|DELETE|PATCH /rest/<store>/async/V1/<resource>. Same request body as the synchronous call. - Bulk:
POST|PUT|DELETE /rest/<store>/async/bulk/V1/<resource>with an array of the request bodies that the single endpoint would take. GET is not supported on either lane.
Both return immediately:
{
"bulk_uuid": "799a59c0-09ca-4d60-b432-2953986c1c38",
"request_items": [
{ "id": 0, "data_hash": "a1b2c3...", "status": "accepted" }
],
"errors": false
}
Progress is polled by bulk uuid through the bulk status endpoints. accepted means queued, not applied, so a bulk price or stock push must be followed up by reading the operation status before we record the write as successful.
On the SaaS Cloud Service the path order changes to /<tenant-id>/V1/async/bulk/<resource>.
Webhooks and notifications
Magento Open Source has no outbound webhook feature. Out of the box, the only way to learn that something changed is to poll.
Adobe layers two different things on top, and they are not interchangeable.
Adobe I/O Events for Adobe Commerce is the asynchronous one. Commerce emits events, based on observers and plugins, into Adobe I/O, where an App Builder application or a journaling consumer picks them up. It needs an Adobe Developer Console project, the Commerce eventing modules, and cron running on the store. This is the closest thing to a Shopify style webhook, but the consumer sits inside Adobe's ecosystem rather than being any URL we choose.
Adobe Commerce Webhooks are synchronous. A configured webhook calls an external endpoint in the middle of a Commerce operation and the response can change or block that operation, for example to validate stock or tax before an order is placed. They are configured in webhooks.xml on PaaS and on premise, or in the admin at System, Webhooks on the SaaS edition. Adobe's own guidance is that if an immediate answer is not needed, I/O Events is the better choice. The documentation we could read did not describe a signing or verification scheme for these calls, so treat request authenticity as an open question and put the endpoint behind a shared secret of our own.
Without either, the polling plan is: orders filtered on updated_at every 10 to 15 minutes, shipments and credit memos on the same cadence, products daily plus after any write we make, MSI source items hourly for fast moving SKUs. Remember that updated_at on an order moves for reasons that do not change anything we store, so dedupe on content.
Rate limits and pagination
Magento publishes no API rate limit. The constraints are the store's own: PHP workers, max_input_vars (search criteria URLs are long and can blow the default limit, which surfaces as filters being silently dropped), MySQL, and the reindex load a write triggers. Adobe Commerce on cloud sits behind Fastly, where the merchant can configure rate limiting and a WAF that may block an unfamiliar client; if calls start returning HTML error pages or 403s, that is usually Fastly rather than Magento.
Pagination is page and page size, so it has the usual drift problem on a moving table. Sort ascending by entity_id or updated_at and page forward. total_count in every response lets us size the job up front. Keep pageSize around 50 to 100 for orders, which are heavy objects, and up to a few hundred for source items.
Practical cadence: treat a store as able to serve a handful of requests per second, use the bulk lane for anything above a few hundred writes, and never run a full catalogue sweep during the merchant's business hours without asking.
Mapping to the unified model
orders
order_items
listings
inventory
shipments
returns and settlements
returns: credit memo entity_id to return_id, grand_total to refund_amount, created_at to initiated_at, items[] to items[]. There is no reason field on a credit memo, only a free text comment. On Adobe Commerce, the RMA object would supply reason and a real status lifecycle. settlements: nothing to map; use the gateway.
Gaps and open questions
- RMA endpoints exist only on Adobe Commerce and could not be verified, because the commercial modules are not public. Low confidence on any return lifecycle beyond credit memos.
- Whether an integration access token still works as a plain bearer token depends on a configuration switch and the release; verify per store.
- 2FA makes the admin token endpoint awkward for unattended use, and the exact behaviour when the integration user is exempt was not verified on a live install.
- Adobe Commerce Webhooks: no signing or verification scheme was found in the documentation we could read. Open question.
- Adobe I/O Events availability on Magento Open Source was not confirmed; the modules exist but the Adobe side requires a Developer Console project.
- No published rate limits at all, so cadence is a per store judgement.
- The Cloud Service SaaS edition changes base URLs, the store scoping header and the whole authentication model to Adobe IMS. Treat it as a separate connector variant, not a configuration flag.
- Extension sprawl means any given store can have extra routes, altered order fields or a custom status set. Generate the store's own schema at onboarding rather than assuming the stock shape.
Sources
- Adobe Commerce REST web API overview
- Search using REST, searchCriteria syntax
- Token based authentication
- OAuth based authentication
- Bulk endpoints
- Asynchronous web endpoints
- Adobe I/O Events for Adobe Commerce
- Adobe Commerce Webhooks
- Adobe Commerce GraphQL overview
- Module route definitions read from source: Sales webapi.xml, Catalog webapi.xml, CatalogInventory webapi.xml, InventoryApi webapi.xml, InventorySalesApi webapi.xml, SalesGraphQl schema