v2.0 to v2.0

228 added, 221 removed. Audit A to A.

---
name: frappe-syntax-whitelisted
description: >
Use when creating Frappe Whitelisted Methods (Python API endpoints) for
v14/v15/v16. Covers @frappe.whitelist() decorator, frappe.call/frm.call
invocations, permission checks, error handling, response formats, and
client-server communication. Keywords: whitelisted, API endpoint,
frappe.call, frm.call, REST API, @frappe.whitelist, allow_guest.
license: MIT
compatibility: "Claude Code, Claude.ai Projects, Claude API. Frappe v14-v16."
metadata:
author: OpenAEC-Foundation
version: "2.0"
---
- # ERPNext Syntax: Whitelisted Methods
+ # Frappe Syntax: Whitelisted Methods
- Whitelisted Methods expose Python functions as REST API endpoints.
+ Whitelisted methods expose Python functions as HTTP API endpoints via `/api/method/`.
## Quick Reference
- ### Basic Whitelisted Method
-
```python
import frappe
+ from frappe import _
+ # Authenticated endpoint (default)
@frappe.whitelist()
def get_customer_summary(customer):
- """Basic API endpoint - authenticated users only."""
- if not frappe.has_permission("Customer", "read"):
- frappe.throw(_("Not permitted"), frappe.PermissionError)
-
+ frappe.has_permission("Customer", "read", throw=True)
return frappe.get_doc("Customer", customer).as_dict()
- ```
- ### Endpoint URL
+ # Public endpoint — ALWAYS validate input thoroughly
+ @frappe.whitelist(allow_guest=True, methods=["POST"])
+ def submit_contact(name, email, message):
+ if not name or not email:
+ frappe.throw(_("Name and email required"), frappe.ValidationError)
+ return {"success": True}
- `/api/method/myapp.api.get_customer_summary`
+ # Controller method — called via frm.call('method_name')
+ class SalesOrder(Document):
+ @frappe.whitelist()
+ def calculate_taxes(self, include_shipping=False):
+ return {"tax": self.grand_total * 0.21}
+ ```
- ---
+ **Endpoint URL**: `/api/method/myapp.module.function_name`
- ## Decorator Options
+ ---
- | Parameter | Default | Description |
- |-----------|---------|-------------|
- | `allow_guest` | `False` | `True` = accessible without login |
- | `methods` | All | `["GET"]`, `["POST"]`, or combination |
- | `xss_safe` | `False` | `True` = don't escape HTML |
+ ## Decorator Signature [v14+]
```python
- # Public endpoint, POST only
- @frappe.whitelist(allow_guest=True, methods=["POST"])
- def submit_contact_form(name, email, message):
- # Validate input carefully with guest access!
- if not name or not email:
- frappe.throw(_("Name and email required"))
- return {"success": True}
+ @frappe.whitelist(
+ allow_guest=False, # True = accessible without login
+ xss_safe=False, # True = do NOT escape HTML in response
+ methods=None, # ["GET"], ["POST"], or ["GET","POST"] — default: all
+ force_types=None # True = require type annotations [v15+]
+ )
+ ```
- # Read-only endpoint
- @frappe.whitelist(methods=["GET"])
- def get_status(order_id):
- return frappe.db.get_value("Sales Order", order_id, "status")
+ | Parameter | Default | Effect |
+ |-----------|---------|--------|
+ | `allow_guest` | `False` | `True` = Guest role can call; ALWAYS add extra input validation |
+ | `xss_safe` | `False` | `True` = HTML not escaped; NEVER use without sanitized output |
+ | `methods` | `None` (all) | Restrict allowed HTTP verbs |
+ | `force_types` | `None` | `True` = all params MUST have type annotations [v15+] |
+
+ Full details: [decorator-options.md](references/decorator-options.md)
+
+ ---
+
+ ## Decision Tree
+
```
+ What kind of endpoint?
+ |
+ +-- Standalone API (utility, integration, dashboard)?
+ | --> @frappe.whitelist() on a module-level function
+ | --> Call via: frappe.call('myapp.api.function')
+ | --> URL: /api/method/myapp.api.function
+ |
+ +-- Document-specific action?
+ | --> @frappe.whitelist() on a Document class method
+ | --> Call via: frm.call('method_name')
+ | --> URL: /api/method/run_doc_method (internal)
+ |
+ +-- Server Script (no-code)?
+ --> Use Server Script DocType instead (no decorator needed)
- **Full options**: See [decorator-options.md](references/decorator-options.md)
+ Who may call the API?
+ |
+ +-- Anyone (including guests)?
+ | --> allow_guest=True + thorough input validation + rate limiting
+ |
+ +-- Logged-in users only?
+ +-- Specific role? --> frappe.only_for("RoleName")
+ +-- DocType-level? --> frappe.has_permission(doctype, ptype, throw=True)
+ +-- Document-level? --> frappe.has_permission(doctype, ptype, doc, throw=True)
+ Which HTTP methods?
+ |
+ +-- Read only? --> methods=["GET"]
+ +-- Write only? --> methods=["POST"]
+ +-- Both? --> methods=["GET","POST"] or default
+ ```
+
---
## Permission Patterns
- ### ALWAYS Check Permissions
+ ALWAYS check permissions inside every whitelisted method. The `@frappe.whitelist()` decorator only verifies the user is logged in — it does NOT check DocType or document-level permissions.
```python
+ # DocType-level permission (throw=True raises PermissionError automatically)
@frappe.whitelist()
- def get_data(doctype, name):
- # Check BEFORE fetching data
- if not frappe.has_permission(doctype, "read", name):
- frappe.throw(_("Not permitted"), frappe.PermissionError)
- return frappe.get_doc(doctype, name).as_dict()
- ```
-
- ### Role-Based Access
+ def get_orders():
+ frappe.has_permission("Sales Order", "read", throw=True)
+ return frappe.get_all("Sales Order", limit=20)
- ```python
+ # Document-level permission
@frappe.whitelist()
- def admin_function():
- frappe.only_for("System Manager") # Throws if user lacks role
- return {"admin_data": "sensitive"}
+ def get_order(name):
+ frappe.has_permission("Sales Order", "read", name, throw=True)
+ return frappe.get_doc("Sales Order", name).as_dict()
+ # Role-based restriction
@frappe.whitelist()
- def multi_role_function():
- frappe.only_for(["System Manager", "HR Manager"])
- return {"data": "value"}
+ def admin_action():
+ frappe.only_for("System Manager") # throws if user lacks role
+ return {"secret": "data"}
```
- **Security patterns**: See [permission-patterns.md](references/permission-patterns.md)
+ Full patterns: [permission-patterns.md](references/permission-patterns.md)
---
- ## Error Handling
+ ## Parameter Handling
- ### frappe.throw() for User-Facing Errors
+ Parameters arrive as **strings** from HTTP requests. ALWAYS convert explicitly.
```python
@frappe.whitelist()
- def process_order(order_id, amount):
- # Validation error
- if not order_id:
- frappe.throw(_("Order ID required"), title=_("Missing Data"))
-
- # Permission error
- if not frappe.has_permission("Sales Order", "write"):
- frappe.throw(_("Not permitted"), frappe.PermissionError)
-
- # Business logic error
- if amount < 0:
- frappe.throw(
- _("Amount cannot be negative: {0}").format(amount),
- frappe.ValidationError
- )
+ def calculate(amount, quantity, items=None):
+ amount = float(amount) # ALWAYS cast numeric params
+ quantity = int(quantity)
+ if isinstance(items, str): # ALWAYS parse JSON strings
+ items = frappe.parse_json(items)
+ return amount * quantity
```
- ### Exception Types and HTTP Codes
-
- | Exception | HTTP Code | When |
- |-----------|-----------|------|
- | `frappe.ValidationError` | 417 | Validation errors |
- | `frappe.PermissionError` | 403 | Access denied |
- | `frappe.DoesNotExistError` | 404 | Not found |
- | `frappe.DuplicateEntryError` | 409 | Duplicate |
- | `frappe.AuthenticationError` | 401 | Not logged in |
-
- ### Robust Error Pattern
-
+ Access all request parameters via `frappe.form_dict`:
```python
@frappe.whitelist()
- def robust_api(param):
- try:
- result = process_data(param)
- return {"success": True, "data": result}
- except frappe.DoesNotExistError:
- frappe.local.response["http_status_code"] = 404
- return {"success": False, "error": "Not found"}
- except frappe.PermissionError:
- frappe.local.response["http_status_code"] = 403
- return {"success": False, "error": "Access denied"}
- except Exception:
- frappe.log_error(frappe.get_traceback(), "API Error")
- frappe.local.response["http_status_code"] = 500
- return {"success": False, "error": "Internal error"}
+ def dynamic_handler():
+ all_params = frappe.form_dict
+ customer = frappe.form_dict.get("customer")
```
- **Full error patterns**: See [error-handling.md](references/error-handling.md)
-
- ---
-
- ## Response Patterns
+ ### Type Annotations [v15+]
- ### Return Value (Recommended)
+ Frappe v15+ validates type annotations automatically at request time via Pydantic:
```python
@frappe.whitelist()
- def get_summary(customer):
- return {
- "customer": customer,
- "total": 15000
- }
- # Response: {"message": {"customer": "...", "total": 15000}}
+ def get_orders(customer: str, limit: int = 10, active: bool = True) -> dict:
+ # Frappe auto-validates: limit MUST be convertible to int
+ return {"orders": frappe.get_all("Sales Order", limit=limit)}
```
- ### Custom HTTP Status
+ ### force_types and require_type_annotated_api_methods [v15+]
- ```python
- @frappe.whitelist()
- def create_item(data):
- if not data:
- frappe.local.response["http_status_code"] = 400
- return {"error": "Data required"}
- # ... create item
- frappe.local.response["http_status_code"] = 201
- return {"created": True}
- ```
+ - `@frappe.whitelist(force_types=True)` — EVERY parameter MUST have a type annotation
+ - App-level enforcement via `hooks.py`: `require_type_annotated_api_methods = 1`
+ - Missing annotations raise `FrappeTypeError`
- **Full response patterns**: See [response-patterns.md](references/response-patterns.md)
+ Full details: [parameter-handling.md](references/parameter-handling.md)
---
## Client Calls
- ### frappe.call() - Standalone APIs
+ ### frappe.call() — Standalone APIs
```javascript
- // Promise-based (recommended)
- frappe.call({
- method: 'myapp.api.get_customer_summary',
- args: { customer: 'CUST-00001' }
- }).then(r => {
- console.log(r.message);
- });
-
- // With loading indicator
+ // Promise-based (ALWAYS prefer this)
frappe.call({
- method: 'myapp.api.process_data',
- args: { data: myData },
+ method: 'myapp.api.get_summary',
+ args: { customer: 'CUST-001' },
freeze: true,
- freeze_message: __('Processing...')
+ freeze_message: __('Loading...')
+ }).then(r => {
+ console.log(r.message); // return value is in r.message
+ }).catch(err => {
+ frappe.show_alert({ message: __('Error'), indicator: 'red' });
});
```
- ### frm.call() - Controller Methods
+ ### frm.call() — Controller Methods
```javascript
- frm.call('calculate_taxes', {
- include_shipping: true
- }).then(r => {
- frm.set_value('tax_amount', r.message.tax_amount);
- });
+ frm.call('calculate_taxes', { include_shipping: true })
+ .then(r => frm.set_value('tax_amount', r.message.tax_amount));
```
- **Full client patterns**: See [client-calls.md](references/client-calls.md)
-
- ---
-
- ## Decision Tree: Which Options?
+ ### REST API (External Clients)
+ ```bash
+ # Token auth (ALWAYS use for external integrations)
+ curl -H "Authorization: token api_key:api_secret" \
+ -H "Content-Type: application/json" \
+ -X POST https://site.com/api/method/myapp.api.create_order \
+ -d '{"customer": "CUST-001"}'
```
- Who may call the API?
- │
- ├─► Anyone (including guests)?
- │ └─► allow_guest=True + extra input validation
- │
- └─► Logged-in users only?
- │
- └─► Specific role required?
- ├─► Yes → frappe.only_for("RoleName") in method
- └─► No → frappe.has_permission() check
- Which HTTP methods?
- │
- ├─► Read only?
- │ └─► methods=["GET"]
- │
- ├─► Write only?
- │ └─► methods=["POST"]
- │
- └─► Both?
- └─► methods=["GET", "POST"] or default (all)
- ```
+ Full patterns: [client-calls.md](references/client-calls.md)
---
- ## Security Checklist
+ ## Error Handling
- For EVERY whitelisted method:
+ ```python
+ @frappe.whitelist()
+ def process_order(order_id):
+ if not order_id:
+ frappe.throw(_("Order ID required"), frappe.ValidationError)
- - [ ] Permission check present (`frappe.has_permission()` or `frappe.only_for()`)
- - [ ] Input validation (types, ranges, formats)
- - [ ] No SQL injection (parameterized queries)
- - [ ] No sensitive data in error messages
- - [ ] `allow_guest=True` only with explicit reason
- - [ ] `ignore_permissions=True` only with role check
- - [ ] HTTP method restricted where possible
+ if not frappe.has_permission("Sales Order", "write", order_id):
+ frappe.throw(_("Not permitted"), frappe.PermissionError)
- ---
+ try:
+ result = heavy_operation(order_id)
+ return {"success": True, "data": result}
+ except Exception:
+ frappe.log_error(frappe.get_traceback(), "process_order")
+ frappe.throw(_("Operation failed. Contact support."))
+ ```
- ## Critical Rules
+ | Exception | HTTP Code | When to Use |
+ |-----------|-----------|-------------|
+ | `frappe.ValidationError` | 417 | Input validation failure |
+ | `frappe.PermissionError` | 403 | Access denied |
+ | `frappe.DoesNotExistError` | 404 | Document not found |
+ | `frappe.DuplicateEntryError` | 409 | Duplicate record |
+ | `frappe.AuthenticationError` | 401 | Not logged in |
- ### 1. NEVER Skip Permission Check
+ Full patterns: [error-handling.md](references/error-handling.md)
+ ---
+
+ ## Response Patterns
+
```python
- # ❌ WRONG - anyone can see all data
+ # Return value auto-wraps as {"message": <return_value>}
@frappe.whitelist()
- def get_all_salaries():
- return frappe.get_all("Salary Slip", fields=["*"])
+ def get_data():
+ return {"key": "value"} # Client receives: {"message": {"key": "value"}}
- # ✅ CORRECT
+ # Custom HTTP status
@frappe.whitelist()
- def get_salaries():
- frappe.only_for("HR Manager")
- return frappe.get_all("Salary Slip", fields=["*"])
+ def create_item(data):
+ doc = frappe.get_doc(data).insert()
+ frappe.local.response["http_status_code"] = 201
+ return {"name": doc.name}
+
+ # File download
+ @frappe.whitelist()
+ def download_report(name):
+ content = generate_pdf(name)
+ frappe.response.filename = f"{name}.pdf"
+ frappe.response.filecontent = content
+ frappe.response.type = "download"
```
- ### 2. NEVER Use User Input in SQL
+ Full patterns: [response-patterns.md](references/response-patterns.md)
+ ---
+
+ ## Rate Limiting [v14+]
+
```python
- # ❌ WRONG - SQL injection!
- @frappe.whitelist()
- def search(term):
- return frappe.db.sql(f"SELECT * FROM tabCustomer WHERE name LIKE '%{term}%'")
+ from frappe.rate_limiter import rate_limit
- # ✅ CORRECT - parameterized
- @frappe.whitelist()
- def search(term):
- return frappe.db.sql("""
- SELECT * FROM tabCustomer WHERE name LIKE %(term)s
- """, {"term": f"%{term}%"}, as_dict=True)
+ @frappe.whitelist(allow_guest=True)
+ @rate_limit(limit=5, seconds=60) # 5 requests per 60 seconds per IP
+ def public_endpoint():
+ return {"status": "ok"}
```
- ### 3. NEVER Leak Sensitive Data in Errors
-
+ `rate_limit` signature:
```python
- # ❌ WRONG - leaks internal information
- except Exception as e:
- frappe.throw(str(e)) # May leak stack traces!
-
- # ✅ CORRECT
- except Exception:
- frappe.log_error(frappe.get_traceback(), "API Error")
- frappe.throw(_("An error occurred"))
+ rate_limit(key=None, limit=5, seconds=86400, methods="ALL", ip_based=True)
```
- **All anti-patterns**: See [anti-patterns.md](references/anti-patterns.md)
+ ALWAYS apply `@rate_limit` on `allow_guest=True` endpoints to prevent abuse.
---
- ## Version Differences (v14 vs v15)
+ ## Version Differences
- | Feature | v14 | v15 |
- |---------|-----|-----|
- | Type annotations validation | ❌ | ✅ |
- | API v2 endpoints | ❌ | ✅ `/api/v2/` |
- | Rate limiting decorators | ❌ | ✅ `@rate_limit()` |
- | Document method endpoint | N/A | `/api/v2/document/{dt}/{name}/method/{m}` |
+ | Feature | v14 | v15+ | v16+ |
+ |---------|-----|------|------|
+ | `@frappe.whitelist()` | Yes | Yes | Yes |
+ | `allow_guest`, `xss_safe`, `methods` | Yes | Yes | Yes |
+ | Type annotation validation | No | Yes (auto via Pydantic) | Yes |
+ | `force_types` parameter | No | Yes | Yes |
+ | `require_type_annotated_api_methods` hook | No | Yes | Yes |
+ | `@rate_limit()` decorator | Yes | Yes | Yes |
+ | `FrappeTypeError` for missing annotations | No | Yes | Yes |
- ### v15 Type Validation
+ ---
- ```python
- @frappe.whitelist()
- def get_orders(customer: str, limit: int = 10) -> dict:
- """v15 validates types automatically on request."""
- return {"orders": frappe.get_all("Sales Order", limit=limit)}
- ```
+ ## Critical Rules
+ 1. **NEVER skip permission checks** — `@frappe.whitelist()` only confirms login, not authorization
+ 2. **NEVER use user input in raw SQL** — ALWAYS use parameterized queries or ORM
+ 3. **NEVER leak stack traces** — log with `frappe.log_error()`, show generic messages
+ 4. **ALWAYS validate input types** — parameters arrive as strings from HTTP
+ 5. **ALWAYS apply `@rate_limit` on guest endpoints** — prevents abuse
+ 6. **NEVER use `ignore_permissions=True` without a preceding role check**
+ 7. **ALWAYS use `JSON.stringify()` for complex JS args** — arrays and objects
+
+ Full anti-patterns: [anti-patterns.md](references/anti-patterns.md)
+
---
+ ## Security Checklist
+
+ For EVERY whitelisted method, verify:
+
+ - [ ] Permission check present (`frappe.has_permission()` or `frappe.only_for()`)
+ - [ ] Input validated (types, ranges, formats)
+ - [ ] SQL queries parameterized (NEVER string interpolation)
+ - [ ] Error messages contain no internal details
+ - [ ] `allow_guest=True` only with explicit reason + rate limiting
+ - [ ] `ignore_permissions=True` only with preceding role check
+ - [ ] HTTP methods restricted where possible
+ - [ ] Response contains only necessary fields (no sensitive data leaks)
+
+ ---
+
## Reference Files
| File | Content |
|------|---------|
- | [decorator-options.md](references/decorator-options.md) | All @frappe.whitelist() parameters |
- | [parameter-handling.md](references/parameter-handling.md) | Request parameters and type conversion |
- | [response-patterns.md](references/response-patterns.md) | Response types and structures |
- | [client-calls.md](references/client-calls.md) | frappe.call() and frm.call() patterns |
- | [permission-patterns.md](references/permission-patterns.md) | Security best practices |
- | [error-handling.md](references/error-handling.md) | Error patterns and exception types |
+ | [decorator-options.md](references/decorator-options.md) | All `@frappe.whitelist()` parameters and `force_types` |
+ | [parameter-handling.md](references/parameter-handling.md) | Request parameters, type coercion, `frappe.form_dict` |
+ | [response-patterns.md](references/response-patterns.md) | Return types, file downloads, streaming, HTTP status |
+ | [client-calls.md](references/client-calls.md) | `frappe.call()`, `frm.call()`, REST API, fetch patterns |
+ | [permission-patterns.md](references/permission-patterns.md) | Permission checks, role guards, custom logic |
+ | [error-handling.md](references/error-handling.md) | Exception types, `frappe.throw()`, logging |
| [examples.md](references/examples.md) | Complete working API examples |
- | [anti-patterns.md](references/anti-patterns.md) | What to avoid |
+ | [anti-patterns.md](references/anti-patterns.md) | Security mistakes and performance pitfalls |
+ | [hooks.md](references/hooks.md) | Declaring whitelisted methods in `hooks.py` |
+ | [syntax.md](references/syntax.md) | Core decorator syntax and registration mechanics |