Writing Real Custom API Logic in Frappe

Giving a Frappe app custom REST API endpoints

Building Custom REST API Endpoints in Frappe with frappe.whitelist

You've already got the built-in resource API working โ€” GET /api/resource/Sales Invoice/INV-001 returns exactly what you'd expect, filters work, your API key authenticates cleanly. Then product asks for something else: "when someone approves an invoice, mark it approved, create a linked notification record, and email the customer โ€” all in one call." ๐Ÿค” That's not a CRUD operation on one DocType anymore.

You could try to fake it with three sequential resource API calls from the frontend, but then the frontend owns business logic that belongs on the server, and a partial failure leaves your data in a weird half-state. This is exactly the gap @frappe.whitelist() exists to fill โ€” turning any Python function you write into your own custom, callable API endpoint. ๐Ÿš€


โฑ๏ธ Time to Complete (~30 minutes)

๐ŸŽฏ What You'll Achieve / Learn

  • ๐Ÿงญ Understand when @frappe.whitelist() is the right tool versus the built-in resource API
  • ๐Ÿ” Know the security implications of whitelisting a function, and how allow_guest changes the trust model
  • ๐Ÿงฉ Parse and validate incoming request data by hand (query params and JSON bodies)
  • ๐Ÿ“ฆ Return structured responses, custom HTTP status codes, and clean errors with frappe.throw
  • ๐Ÿ”— Call your custom endpoint from both Frappe's client-side JS and an external Python/JS client

๐Ÿ› ๏ธ Prerequisites

  • ๐Ÿ—๏ธ A custom Frappe app already scaffolded with bench new-app (this is not ERPNext-specific โ€” any Frappe app works)
  • โš™๏ธ A running bench with the app installed on a site
  • ๐Ÿ Basic familiarity with Python and how HTTP requests/responses work

The Core Concept ๐Ÿง 

Anatomy of a Frappe custom endpoint from whitelisted Python function to JSON response

The built-in resource API (/api/resource/<DocType>) is Frappe's generic CRUD layer โ€” it reads and writes one DocType's fields based on standard REST conventions. It's fast to use precisely because it's generic: it doesn't know or care about your business rules.

A whitelisted method is the opposite: it's a specific Python function you wrote, exposed at its own URL, that can do anything your Python code can do โ€” touch multiple DocTypes, call external services, run in a single database transaction, return whatever shape of data you want. ๐Ÿ’ช

Think of it this way: the resource API is a vending machine ๐Ÿฅค โ€” put in a DocType name, get out standard rows. A whitelisted method is a bespoke API you're handing to callers, built to do exactly one job you defined.

Under the hood, @frappe.whitelist() just registers your function in a lookup table Frappe's request router checks before executing anything. Without that decorator, a Python function is invisible to the web layer entirely โ€” no route exists for it, whitelisted or not.


Step 1: Create Your First Whitelisted Function

Step 1: Create Your First Whitelisted Function

Section visual: Create Your First Whitelisted Function.

