impertio-studio/frappe-impl-whitelisted
> 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. frappe.whitelist, API permission, guest API, secure endpoint, rate limiting, curl testing, frm.call.
npx skills add https://github.com/Impertio-Studio/Frappe_Claude_Skill_Package --skill frappe-impl-whitelisted
Step-by-step workflows for building API endpoints. For decorator syntax, see frappe-syntax-whitelisted.
Version: v14/v15/v16 (version-specific features noted)
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()
WHERE SHOULD THE CODE LIVE?
│
├─► Related to a DocType, called from its form?
│ └─► doctype/xxx/xxx.py (controller method)
│ Client: frm.call('method_name', 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
WHO CAN CALL THIS API?
│
├─► 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 per record!
│
├─► Specific role(s) → frappe.only_for("Role")
│
└─► Document-level → frappe.has_permission(doctype, ptype, doc)
WHAT DOES THE API DO?
│
├─► Read-only → methods=["GET"]
├─► Creates/modifies data → methods=["POST"]
└─► Both or default → omit methods parameter (all allowed)
Step 1: Create the function
# myapp/api.py
import frappe
from frappe import _
@frappe.whitelist()
def get_customer_balance(customer):
"""Get outstanding balance for a customer."""
# 1. Permission check
if not frappe.has_permission("Customer", "read", customer):
frappe.throw(_("Not permitted"), frappe.PermissionError)
# 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
frappe.call({
method: 'myapp.api.get_customer_balance',
args: { customer: 'CUST-00001' },
callback(r) {
if (r.message) console.log(r.message.balance);
}
});
Step 3: Test with curl
# 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"}'
Step 1: Create with strict validation
@frappe.whitelist(allow_guest=True, methods=["POST"])
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"))
# 3. Sanitize ALL input
name = frappe.utils.strip_html(name)[:100]
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,
"phone": phone, "notes": message, "source": "Website"
})
lead.insert(ignore_permissions=True)
return {"success": True, "message": _("Thank you")}
Step 2: Add rate limiting (v15+)
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):
...
methods=["POST"] for data-writing endpointsfrappe.log_error(), show generic messagefrappe.get_doc() — use explicit field mappingStep 1: Add method to DocType controller
# myapp/doctype/sales_order/sales_order.py
class SalesOrder(Document):
@frappe.whitelist()
def calculate_shipping(self, carrier):
"""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
frm.call('calculate_shipping', {
carrier: 'FedEx'
}).then(r => {
frm.set_value('shipping_amount', r.message.rate);
});
Key difference: Frappe automatically checks document permissions for controller methods called via frm.call(). No manual permission check needed for the document itself.
@frappe.whitelist()
def process_payment(invoice, amount):
try:
# 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_payment(invoice, float(amount))
return {"success": True, "data": result}
except (frappe.ValidationError, frappe.PermissionError):
raise # Let Frappe handle (417 / 403)
except frappe.DoesNotExistError:
frappe.throw(_("Not found"), frappe.DoesNotExistError) # 404
except Exception:
frappe.log_error(frappe.get_traceback(), "Payment Error")
frappe.local.response["http_status_code"] = 500
return {"success": False, "error": _("Internal error")}
| Code | Frappe Exception | When |
|------|-----------------|------|
| 200 | — | Success |
| 403 | PermissionError | Access denied |
| 404 | DoesNotExistError | Not found |
| 409 | DuplicateEntryError | Duplicate |
| 417 | ValidationError | Validation failed |
| 429 | — | Rate limit exceeded (v15+) |
| 500 | Exception | Server error |
@frappe.whitelist()
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)
file = frappe.request.files.get('file')
if not file:
frappe.throw(_("No file provided"))
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}
@frappe.whitelist()
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")}
frappe.call({
method: 'myapp.api.my_method',
args: { param: 'value' },
freeze: true,
freeze_message: __('Processing...'),
callback(r) { console.log(r.message); },
error(r) { frappe.msgprint(__('Error')); }
});
const r = await frappe.call({
method: 'myapp.api.get_data',
args: { id: 123 }
});
console.log(r.message);
# 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
frappe.only_for() before using ignore_permissions=Truemethods=["GET"] for endpoints that modify datadata dicts from guest APIs — use explicit parameter namesX-Frappe-CSRF-Token header in fetch() calls from browser| 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 |
| Feature | v14 | v15 | v16 |
|---------|:---:|:---:|:---:|
| @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 |
@frappe.whitelist()
def typed_api(customer: str, limit: int = 10) -> dict:
"""v15+ validates types from annotations automatically."""
return {"customer": customer, "limit": limit}
| File | Contents |
|------|----------|
| decision-tree.md | Complete API type and permission selection guide |
| workflows.md | Step-by-step implementation patterns (10+ workflows) |
| examples.md | Production-ready code examples |
Take impertio-studio/frappe-impl-whitelisted from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.