frappe-api

Frappe REST API Development

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "frappe-api" with this command: npx skills add sergio-bershadsky/ai/sergio-bershadsky-ai-frappe-api

Frappe REST API Development

Create secure, well-documented REST API endpoints for Frappe Framework v15 following best practices for authentication, permission checking, and input validation.

When to Use

  • Building custom REST API endpoints

  • Exposing service layer methods via HTTP

  • Creating public/private API routes

  • Implementing webhook handlers

  • Building integrations with external systems

Arguments

/frappe-api <endpoint_name> [--doctype <doctype>] [--public]

Examples:

/frappe-api get_dashboard_stats /frappe-api create_order --doctype "Sales Order" /frappe-api webhook_handler --public

Procedure

Step 1: Gather API Requirements

Ask the user for:

  • Endpoint Name (snake_case, e.g., get_dashboard_stats )

  • HTTP Methods supported (GET, POST, PUT, DELETE)

  • Authentication Type:

  • Token (API Key + Secret)

  • Session (Cookie-based)

  • OAuth 2.0

  • Public (no auth required - use sparingly)

  • Parameters - Input parameters with types

  • Related DocType (if applicable)

  • Allowed Roles (who can access this endpoint)

Step 2: Design API Contract

Create the API specification:

Endpoint: /api/method/<app>.<module>.api.<endpoint_name> Methods: GET, POST Auth: Token | Session Rate Limit: 100 req/min (if applicable)

Parameters:

  • name: param1 type: string required: true description: Description of param1
  • name: param2 type: integer required: false default: 10

Response: 200: description: Success schema: message: object 400: description: Validation Error 403: description: Permission Denied

Step 3: Generate API Module Structure

Create <app>/<module>/api/<endpoint_name>.py :

""" <Endpoint Name> API

<Brief description of what this API does>

Endpoints: GET/POST /api/method/<app>.<module>.api.<endpoint_name>.<method_name>

Authentication: Token: Authorization: token api_key:api_secret Session: Cookie-based after login

Example: curl -X POST "https://site.com/api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint_name>.create"
-H "Authorization: token api_key:api_secret"
-H "Content-Type: application/json"
-d '{"title": "Test"}' """

import frappe from frappe import _ from frappe.utils import cint, cstr, flt from typing import Optional, Any from <app>.<module>.services.<service>_service import <Service>Service

──────────────────────────────────────────────────────────────────────────────

API Endpoints

v15 TYPE ANNOTATION VALIDATION:

Frappe v15 automatically validates function parameter types based on

Python type hints. For example, if you declare limit: int, passing

a non-integer will raise a validation error automatically.

TRANSACTION HANDLING:

Frappe automatically commits on successful POST/PUT requests and

rolls back on exceptions. Manual frappe.db.commit() is rarely needed.

──────────────────────────────────────────────────────────────────────────────

@frappe.whitelist() def get(name: str) -> dict: """ Get single document by name.

Args:
    name: Document name/ID

Returns:
    Document data

Raises:
    frappe.DoesNotExistError: Document not found
    frappe.PermissionError: No read permission

Example:
    GET /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.get?name=DOC-00001
"""
_check_permission("&#x3C;DocType>", "read")

service = &#x3C;Service>Service()
return {
    "success": True,
    "data": service.get(name)
}

@frappe.whitelist() def get_list( status: Optional[str] = None, limit: int = 20, offset: int = 0 ) -> dict: """ Get list of documents with optional filtering.

Args:
    status: Filter by status
    limit: Maximum records to return (default: 20, max: 100)
    offset: Skip N records for pagination

Returns:
    List of documents with pagination info

Example:
    GET /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.get_list?status=Draft&#x26;limit=10
"""
_check_permission("&#x3C;DocType>", "read")

# Validate and sanitize inputs
limit = min(cint(limit) or 20, 100)  # Cap at 100
offset = max(cint(offset), 0)

service = &#x3C;Service>Service()
filters = {}
if status:
    filters["status"] = status

data = service.repo.get_list(
    filters=filters,
    fields=["name", "title", "status", "date", "modified"],
    limit=limit,
    offset=offset
)
total = service.repo.get_count(filters)

return {
    "success": True,
    "data": data,
    "pagination": {
        "total": total,
        "limit": limit,
        "offset": offset,
        "has_more": (offset + limit) &#x3C; total
    }
}