Inside your custom app, create a Python file for your endpoint logic (we'll organize this properly in a later step). Start simple:

# my_app/api/shipping.py
import frappe

@frappe.whitelist()
def calculate_shipping_cost():
    order_id = frappe.form_dict.get("order_id")

    if not order_id:
        frappe.throw("order_id is required")

    order = frappe.get_doc("Sales Order", order_id)
    weight = sum(item.weight or 0 for item in order.items)
    cost = round(weight * 2.5, 2)

    return {"order_id": order_id, "shipping_cost": cost}

This is now reachable at:

GET/POST /api/method/my_app.api.shipping.calculate_shipping_cost

The URL path is generated directly from the Python import path of the function โ€” <app>.<module_path>.<function_name> โ€” not from any route you configure separately. ๐Ÿงญ

โš ๏ธ Common Mistake: Assuming @frappe.whitelist() only makes a function available inside Frappe (client scripts, server scripts). It also opens an HTTP route immediately, reachable by anyone who knows or guesses the URL, subject only to the session/auth rules below.


Step 2: Understand What Whitelisting Actually Exposes

Step 2: Understand What Whitelisting Actually Exposes

Section visual: Understand What Whitelisting Actually Exposes.

This is the single most important security concept ๐Ÿ”’ in this whole guide, so it's worth stating plainly: @frappe.whitelist() makes a function part of your app's public HTTP surface.

By default, Frappe still requires a valid session or API key โ€” an anonymous request gets rejected. But "requires auth" is not the same as "safe." Any logged-in user, even one with minimal permissions, can hit that URL and pass in whatever arguments they want.

Frappe does not automatically check DocType-level permissions inside a whitelisted function just because the function touches a DocType. If your code does frappe.get_doc("Sales Order", order_id) without checking frappe.has_permission(), a low-privilege user could read data they shouldn't via your custom endpoint, even if the resource API would have blocked them. ๐Ÿšจ

๐Ÿ’ก Pro Tip: Treat every whitelisted function like a mini public API contract. Validate inputs, check permissions explicitly with frappe.has_permission() or role checks, and never assume "it's only called from our own JS" โ€” nothing enforces that assumption at the HTTP layer.


Step 3: allow_guest=True vs the Authenticated Default

Comparison of authenticated and public guest endpoints in Frappe

By default, @frappe.whitelist() requires an authenticated session (cookie-based session or API key/secret header). That's the right default for almost everything โ€” invoice approvals, internal reports, anything tied to a specific user.

Sometimes you genuinely need a fully public endpoint ๐ŸŒ โ€” a webhook receiver, a public status-check page, a signup form callback. That's what allow_guest=True is for:

# my_app/api/webhooks.py
import frappe

@frappe.whitelist(allow_guest=True)
def payment_webhook():
    payload = frappe.request.get_json(silent=True) or {}
    signature = frappe.request.headers.get("X-Webhook-Signature")

    if not verify_signature(payload, signature):
        frappe.throw("Invalid signature", frappe.PermissionError)

    # process payload...
    return {"status": "received"}

Because a guest endpoint has no session context at all, Frappe can't rely on the normal permission framework to protect it โ€” there's no logged-in user to check roles against. Every bit of validation, rate-limiting sanity, and signature/secret verification has to be written by hand inside the function. โœ๏ธ

โš ๏ธ Common Mistake: Using allow_guest=True just to make local testing easier, then forgetting to lock it back down. If an endpoint doesn't strictly need to be public, leave the authenticated default in place.


Step 4: Reading Request Data Manually

Reading query parameters, form data, JSON bodies, and HTTP methods in Frappe

Frappe doesn't generate request schemas or auto-validate types for whitelisted methods โ€” that's on you. The two main tools are frappe.form_dict (works for query params on GET and form-encoded/JSON body on POST) and frappe.request for lower-level access:

import frappe

@frappe.whitelist()
def approve_invoice():
    invoice_name = frappe.form_dict.get("invoice_name")
    send_notification = frappe.form_dict.get("send_notification", "1")

    if not invoice_name:
        frappe.throw("invoice_name is required")

    # form_dict values arrive as strings โ€” cast explicitly
    send_notification = frappe.parse_json(send_notification) if isinstance(send_notification, str) else send_notification

    ...

For query string parameters specifically, frappe.request.args gives you Werkzeug's MultiDict directly:

page = frappe.request.args.get("page", 1, type=int)

Because everything arrives untyped (strings, or raw JSON-decoded values), always cast and validate โœ… โ€” an int field coming through as "5" will break silently if you compare it to a real integer somewhere downstream without converting it first.

๐Ÿ’ก Pro Tip: For anything beyond a couple of fields, write a small validation helper at the top of the function and frappe.throw() immediately on the first bad field, rather than letting a KeyError or TypeError bubble up as an ugly 500.


Step 5: Supporting Multiple HTTP Methods

Step 5: Supporting Multiple HTTP Methods

Section visual: Supporting Multiple HTTP Methods.

A single whitelisted function can respond to more than one HTTP verb by passing methods to the decorator:

@frappe.whitelist(methods=["GET", "POST"])
def invoice_summary():
    if frappe.request.method == "GET":
        invoice_name = frappe.request.args.get("invoice_name")
    else:
        invoice_name = frappe.form_dict.get("invoice_name")

    if not invoice_name:
        frappe.throw("invoice_name is required")

    doc = frappe.get_doc("Sales Invoice", invoice_name)
    return {"invoice_name": doc.name, "status": doc.status, "grand_total": doc.grand_total}

Checking frappe.request.method inside the body lets one function branch its parsing logic per verb, which is handy when GET should read from query params but POST should read from a JSON body โ€” without duplicating the underlying logic in two separate functions. ๐Ÿ”€

If you omit methods entirely, Frappe defaults to allowing both GET and POST for whitelisted methods, so being explicit is mostly about self-documentation and, in some setups, restricting a mutating action to POST only.


Step 6: Returning Structured Responses and Status Codes

Structured JSON responses and HTTP status codes from a Frappe API endpoint

Whatever you return from a whitelisted function gets JSON-serialized and wrapped by Frappe automatically:

@frappe.whitelist()
def get_customer_stats(customer):
    total_orders = frappe.db.count("Sales Order", {"customer": customer})
    return {"customer": customer, "total_orders": total_orders}

The actual HTTP response body looks like:

{
  "message": {
    "customer": "CUST-0001",
    "total_orders": 12
  }
}

That message wrapper is a Frappe convention, not something you opt into โ€” client code (JS or external) needs to unwrap it, which trips people up ๐Ÿคฆ the first time they hit a custom endpoint.

To set a custom HTTP status code (say, 201 Created for something you just inserted), write directly to frappe.local.response:

@frappe.whitelist(methods=["POST"])
def create_support_ticket():
    subject = frappe.form_dict.get("subject")
    if not subject:
        frappe.throw("subject is required")

    doc = frappe.get_doc({"doctype": "Support Ticket", "subject": subject}).insert()

    frappe.local.response.http_status_code = 201
    return {"name": doc.name, "status": "created"}

For errors, prefer frappe.throw() over raising a bare Exception โ€” it produces a clean, structured error response and a sane status code instead of leaking a raw Python traceback:

if not frappe.db.exists("Sales Invoice", invoice_name):
    frappe.throw(f"Invoice {invoice_name} not found", frappe.DoesNotExistError)

frappe.throw() accepts an optional exception class as a second argument, which lets Frappe map it to the right HTTP status ๐ŸŽฏ (a DoesNotExistError becomes a 404, a PermissionError becomes a 403, and so on), rather than everything falling back to a generic 500.

โš ๏ธ Common Mistake: Returning an error as a normal {"error": "..."} dict with a 200 status. Callers checking response.ok or an HTTP status code will treat that as success โ€” always raise, don't just return, for actual failure states.


Step 7: Calling Your Endpoint from Frappe's Own Client-Side JS

Step 7: Calling Your Endpoint from Frappe's Own Client-Side JS

Section visual: Calling Your Endpoint from Frappe's Own Client-Side JS.

Inside a Frappe form view or custom page, frappe.call() is the standard way to hit a whitelisted method โ€” it automatically attaches the current session's CSRF token and cookies:

frappe.call({
  method: "my_app.api.shipping.calculate_shipping_cost",
  args: {
    order_id: cur_frm.doc.name,
  },
  callback: function (response) {
    console.log(response.message.shipping_cost);
  },
});

Note the .message on the callback response โ€” that's the same wrapper from Step 6, and it's the most common source ๐Ÿ› of "why is my data undefined" bugs when people first switch from fetch to frappe.call().


Step 8: Calling Your Endpoint from an External Client

Step 8: Calling Your Endpoint from an External Client

Section visual: Calling Your Endpoint from an External Client.

Outside of Frappe's own client-side JS, you authenticate with the same API key/secret pair used for the resource API, sent as an Authorization header. Here's a Python example with requests:

import requests

url = "https://yoursite.com/api/method/my_app.api.shipping.calculate_shipping_cost"
headers = {
    "Authorization": "token api_key:api_secret"
}
params = {"order_id": "SO-0042"}

response = requests.get(url, headers=headers, params=params)
print(response.json()["message"])

And the equivalent from JavaScript with fetch:

const response = await fetch(
  "https://yoursite.com/api/method/my_app.api.shipping.calculate_shipping_cost?order_id=SO-0042",
  {
    method: "GET",
    headers: {
      Authorization: "token api_key:api_secret",
    },
  },
);

const data = await response.json();
console.log(data.message.shipping_cost);

Both external calls hit the exact same /api/method/... URL โ€” a whitelisted function doesn't distinguish between "called from inside Frappe's UI" and "called from a curl command," which loops back to why the permission checks ๐Ÿ” from Step 2 matter so much.


Step 9: A Full Example โ€” Approve Invoice and Notify

Complete Frappe custom API flow from client request through authentication and permission checks

Here's the kind of multi-DocType action that the resource API genuinely can't express in one call, tied together with everything above:

# my_app/api/v1/invoices.py
import frappe

@frappe.whitelist(methods=["POST"])
def approve_invoice_and_notify():
    invoice_name = frappe.form_dict.get("invoice_name")
    if not invoice_name:
        frappe.throw("invoice_name is required")

    if not frappe.db.exists("Sales Invoice", invoice_name):
        frappe.throw(f"Invoice {invoice_name} not found", frappe.DoesNotExistError)

    invoice = frappe.get_doc("Sales Invoice", invoice_name)

    if not frappe.has_permission("Sales Invoice", "write", invoice):
        frappe.throw("Not permitted to approve this invoice", frappe.PermissionError)

    if invoice.status == "Approved":
        frappe.throw("Invoice is already approved")

    invoice.status = "Approved"
    invoice.save()

    frappe.get_doc({
        "doctype": "Notification Log",
        "subject": f"Invoice {invoice.name} approved",
        "for_user": invoice.owner,
        "type": "Alert"
    }).insert(ignore_permissions=True)

    frappe.sendmail(
        recipients=[invoice.contact_email],
        subject=f"Your invoice {invoice.name} has been approved",
        message="Thanks for your business โ€” your invoice is approved and on file."
    )

    frappe.local.response.http_status_code = 200
    return {"invoice_name": invoice.name, "status": invoice.status}

One call updates the invoice, logs a notification, and sends an email โœ‰๏ธ โ€” all in one server-side transaction, which is exactly the case where a plain resource API PUT request falls short.


Common Gotchas & Troubleshooting ๐Ÿ› ๏ธ

Common Gotchas & Troubleshooting

Section visual: Common Gotchas & Troubleshooting.

  • ๐Ÿšซ 404 on your endpoint URL โ€” double check the URL matches the exact Python import path (app.module.submodule.function_name), including any nested folders under api/.
  • ๐Ÿ”‘ CSRF token errors from external clients โ€” external requests using API key/secret auth don't need a CSRF token; that requirement only applies to session/cookie-based calls from a browser.
  • ๐Ÿ“ญ frappe.form_dict missing fields on JSON POST โ€” confirm the client is sending Content-Type: application/json; without it, Frappe may not parse the body as JSON correctly.
  • ๐Ÿข Function runs but changes aren't visible immediately elsewhere โ€” remember to call frappe.db.commit() only when truly necessary (Frappe commits automatically at the end of a request); manual mid-function commits inside a whitelisted method can cause partial-state bugs if a later line throws.
  • ๐Ÿ”“ Endpoint returns data to unauthorized users โ€” a whitelisted function never automatically enforces DocType permissions; always add explicit frappe.has_permission() checks for anything touching real records.
  • ๐Ÿค– allow_guest endpoint getting hammered by bots โ€” since there's no session to rate-limit by user, consider pairing it with Frappe's rate-limiting decorators or a reverse-proxy-level rate limit.

Recommended App Structure ๐Ÿ“

Recommended App Structure

Section visual: Recommended App Structure.

Keep custom endpoints out of your DocType controller files ๐Ÿงน โ€” group them under a dedicated api/ package, and version the module path yourself since Frappe won't do it for you:

my_app/
โ”œโ”€โ”€ my_app/
โ”‚   โ”œโ”€โ”€ api/
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ v1/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ invoices.py       # approve_invoice_and_notify, etc.
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ shipping.py       # calculate_shipping_cost
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ webhooks.py       # allow_guest endpoints
โ”‚   โ”‚   โ””โ”€โ”€ v2/
โ”‚   โ”‚       โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚       โ””โ”€โ”€ invoices.py       # breaking-change rewrite, new URL path
โ”‚   โ”œโ”€โ”€ doctype/
โ”‚   โ””โ”€โ”€ ...

A function moved from api.v1.invoices.approve_invoice_and_notify to api.v2.invoices.approve_invoice_and_notify gets an entirely new URL automatically, because the URL is the import path โ€” that's your versioning strategy ๐Ÿท๏ธ, no extra routing config required.


Final Thoughts ๐Ÿ’ก

Final Thoughts

Section visual: Final Thoughts.

@frappe.whitelist() is what turns Frappe from "a framework with a generic REST API" into "a framework you can build any REST API on top of." ๐Ÿ—๏ธ The tradeoff for that power is that nothing is validated or protected for you automatically โ€” every input check, permission check, and error shape is your responsibility. Get comfortable with frappe.form_dict, frappe.throw, and explicit permission checks, and custom endpoints stop feeling risky and start feeling like just another Python function. โœจ


References ๐Ÿ”—

Related posts