ERPNext is an open source ERP built on the Frappe framework, developed by Frappe Technologies in Mumbai and offered as Frappe Cloud (SaaS) or self-hosted. Like Odoo it exposes its own metadata layer rather than a curated REST surface: every DocType, which is Frappe's word for a model, is automatically available at /api/resource/<DocType> with the same filtering, field selection and pagination vocabulary. Unlike Odoo it is genuinely REST-shaped, with ordinary GET, POST, PUT and DELETE, JSON bodies, and a static api_key:api_secret credential that never expires. That combination makes ERPNext one of the easiest systems in this set to integrate and one of the easiest to get wrong, because the whole database is reachable through one URL pattern and access control is entirely a matter of the API user's roles.
At a glance
What it is
Frappe Technologies is an Indian company. Frappe is the underlying web framework: a metadata-driven Python and JavaScript stack where every model is a DocType record, and the REST API, permissions, list views, reports and webhooks are all generated from that metadata. ERPNext is the largest application built on it, covering accounting, selling, buying, stock, manufacturing, projects, HR and CRM.
Everything is GPLv3 open source, which has two practical consequences. The data model is fully readable: the DocType JSON files in the repository are the authoritative schema, so you never have to guess a field name. And installations are heavily customised, because adding a field or a DocType is a UI operation, so a connector must expect fields that are not in the upstream schema.
Deployment shapes you will meet: Frappe Cloud, the first-party managed hosting with a site per customer at <site>.frappe.cloud or a custom domain; self-hosted on a VPS via bench or Docker; and various third-party ERPNext hosts. All three speak the same API on the site's own domain. There is no central API host, so a connector stores a base URL per customer.
The commerce-relevant DocTypes:
- Sales Order and Sales Order Item: the order and its lines.
- Sales Invoice and Sales Invoice Item: the invoice. A credit note is a Sales Invoice with
is_returnset. - Delivery Note: the outbound shipment document.
- Item: the product. Variants are separate Item records linked by
variant_of. - Bin: quantity of one Item in one Warehouse. This is the inventory table.
- Stock Ledger Entry: the immutable movement log.
- Customer, Address, Contact: the customer and its PII, in three linked DocTypes.
- Payment Entry: money received or paid.
- Warehouse: a location, in a tree.
API access
- Log into the site as an administrator, open the target user, go to the Settings tab, expand API Access and click Generate Keys. That produces an
api_keyand anapi_secret. The secret is shown once. - Give that user the roles the integration needs. This is the whole of the access control story: "every request you make with these keys will be logged against the user you selected", and permissions are evaluated against that user's roles on every call.
- There is nothing else. No app registration, no review, no quota tier.
Create a dedicated integration user rather than reusing a human's account, both for the audit trail and so that revoking the integration does not lock out a person.
API versions. Frappe routes three prefixes, all live:
v1 is what almost every existing integration uses and what the user documentation describes. v2 is newer, is the one being developed, and adds things v1 lacks: a discovery endpoint, bulk update and bulk delete, a has_next_page flag on lists, a metadata endpoint, and document-scoped method execution. Pick one deliberately; the parameter names differ (v1 uses limit_start and limit_page_length, v2 uses start and limit).
Official SDKs. Frappe publishes frappe-client (Python) and community clients exist for JavaScript and PHP. Because the API is a uniform URL pattern over JSON, calling it directly is entirely reasonable and is what most integrations do.
Authentication
Three schemes, and for a server-to-server connector the first is the one to use.
Token, the recommended option
curl 'https://acme.frappe.cloud/api/resource/Sales%20Order?limit_page_length=20' \ -H 'Authorization: token 8a1bcd2e3f45678:9876543210fedcb' \ -H 'Accept: application/json'
The header format is literally Authorization: token <api_key>:<api_secret>. There is no expiry and no refresh. That is convenient and is also the main risk: a leaked key works until somebody regenerates it, and regenerating invalidates the old pair immediately with no overlap window, so rotation needs a maintenance moment.
Basic auth
The same <api_key>:<api_secret> pair, joined with a colon and base64-encoded into a standard Authorization: Basic ... header. Functionally identical; use it only when a client library will not let you set an arbitrary scheme.
OAuth 2.0
Frappe ships an OAuth 2.0 provider, so a site can issue bearer tokens: Authorization: Bearer <access_token>. This is the right choice for a multi-customer application where you do not want to hold long-lived secrets, but it requires the customer to register an OAuth client on their site, which is more onboarding friction than handing over a key pair. The v2 router also exposes /api/v2/method/login and /api/v2/method/logout for cookie sessions, which are for interactive clients rather than connectors.
Verifying a credential
The cheapest probe, from Frappe's own documentation:
import requests
url = "https://acme.frappe.cloud/api/method/frappe.auth.get_logged_user"
headers = {"Authorization": "token <api_key>:<api_secret>"}
response = requests.request("GET", url, headers=headers)
It returns the username the keys resolve to, which is also how you confirm you are pointed at the right user.
Required headers on any call with a body: Accept: application/json and Content-Type: application/json.
Multi-account. Per customer you store a base URL, an api_key and an api_secret. There is no tenant header and no account id: the site is the tenant. A multi-company ERPNext site adds a company field on most transactional DocTypes, so within one site you may still need to filter by company.
/api/resource/<DocType> reaches every DocType the API user can read, including User, Email Account and Integration Request, some of which hold other credentials. There is no scope mechanism narrowing a key to a subset of DocTypes. Roles are the only control, so the integration user's role set is the entire security boundary. Give it the minimum, and audit what it can read before going live.
Objects we can read
One pattern covers everything.
List: GET /api/resource/<DocType>. By default it returns 20 records and only the name field, which surprises people:
{"data":[{"name":"f765eef382"},{"name":"2a26fa1c64"}]}
name is Frappe's primary key: a string, which for transactional DocTypes is the human-readable document number (SAL-ORD-2026-00042) and for others may be a hash.
Query parameters (v1):
Filter operators follow Frappe's query vocabulary: =, !=, >, <, >=, <=, like, not like, in, not in, between, is. Values are JSON, so the whole filters parameter is a URL-encoded JSON string.
Read one: GET /api/resource/<DocType>/<name>, which returns the full document including its child tables (the line items) as nested arrays. ?expand_links=True expands every link field into the linked document. This is the important difference from the list endpoint: lines are only available on a single-document read, or by querying the child DocType directly.
Query the child table directly when you want many documents' lines at once. Child DocTypes are ordinary DocTypes, so Sales Order Item is queryable with a parent filter:
GET /api/resource/Sales Order Item ?fields=["parent","item_code","qty","rate","amount","warehouse","delivered_qty"] &filters=[["parent","in",["SAL-ORD-2026-00042","SAL-ORD-2026-00043"]]] &parent=Sales Order &limit_page_length=0
The parent=Sales Order parameter is required for child DocTypes, because Frappe needs to know which parent DocType's permissions to check. limit_page_length=0 means no limit, which is convenient and dangerous in equal measure.
v2 equivalents: GET /api/v2/document/<DocType> with fields, filters, order_by, group_by, start, limit (default 20) and as_dict. The v2 list response adds has_next_page, which removes the need for a separate count call. GET /api/v2/doctype/<DocType>/count and GET /api/v2/doctype/<DocType>/meta give the total and the schema.
Pagination. Offset-based. limit_start plus limit_page_length in v1, start plus limit in v2. There is no cursor. For a backfill, prefer keyset paging: order_by=creation asc with filters=[["creation",">","<last seen>"]], or page by modified.
Incremental sync. Every DocType carries four framework fields: creation, modified, modified_by and owner, plus docstatus (0 draft, 1 submitted, 2 cancelled) and idx. modified is the high-water mark:
GET /api/resource/Sales Order ?fields=["name","customer","transaction_date","status","grand_total","modified"] &filters=[["modified",">","2026-09-21 10:00:00"]] &order_by=modified asc &limit_page_length=500
Timestamps are in the site's timezone, not UTC, which is set per site in System Settings. Read it once from /api/resource/System Settings/System Settings and convert, or you will silently drift by hours.
Discovering the schema. GET /api/resource/DocType/<name> returns the DocType definition including every field, and GET /api/v2/doctype/<DocType>/meta does the same in v2. Call it at connector start to find a customer's custom fields, which arrive as ordinary fields on the document.
Orders
Sales Order. Fields verified from erpnext/selling/doctype/sales_order/sales_order.json on develop:
status is a Select with these exact options: Draft, On Hold, To Pay, To Deliver and Bill, To Bill, To Deliver, Completed, Cancelled, Closed. That is unusually informative for an ERP because it folds fulfilment and billing into one value, but note it is derived: per_delivered and per_billed are the underlying numbers and are what you should map.
{
"data": {
"name": "SAL-ORD-2026-00042",
"customer": "Kumar Retail",
"customer_name": "Kumar Retail",
"transaction_date": "2026-09-20",
"delivery_date": "2026-09-24",
"po_no": "AMZ-402-1234567-8901234",
"status": "To Deliver and Bill",
"delivery_status": "Not Delivered",
"per_delivered": 0.0,
"per_billed": 0.0,
"currency": "INR",
"conversion_rate": 1.0,
"total_qty": 5.0,
"total": 2250.0,
"net_total": 2250.0,
"total_taxes_and_charges": 405.0,
"grand_total": 2655.0,
"rounded_total": 2655.0,
"advance_paid": 0.0,
"set_warehouse": "Stores - AC",
"company": "Acme Commerce",
"docstatus": 1,
"modified": "2026-09-20 16:35:03.412094",
"items": [
{
"name": "a1b2c3d4e5",
"item_code": "OIL-500",
"item_name": "Cold Pressed Oil 500ml",
"qty": 5.0,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1.0,
"stock_qty": 5.0,
"rate": 450.0,
"amount": 2250.0,
"warehouse": "Stores - AC",
"delivered_qty": 0.0,
"billed_amt": 0.0,
"brand": "Acme",
"item_group": "Foods",
"idx": 1
}
]
}
}
Order items
Sales Order Item, verified from erpnext/selling/doctype/sales_order_item/sales_order_item.json:
Two filters to apply before summing: is_free_item lines carry quantity but no money, and is_product_bundle lines expand into the packed_items child table on the parent rather than moving stock themselves.
Products and listings
Item, verified from erpnext/stock/doctype/item/item.json:
The Indian localisation adds a gst_hsn_code field on Item through the india_compliance app, so HSN is present on Indian installations and absent otherwise. Check with a metadata call rather than assuming.
There is no listing object. Price is not on the Item in general: standard_rate is a default, and the real selling price lives in Item Price records, each linking an item_code, a price_list and a price_list_rate, optionally with validity dates, quantity breaks and a customer. Query Item Price filtered by price_list to get a channel's prices.
Inventory
Bin is the inventory table: one row per Item and Warehouse. Verified from erpnext/stock/doctype/bin/bin.json:
This is the richest inventory model in this set: available, reserved and inbound are all separate published fields, with no derivation needed.
GET /api/resource/Bin ?fields=["item_code","warehouse","actual_qty","reserved_qty","ordered_qty","projected_qty","stock_uom","valuation_rate","modified"] &filters=[["item_code","in",["OIL-500","OIL-1L"]]] &limit_page_length=0
Stock Ledger Entry is the immutable movement log, one row per stock movement with item_code, warehouse, posting_date, posting_time, actual_qty (the delta), qty_after_transaction, voucher_type, voucher_no, batch_no, serial_no and valuation_rate. Read it when you need movements rather than balances, and note that it is append-only, so it is the cheapest thing to sync incrementally.
Warehouse is a tree DocType with is_group marking grouping nodes that hold no stock. Filter those out.
Shipments and tracking
Delivery Note is the outbound shipment, with Delivery Note Item lines. It carries customer, posting_date, posting_time, status, items, shipping_address_name, the Sales Order reference on each line through against_sales_order, and, on Indian and general installations, lr_no (the transporter's document number) and lr_date, plus transporter and vehicle_no.
Shipment is a separate DocType in newer ERPNext, designed for carrier integrations, with awb_number, service_provider, tracking_status and tracking_url. Whether it exists on a given site depends on version and on whether a shipping integration app is installed, so probe with a metadata call.
There are no scan events in either case. ERPNext stores a tracking reference, not a history.
Returns and cancellations
Returns are the same DocTypes with a flag, which is elegant and easy to get wrong.
- A sales return is a
Sales Invoicewithis_returnset to 1 andreturn_againstpointing at the original invoice. Quantities and amounts are negative. In the UI it is called a Credit Note. - A goods return is a
Delivery Notewithis_returnset andreturn_againstpointing at the original delivery note, which moves stock back in. Sales Invoice.statusincludesReturnandCredit Note Issuedas distinct values, so you can tell a credit note from an invoice that has had one raised against it.
The full verified Sales Invoice.status option list: Draft, Return, Credit Note Issued, Submitted, Paid, Partly Paid, Unpaid, Unpaid and Discounted, Partly Paid and Discounted, Overdue and Discounted, Overdue, Cancelled, Internal Transfer.
Cancellation is docstatus. Frappe's submittable documents have three states: docstatus 0 is a draft, 1 is submitted (posted to the ledger, immutable except for a small set of allow-on-submit fields), and 2 is cancelled. A cancelled document is never deleted; its ledger effect is reversed. Any financial aggregate must filter docstatus = 1, and a connector that ignores docstatus will double-count drafts.
docstatus is the single most important field in a Frappe integration and does not appear in any business module's documentation, because it belongs to the framework. Filter ["docstatus","=",1] on every transactional read unless you specifically want drafts, and treat a move from 1 to 2 as a cancellation event, not a deletion.
Payments and settlements
Payment Entry records money in and out, with payment_type (Receive, Pay, Internal Transfer), party_type, party, posting_date, paid_amount, mode_of_payment, paid_from and paid_to accounts, and a references child table (Payment Entry Reference) linking to the invoices being settled with reference_doctype, reference_name, total_amount, outstanding_amount and allocated_amount. That child table is the join you want for settlement.
Sales Invoice.outstanding_amount gives the unpaid balance without a second call.
There is no marketplace settlement object. A payout would be a Bank Transaction or a Journal Entry, reconciled against invoices.
Customers
Three DocTypes, linked, and this is the shape that surprises people coming from Xero or QuickBooks.
Customerholds the commercial relationship:customer_name,customer_group,territory,customer_type(Company or Individual),tax_id,default_currency,default_price_list,payment_terms,credit_limits,disabled, andcustomer_primary_addressandcustomer_primary_contactas convenience links.Addressholds postal addresses, withaddress_line1,address_line2,city,state,country,pincode,address_type(Billing, Shipping, Office and others), and alinkschild table connecting it to one or more parties. One address can serve several customers.Contactholds people, withfirst_name,last_name,email_idsandphone_nosas child tables, and the samelinkspattern.
So a customer's addresses are not a field on the customer. Query Address with a filter on the Dynamic Link child table, or read Customer and follow customer_primary_address. All three DocTypes are unmasked PII.
Locations
Warehouse, a tree with warehouse_name, parent_warehouse, is_group, company, account, and an embedded address. Only leaf warehouses (is_group false) hold stock. Warehouse is also where ERPNext's bin-level detail stops: there is no shelf or storage-location dimension below the warehouse unless an app adds one.
Writing back: sales orders, invoices and stock adjustments
Creates and updates are ordinary REST, and the document lifecycle is the thing to understand.
The draft-submit lifecycle
Most transactional DocTypes are submittable. A POST creates a draft (docstatus 0), which has no ledger or stock effect. Submitting it (docstatus 1) posts it and makes it immutable. This is not optional: a Sales Order that is never submitted reserves nothing, and a Sales Invoice that is never submitted does not exist financially.
Two ways to submit. Either include "docstatus": 1 in the create body, which works for simple documents, or create then call the submit method:
curl -X POST 'https://acme.frappe.cloud/api/resource/Sales%20Order' \
-H 'Authorization: token <api_key>:<api_secret>' \
-H 'Content-Type: application/json' \
-d '{
"customer": "Kumar Retail",
"transaction_date": "2026-09-22",
"delivery_date": "2026-09-26",
"po_no": "AMZ-402-1234567-8901234",
"company": "Acme Commerce",
"currency": "INR",
"items": [
{
"item_code": "OIL-500",
"qty": 5,
"rate": 450,
"warehouse": "Stores - AC",
"delivery_date": "2026-09-26"
}
]
}'
Child tables are nested arrays in the body, which is much simpler than Odoo's command tuples. Omitted fields are filled from the DocType's defaults and from the customer's and item's defaults, so a minimal body goes a long way, and also means the created document may not look exactly like what you sent. Read it back.
To submit separately, PUT the document with {"docstatus": 1}, or call the whitelisted method:
POST /api/method/frappe.client.submit
with the document as the doc parameter. In v2 there is a cleaner route: POST /api/v2/document/<DocType>/<name>/method/submit.
Updates are PUT /api/resource/<DocType>/<name> with a partial body: only the fields you are changing. Once docstatus is 1, only fields marked allow-on-submit can change, and attempting others returns a validation error. To correct a submitted document you cancel it (docstatus 2) and amend it, which creates a new document with amended_from pointing at the old one.
Sales orders
As above. po_no is the natural carrier for an external order id and is filterable, which makes it a workable idempotency key: query Sales Order filtered on po_no before creating.
Invoices
POST /api/resource/Sales Invoice. Two flags decide the accounting shape:
update_stock: when 1 the invoice moves stock itself, which is what you want for a point-of-sale or a marketplace order with no separate delivery note. When 0, stock moves through a Delivery Note instead.is_pos: marks a point-of-sale invoice, which changes payment handling.
To bill an existing order, create the invoice with each line carrying sales_order and so_detail (the Sales Order Item's name), which is how ERPNext links billing back to the order and updates per_billed. There is also a server-side helper, erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice, callable through /api/method/ with the order name, which builds the invoice body for you and is far less error-prone than assembling it by hand.
A credit note is a Sales Invoice with is_return: 1, return_against set to the original invoice name, and negative qty on each line.
Stock adjustments
Three mechanisms, and the first is the one a marketplace connector wants.
Stock Reconciliation sets absolute quantities. Verified fields on Stock Reconciliation Item: item_code, warehouse, qty, valuation_rate, amount, batch_no, serial_no, plus read-only current_qty, current_valuation_rate, quantity_difference and amount_difference that ERPNext computes for you.
curl -X POST 'https://acme.frappe.cloud/api/resource/Stock%20Reconciliation' \
-H 'Authorization: token <api_key>:<api_secret>' \
-H 'Content-Type: application/json' \
-d '{
"purpose": "Stock Reconciliation",
"company": "Acme Commerce",
"posting_date": "2026-09-22",
"posting_time": "18:30:00",
"set_posting_time": 1,
"expense_account": "Stock Adjustment - AC",
"items": [
{
"item_code": "OIL-500",
"warehouse": "Stores - AC",
"qty": 42,
"valuation_rate": 320
}
]
}'
qty here is the target, not a delta. ERPNext reads the current actual_qty, computes the difference and posts the movement. That makes it the natural fit for "stock is now 42" and is a real advantage over Xero, Tally and Intacct. Submit it (docstatus 1) for it to take effect.
Stock Entry posts a movement: purpose of Material Receipt, Material Issue, Material Transfer, Material Transfer for Manufacture, Repack or Send to Subcontractor, with items carrying item_code, qty, s_warehouse and t_warehouse. Use it for transfers between warehouses and for receipts with a known cost.
A Stock Reconciliation needs an expense account (usually "Stock Adjustment") and posts the value difference to it, so every stock correction touches the general ledger. It also requires the Stock Manager role. Both need agreeing with the customer's accountant before a connector starts writing stock automatically, and the account name differs per company because ERPNext suffixes accounts with the company abbreviation.
Items and price
POST /api/resource/Item with at least item_code, item_name, item_group and stock_uom. PUT to update. Archiving is {"disabled": 1}, not DELETE: deleting an item referenced by any transaction will fail on a link check.
Price changes are a new or updated Item Price record, not a field on the Item:
{
"item_code": "OIL-500",
"price_list": "Standard Selling",
"price_list_rate": 460,
"valid_from": "2026-10-01"
}
Bulk operations
v1 has none: one document per request. v2 adds POST /api/v2/document/<DocType>/bulk_update and POST /api/v2/document/<DocType>/bulk_delete, plus site-wide POST /api/v2/method/bulk_update and bulk_delete. Frappe runs these synchronously below a configurable threshold and switches to a background job above it, so the response may be a job id rather than a result. If you need bulk on v1, /api/method/frappe.client.insert_many exists and takes a list of documents.
Idempotency
No idempotency key. Two guards. Set name explicitly in the create body for DocTypes whose naming allows it, and a duplicate will fail with a duplicate-entry error rather than creating a second record. Or put the external id in a filterable field (po_no on Sales Order, po_no on Sales Invoice, ref conventions elsewhere) and query first. The second is the one that works everywhere.
Webhooks and notifications
Frappe has proper webhooks, and unlike Xero and Intuit they are configured per site and can be created through the API, because a webhook is just a Webhook document.
Verified from frappe/integrations/doctype/webhook/webhook.json:
That condition field is the useful part: doc.docstatus == 1 and doc.status == "To Deliver and Bill" gives you a single webhook that fires only on the transition you care about, rather than a firehose of on_update events.
Signature verification. With enable_security on, Frappe computes an HMAC-SHA256 over the JSON-serialised body using webhook_secret as the key, base64-encodes it, and sends it in the header X-Frappe-Webhook-Signature. From the source:
signature = base64(hmac_sha256(webhook_secret, frappe.as_json(data))) headers["X-Frappe-Webhook-Signature"] = signature
To verify, recompute the HMAC over the raw request body and compare in constant time. The subtlety is that Frappe signs frappe.as_json(data), its own serialisation of the selected fields, so the bytes you receive are what was signed only if your framework has not re-serialised them. Read the raw body before any JSON parsing middleware touches it.
Body. Whatever webhook_data selects. If nothing is selected, the whole document. So unlike Intuit and Xero, a Frappe webhook can carry the data, and a well-configured one removes the follow-up read entirely. Select the fields you need, including name, modified and docstatus.
Delivery. Webhooks are dispatched from a background queue, so they are asynchronous and do not block the user's save. Every attempt is recorded as a Webhook Request Log document, readable through the API at /api/resource/Webhook Request Log, which is a genuinely useful audit trail and a way to detect a receiver outage from your side. Retry behaviour is not documented as a fixed schedule; treat delivery as at-most-once in the worst case and back it with polling.
Setup on a new customer. Because a webhook is a document, a connector with the right role can create one during onboarding with a POST /api/resource/Webhook carrying webhook_doctype, webhook_docevent, request_url, enable_security, webhook_secret, a condition and a webhook_data field selection. That is a real advantage over every other accounting platform in this set, where webhook setup is a manual console step.
What to poll anyway. Bin for stock (no webhook is needed if you poll, and stock changes are frequent enough that per-change events would be noisy), and modified-filtered sweeps of Sales Order, Sales Invoice, Delivery Note and Payment Entry every 5 to 15 minutes as a safety net behind the webhooks.
Rate limits and pagination
No published rate limit in the framework itself. Self-hosted installations have none beyond the customer's reverse proxy. Frappe Cloud applies plan-based resource limits (workers, CPU seconds, memory) rather than a request quota, and those are not published as a request-per-minute number.
Frappe does ship a per-site rate limiter that an administrator can enable in site_config.json with a request limit and a window, and when active it returns HTTP 429. It is off by default. A connector should handle 429 with backoff regardless, and read Retry-After if present.
The practical limits are resource limits, and the failure mode is that you degrade the customer's own site:
- Always set
fields. Omitting it on the single-document read returns every field and every child table; on some DocTypes that is hundreds of columns. - Never use
limit_page_length=0on a large DocType. It means unlimited, andStock Ledger Entryon a real site has millions of rows. - Query child DocTypes directly rather than reading N parent documents to get their lines. One call with
filters=[["parent","in",[...]]]replaces a hundred. - Keep concurrency low. A Frappe site runs a small number of gunicorn workers; four parallel heavy queries can block the UI.
- Prefer
Stock Ledger Entryfor incremental stock sync over re-reading all ofBin: it is append-only and indexed onposting_date.
Pagination is offset-based and gets slower with depth. For a backfill, keyset page on creation or name rather than walking limit_start.
A cadence that behaves well: webhooks on submit and cancel for Sales Order, Sales Invoice and Delivery Note; a modified-filtered sweep of those four DocTypes every 10 minutes with a 500-record page size; Bin every 10 minutes filtered to the items you care about; Item, Item Price and Customer hourly; and a nightly full reconciliation of a trailing 7 days.
Mapping to the unified model
Gaps and open questions
- The schema is per site. Every field above was verified against
frappe/erpnextondevelop, but a customer may be on v13 through v16 and will very likely have custom fields and possibly custom DocTypes. Call the metadata endpoint at connector start and adapt. Also probe for apps:gst_hsn_codeneeds India Compliance, and theShipmentDocType needs a shipping integration. - v1 versus v2. Both are live and the parameter names differ (
limit_startandlimit_page_lengthversusstartandlimit). v2 has bulk operations, discovery, metadata andhas_next_page; v1 has the documentation and the installed base. Whether v1 has a deprecation date was not established and is worth asking, because it would change the choice. - Timezone. Timestamps are in the site's timezone, configured in System Settings, not UTC. Read it once and convert everywhere. Getting this wrong produces an incremental sync that silently skips or re-reads hours of records.
- Webhook retry policy. Frappe logs every attempt as a
Webhook Request Log, but a fixed retry schedule is not documented. Treat webhooks as best-effort, back them with polling, and monitor the request log. - Rate limits. The framework publishes none, and the optional per-site limiter is off by default. Frappe Cloud's resource limits are plan-based and not published as request quotas. There is therefore no signal telling you when you are overloading a customer's site.
- Stock Reconciliation accounting. It posts the value difference to an expense account whose name includes the company abbreviation, and requires the Stock Manager role. Both need establishing per customer before automating stock writes.
- Roles are the only scope. There is no way to restrict an API key to a subset of DocTypes. The integration user's role set is the entire boundary, and
/api/resource/Userand/api/resource/Email Accountare reachable with broad roles. Audit what a proposed integration user can read.
Sources
- Frappe REST API reference: the
/api/resourceendpoints for list, read, create, update and delete, the three authentication schemes, the query parametersfields,filters,or_filters,order_by,limit_start,limit_page_length,expand,as_dictanddebug, the default of 20 records and thenamefield only,/api/methodremote calls, and the required headers - Token-based authentication, Frappe docs: generating keys from the user's API Access section, the
Authorization: token <api_key>:<api_secret>format, the basic auth and OAuth 2.0 alternatives, and the note that requests are logged against the selected user - frappe/frappe at branch develop, source read:
frappe/api/__init__.pyfor the v1 and v2 URL maps,frappe/api/v2.pyfor the v2 routes and thedocument_listparameter set,frappe/integrations/doctype/webhook/webhook.jsonfor the Webhook DocType fields and thewebhook_doceventoption list, andfrappe/integrations/doctype/webhook/webhook.pyfor theX-Frappe-Webhook-Signatureheader name and the base64 HMAC-SHA256 signing overfrappe.as_json(data) - frappe/erpnext at branch develop, source read for every field name and status value in this page:
erpnext/selling/doctype/sales_order/sales_order.json(including thestatusoption list),erpnext/selling/doctype/sales_order_item/sales_order_item.json,erpnext/accounts/doctype/sales_invoice/sales_invoice.json(including thestatusoption list,is_return,return_againstandupdate_stock),erpnext/stock/doctype/item/item.json,erpnext/stock/doctype/bin/bin.json, anderpnext/stock/doctype/stock_reconciliation_item/stock_reconciliation_item.json