@frappe.whitelist(methods=["POST"]) def create( title: str, date: Optional[str] = None, description: Optional[str] = None ) -> dict: """ Create new document.

Args:
    title: Document title (required)
    date: Date in YYYY-MM-DD format
    description: Optional description

Returns:
    Created document data

Raises:
    frappe.ValidationError: Invalid input data
    frappe.PermissionError: No create permission

Example:
    POST /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.create
    Body: {"title": "New Document", "date": "2024-01-15"}
"""
_check_permission("&#x3C;DocType>", "create")

# Validate required fields
if not title or not cstr(title).strip():
    frappe.throw(_("Title is required"), frappe.ValidationError)

service = &#x3C;Service>Service()
result = service.create({
    "title": cstr(title).strip(),
    "date": date or frappe.utils.today(),
    "description": description
})

frappe.db.commit()

return {
    "success": True,
    "message": _("Document created successfully"),
    "data": result
}

@frappe.whitelist(methods=["PUT", "POST"]) def update( name: str, title: Optional[str] = None, status: Optional[str] = None, description: Optional[str] = None ) -> dict: """ Update existing document.

Args:
    name: Document name (required)
    title: New title
    status: New status
    description: New description

Returns:
    Updated document data

Example:
    PUT /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.update
    Body: {"name": "DOC-00001", "title": "Updated Title"}
"""
_check_permission("&#x3C;DocType>", "write")

if not name:
    frappe.throw(_("Document name is required"), frappe.ValidationError)

# Build update data from provided fields
update_data = {}
if title is not None:
    update_data["title"] = cstr(title).strip()
if status is not None:
    update_data["status"] = status
if description is not None:
    update_data["description"] = description

if not update_data:
    frappe.throw(_("No fields to update"), frappe.ValidationError)

service = &#x3C;Service>Service()
result = service.update(name, update_data)

frappe.db.commit()

return {
    "success": True,
    "message": _("Document updated successfully"),
    "data": result
}

@frappe.whitelist(methods=["DELETE", "POST"]) def delete(name: str) -> dict: """ Delete document.

Args:
    name: Document name to delete

Returns:
    Success confirmation

Example:
    DELETE /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.delete?name=DOC-00001
"""
_check_permission("&#x3C;DocType>", "delete")

if not name:
    frappe.throw(_("Document name is required"), frappe.ValidationError)

service = &#x3C;Service>Service()
service.repo.delete(name)

frappe.db.commit()

return {
    "success": True,
    "message": _("Document deleted successfully")
}

@frappe.whitelist(methods=["POST"]) def submit(name: str) -> dict: """ Submit document for processing.

Args:
    name: Document name to submit

Returns:
    Submitted document data

Example:
    POST /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.submit
    Body: {"name": "DOC-00001"}
"""
_check_permission("&#x3C;DocType>", "submit")

service = &#x3C;Service>Service()
result = service.submit(name)

frappe.db.commit()

return {
    "success": True,
    "message": _("Document submitted successfully"),
    "data": result
}

@frappe.whitelist(methods=["POST"]) def cancel(name: str, reason: Optional[str] = None) -> dict: """ Cancel submitted document.

Args:
    name: Document name to cancel
    reason: Cancellation reason

Returns:
    Cancelled document data

Example:
    POST /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.cancel
    Body: {"name": "DOC-00001", "reason": "Customer request"}
"""
_check_permission("&#x3C;DocType>", "cancel")

service = &#x3C;Service>Service()
result = service.cancel(name, reason)

frappe.db.commit()

return {
    "success": True,
    "message": _("Document cancelled successfully"),
    "data": result
}

──────────────────────────────────────────────────────────────────────────────

Bulk Operations

──────────────────────────────────────────────────────────────────────────────

@frappe.whitelist(methods=["POST"]) def bulk_update_status(names: list[str], status: str) -> dict: """ Bulk update status for multiple documents.

Args:
    names: List of document names
    status: New status to set

Returns:
    Number of documents updated

Example:
    POST /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.bulk_update_status
    Body: {"names": ["DOC-001", "DOC-002"], "status": "Completed"}
"""
_check_permission("&#x3C;DocType>", "write")

if not names or not isinstance(names, list):
    frappe.throw(_("Names must be a non-empty list"), frappe.ValidationError)

