v2.0 to v2.0

282 added, 311 removed. Audit A to A.

---
name: frappe-impl-whitelisted
description: >
- Use when determining HOW to implement Frappe Whitelisted Methods (REST
- APIs): public vs authenticated endpoints, permission patterns, error
- handling, response formats, client integration. Keywords: how to create
- API, build REST endpoint, frappe.call pattern, API permission check,
- guest API, secure endpoint.
+ Use when building API endpoints with @frappe.whitelist() in Frappe.
+ Covers endpoint design, permission patterns, error handling, client
+ integration, file uploads, background jobs, rate limiting, REST API
+ testing, and migration from Server Scripts to whitelisted methods.
+ Prevents permission bypasses, SQL injection, and data exposure.
+ Keywords: how to create API, build REST endpoint, frappe.call,
+ frappe.whitelist, API permission, guest API, secure endpoint,
+ rate limiting, curl testing, frm.call.
license: MIT
compatibility: "Claude Code, Claude.ai Projects, Claude API. Frappe v14-v16."
metadata:
author: OpenAEC-Foundation
version: "2.0"
---
- # ERPNext Whitelisted Methods - Implementation
+ # Frappe Whitelisted Methods Implementation Workflow
- This skill helps you determine HOW to implement REST API endpoints. For exact syntax, see `frappe-syntax-whitelisted`.
+ Step-by-step workflows for building API endpoints. For decorator syntax, see `frappe-syntax-whitelisted`.
- **Version**: v14/v15/v16 compatible
+ **Version**: v14/v15/v16 (version-specific features noted)
- ## Main Decision: What Type of API?
+ ---
+ ## Master Decision: What Type of Endpoint?
+
```
- ┌───────────────────────────────────────────────────────────────────┐
- │ WHAT ARE YOU BUILDING? │
- ├───────────────────────────────────────────────────────────────────┤
- │ │
- │ ► Public API (contact forms, status checks)? │
- │ └── allow_guest=True + strict input validation │
- │ │
- │ ► Internal API for logged-in users? │
- │ └── Default (no allow_guest) + permission checks │
- │ │
- │ ► Admin-only API? │
- │ └── frappe.only_for("System Manager") │
- │ │
- │ ► Document-specific method (on a form)? │
- │ └── Controller method + frm.call() │
- │ │
- │ ► Standalone utility API? │
- │ └── Separate api.py + frappe.call() │
- │ │
- └───────────────────────────────────────────────────────────────────┘
+ WHAT ARE YOU BUILDING?
+ │
+ ├─► Public API (no login required)?
+ │ └─► allow_guest=True + STRICT input validation + rate limiting
+ │
+ ├─► Authenticated API for logged-in users?
+ │ └─► Default @frappe.whitelist() + document permission checks
+ │
+ ├─► Admin-only API?
+ │ └─► frappe.only_for("System Manager")
+ │
+ ├─► Document-specific method (called from form)?
+ │ └─► Controller method + frm.call() from JS
+ │
+ ├─► Standalone utility API?
+ │ └─► Separate api.py + frappe.call() from JS
+ │
+ ├─► External webhook receiver?
+ │ └─► allow_guest=True + signature verification
+ │
+ └─► Background job trigger?
+ └─► Authenticated API that calls frappe.enqueue()
```
- → See [references/decision-tree.md](references/decision-tree.md) for complete guide.
-
---
- ## Decision: Where to Put API Code?
+ ## Workflow 1: Design the Endpoint
+ ### Step 1: Choose Location
+
```
- WHERE SHOULD YOUR API LIVE?
+ WHERE SHOULD THE CODE LIVE?
- ├─► Related to a specific DocType?
- │ │
- │ ├─► Called from that DocType's form?
- │ │ └─► Controller method (doctype/xxx/xxx.py)
- │ │ Client: frm.call('method_name', args)
- │ │
- │ └─► Standalone but DocType-related?
- │ └─► Same file or doctype/xxx/xxx_api.py
- │ Client: frappe.call('path.to.method', args)
+ ├─► Related to a DocType, called from its form?
+ │ └─► doctype/xxx/xxx.py (controller method)
+ │ Client: frm.call('method_name', args)
- ├─► General utility API?
- │ └─► myapp/api.py or myapp/api/module.py
- │ Client: frappe.call('myapp.api.method', args)
+ ├─► Related to a DocType, standalone?
+ │ └─► doctype/xxx/xxx_api.py or myapp/api/module.py
+ │ Client: frappe.call('myapp.api.module.method')
+ ├─► General app utility?
+ │ └─► myapp/api.py (small app) or myapp/api/module.py (large app)
+ │
└─► External integration?
└─► myapp/integrations/service_name.py
- Often combined with webhooks
```
- ---
-
- ## Decision: Permission Model
+ ### Step 2: Choose Permission Model
```
- WHO CAN ACCESS THIS API?
+ WHO CAN CALL THIS API?
- ├─► Anyone (public)?
- │ └─► allow_guest=True
- │ ⚠️ MUST validate all input
- │ ⚠️ MUST rate limit if possible
- │ ⚠️ NEVER expose sensitive data
+ ├─► Anyone (public) → allow_guest=True
+ │ ⚠️ MUST validate ALL input, sanitize for XSS, rate limit
- ├─► Any logged-in user?
- │ └─► Default (no allow_guest)
- │ Still check document permissions!
+ ├─► Any logged-in user → Default (no allow_guest)
+ │ Still check document permissions per record!
- ├─► Specific role(s)?
- │ └─► frappe.only_for("Role") or frappe.only_for(["Role1", "Role2"])
- │ Throws PermissionError if user lacks role
+ ├─► Specific role(s) → frappe.only_for("Role")
- ├─► Document-level permission?
- │ └─► frappe.has_permission(doctype, ptype, doc)
- │ Check before accessing each document
+ └─► Document-level → frappe.has_permission(doctype, ptype, doc)
+ ```
+
+ ### Step 3: Choose HTTP Method
+
+ ```
+ WHAT DOES THE API DO?
- └─► Custom permission logic?
- └─► Implement your own checks
- Always deny by default
+ ├─► Read-only → methods=["GET"]
+ ├─► Creates/modifies data → methods=["POST"]
+ └─► Both or default → omit methods parameter (all allowed)
```
---
- ## Quick Implementation Patterns
+ ## Workflow 2: Implement an Authenticated API
- ### Pattern 1: Simple Authenticated API
+ ### Step-by-Step
+ **Step 1: Create the function**
+
```python
# myapp/api.py
import frappe
from frappe import _
@frappe.whitelist()
def get_customer_balance(customer):
- """Get customer's outstanding balance."""
- # Permission check
+ """Get outstanding balance for a customer."""
+ # 1. Permission check
if not frappe.has_permission("Customer", "read", customer):
frappe.throw(_("Not permitted"), frappe.PermissionError)
-
- # Fetch data
- balance = frappe.db.get_value("Customer", customer, "outstanding_amount")
-
- return {"customer": customer, "balance": balance or 0}
+
+ # 2. Validate input
+ if not customer or not frappe.db.exists("Customer", customer):
+ frappe.throw(_("Customer not found"), frappe.DoesNotExistError)
+
+ # 3. Fetch and return
+ balance = frappe.db.sql("""
+ SELECT COALESCE(SUM(outstanding_amount), 0)
+ FROM `tabSales Invoice`
+ WHERE customer = %s AND docstatus = 1
+ """, customer)[0][0]
+
+ return {"customer": customer, "balance": balance}
```
+ **Step 2: Call from Client Script**
+
```javascript
- // Client call
frappe.call({
method: 'myapp.api.get_customer_balance',
- args: { customer: 'CUST-00001' }
- }).then(r => {
- console.log(r.message.balance);
+ args: { customer: 'CUST-00001' },
+ callback(r) {
+ if (r.message) console.log(r.message.balance);
+ }
});
```
- ### Pattern 2: Public API with Validation
+ **Step 3: Test with curl**
+ ```bash
+ # Authenticate first
+ curl -X POST https://site.com/api/method/login \
+ -d 'usr=admin&pwd=password'
+
+ # Call the API
+ curl -X POST https://site.com/api/method/myapp.api.get_customer_balance \
+ -H "Content-Type: application/json" \
+ -d '{"customer": "CUST-00001"}' \
+ --cookie cookies.txt
+
+ # Or use token auth
+ curl -X POST https://site.com/api/method/myapp.api.get_customer_balance \
+ -H "Authorization: token api_key:api_secret" \
+ -H "Content-Type: application/json" \
+ -d '{"customer": "CUST-00001"}'
+ ```
+
+ ---
+
+ ## Workflow 3: Implement a Public (Guest) API
+
+ ### Step-by-Step
+
+ **Step 1: Create with strict validation**
+
```python
@frappe.whitelist(allow_guest=True, methods=["POST"])
- def submit_inquiry(name, email, message):
- """Public contact form - strict validation required."""
- # Validate required fields
- if not all([name, email, message]):
- frappe.throw(_("All fields are required"))
-
- # Validate email format
+ def submit_inquiry(name, email, phone=None, message=None):
+ """Public contact form — strict validation required."""
+ # 1. Validate required fields
+ if not all([name, email]):
+ frappe.throw(_("Name and email are required"))
+
+ # 2. Validate email format
if not frappe.utils.validate_email_address(email):
frappe.throw(_("Invalid email address"))
-
- # Sanitize input
+
+ # 3. Sanitize ALL input
name = frappe.utils.strip_html(name)[:100]
- message = frappe.utils.strip_html(message)[:2000]
-
- # Create record
- doc = frappe.get_doc({
+ email = email.strip().lower()[:200]
+ phone = frappe.utils.strip_html(phone)[:20] if phone else None
+ message = frappe.utils.strip_html(message)[:2000] if message else None
+
+ # 4. Create record with ignore_permissions
+ lead = frappe.get_doc({
"doctype": "Lead",
- "lead_name": name,
- "email_id": email,
- "notes": message,
- "source": "Website"
+ "lead_name": name, "email_id": email,
+ "phone": phone, "notes": message, "source": "Website"
})
- doc.insert(ignore_permissions=True)
-
- return {"success": True, "id": doc.name}
+ lead.insert(ignore_permissions=True)
+
+ return {"success": True, "message": _("Thank you")}
```
- ### Pattern 3: Role-Restricted API
+ **Step 2: Add rate limiting (v15+)**
```python
- @frappe.whitelist()
- def get_salary_data(employee):
- """HR-only endpoint."""
- # Role check - throws if not HR
- frappe.only_for(["HR Manager", "HR User"])
-
- return frappe.get_doc("Employee", employee).as_dict()
+ from frappe.rate_limiter import rate_limit
+
+ @frappe.whitelist(allow_guest=True, methods=["POST"])
+ @rate_limit(limit=5, seconds=60) # 5 calls per minute
+ def submit_inquiry(name, email, phone=None, message=None):
+ ...
```
- ### Pattern 4: Document Controller Method
+ ### Critical Rules for Guest APIs
+ - **ALWAYS** validate and sanitize every input parameter
+ - **ALWAYS** use `methods=["POST"]` for data-writing endpoints
+ - **ALWAYS** add rate limiting (v15+ decorator or manual cache-based throttle on v14)
+ - **NEVER** expose internal error details — log with `frappe.log_error()`, show generic message
+ - **NEVER** return sensitive data (internal IDs, file paths, stack traces)
+ - **NEVER** pass raw user input to `frappe.get_doc()` — use explicit field mapping
+
+ ---
+
+ ## Workflow 4: Implement a Controller Method
+
+ ### Step-by-Step
+
+ **Step 1: Add method to DocType controller**
+
```python
- # In doctype/sales_order/sales_order.py
+ # myapp/doctype/sales_order/sales_order.py
class SalesOrder(Document):
@frappe.whitelist()
def calculate_shipping(self, carrier):
- """Called via frm.call() from form."""
- # Permission already checked by Frappe for doc access
+ """Called from form via frm.call()."""
+ if not self.shipping_address:
+ frappe.throw(_("Shipping address required"))
rate = get_shipping_rate(self.shipping_address, carrier)
return {"carrier": carrier, "rate": rate}
```
+ **Step 2: Call from form JS**
+
```javascript
- // Client (in sales_order.js)
frm.call('calculate_shipping', {
carrier: 'FedEx'
}).then(r => {
frm.set_value('shipping_amount', r.message.rate);
});
```
- → See [references/workflows.md](references/workflows.md) for 10+ complete workflows.
-
- ---
-
- ## Critical Security Rules
-
- ### 1. ALWAYS Check Permissions
-
- ```python
- # ❌ WRONG - exposes all data
- @frappe.whitelist()
- def get_document(doctype, name):
- return frappe.get_doc(doctype, name).as_dict()
-
- # ✅ CORRECT
- @frappe.whitelist()
- def get_document(doctype, name):
- if not frappe.has_permission(doctype, "read", name):
- frappe.throw(_("Not permitted"), frappe.PermissionError)
- return frappe.get_doc(doctype, name).as_dict()
- ```
-
- ### 2. NEVER Trust User Input in SQL
-
- ```python
- # ❌ WRONG - SQL injection!
- @frappe.whitelist()
- def search(term):
- return frappe.db.sql(f"SELECT * FROM tabItem WHERE name LIKE '%{term}%'")
-
- # ✅ CORRECT - parameterized
- @frappe.whitelist()
- def search(term):
- return frappe.db.sql("""
- SELECT name, item_name FROM tabItem
- WHERE name LIKE %(term)s
- LIMIT 20
- """, {"term": f"%{term}%"}, as_dict=True)
- ```
-
- ### 3. VALIDATE All Input for Guest APIs
-
- ```python
- @frappe.whitelist(allow_guest=True)
- def public_api(data):
- # ❌ WRONG - trusts input
- doc = frappe.get_doc(data)
- doc.insert(ignore_permissions=True)
-
- # ✅ CORRECT - validate everything
- if not isinstance(data, dict):
- frappe.throw(_("Invalid data format"))
-
- allowed_fields = {"name", "email", "message"}
- clean_data = {k: v for k, v in data.items() if k in allowed_fields}
-
- # Validate each field...
- ```
-
- ### 4. NEVER Expose Sensitive Data in Errors
-
- ```python
- # ❌ WRONG - leaks internal info
- except Exception as e:
- frappe.throw(str(e))
-
- # ✅ CORRECT - generic message, log details
- except Exception:
- frappe.log_error(frappe.get_traceback(), "API Error")
- frappe.throw(_("An error occurred. Please try again."))
- ```
-
- ### 5. Use ignore_permissions Sparingly
-
- ```python
- # ❌ WRONG - bypasses all security
- @frappe.whitelist()
- def get_all_data():
- return frappe.get_all("Salary Slip", ignore_permissions=True)
-
- # ✅ CORRECT - check role first
- @frappe.whitelist()
- def get_all_data():
- frappe.only_for("HR Manager") # Verify role first!
- return frappe.get_all("Salary Slip", ignore_permissions=True)
- ```
+ **Key difference**: Frappe automatically checks document permissions for controller methods called via `frm.call()`. No manual permission check needed for the document itself.
---
- ## Error Handling Pattern
+ ## Workflow 5: Implement Error Handling
- ### Standard Error Response
+ ### Standard Pattern
```python
@frappe.whitelist()
- def robust_api(param):
- """API with proper error handling."""
+ def process_payment(invoice, amount):
try:
- # Validate input
- if not param:
- frappe.throw(_("Parameter required"), frappe.ValidationError)
-
- # Check permissions
- if not frappe.has_permission("MyDocType", "read"):
+ # Validate
+ if not invoice:
+ frappe.throw(_("Invoice required"), frappe.ValidationError)
+ if not frappe.has_permission("Sales Invoice", "write", invoice):
frappe.throw(_("Not permitted"), frappe.PermissionError)
-
+
# Process
- result = do_something(param)
+ result = do_payment(invoice, float(amount))
return {"success": True, "data": result}
-
- except frappe.ValidationError:
- raise # Let Frappe handle (417)
- except frappe.PermissionError:
- raise # Let Frappe handle (403)
+
+ except (frappe.ValidationError, frappe.PermissionError):
+ raise # Let Frappe handle (417 / 403)
except frappe.DoesNotExistError:
- frappe.local.response["http_status_code"] = 404
- return {"success": False, "error": "Not found"}
+ frappe.throw(_("Not found"), frappe.DoesNotExistError) # 404
except Exception:
- frappe.log_error(frappe.get_traceback(), "API Error")
+ frappe.log_error(frappe.get_traceback(), "Payment Error")
frappe.local.response["http_status_code"] = 500
- return {"success": False, "error": "Internal error"}
+ return {"success": False, "error": _("Internal error")}
```
- ### HTTP Status Codes
+ ### HTTP Status Code Reference
- | Code | Exception | When to Use |
- |------|-----------|-------------|
- | 200 | - | Success |
- | 201 | - | Created (set manually) |
- | 400 | - | Bad request (set manually) |
- | 401 | AuthenticationError | Not logged in |
+ | Code | Frappe Exception | When |
+ |------|-----------------|------|
+ | 200 | — | Success |
| 403 | PermissionError | Access denied |
| 404 | DoesNotExistError | Not found |
- | 417 | ValidationError | Validation failed |
| 409 | DuplicateEntryError | Duplicate |
+ | 417 | ValidationError | Validation failed |
+ | 429 | — | Rate limit exceeded (v15+) |
| 500 | Exception | Server error |
---
- ## Response Patterns
-
- ### Simple Return (Most Common)
+ ## Workflow 6: File Upload Endpoint
```python
@frappe.whitelist()
- def get_data():
- return {"key": "value"}
- # Response: {"message": {"key": "value"}}
- ```
+ def upload_attachment(doctype, docname):
+ """Handle file upload attached to a document."""
+ if not frappe.has_permission(doctype, "write", docname):
+ frappe.throw(_("Not permitted"), frappe.PermissionError)
- ### List Response
+ file = frappe.request.files.get('file')
+ if not file:
+ frappe.throw(_("No file provided"))
- ```python
- @frappe.whitelist()
- def get_items():
- return frappe.get_all("Item", fields=["name", "item_name"], limit=10)
- # Response: {"message": [{"name": "...", "item_name": "..."}, ...]}
+ file_doc = frappe.get_doc({
+ "doctype": "File",
+ "file_name": file.filename,
+ "attached_to_doctype": doctype,
+ "attached_to_name": docname,
+ "content": file.read(),
+ "is_private": 1
+ })
+ file_doc.insert(ignore_permissions=True)
+ return {"file_url": file_doc.file_url}
```
- ### With Metadata
+ ---
+ ## Workflow 7: Background Job Endpoint
+
```python
@frappe.whitelist()
- def get_paged_data(page=1, page_size=20):
- offset = (int(page) - 1) * int(page_size)
- total = frappe.db.count("Item")
- items = frappe.get_all("Item", limit=page_size, start=offset)
-
- return {
- "data": items,
- "total": total,
- "page": page,
- "page_size": page_size,
- "pages": (total + page_size - 1) // page_size
- }
+ def start_heavy_export(doctype, filters=None):
+ """Trigger a background job — returns immediately."""
+ frappe.only_for("System Manager")
+
+ frappe.enqueue(
+ "myapp.tasks.export_data",
+ queue="long",
+ timeout=1500,
+ doctype=doctype,
+ filters=filters,
+ user=frappe.session.user
+ )
+ return {"status": "queued", "message": _("Export started")}
```
---
- ## Client Integration
+ ## Client Integration Patterns
- ### frappe.call() Options
+ ### frappe.call() with Options
```javascript
frappe.call({
method: 'myapp.api.my_method',
- args: { param1: 'value' },
-
- // UI Options
- freeze: true, // Show loading overlay
- freeze_message: __('Loading...'), // Custom message
-
- // Callbacks
- callback: function(r) {
- if (r.message) { /* success */ }
- },
- error: function(r) {
- // Handle error
- },
- always: function() {
- // Always runs (finally)
- },
-
- // Other
- async: true, // Default true
- type: 'POST' // Default POST
+ args: { param: 'value' },
+ freeze: true,
+ freeze_message: __('Processing...'),
+ callback(r) { console.log(r.message); },
+ error(r) { frappe.msgprint(__('Error')); }
});
```
### Async/Await Pattern
```javascript
- async function fetchData() {
- try {
- const r = await frappe.call({
- method: 'myapp.api.get_data',
- args: { id: 123 }
- });
- return r.message;
- } catch (e) {
- frappe.msgprint(__('Error loading data'));
- console.error(e);
- }
- }
+ const r = await frappe.call({
+ method: 'myapp.api.get_data',
+ args: { id: 123 }
+ });
+ console.log(r.message);
```
+ ### REST API from External System
+
+ ```bash
+ # Token auth (recommended for integrations)
+ curl -H "Authorization: token api_key:api_secret" \
+ https://site.com/api/method/myapp.api.method
+
+ # Bearer auth (OAuth)
+ curl -H "Authorization: Bearer access_token" \
+ https://site.com/api/method/myapp.api.method
+ ```
+
---
- ## Reference Files
+ ## Security Rules (ALWAYS/NEVER)
- | File | Contents |
- |------|----------|
- | [decision-tree.md](references/decision-tree.md) | Complete API type selection guide |
- | [workflows.md](references/workflows.md) | Step-by-step implementation patterns |
- | [examples.md](references/examples.md) | Complete working examples |
- | [anti-patterns.md](references/anti-patterns.md) | Common mistakes to avoid |
+ 1. **ALWAYS** check permissions before accessing any document
+ 2. **ALWAYS** use parameterized queries — **NEVER** use f-strings in SQL
+ 3. **ALWAYS** validate and sanitize input for guest APIs
+ 4. **ALWAYS** check role with `frappe.only_for()` before using `ignore_permissions=True`
+ 5. **NEVER** expose internal errors — log details, return generic message
+ 6. **NEVER** use `methods=["GET"]` for endpoints that modify data
+ 7. **NEVER** trust `data` dicts from guest APIs — use explicit parameter names
+ 8. **ALWAYS** include `X-Frappe-CSRF-Token` header in fetch() calls from browser
---
+ ## Migration: Server Script API to Whitelisted Method
+
+ | Step | Action |
+ |------|--------|
+ | 1 | Copy Server Script logic to `myapp/api/module.py` |
+ | 2 | Add `@frappe.whitelist()` decorator with same permission model |
+ | 3 | Update all `frappe.call()` references to new dotted path |
+ | 4 | Disable or delete the Server Script |
+ | 5 | Run `bench --site sitename migrate` |
+
+ ---
+
## Version Differences
| Feature | v14 | v15 | v16 |
|---------|:---:|:---:|:---:|
- | @frappe.whitelist() | ✅ | ✅ | ✅ |
- | allow_guest | ✅ | ✅ | ✅ |
- | methods parameter | ✅ | ✅ | ✅ |
- | Type annotation validation | ❌ | ✅ | ✅ |
- | Rate limiting decorator | ❌ | ✅ | ✅ |
- | API v2 endpoints | ❌ | ✅ | ✅ |
+ | @frappe.whitelist() | Yes | Yes | Yes |
+ | allow_guest | Yes | Yes | Yes |
+ | methods parameter | Yes | Yes | Yes |
+ | Type annotation validation | No | **Yes** | Yes |
+ | @rate_limit decorator | No | **Yes** | Yes |
+ | API v2 endpoints | No | **Yes** | Yes |
### v15+ Type Validation
```python
- # v15+ validates types automatically
@frappe.whitelist()
def typed_api(customer: str, limit: int = 10) -> dict:
+ """v15+ validates types from annotations automatically."""
return {"customer": customer, "limit": limit}
```
- ### v15+ Rate Limiting
+ ---
- ```python
- from frappe.rate_limiter import rate_limit
+ ## Reference Files
- @frappe.whitelist(allow_guest=True)
- @rate_limit(limit=5, seconds=60) # 5 calls per minute
- def rate_limited_api():
- return {"status": "ok"}
- ```
+ | File | Contents |
+ |------|----------|
+ | [decision-tree.md](references/decision-tree.md) | Complete API type and permission selection guide |
+ | [workflows.md](references/workflows.md) | Step-by-step implementation patterns (10+ workflows) |
+ | [examples.md](references/examples.md) | Production-ready code examples |