REST API, RPC & Real-Time
Frappe auto-generates a complete REST API for every DocType — no code needed. This
means every Custom DocType you create in your franchise app immediately gets full
CRUD endpoints. On top of that, you can expose custom server logic via RPC (Remote
Procedure Calls) using @frappe.whitelist().
Auto-Generated REST API: CRUD for Every DocType
Section titled “Auto-Generated REST API: CRUD for Every DocType”Every DocType automatically gets these endpoints:
| Method | Endpoint | Description |
|---|---|---|
GET | /api/resource/{doctype} | List documents (with filters, pagination) |
GET | /api/resource/{doctype}/{name} | Read a single document |
POST | /api/resource/{doctype} | Create a new document |
PUT | /api/resource/{doctype}/{name} | Update a document |
DELETE | /api/resource/{doctype}/{name} | Delete a document |
Listing Documents with Filters, Fields, and Sorting
Section titled “Listing Documents with Filters, Fields, and Sorting”You almost never want every field of every row. List requests take fields,
filters, order_by, and pagination params as query strings. If you’ve called a
REST API from a Node.js backend before, this is the same idea — just with Frappe’s
JSON-encoded filter syntax.
// Using fetch from an external appconst response = await fetch( "https://erp.icecreamcorp.com/api/resource/Franchise Outlet?" + new URLSearchParams({ fields: JSON.stringify(["name", "outlet_name", "city", "status"]), filters: JSON.stringify([["status", "=", "Active"]]), order_by: "outlet_name asc", limit_page_length: 20, limit_start: 0, }), { headers: { "Authorization": "token api_key:api_secret", "Accept": "application/json", }, });const data = await response.json();// data.data = [ { name: "OUTLET-001", outlet_name: "Downtown Scoop", ... }, ... ]# List all active franchise outlets, returning only specific fieldscurl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet?\fields=[\"name\",\"outlet_name\",\"city\",\"status\",\"custom_royalty_percentage\"]&\filters=[[\"status\",\"=\",\"Active\"]]&\order_by=outlet_name asc&\limit_page_length=20&\limit_start=0" \ -H "Authorization: token api_key:api_secret" \ -H "Accept: application/json"Filter operators:
| Operator | Example | Description |
|---|---|---|
= | ["status", "=", "Active"] | Equals |
!= | ["status", "!=", "Closed"] | Not equals |
>, >=, <, <= | ["grand_total", ">=", 1000] | Comparison |
like | ["outlet_name", "like", "%Scoop%"] | SQL LIKE |
not like | ["outlet_name", "not like", "%Test%"] | SQL NOT LIKE |
in | ["status", "in", ["Active", "Suspended"]] | IN list |
not in | ["city", "not in", ["Test City"]] | NOT IN list |
between | ["posting_date", "between", ["2025-01-01", "2025-12-31"]] | BETWEEN |
is, set | ["franchise_outlet", "is", "set"] | IS NOT NULL |
is, not set | ["franchise_outlet", "is", "not set"] | IS NULL |
OR filters: Use the or_filters parameter alongside filters:
curl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet?\filters=[[\"status\",\"=\",\"Active\"]]&\or_filters=[[\"city\",\"=\",\"New York\"],[\"city\",\"=\",\"Chicago\"]]"Reading a Single Document
Section titled “Reading a Single Document”const doc = await fetch( "https://erp.icecreamcorp.com/api/resource/Franchise Outlet/OUTLET-001", { headers: { "Authorization": "token api_key:api_secret" } }).then(r => r.json());// doc.data = { name: "OUTLET-001", outlet_name: "Downtown Scoop", ... }curl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet/OUTLET-001" \ -H "Authorization: token api_key:api_secret" \ -H "Accept: application/json"Creating a Document
Section titled “Creating a Document”A POST to the collection endpoint with a JSON body creates a record. The body is
just the document’s fields:
const newOutlet = await fetch( "https://erp.icecreamcorp.com/api/resource/Franchise Outlet", { method: "POST", headers: { "Authorization": "token api_key:api_secret", "Content-Type": "application/json", }, body: JSON.stringify({ outlet_name: "Lakeside Scoop", city: "Chicago", territory: "Midwest", custom_franchise_type: "Premium", custom_royalty_percentage: 6.5, }), }).then(r => r.json());curl -X POST "https://erp.icecreamcorp.com/api/resource/Franchise Outlet" \ -H "Authorization: token api_key:api_secret" \ -H "Content-Type: application/json" \ -d '{ "outlet_name": "Lakeside Scoop", "city": "Chicago", "territory": "Midwest", "custom_franchise_type": "Premium", "custom_royalty_percentage": 6.5, "custom_manager_user": "manager@lakeside.com" }'Updating a Document
Section titled “Updating a Document”curl -X PUT "https://erp.icecreamcorp.com/api/resource/Franchise Outlet/OUTLET-001" \ -H "Authorization: token api_key:api_secret" \ -H "Content-Type: application/json" \ -d '{"status": "Suspended", "custom_suspension_reason": "Non-payment of royalties"}'Deleting a Document
Section titled “Deleting a Document”curl -X DELETE "https://erp.icecreamcorp.com/api/resource/Franchise Outlet/OUTLET-TEST" \ -H "Authorization: token api_key:api_secret"RPC Calls: Custom Whitelisted Methods
Section titled “RPC Calls: Custom Whitelisted Methods”For logic that does not map to simple CRUD, expose custom Python functions as API
endpoints using @frappe.whitelist(). This is Frappe’s answer to writing an Express
route handler — except the routing is automatic from the function’s dotted path.
import frappefrom frappe.utils import today, add_months, flt
@frappe.whitelist()def get_outlet_dashboard_data(outlet: str, period: str = "monthly"): """ Return aggregated dashboard data for a franchise outlet.
Args: outlet: The Franchise Outlet name period: "daily", "weekly", or "monthly"
Returns: dict with sales data, royalty info, and top items """ # Permission check -- the calling user must have access to this outlet frappe.has_permission("Franchise Outlet", doc=outlet, throw=True)
# Date range based on period date_ranges = { "daily": (today(), today()), "weekly": (add_months(today(), 0), today()), # simplified "monthly": (frappe.utils.get_first_day(today()), today()), } start_date, end_date = date_ranges.get(period, date_ranges["monthly"])
# Fetch sales data using parameterized query sales_data = frappe.db.sql(""" SELECT SUM(grand_total) as total_sales, COUNT(*) as invoice_count, AVG(grand_total) as avg_order_value FROM `tabSales Invoice` WHERE custom_franchise_outlet = %s AND posting_date BETWEEN %s AND %s AND docstatus = 1 """, (outlet, start_date, end_date), as_dict=True)[0]
# Fetch top-selling items top_items = frappe.db.sql(""" SELECT sii.item_code, sii.item_name, SUM(sii.qty) as total_qty, SUM(sii.amount) as total_amount FROM `tabSales Invoice Item` sii JOIN `tabSales Invoice` si ON si.name = sii.parent WHERE si.custom_franchise_outlet = %s AND si.posting_date BETWEEN %s AND %s AND si.docstatus = 1 GROUP BY sii.item_code, sii.item_name ORDER BY total_qty DESC LIMIT 10 """, (outlet, start_date, end_date), as_dict=True)
return { "total_sales": flt(sales_data.total_sales), "invoice_count": sales_data.invoice_count or 0, "avg_order_value": flt(sales_data.avg_order_value), "top_items": top_items, }
@frappe.whitelist()def create_franchise_outlet( outlet_name: str, territory: str, franchise_type: str = "Standard", email: str = None, investment: float = 0, royalty_pct: float = 5.0, start_date: str = None,): """Create a new Franchise Outlet with all related setup.""" # Only Franchise Managers can create outlets frappe.only_for(["Franchise Manager", "System Manager"])
outlet = frappe.get_doc({ "doctype": "Franchise Outlet", "outlet_name": outlet_name, "territory": territory, "custom_franchise_type": franchise_type, "custom_manager_email": email, "custom_initial_investment": investment, "custom_royalty_percentage": royalty_pct, "custom_agreement_start_date": start_date or today(), "custom_agreement_end_date": add_months(start_date or today(), 60), "status": "Active", }) outlet.insert()
return outlet.name
@frappe.whitelist(allow_guest=True, methods=["GET"])def get_franchise_locations(): """ Public API: Return all active franchise locations for a store locator. allow_guest=True means no authentication required. methods=["GET"] restricts to GET requests only. """ return frappe.get_all( "Franchise Outlet", filters={"status": "Active"}, fields=["outlet_name", "city", "state", "custom_latitude", "custom_longitude"], )Whitelisted methods are reachable at /api/method/<dotted.path>. State-changing
methods take a JSON body; guest-readable ones can be hit with a plain GET:
# Call a whitelisted method via RESTcurl -X POST "https://erp.icecreamcorp.com/api/method/franchise_management.api.get_outlet_dashboard_data" \ -H "Authorization: token api_key:api_secret" \ -H "Content-Type: application/json" \ -d '{"outlet": "OUTLET-001", "period": "monthly"}'
# Call a guest-accessible method (no auth needed)curl -X GET "https://erp.icecreamcorp.com/api/method/franchise_management.api.get_franchise_locations"From the client side, use frappe.xcall inside Desk, or a plain fetch from an
external app:
// From external appconst response = await fetch( "https://erp.icecreamcorp.com/api/method/franchise_management.api.get_outlet_dashboard_data", { method: "POST", headers: { "Authorization": "token api_key:api_secret", "Content-Type": "application/json", }, body: JSON.stringify({ outlet: "OUTLET-001", period: "monthly" }), });// From within Deskconst data = await frappe.xcall( "franchise_management.api.get_outlet_dashboard_data", { outlet: "OUTLET-001", period: "monthly" });Authentication Methods
Section titled “Authentication Methods”Frappe supports four authentication mechanisms.
Token Authentication (API Key + Secret)
Section titled “Token Authentication (API Key + Secret)”The most common method for server-to-server integration. Generate an API key for a
user via the User form’s API Access section, then concatenate as
token {api_key}:{api_secret}.
// Token auth from Node.js backendconst headers = { "Authorization": "token 1234abcd:5678efgh", "Content-Type": "application/json",};curl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet" \ -H "Authorization: token 1234abcd:5678efgh"Basic Authentication
Section titled “Basic Authentication”# Base64 encode "username:password"curl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet" \ -H "Authorization: Basic YWRtaW5AZXhhbXBsZS5jb206cGFzc3dvcmQ="OAuth 2.0 (Bearer Token)
Section titled “OAuth 2.0 (Bearer Token)”Frappe has a built-in OAuth 2.0 provider. Set it up via Setup > Integrations > OAuth Client.
# Step 1: Get an access token via OAuth flow# (After setting up OAuth Client in Frappe)
# Step 2: Use the Bearer tokencurl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Session-Based (Cookie)
Section titled “Session-Based (Cookie)”Used automatically by the Desk (browser client). Log in to get a session cookie, then send it on subsequent requests:
# Login to get a session cookiecurl -X POST "https://erp.icecreamcorp.com/api/method/login" \ -H "Content-Type: application/json" \ -d '{"usr": "admin@example.com", "pwd": "password"}' \ -c cookies.txt
# Subsequent requests use the cookiecurl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet" \ -b cookies.txtFile Upload API
Section titled “File Upload API”Uploads go through the upload_file method as multipart form data, and can attach
directly to a document field:
// Upload from browserasync function upload_file(file, doctype, docname, fieldname) { const formData = new FormData(); formData.append("file", file); formData.append("doctype", doctype); formData.append("docname", docname); formData.append("fieldname", fieldname); formData.append("is_private", 1);
const response = await fetch("/api/method/upload_file", { method: "POST", body: formData, headers: { "X-Frappe-CSRF-Token": frappe.csrf_token, }, }); return response.json();}# Upload a file to a documentcurl -X POST "https://erp.icecreamcorp.com/api/method/upload_file" \ -H "Authorization: token api_key:api_secret" \ -F "file=@/path/to/franchise_agreement.pdf" \ -F "doctype=Franchise Outlet" \ -F "docname=OUTLET-001" \ -F "fieldname=custom_agreement_file" \ -F "is_private=1"Real-Time with frappe.publish_realtime()
Section titled “Real-Time with frappe.publish_realtime()”Build live dashboards where the server pushes data to connected clients. Under the hood Frappe publishes events to Redis and the Node.js Socket.IO server fans them out to browsers — so the server emits, the client subscribes.
The Python side publishes events. Note after_commit=True, which delays the push
until the database transaction commits so clients never see uncommitted data:
import frappe
@frappe.whitelist()def start_live_sales_feed(outlet: str): """Subscribe the current user to live sales updates for an outlet.""" frappe.publish_realtime( event="sales_feed_subscribed", message={"outlet": outlet, "status": "active"}, user=frappe.session.user, )
# Called from doc_events when a POS Invoice is submitteddef push_pos_sale_to_dashboard(doc, method): """Push real-time sale data to all subscribed franchise dashboard users.""" if not doc.custom_franchise_outlet: return
# Publish to all users -- they filter client-side by outlet frappe.publish_realtime( event="live_pos_sale", message={ "outlet": doc.custom_franchise_outlet, "invoice": doc.name, "grand_total": doc.grand_total, "items": [ {"item": row.item_name, "qty": row.qty, "amount": row.amount} for row in doc.items ], "timestamp": str(doc.posting_date) + " " + str(doc.posting_time), }, after_commit=True, )
# Also publish a progress-style event for running totals daily_total = frappe.db.sql(""" SELECT SUM(grand_total) as total FROM `tabPOS Invoice` WHERE custom_franchise_outlet = %s AND posting_date = %s AND docstatus = 1 """, (doc.custom_franchise_outlet, doc.posting_date), as_dict=True)[0].total
frappe.publish_realtime( event="daily_sales_update", message={ "outlet": doc.custom_franchise_outlet, "daily_total": daily_total, }, after_commit=True, )The client subscribes to those named events with frappe.realtime.on() and updates
the DOM as each one arrives:
// Client-side: Live sales dashboardfrappe.pages["franchise-live-sales"].on_page_load = function(wrapper) { const page = frappe.ui.make_app_page({ parent: wrapper, title: __("Live Sales Feed"), single_column: true, });
const sales_list = $('<div class="sales-feed"></div>').appendTo(page.body); const counter = $('<h2 class="daily-total text-center">Loading...</h2>').prependTo(page.body);
// Subscribe to real-time POS sales events frappe.realtime.on("live_pos_sale", (data) => { const items = data.items.map(i => `${i.item} x${i.qty}`).join(", "); sales_list.prepend(` <div class="sale-entry card p-3 mb-2"> <strong>${data.outlet}</strong> - ${data.invoice} <br> <span class="text-muted">${items}</span> <span class="pull-right text-bold"> ${frappe.format(data.grand_total, {fieldtype: "Currency"})} </span> <br> <small class="text-muted">${data.timestamp}</small> </div> `);
// Keep only last 50 entries if (sales_list.children().length > 50) { sales_list.children().last().remove(); } });
// Update running total frappe.realtime.on("daily_sales_update", (data) => { counter.text( `Today's Total: ${frappe.format(data.daily_total, {fieldtype: "Currency"})}` ); });};Rate Limiting
Section titled “Rate Limiting”Frappe has built-in rate limiting based on cumulative request processing time, not
request count. It uses a fixed-window algorithm. Configure it in site_config.json:
{ "rate_limit": { "limit": 500, "window": 300 }}This means a maximum of 500 seconds of total processing time per 300-second window
(5 minutes) per user. When exceeded, the API returns 429 Too Many Requests.
API Best Practices
Section titled “API Best Practices”Pagination: Always paginate large result sets. The default page size is 20.
# Page 1curl "https://erp.example.com/api/resource/Sales Invoice?limit_start=0&limit_page_length=100"# Page 2curl "https://erp.example.com/api/resource/Sales Invoice?limit_start=100&limit_page_length=100"Field Selection: Always specify fields to reduce payload size and improve performance.
# Bad -- returns ALL fields for every documentcurl "https://erp.example.com/api/resource/Sales Invoice"
# Good -- returns only what you needcurl "https://erp.example.com/api/resource/Sales Invoice?fields=[\"name\",\"grand_total\",\"posting_date\"]"Error Handling: Frappe returns structured error responses:
{ "exc_type": "ValidationError", "exception": "frappe.exceptions.ValidationError: Franchise agreement is not active", "_server_messages": "[{\"message\": \"Franchise agreement is not active\"}]", "exc": "Traceback..."}Handle them in your client by catching the rejected call — error.message carries
the frappe.throw() message and error.exc_type the exception class name:
try { const result = await frappe.xcall("franchise_management.api.some_method", args);} catch (error) { // error.message contains the frappe.throw() message // error.exc_type contains the exception class name console.error("API Error:", error);}