valid_statuses = ["Draft", "Pending", "Completed", "Cancelled"]
if status not in valid_statuses:
    frappe.throw(
        _("Invalid status. Must be one of: {0}").format(", ".join(valid_statuses)),
        frappe.ValidationError
    )

service = &#x3C;Service>Service()
count = service.repo.bulk_update_status(names, status)

frappe.db.commit()

return {
    "success": True,
    "message": _("{0} documents updated").format(count),
    "data": {"updated_count": count}
}

──────────────────────────────────────────────────────────────────────────────

Helper Functions

──────────────────────────────────────────────────────────────────────────────

def _check_permission(doctype: str, ptype: str, doc: Any = None) -> None: """ Check if current user has permission.

Args:
    doctype: DocType to check
    ptype: Permission type (read, write, create, delete, submit, cancel)
    doc: Optional specific document

Raises:
    frappe.PermissionError: If permission denied
"""
if not frappe.has_permission(doctype, ptype, doc=doc):
    frappe.throw(
        _("You don't have permission to {0} {1}").format(ptype, doctype),
        frappe.PermissionError
    )

def _validate_request_data(data: dict, required: list[str]) -> None: """ Validate request data has required fields.

Args:
    data: Request data dict
    required: List of required field names

Raises:
    frappe.ValidationError: If required fields missing
"""
missing = [f for f in required if not data.get(f)]
if missing:
    frappe.throw(
        _("Missing required fields: {0}").format(", ".join(missing)),
        frappe.ValidationError
    )

──────────────────────────────────────────────────────────────────────────────

Public Endpoints (No Auth Required - Use with Caution!)

──────────────────────────────────────────────────────────────────────────────

@frappe.whitelist(allow_guest=True) def ping() -> dict: """ Health check endpoint (public).

Returns:
    Server status

Example:
    GET /api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.ping
"""
return {
    "success": True,
    "message": "pong",
    "timestamp": frappe.utils.now()
}

Step 4: Generate API v2 Endpoints (Optional)

For v2 REST API pattern, create custom routes in hooks.py :

hooks.py

Override standard DocType REST endpoints

override_doctype_dashboards = { "<DocType>": "<app>.<module>.api.<endpoint>.get_doctype_dashboard" }

Custom website routes for cleaner URLs

website_route_rules = [ {"from_route": "/api/v2/<app>/<endpoint>", "to_route": "<app>.<module>.api.<endpoint>.handle_v2"}, ]

Step 5: Generate API Tests

Create <app>/<module>/api/test_<endpoint_name>.py :

""" Tests for <Endpoint Name> API """

import frappe from frappe.tests import IntegrationTestCase

class TestAPI<EndpointName>(IntegrationTestCase): """API integration tests."""

@classmethod
def setUpClass(cls):
    super().setUpClass()
    cls.test_user = cls._create_test_user()
    cls.test_doc = cls._create_test_document()

@classmethod
def _create_test_user(cls):
    """Create test user with API access."""
    if frappe.db.exists("User", "test_api@example.com"):
        return frappe.get_doc("User", "test_api@example.com")

    user = frappe.get_doc({
        "doctype": "User",
        "email": "test_api@example.com",
        "first_name": "Test",
        "last_name": "API User",
        "send_welcome_email": 0
    }).insert(ignore_permissions=True)
    user.add_roles("System Manager")
    return user

@classmethod
def _create_test_document(cls):
    """Create test document."""
    return frappe.get_doc({
        "doctype": "&#x3C;DocType>",
        "title": "API Test Document",
        "date": frappe.utils.today()
    }).insert()

def test_get_returns_document(self):
    """Test GET endpoint returns document."""
    from &#x3C;app>.&#x3C;module>.api.&#x3C;endpoint_name> import get

    frappe.set_user(self.test_user.name)
    result = get(self.test_doc.name)

    self.assertTrue(result.get("success"))
    self.assertIsNotNone(result.get("data"))

def test_get_list_with_pagination(self):
    """Test GET list with pagination."""
    from &#x3C;app>.&#x3C;module>.api.&#x3C;endpoint_name> import get_list

    frappe.set_user(self.test_user.name)
    result = get_list(limit=5, offset=0)

    self.assertTrue(result.get("success"))
    self.assertIn("pagination", result)
    self.assertLessEqual(len(result["data"]), 5)

