Skip to content

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:

MethodEndpointDescription
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 app
const 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", ... }, ... ]

Filter operators:

OperatorExampleDescription
=["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:

Terminal window
curl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet?\
filters=[[\"status\",\"=\",\"Active\"]]&\
or_filters=[[\"city\",\"=\",\"New York\"],[\"city\",\"=\",\"Chicago\"]]"
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", ... }

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());
Terminal window
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"}'
Terminal window
curl -X DELETE "https://erp.icecreamcorp.com/api/resource/Franchise Outlet/OUTLET-TEST" \
-H "Authorization: token api_key:api_secret"

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.

franchise_management/api.py
import frappe
from 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:

Terminal window
# Call a whitelisted method via REST
curl -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 app
const 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" }),
}
);

Frappe supports four authentication mechanisms.

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 backend
const headers = {
"Authorization": "token 1234abcd:5678efgh",
"Content-Type": "application/json",
};
Terminal window
# Base64 encode "username:password"
curl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet" \
-H "Authorization: Basic YWRtaW5AZXhhbXBsZS5jb206cGFzc3dvcmQ="

Frappe has a built-in OAuth 2.0 provider. Set it up via Setup > Integrations > OAuth Client.

Terminal window
# Step 1: Get an access token via OAuth flow
# (After setting up OAuth Client in Frappe)
# Step 2: Use the Bearer token
curl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Used automatically by the Desk (browser client). Log in to get a session cookie, then send it on subsequent requests:

Terminal window
# Login to get a session cookie
curl -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 cookie
curl -X GET "https://erp.icecreamcorp.com/api/resource/Franchise Outlet" \
-b cookies.txt

Uploads go through the upload_file method as multipart form data, and can attach directly to a document field:

// Upload from browser
async 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();
}

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:

franchise_management/api.py
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 submitted
def 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 dashboard
frappe.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"})}`
);
});
};

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:

sites/icecream.localhost/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.

Pagination: Always paginate large result sets. The default page size is 20.

Terminal window
# Page 1
curl "https://erp.example.com/api/resource/Sales Invoice?limit_start=0&limit_page_length=100"
# Page 2
curl "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.

Terminal window
# Bad -- returns ALL fields for every document
curl "https://erp.example.com/api/resource/Sales Invoice"
# Good -- returns only what you need
curl "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);
}