Frappe Whitelisted Methods Implementation Workflow

SkillFiles & storage

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,, create API endpoint, call from frontend, custom API, REST endpoint, how to call python from JS. frappe.whitelist, API permission, guest API, secure endpoint, rate limiting, curl testing, frm.call.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Frappe Whitelisted Methods Implementation Workflow skill

What this skill tells your AI

The instructions your AI receives, as published by impertio-studio/frappe_claude_skill_package in skills/source/impl/frappe-impl-whitelisted/SKILL.md and read by ahel’s review.

Step-by-step workflows for building API endpoints. For decorator syntax, see frappe-syntax-whitelisted.

Version: v14/v15/v16 (version-specific features noted)


Master Decision: What Type of Endpoint?

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()

Workflow 1: Design the Endpoint

Step 1: Choose Location

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

Step 2: Choose Permission Model

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)

Step 3: Choose HTTP Method

WHAT DOES THE API DO?
│
├─► Read-only → methods=["GET"]
├─► Creates/modifies data → methods=["POST"]
└─► Both or default → omit methods parameter (all allowed)

Workflow 2: Implement an Authenticated API

Step-by-Step

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"}'

Workflow 3: Implement a Public (Guest) API

Step-by-Step

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):
    ...

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

# 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.


Workflow 5: Implement Error Handling

Standard Pattern

@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")}

HTTP Status Code Reference

CodeFrappe ExceptionWhen
200Success
403PermissionErrorAccess denied
404DoesNotExistErrorNot found
409DuplicateEntryErrorDuplicate
417ValidationErrorValidation failed
429Rate limit exceeded (v15+)
500ExceptionServer error

Workflow 6: File Upload Endpoint

@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}

Workflow 7: Background Job Endpoint

@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")}

Client Integration Patterns

frappe.call() with Options

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')); }
});

Async/Await Pattern

const r = await frappe.call({
    method: 'myapp.api.get_data',
    args: { id: 123 }
});
console.log(r.message);

REST API from External System

# 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

Security Rules (ALWAYS/NEVER)

  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

StepAction
1Copy Server Script logic to myapp/api/module.py
2Add @frappe.whitelist() decorator with same permission model
3Update all frappe.call() references to new dotted path
4Disable or delete the Server Script
5Run bench --site sitename migrate

Version Differences

Featurev14v15v16
@frappe.whitelist()YesYesYes
allow_guestYesYesYes
methods parameterYesYesYes
Type annotation validationNoYesYes
@rate_limit decoratorNoYesYes
API v2 endpointsNoYesYes

v15+ Type Validation

@frappe.whitelist()
def typed_api(customer: str, limit: int = 10) -> dict:
    """v15+ validates types from annotations automatically."""
    return {"customer": customer, "limit": limit}

Reference Files

FileContents
decision-tree.mdComplete API type and permission selection guide
workflows.mdStep-by-step implementation patterns (10+ workflows)
examples.mdProduction-ready code examples

Signals

GitHub stars
178
Forks
53
Last commit
Jul 2026
Advanced
Catalog kind
skill
Gateway key
frappe-impl-whitelisted
Source
github.com/impertio-studio/frappe_claude_skill_package