def test_create_validates_input(self):
    """Test CREATE validates required fields."""
    from &#x3C;app>.&#x3C;module>.api.&#x3C;endpoint_name> import create

    frappe.set_user(self.test_user.name)

    with self.assertRaises(frappe.ValidationError):
        create(title="")  # Empty title should fail

def test_create_returns_document(self):
    """Test CREATE returns new document."""
    from &#x3C;app>.&#x3C;module>.api.&#x3C;endpoint_name> import create

    frappe.set_user(self.test_user.name)
    result = create(title="New Test Doc", date=frappe.utils.today())

    self.assertTrue(result.get("success"))
    self.assertIsNotNone(result["data"].get("name"))

def test_unauthorized_access_denied(self):
    """Test unauthenticated access is denied."""
    from &#x3C;app>.&#x3C;module>.api.&#x3C;endpoint_name> import get

    frappe.set_user("Guest")

    with self.assertRaises(frappe.PermissionError):
        get(self.test_doc.name)

def test_ping_public_access(self):
    """Test ping endpoint is publicly accessible."""
    from &#x3C;app>.&#x3C;module>.api.&#x3C;endpoint_name> import ping

    frappe.set_user("Guest")
    result = ping()

    self.assertTrue(result.get("success"))
    self.assertEqual(result.get("message"), "pong")

Step 6: Show API Documentation Preview

API Endpoint Preview

Module: <app>.<module>.api.<endpoint_name> Base URL: /api/method/<app>.<module>.api.<endpoint_name>

Endpoints:

MethodEndpointAuthDescription
GET.getToken/SessionGet single document
GET.get_listToken/SessionList with pagination
POST.createToken/SessionCreate document
PUT.updateToken/SessionUpdate document
DELETE.deleteToken/SessionDelete document
POST.submitToken/SessionSubmit for processing
POST.cancelToken/SessionCancel document
POST.bulk_update_statusToken/SessionBulk status update
GET.pingPublicHealth check

Authentication:

# Token auth (recommended for integrations)
curl -H "Authorization: token api_key:api_secret" \
     https://site.com/api/method/&#x3C;endpoint>

# Session auth (for browser clients)
# First login, then use session cookie

Files to Create:

📁 /api/
├── 📄 init.py
├── 📄 &#x3C;endpoint_name>.py
└── 📄 test_&#x3C;endpoint_name>.py

Create this API module?

### Step 7: Execute and Verify

After approval, create files and run tests:

```bash
bench --site &#x3C;site> run-tests --module "&#x3C;app>.&#x3C;module>.api.test_&#x3C;endpoint_name>"

Output Format

## API Created

**Module:** &#x3C;app>.&#x3C;module>.api.&#x3C;endpoint_name>
**Endpoints:** 9

### Files Created:
- ✅ &#x3C;endpoint_name>.py (API endpoints)
- ✅ test_&#x3C;endpoint_name>.py (API tests)
- ✅ Updated __init__.py

### cURL Examples:

```bash
# Get document
curl -X GET "https://site.com/api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.get?name=DOC-001" \
     -H "Authorization: token api_key:api_secret"

# Create document
curl -X POST "https://site.com/api/method/&#x3C;app>.&#x3C;module>.api.&#x3C;endpoint>.create" \
     -H "Authorization: token api_key:api_secret" \
     -H "Content-Type: application/json" \
     -d '{"title": "New Document"}'

Next Steps:

- Create API keys: Setup > User > API Access

- Test endpoints with curl or Postman

- Run API tests: bench --site &#x3C;site> run-tests --module &#x3C;test_module>

## Rules

1. **Always Check Permissions** — Every endpoint must call `_check_permission()` first
2. **Validate All Input** — Never trust user input, validate and sanitize everything
3. **Type Annotations** — Use Python type hints for v15 auto-validation
4. **Transaction Handling** — Frappe auto-commits on successful requests; manual `frappe.db.commit()` rarely needed except in background jobs
5. **Public Endpoints** — Use `allow_guest=True` sparingly, only for truly public data
6. **Error Handling** — Use `frappe.throw()` with appropriate exception types
7. **Documentation** — Every endpoint must have docstring with Args/Returns/Example
8. **ALWAYS Confirm** — Never create files without explicit user approval

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

django-dev-ninja

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

django-dev-unfold

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

django-dev

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

django-dev-test

No summary provided by upstream source.

Repository SourceNeeds Review