Reverse-Engineering the Frappe REST API: the Reference Nobody Wrote
You are integrating a payment gateway, a mobile app, or another ERP with ERPNext. You reach for the API docs to generate a client, and you discover there is nothing to generate from. Frappe auto-creates a full REST API from every DocType in the system, which sounds wonderful, right up until you realise there is no OpenAPI spec, no Swagger UI, no machine-readable contract of any kind. The feature request asking for one has been open for years with no resolution.
So every integration guide you have ever read for Frappe was written the same way: by someone poking at undocumented endpoints in Postman until the shapes made sense, then writing down what worked. That is a fragile way to build, and it is why the same questions get re-asked forever. This article is the reference I wish existed when I started shipping Frappe integrations for clients: the endpoint shapes, the filter and field syntax that is not in the docs, authentication, child tables, and the silent gotcha that eats half of everyone's debugging time.
the API is real and consistent, it is just undocumented. The core shapes:
- List:
GET /api/resource/:doctypewithfields,filters,order_by,limit_start,limit_page_lengthas JSON-encoded query params. - Single doc (with child tables):
GET /api/resource/:doctype/:name. - Auth: header
Authorization: token api_key:api_secretfor server-to-server. - The big gotcha: permission query conditions silently filter results and the default page length is small, so "missing records" is usually permissions or paging, not a bug.
- No OpenAPI ships. You can build one yourself from the DocType metadata the API already exposes.
How Frappe turns DocTypes into endpoints
Every DocType, the standard ones like Sales Invoice and any custom one you create, automatically gets a matching resource under /api/resource. There is no route file to write and no controller to register. The DocType is the API. That is the elegant part. The consequence is that the "API surface" is really the union of every DocType's field schema, and that schema lives in the database, not in any document a client generator could read.
There are two families of endpoint, and choosing the right one avoids most confusion:
| Endpoint family | Use for |
|---|---|
/api/resource/:doctype | Standard CRUD on documents: list, get, create, update, delete |
/api/method/:dotted.path | Calling a whitelisted Python function directly, for anything CRUD cannot express |
Most integrations live entirely in the first family. You drop to /api/method when you need server-side logic, like submitting a document, running a report, or a custom action. I covered the calling mechanics of the method family in making API calls in ERPNext; this article is about the resource family, which is where the undocumented sharp edges are.
Authentication that does not fight you
Three methods exist, for three different situations. Pick by who is calling.
# 1. Token auth: server-to-server, the one you almost always want.
# Generate api_key/api_secret on a User in Frappe, then:
curl -H "Authorization: token 3f2a...:9c7b..." \
"https://erp.example.com/api/resource/Customer"
# 2. Session cookie: for browsers. Logs in, sets sid cookie.
curl -c cookies.txt -X POST \
"https://erp.example.com/api/method/login" \
-d "usr=me@example.com&pwd=secret"
# 3. OAuth2: for third-party apps acting on behalf of a user (delegated access).
The token header format is exact: Authorization: token api_key:api_secret, the two values joined by a single colon, with the literal word token in front. Get the spacing or the word wrong and you get a 401 that does not explain itself. Every action is logged against the user who owns those credentials, so create a dedicated integration user with only the roles it needs, never reuse the Administrator. The authentication setup is covered end to end in how to authenticate and login to ERPNext with an API.
Filters, fields and pagination: the syntax that is not in the docs
This is where trial-and-error usually happens, because the query parameters take JSON-encoded values, not the flat ?field=value most APIs use. Get the encoding right and everything opens up.
# Select specific fields (JSON array, URL-encoded)
GET /api/resource/Customer?fields=["name","customer_group","territory"]
# Filter: array of [field, operator, value] triples
GET /api/resource/Sales Invoice?filters=[["status","=","Overdue"],["grand_total",">",10000]]
# Order and paginate
GET /api/resource/Sales Invoice?order_by=creation desc&limit_start=0&limit_page_length=100
A few things nobody tells you until you have lost an afternoon:
- The default page length is small. If you do not set
limit_page_length, you get the first page only (20 by default) and quietly conclude records are "missing". Set it explicitly, and page withlimit_start. To fetch everything, loop until a page returns fewer thanlimit_page_lengthrows. filtersoperators include=,!=,>,<,>=,<=,like,in,not in, andbetween. Forlike, you supply the wildcards yourself:["customer_name","like","%ltd%"].- The list endpoint returns parent fields only. By default it returns just
nameunless you passfields. It never returns child table rows, no matter what you put infields.
Here is a real paging loop in Python, the kind every integration needs and no doc provides:
import requests, json
BASE = "https://erp.example.com"
HEADERS = {"Authorization": "token 3f2a...:9c7b..."}
def fetch_all(doctype, fields, filters=None, page=200):
rows, start = [], 0
while True:
params = {
"fields": json.dumps(fields),
"limit_start": start,
"limit_page_length": page,
}
if filters:
params["filters"] = json.dumps(filters)
r = requests.get(f"{BASE}/api/resource/{doctype}",
headers=HEADERS, params=params, timeout=30)
r.raise_for_status()
batch = r.json()["data"]
rows.extend(batch)
if len(batch) < page: # last page reached
break
start += page
return rows
invoices = fetch_all("Sales Invoice",
["name", "customer", "grand_total", "status"],
filters=[["status", "=", "Overdue"]])
print(len(invoices))
Child tables and linked documents
This trips up nearly every first integration. A Sales Invoice has line items in a child table (items). The list endpoint will never give them to you. To read child rows, fetch the single document:
GET /api/resource/Sales Invoice/ACC-SINV-2026-00042
The response embeds the full child tables under their fieldnames:
{
"data": {
"name": "ACC-SINV-2026-00042",
"customer": "Jani Retailers",
"grand_total": 45000,
"items": [
{"item_code": "WIDGET-A", "qty": 3, "rate": 5000, "amount": 15000},
{"item_code": "WIDGET-B", "qty": 2, "rate": 15000, "amount": 30000}
]
}
}
Link fields hold only the linked document's name, not its data. A Sales Invoice's customer field is the customer's ID, not the customer object. To get the customer's details you make a second request to /api/resource/Customer/:name, or, for a single field, use a dotted path in some report contexts. There is no automatic joining or expansion in the resource API.
Building your own OpenAPI spec from a running site
Since no spec ships, generate one from the site itself. Every DocType's schema is available over the API, so you can walk the DocTypes you care about and emit an OpenAPI document that a client generator understands. The metadata lives in the DocType and DocField doctypes:
# the field schema of a DocType, over the same API
GET /api/resource/DocType/Sales Invoice
From that you have the fieldnames, types, whether each is required, and which are child tables (fieldtype Table) pointing at another DocType. Walk the set of DocTypes your integration touches, map Frappe fieldtypes to JSON Schema types, and write out paths for /api/resource/{doctype} and /api/resource/{doctype}/{name}. It is a small script, and the payoff is a real, versioned contract you can generate typed clients from and hand to the team on the other side of the integration. It will not be perfect, because Frappe's behaviour has undocumented corners, but a generated spec from the live site beats guessing, and it updates when your custom fields do.
The gotcha that eats a day: permission query conditions
This is the single most important thing in this article. Frappe applies permission query conditions to list queries, silently. If the authenticating user's roles restrict them to, say, their own territory, the API returns only rows in that territory, with no error, no warning, and a perfectly normal 200 response. Your integration reports "half the invoices are missing" and you spend hours looking for a bug in your paging code that is not there.
When record counts do not match the database:
- Check the integration user's roles and permission levels. Test with a user you know has broad read access to confirm the rows exist.
- Check for custom permission query conditions in the app (hooks named
permission_query_conditions), which inject extraWHEREclauses per user. - Confirm you set
limit_page_length; the default page cap is the other common cause of "missing" rows.
Verification
Confirm the four things every integration depends on before you trust it:
# 1. Auth works and identifies the right user
curl -s -H "Authorization: token KEY:SECRET" \
"https://erp.example.com/api/method/frappe.auth.get_logged_user"
# -> {"message": "integration@example.com"}
# 2. Counts match: compare an API count to a known total
curl -s -H "Authorization: token KEY:SECRET" \
"https://erp.example.com/api/resource/Customer?limit_page_length=0" | head
# limit_page_length=0 returns all rows; compare the length to the DB
# 3. Child tables come back on the single-doc endpoint
# 4. A filtered query returns exactly the rows you expect
The get_logged_user call is the fastest way to prove your credentials resolve to the user you think they do, which pre-empts the permissions surprise.
What people get wrong
Expecting child rows from the list endpoint. They are never there. Fetch the document. This one assumption causes more broken integrations than anything else.
Treating "fewer rows than expected" as a paging bug. It is usually permissions. The API filters to what the user may see and says nothing about it. Test with a high-privilege user before you touch your code.
Forgetting limit_page_length. The default page is small. If you read one page and stop, you have read a fraction of the data and will draw wrong conclusions from it.
Using Administrator credentials for the integration. It works, and it is a security and auditing mistake. Every action is logged against that user; use a dedicated integration user with scoped roles so the audit trail and the blast radius are both contained.
Assuming the API will validate like the UI does. Business logic in validate and before_save runs on the API too, but the UI also runs client scripts the API does not. A create that succeeds via the API may skip a UI-side check, so submit through the proper method endpoints when document workflow matters.
When it is still broken
- 417 or a cryptic validation error on create. A server-side
validatehook rejected the document. The response body usually contains the Frappe exception message; read it fully, it names the failing field or rule. - Writes succeed but the document is a draft. Creating via the resource API leaves submittable documents in draft. To submit, call the method endpoint that runs
submit(), do not try to setdocstatusdirectly. - Intermittent 500s under load. You may be exhausting workers or hitting a slow query behind the API. That is a server problem, not an API-shape problem; the MariaDB slow query log playbook is where I would go next.
- Fields exist in the UI but not in the API response. They are likely child table or dependent fields, or you did not request them in
fields. Fetch the single document to see the complete shape.
The API is not badly designed, it is undocumented, and those are different problems. Once you internalise the two endpoint families, the JSON-encoded query params, the child-table rule, and the silent permission filtering, you can integrate anything with Frappe. Write the spec the project never shipped, from the site you are actually integrating with, and you turn guesswork into a contract.
Frequently asked questions
- Does Frappe or ERPNext provide an OpenAPI or Swagger spec?
- No. Frappe auto-generates REST endpoints from every DocType but ships no machine-readable OpenAPI or Swagger contract, and the feature request for one has been open for years. You cannot generate a client from a spec because there is no spec. The practical workaround is to build your own OpenAPI document from a running site's DocType metadata, which is exposed over the same API.
- How do I authenticate to the Frappe REST API?
- The cleanest method for server-to-server integration is a token: send the header Authorization: token api_key:api_secret, where both values come from the target user's settings. Session-cookie auth via /api/method/login exists but is meant for browsers, and OAuth2 is available for delegated third-party access. Every request is logged against the user who owns the credentials, so use a dedicated integration user with scoped permissions.
- Why does the Frappe API return fewer records than exist in the database?
- Because permission query conditions silently filter the result set to what the authenticating user is allowed to see, and the default page length caps results at 20. It is not a bug and there is no error; the rows are simply omitted. Check the user's role permissions and any custom permission query conditions, and set limit_page_length explicitly to page through everything.
- How do I read a document's child table rows over the API?
- Child table rows are not returned by the list endpoint. Fetch the single document with GET /api/resource/:doctype/:name and the full child tables are embedded in the response under their field names. On the list endpoint you only get parent fields, so any integration that needs line items must fetch each document individually or use a report/method endpoint.