
Frappe Core Api
- 26 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with backend & apis tasks.
About
frappe-core-api is a Claude Code skill for backend & apis. It helps solo builders move faster with AI-assisted development.
- frappe-core-api
- Backend & APIs
- AI-coding skill
Frappe Core Api by the numbers
- 26 all-time installs (skills.sh)
- Ranked #3,410 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-core-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with backend & apis tasks.
Files
Frappe API Patterns
Deterministic patterns for REST, RPC, and webhook integrations with Frappe.
---
Decision Tree
What do you need?
├── CRUD on documents (external client)
│ ├── v14: REST /api/resource/{doctype}
│ └── v15+: REST /api/v2/document/{doctype} (new) or /api/resource/ (still works)
│
├── Call custom server logic (external client)
│ └── RPC: POST /api/method/{dotted.path.to.function}
│
├── Notify external systems on document events
│ └── Webhooks (configured in UI or via DocType)
│
├── Client-side calls (JavaScript in Frappe desk)
│ ├── frappe.xcall() — async/await (RECOMMENDED)
│ └── frappe.call() — callback/promise pattern
│
└── Authentication method?
├── Server-to-server integration → Token Auth (RECOMMENDED)
├── Third-party app / mobile → OAuth 2.0
├── Browser session (short-lived) → Session/Cookie Auth
└── Quick scripting / testing → Token Auth---
Authentication Methods
Token Auth (RECOMMENDED for integrations)
headers = {
'Authorization': 'token api_key:api_secret',
'Accept': 'application/json',
'Content-Type': 'application/json'
}Generate keys: User > Settings > API Access > Generate Keys. ALWAYS store API secret immediately — it is shown only once.
Basic Auth (alternative token format)
import base64
credentials = base64.b64encode(b'api_key:api_secret').decode()
headers = {'Authorization': f'Basic {credentials}'}OAuth 2.0 (third-party apps)
# Step 1: Authorization redirect
GET /api/method/frappe.integrations.oauth2.authorize
?client_id={id}&response_type=code&scope=openid all
&redirect_uri={uri}&state={random}
# Step 2: Exchange code for token
POST /api/method/frappe.integrations.oauth2.get_token
grant_type=authorization_code&code={code}
&redirect_uri={uri}&client_id={id}
# Step 3: Use bearer token
Authorization: Bearer {access_token}
# Refresh token
POST /api/method/frappe.integrations.oauth2.get_token
grant_type=refresh_token&refresh_token={token}&client_id={id}Session/Cookie Auth
session = requests.Session()
session.post(url + '/api/method/login', json={'usr': 'email', 'pwd': 'pass'})
# Subsequent requests use session cookie automaticallySession cookies expire after ~3 days. NEVER use for long-running integrations.
---
REST API: Resource CRUD
Endpoints
| Operation | Method | v14 Endpoint | v15+ v2 Endpoint |
|---|---|---|---|
| List | GET | /api/resource/{doctype} | /api/v2/document/{doctype} |
| Create | POST | /api/resource/{doctype} | /api/v2/document/{doctype} |
| Read | GET | /api/resource/{doctype}/{name} | /api/v2/document/{doctype}/{name} |
| Update | PUT | /api/resource/{doctype}/{name} | PATCH /api/v2/document/{doctype}/{name} |
| Delete | DELETE | /api/resource/{doctype}/{name} | DELETE /api/v2/document/{doctype}/{name} |
| Copy | — | — | GET /api/v2/document/{doctype}/{name}/copy [v15+] |
| Doc Method | — | — | POST /api/v2/document/{doctype}/{name}/method/{method} [v15+] |
ALWAYS include Accept: application/json header — without it, Frappe MAY return HTML.
List Parameters
| Parameter | Type | Description | Default |
|---|---|---|---|
fields | JSON array | Fields to return | ["name"] |
filters | JSON array | AND conditions | none |
or_filters | JSON array | OR conditions | none |
order_by | string | Sort expression | modified desc |
limit_start | int | Pagination offset | 0 |
limit_page_length | int | Page size | 20 |
limit | int | Alias for limit_page_length [v15+] | — |
debug | bool | Show SQL in response | false |
Filter Operators
filters = [["status", "=", "Open"]]
filters = [["amount", ">", 1000]]
filters = [["status", "in", ["Open", "Pending"]]]
filters = [["date", "between", ["2024-01-01", "2024-12-31"]]]
filters = [["reference", "is", "set"]] # NOT NULL
filters = [["reference", "is", "not set"]] # IS NULL
filters = [["name", "like", "%INV%"]]
filters = [["status", "not in", ["Cancelled"]]]Full operator list: =, !=, >, <, >=, <=, like, not like, in, not in, is, between.
Pagination Pattern
import json, requests
def get_all_records(doctype, headers, base_url, page_size=100):
all_data, offset = [], 0
while True:
params = {
'fields': json.dumps(["name", "modified"]),
'limit_start': offset,
'limit_page_length': page_size
}
resp = requests.get(f'{base_url}/api/resource/{doctype}',
params=params, headers=headers)
data = resp.json().get('data', [])
if not data:
break
all_data.extend(data)
if len(data) < page_size:
break
offset += page_size
return all_dataCreate with Child Table
requests.post(f'{base_url}/api/resource/Sales Order', json={
"customer": "CUST-001",
"items": [
{"item_code": "ITEM-001", "qty": 5, "rate": 100},
{"item_code": "ITEM-002", "qty": 2, "rate": 250}
]
}, headers=headers)Update (Partial)
# Only specified fields are changed
requests.put(f'{base_url}/api/resource/Customer/CUST-001',
json={"customer_group": "Premium"}, headers=headers)File Upload
requests.post(f'{base_url}/api/method/upload_file',
files={'file': ('doc.pdf', open('doc.pdf', 'rb'), 'application/pdf')},
data={'doctype': 'Customer', 'docname': 'CUST-001', 'is_private': 1},
headers={'Authorization': 'token api_key:api_secret'})
# NOTE: Do NOT set Content-Type header — requests sets multipart boundary automatically---
RPC API: Custom Methods
Server-Side Endpoint
@frappe.whitelist()
def get_balance(customer):
"""GET /api/method/myapp.api.get_balance?customer=CUST-001"""
return frappe.db.get_value("Customer", customer, "outstanding_amount")
@frappe.whitelist(methods=["POST"])
def create_payment(customer, amount):
"""POST /api/method/myapp.api.create_payment"""
if not frappe.has_permission("Payment Entry", "create"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
pe = frappe.new_doc("Payment Entry")
pe.party_type = "Customer"
pe.party = customer
pe.paid_amount = float(amount)
pe.insert()
return pe.name
@frappe.whitelist(allow_guest=True)
def public_status():
"""No authentication required."""
return {"status": "ok"}Decorator Options
| Option | Effect | Version |
|---|---|---|
allow_guest=True | No authentication needed | All |
methods=["POST"] | Restrict HTTP methods | [v14+] |
xss_safe=True | Skip XSS escaping on response | All |
Response Structure
// RPC success
{"message": "return_value"}
// REST success
{"data": {...}}
// Error
{"exc_type": "ValidationError", "_server_messages": "[{\"message\": \"...\"}]"}Client-Side Calls (JavaScript)
// RECOMMENDED: async/await with frappe.xcall
const result = await frappe.xcall('myapp.api.get_balance', {
customer: 'CUST-001'
});
// Alternative: frappe.call with promise
frappe.call({
method: 'myapp.api.get_balance',
args: {customer: 'CUST-001'},
freeze: true,
freeze_message: __('Loading...')
}).then(r => console.log(r.message));
// Document method (frm.call)
frm.call('get_linked_doc', {throw_if_missing: true})
.then(r => console.log(r.message));Standard frappe.client Methods
| Method | Endpoint | Purpose |
|---|---|---|
frappe.client.get_value | POST | Get single field value |
frappe.client.get_list | POST | List with filters |
frappe.client.get | POST | Get full document |
frappe.client.insert | POST | Create document |
frappe.client.save | POST | Update document |
frappe.client.delete | POST | Delete document |
frappe.client.submit | POST | Submit document |
frappe.client.cancel | POST | Cancel document |
frappe.client.get_count | POST | Count documents |
---
Webhooks
Configure via Webhook DocType in the UI. Events:
| Event | Trigger |
|---|---|
after_insert | New document created |
on_update | Every save |
on_submit | After submit (docstatus=1) |
on_cancel | After cancel (docstatus=2) |
on_trash | Before delete |
on_update_after_submit | After amendment |
on_change | On every change |
Security: ALWAYS set a Webhook Secret. Frappe adds X-Frappe-Webhook-Signature header with base64-encoded HMAC-SHA256 of payload. Verify on receiving end.
Conditions: Use Jinja2 — {{ doc.grand_total > 10000 }}.
See references/webhooks-reference.md for complete handler examples.
---
HTTP Status Codes
| Code | Meaning | Common Cause |
|---|---|---|
200 | Success | — |
400 | Bad request | Validation error |
401 | Unauthorized | Missing or invalid auth |
403 | Forbidden | No permission for operation |
404 | Not found | Document does not exist |
417 | Expectation failed | Server exception (frappe.throw) |
429 | Rate limited | Too many requests |
500 | Server error | Unhandled exception |
---
Critical Rules
1. ALWAYS include Accept: application/json header in API requests 2. ALWAYS add permission checks in @frappe.whitelist() methods 3. ALWAYS validate and sanitize input in whitelisted methods 4. ALWAYS use parameterized queries — NEVER string-interpolate SQL 5. ALWAYS use timeout=30 on external requests calls 6. ALWAYS store credentials in frappe.conf or env vars — NEVER hardcode 7. ALWAYS verify webhook signatures with HMAC-SHA256 8. ALWAYS paginate list responses — NEVER return unbounded result sets 9. NEVER use allow_guest=True on state-changing endpoints 10. NEVER log credentials or sensitive data 11. NEVER use Administrator API keys for integrations — create dedicated API users
---
Anti-Patterns
| Do NOT | Do Instead |
|---|---|
| No permission check in whitelist | frappe.has_permission() before action |
frappe.db.sql(f"...{user_input}") | Parameterized %s queries |
allow_guest=True + state change | Require authentication |
| Return all records without limit | Paginate with limit_page_length |
| Hardcode API credentials | frappe.conf.get("api_key") |
| Synchronous heavy processing | frappe.enqueue() for long tasks |
| No timeout on external calls | requests.get(url, timeout=30) |
| Inconsistent response format | ALWAYS return {"status": "...", "data": ...} |
---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
/api/resource/ (v1) | Yes | Yes | Yes |
/api/v2/document/ (v2) | No | Yes | Yes |
/api/v2/doctype/{dt}/meta | No | Yes | Yes |
/api/v2/doctype/{dt}/count | No | Yes | Yes |
limit alias parameter | No | Yes | Yes |
| PKCE for OAuth2 | Limited | Yes | Yes |
| Server Script rate limiting | No | Yes | Yes |
| Doc method via v2 URL | No | Yes | Yes |
---
Reference Files
| File | Contents |
|---|---|
| authentication-methods.md | Token, Session, OAuth2 with code examples |
| rest-api-reference.md | Complete REST CRUD with filters and pagination |
| rpc-api-reference.md | Whitelisted methods, frappe.call, frappe.xcall |
| webhooks-reference.md | Webhook config, security, handler examples |
| anti-patterns.md | Common mistakes with fixes |
| examples.md | Python/JS/cURL client implementations |
Related Skills
frappe-core-permissions— Permission system for API endpointsfrappe-core-database— Database queries behind API methodsfrappe-syntax-hooks— Hook configuration for webhooksfrappe-syntax-controllers— Controller methods called via API
---
Verified against Frappe docs 2026-03-20 | Frappe v14/v15/v16
API Anti-Patterns
Common API mistakes and their correct alternatives.
---
1. No Permission Check in Whitelisted Method
# WRONG — any authenticated user can delete
@frappe.whitelist()
def delete_customer(name):
frappe.get_doc("Customer", name).delete()
# CORRECT — check permission first
@frappe.whitelist()
def delete_customer(name):
if not frappe.has_permission("Customer", "delete"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
doc = frappe.get_doc("Customer", name)
doc.delete()
return {"status": "success"}@frappe.whitelist() only checks authentication — it does NOT verify DocType permissions.
---
2. SQL Injection in Queries
# WRONG — vulnerable to injection
@frappe.whitelist()
def search(term):
return frappe.db.sql(f"SELECT * FROM tabCustomer WHERE name LIKE '%{term}%'")
# CORRECT — parameterized query
@frappe.whitelist()
def search(term):
return frappe.db.sql(
"SELECT name, customer_name FROM tabCustomer WHERE name LIKE %s",
(f"%{term}%",), as_dict=True)---
3. Hardcoded Credentials
# WRONG
API_KEY = "abc123"
API_SECRET = "secret456"
# CORRECT — from config or environment
api_key = frappe.conf.get("external_api_key")
api_secret = frappe.conf.get("external_api_secret")
if not api_key or not api_secret:
frappe.throw(_("API credentials not configured"))---
4. No Input Validation
# WRONG — trusts all input
@frappe.whitelist()
def create_order(customer, amount):
order = frappe.new_doc("Sales Order")
order.customer = customer
order.grand_total = amount
order.insert()
# CORRECT — validate everything
@frappe.whitelist()
def create_order(customer, amount):
if not customer:
frappe.throw(_("Customer is required"))
if not frappe.db.exists("Customer", customer):
frappe.throw(_("Customer {0} does not exist").format(customer))
try:
amount = float(amount)
except (TypeError, ValueError):
frappe.throw(_("Amount must be a number"))
if amount <= 0:
frappe.throw(_("Amount must be positive"))
order = frappe.new_doc("Sales Order")
order.customer = customer
order.grand_total = amount
order.insert()
return {"name": order.name}---
5. No Error Handling
# WRONG — unhandled exceptions expose stack traces
@frappe.whitelist()
def process(docname):
doc = frappe.get_doc("Customer", docname)
doc.delete()
return "done"
# CORRECT — structured error handling
@frappe.whitelist()
def process(docname):
try:
doc = frappe.get_doc("Customer", docname)
doc.check_permission("delete")
doc.delete()
return {"status": "success"}
except frappe.DoesNotExistError:
frappe.throw(_("Customer not found"))
except frappe.PermissionError:
raise
except Exception:
frappe.log_error(title="API Error")
frappe.throw(_("Operation failed"))---
6. allow_guest on State-Changing Endpoints
# WRONG — anyone can modify data
@frappe.whitelist(allow_guest=True)
def update_status(name, status):
frappe.db.set_value("Order", name, "status", status)
# CORRECT — require authentication
@frappe.whitelist()
def update_status(name, status):
doc = frappe.get_doc("Order", name)
doc.check_permission("write")
doc.status = status
doc.save()NEVER use allow_guest=True on endpoints that modify data.
---
7. No Pagination for Large Datasets
# WRONG — could return millions of records
@frappe.whitelist()
def get_all_invoices():
return frappe.get_all("Sales Invoice")
# CORRECT — enforced pagination
@frappe.whitelist()
def get_invoices(page=0, page_size=20):
page_size = min(int(page_size), 100) # Enforce max
return frappe.get_all("Sales Invoice",
fields=["name", "customer", "grand_total"],
limit_start=int(page) * page_size,
limit_page_length=page_size,
order_by="modified desc")---
8. Synchronous Heavy Operations
# WRONG — blocks worker for minutes
@frappe.whitelist()
def generate_report():
return expensive_computation() # Timeout risk
# CORRECT — queue background job
@frappe.whitelist()
def generate_report():
frappe.enqueue("myapp.tasks.expensive_computation",
queue="long", timeout=1800)
return {"status": "queued", "message": "Processing started"}---
9. No Timeout on External Calls
# WRONG — can hang indefinitely
response = requests.get(external_url)
# CORRECT — always set timeout
response = requests.get(external_url, timeout=30)---
10. Sensitive Data in Logs
# WRONG — credentials in logs
frappe.logger().info(f"Login: {username}:{password}")
# CORRECT — only non-sensitive info
frappe.logger().info(f"Login attempt for: {username}")---
11. Inconsistent Response Format
# WRONG — sometimes string, sometimes dict
@frappe.whitelist()
def get_data(name):
if not name:
return "Error: name required" # String
return frappe.get_doc("Customer", name).as_dict() # Dict
# CORRECT — consistent format with frappe.throw for errors
@frappe.whitelist()
def get_data(name):
if not name:
frappe.throw(_("Name is required"))
return {"status": "success", "data": frappe.get_doc("Customer", name).as_dict()}---
12. Using Administrator API Keys
# WRONG — overprivileged API access
# Using Administrator's API key for integration
# CORRECT — dedicated API user
# 1. Create "API User" role with ONLY required permissions
# 2. Create dedicated user with that role
# 3. Generate API keys for that user
# 4. Use those restricted keys for integration---
Pre-Deploy Checklist
- [ ] Permission check present in every whitelisted method?
- [ ] All SQL queries use parameterized
%splaceholders? - [ ] Input validation complete for all parameters?
- [ ] Error handling with try/except implemented?
- [ ] Sensitive data NOT logged?
- [ ] Response format consistent across all endpoints?
- [ ] Rate limiting applied where needed?
- [ ] Pagination enforced for list endpoints?
- [ ] Credentials loaded from config, NOT hardcoded?
- [ ] Timeout set on all external HTTP calls?
- [ ]
allow_guestNOT used on state-changing endpoints? - [ ] Dedicated API user (NOT Administrator) for integrations?
Authentication Methods Reference
All authentication methods for Frappe API access.
---
1. Token Based Authentication (RECOMMENDED)
Most common method for server-to-server integrations. Available since Frappe v11.
Generate API Keys
Via UI: 1. User list > Open user > Settings tab 2. Expand "API Access" section 3. Click "Generate Keys" 4. Copy API Secret immediately — shown only once
Via CLI:
bench execute frappe.core.doctype.user.user.generate_keys --args ['api_user@example.com']Via RPC:
POST /api/method/frappe.core.doctype.user.user.generate_keys
{"user": "api_user@example.com"}Token Format
Authorization: token <api_key>:<api_secret>Python Example
import requests
import os
API_KEY = os.environ.get('FRAPPE_API_KEY')
API_SECRET = os.environ.get('FRAPPE_API_SECRET')
BASE_URL = 'https://erp.example.com'
headers = {
'Authorization': f'token {API_KEY}:{API_SECRET}',
'Accept': 'application/json',
'Content-Type': 'application/json'
}
response = requests.get(f'{BASE_URL}/api/resource/Customer', headers=headers)JavaScript (Node.js) Example
const API_KEY = process.env.FRAPPE_API_KEY;
const API_SECRET = process.env.FRAPPE_API_SECRET;
const response = await fetch('https://erp.example.com/api/resource/Customer', {
headers: {
'Authorization': `token ${API_KEY}:${API_SECRET}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});cURL Example
curl -X GET "https://erp.example.com/api/resource/Customer" \
-H "Authorization: token api_key:api_secret" \
-H "Accept: application/json"---
2. Basic Authentication (Alternative Token Format)
Same credentials as Token auth but encoded differently.
Authorization: Basic base64(<api_key>:<api_secret>)import base64
credentials = base64.b64encode(f'{API_KEY}:{API_SECRET}'.encode()).decode()
headers = {'Authorization': f'Basic {credentials}'}---
3. OAuth 2.0 (Third-Party Applications)
Step 1: Register OAuth Client
OAuth Client DocType > New:
- App Name, Redirect URIs, Default Redirect URI
- Grant Type: Authorization Code
- Scopes:
openid all(or specific scopes) - Save > Get Client ID and Client Secret
Step 2: Authorization Request
GET /api/method/frappe.integrations.oauth2.authorize
?client_id={client_id}
&response_type=code
&scope=openid all
&redirect_uri={redirect_uri}
&state={random_state}User authenticates and approves. Redirected to: {redirect_uri}?code={auth_code}&state={state}
Step 3: Exchange Code for Token
POST /api/method/frappe.integrations.oauth2.get_token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code={authorization_code}
&redirect_uri={redirect_uri}
&client_id={client_id}Response:
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "openid all"
}Step 4: Use Bearer Token
Authorization: Bearer {access_token}Token Refresh
POST /api/method/frappe.integrations.oauth2.get_token
grant_type=refresh_token&refresh_token={token}&client_id={client_id}Token Revocation
POST /api/method/frappe.integrations.oauth2.revoke_token
token={access_token}Token Introspection
POST /api/method/frappe.integrations.oauth2.introspect_token
token={access_token}&token_type_hint=access_tokenPKCE Support [v15+]
Full PKCE (Proof Key for Code Exchange) support for SPA and mobile apps.
---
4. Session/Cookie Authentication
For browser-based applications.
Login
session = requests.Session()
response = session.post('https://erp.example.com/api/method/login', json={
'usr': 'user@example.com',
'pwd': 'password'
})
# Session cookie set automaticallySubsequent Requests
# Cookie sent automatically by session
data = session.get('https://erp.example.com/api/resource/Customer')Logout
session.post('https://erp.example.com/api/method/logout')JavaScript (Browser)
// Login
await fetch('/api/method/login', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({usr: 'user@example.com', pwd: 'password'})
});
// Subsequent — cookies sent automatically
const data = await fetch('/api/resource/Customer', {credentials: 'include'});WARNING: Session cookies expire after ~3 days. NEVER use for long-running integrations.
NOTE: POST/PUT/DELETE requests via session auth require CSRF token (X-Frappe-CSRF-Token header).
---
Authentication Decision Matrix
| Use Case | Method | Reason |
|---|---|---|
| Server-to-server integration | Token Auth | Simple, no expiry |
| Third-party web app | OAuth 2.0 | Standard, delegated access |
| Mobile app | OAuth 2.0 + PKCE [v15+] | Secure, no client secret |
| SPA (single-page app) | OAuth 2.0 + PKCE [v15+] | No client secret exposure |
| Quick scripting/testing | Token Auth | Simplest setup |
| Browser session (short) | Session/Cookie | Built-in to Frappe desk |
---
Security Best Practices
1. ALWAYS generate separate API keys per integration 2. ALWAYS store credentials in environment variables or site_config.json 3. ALWAYS use HTTPS in production 4. ALWAYS create dedicated API users with minimal required roles 5. ALWAYS rotate API secrets regularly 6. NEVER hardcode credentials in source code 7. NEVER put API secrets in version control 8. NEVER use Administrator credentials for API integrations 9. NEVER put credentials in URL query parameters
Credential Storage Pattern
# In site_config.json
# {"external_api_key": "abc123", "external_api_secret": "secret456"}
api_key = frappe.conf.get("external_api_key")
api_secret = frappe.conf.get("external_api_secret")---
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 Unauthorized | Missing/wrong auth header | Check header format and credentials |
| 403 Forbidden | Valid auth but no permission | Check user roles and User Permissions |
| 403 + CSRF error | Session auth without CSRF | Add X-Frappe-CSRF-Token header |
| Token invalid | Wrong format or expired secret | Regenerate API keys |
API Examples
Complete working examples for common integration scenarios.
---
1. Python API Client
"""Frappe API Client — production-ready implementation."""
import requests
import json
import os
from typing import Optional, Dict, List, Any
class FrappeClient:
def __init__(self, url: str, api_key: str, api_secret: str, timeout: int = 30):
self.url = url.rstrip('/')
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
'Authorization': f'token {api_key}:{api_secret}',
'Accept': 'application/json',
'Content-Type': 'application/json'
})
def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
kwargs.setdefault('timeout', self.timeout)
response = self.session.request(method, f'{self.url}{endpoint}', **kwargs)
if not response.ok:
error = response.json()
raise Exception(error.get('_server_messages') or
error.get('message') or response.text)
return response.json()
# Resource API
def get_list(self, doctype: str, fields: Optional[List[str]] = None,
filters: Optional[List] = None, order_by: Optional[str] = None,
limit_start: int = 0, limit_page_length: int = 20) -> List[Dict]:
params = {'limit_start': limit_start, 'limit_page_length': limit_page_length}
if fields:
params['fields'] = json.dumps(fields)
if filters:
params['filters'] = json.dumps(filters)
if order_by:
params['order_by'] = order_by
return self._request('GET', f'/api/resource/{doctype}', params=params).get('data', [])
def get_doc(self, doctype: str, name: str) -> Dict:
return self._request('GET', f'/api/resource/{doctype}/{name}').get('data', {})
def create_doc(self, doctype: str, data: Dict) -> Dict:
return self._request('POST', f'/api/resource/{doctype}', json=data).get('data', {})
def update_doc(self, doctype: str, name: str, data: Dict) -> Dict:
return self._request('PUT', f'/api/resource/{doctype}/{name}', json=data).get('data', {})
def delete_doc(self, doctype: str, name: str) -> bool:
self._request('DELETE', f'/api/resource/{doctype}/{name}')
return True
# Method API
def call_method(self, method: str, **kwargs) -> Any:
return self._request('POST', f'/api/method/{method}', json=kwargs).get('message')
# Convenience
def get_count(self, doctype: str, filters: Optional[Dict] = None) -> int:
return self.call_method('frappe.client.get_count',
doctype=doctype, filters=filters or {})
def submit_doc(self, doctype: str, name: str) -> Dict:
return self.call_method('frappe.client.submit',
doc={'doctype': doctype, 'name': name})
# Usage
client = FrappeClient(
url='https://erp.example.com',
api_key=os.environ['FRAPPE_API_KEY'],
api_secret=os.environ['FRAPPE_API_SECRET']
)
customers = client.get_list('Customer',
fields=['name', 'customer_name', 'outstanding_amount'],
filters=[['outstanding_amount', '>', 0]],
order_by='outstanding_amount desc',
limit_page_length=10)---
2. JavaScript/Node.js Client
class FrappeClient {
constructor(url, apiKey, apiSecret) {
this.url = url.replace(/\/$/, '');
this.auth = `token ${apiKey}:${apiSecret}`;
}
async _request(method, endpoint, options = {}) {
const response = await fetch(`${this.url}${endpoint}`, {
method,
headers: {
'Authorization': this.auth,
'Accept': 'application/json',
'Content-Type': 'application/json',
...options.headers
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const data = await response.json();
if (!response.ok) {
throw new Error(data._server_messages || data.message || response.statusText);
}
return data;
}
async getList(doctype, options = {}) {
const params = new URLSearchParams();
if (options.fields) params.set('fields', JSON.stringify(options.fields));
if (options.filters) params.set('filters', JSON.stringify(options.filters));
if (options.orderBy) params.set('order_by', options.orderBy);
params.set('limit_start', options.limitStart || 0);
params.set('limit_page_length', options.limitPageLength || 20);
return (await this._request('GET', `/api/resource/${doctype}?${params}`)).data || [];
}
async getDoc(doctype, name) {
return (await this._request('GET', `/api/resource/${doctype}/${name}`)).data || {};
}
async createDoc(doctype, data) {
return (await this._request('POST', `/api/resource/${doctype}`, {body: data})).data;
}
async updateDoc(doctype, name, data) {
return (await this._request('PUT', `/api/resource/${doctype}/${name}`, {body: data})).data;
}
async deleteDoc(doctype, name) {
await this._request('DELETE', `/api/resource/${doctype}/${name}`);
return true;
}
async callMethod(method, args = {}) {
return (await this._request('POST', `/api/method/${method}`, {body: args})).message;
}
}---
3. cURL Examples
#!/bin/bash
BASE_URL="https://erp.example.com"
AUTH="Authorization: token ${FRAPPE_API_KEY}:${FRAPPE_API_SECRET}"
# List with filters
curl -s -X GET "${BASE_URL}/api/resource/Customer" \
-H "${AUTH}" -H "Accept: application/json" -G \
--data-urlencode 'fields=["name","customer_name"]' \
--data-urlencode 'filters=[["customer_type","=","Company"]]' \
--data-urlencode 'limit_page_length=10'
# Create
curl -s -X POST "${BASE_URL}/api/resource/Customer" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d '{"customer_name":"New Corp","customer_type":"Company"}'
# Update
curl -s -X PUT "${BASE_URL}/api/resource/Customer/CUST-001" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d '{"customer_group":"Premium"}'
# Delete
curl -s -X DELETE "${BASE_URL}/api/resource/Customer/CUST-001" \
-H "${AUTH}" -H "Accept: application/json"
# Call method
curl -s -X POST "${BASE_URL}/api/method/frappe.client.get_count" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d '{"doctype":"Sales Order","filters":{"status":"Draft"}}'---
4. Pagination Helper
def fetch_all_documents(client, doctype, filters=None, fields=None, batch_size=100):
"""Retrieve all documents with automatic pagination."""
all_docs, offset = [], 0
while True:
batch = client.get_list(doctype, filters=filters, fields=fields,
limit_start=offset, limit_page_length=batch_size)
if not batch:
break
all_docs.extend(batch)
if len(batch) < batch_size:
break
offset += batch_size
return all_docs---
5. Batch Operations
def batch_create(client, doctype, documents, batch_size=50):
"""Create multiple documents with error tracking."""
results = []
for i in range(0, len(documents), batch_size):
for doc in documents[i:i + batch_size]:
try:
result = client.create_doc(doctype, doc)
results.append({'success': True, 'name': result.get('name')})
except Exception as e:
results.append({'success': False, 'error': str(e), 'data': doc})
return results
# Usage
results = batch_create(client, 'Customer', [
{'customer_name': 'Corp A', 'customer_type': 'Company'},
{'customer_name': 'Corp B', 'customer_type': 'Company'},
])
failed = [r for r in results if not r['success']]---
6. Error Handling Classes
class FrappeAPIError(Exception):
pass
class ValidationError(FrappeAPIError):
pass
class PermissionError(FrappeAPIError):
pass
class NotFoundError(FrappeAPIError):
pass
def handle_response(response):
if response.status_code == 200:
return response.json()
try:
error = response.json()
except Exception:
raise FrappeAPIError(f"HTTP {response.status_code}: {response.text}")
exc_type = error.get('exc_type', '')
msg = error.get('_server_messages', '')
if 'ValidationError' in exc_type:
raise ValidationError(msg)
elif 'PermissionError' in exc_type or response.status_code == 403:
raise PermissionError(msg)
elif 'DoesNotExistError' in exc_type or response.status_code == 404:
raise NotFoundError(msg)
else:
raise FrappeAPIError(msg or error.get('message'))---
7. Webhook Receiver (Flask)
from flask import Flask, request, jsonify
import hmac, hashlib, base64, os
app = Flask(__name__)
SECRET = os.environ.get('WEBHOOK_SECRET')
@app.route('/webhook/order', methods=['POST'])
def handle_webhook():
sig = request.headers.get('X-Frappe-Webhook-Signature')
if sig:
expected = base64.b64encode(
hmac.new(SECRET.encode(), request.data, hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(expected, sig):
return jsonify({'error': 'Invalid signature'}), 401
data = request.json
# Process asynchronously for production
return jsonify({'status': 'received'}), 200REST API Reference
Complete reference for Frappe REST API (resource-based CRUD).
---
API Versions
| Version | Prefix | Available |
|---|---|---|
| v1 | /api/resource/{doctype} | All versions |
| v2 | /api/v2/document/{doctype} | [v15+] |
The v1 API continues to work in v15+. Use v2 for new integrations targeting v15+.
---
Required Headers
ALWAYS include for JSON responses:
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'token api_key:api_secret'
}Without Accept: application/json, Frappe MAY return HTML instead of JSON.
---
List Documents (GET)
GET /api/resource/{doctype}Default Behavior
- Returns 20 records
- Only
namefield
Query Parameters
| Parameter | Type | Description | Default |
|---|---|---|---|
fields | JSON array | Fields to retrieve | ["name"] |
filters | JSON array | AND filter conditions | none |
or_filters | JSON array | OR filter conditions | none |
order_by | string | Sort expression | modified desc |
limit_start | int | Offset for pagination | 0 |
limit_page_length | int | Number of results | 20 |
limit | int | Alias for limit_page_length [v15+] | — |
as_dict | bool | Response as dict (default) | true |
debug | bool | Include SQL query in response | false |
Filter Syntax
Filters MUST be a JSON array. Each condition is [field, operator, value] or [doctype, field, operator, value].
# Equality
filters = [["status", "=", "Open"]]
# Comparison
filters = [["amount", ">", 1000]]
filters = [["amount", ">=", 500]]
filters = [["amount", "<", 10000]]
filters = [["amount", "<=", 999]]
# Not equal
filters = [["status", "!=", "Cancelled"]]
# Pattern matching
filters = [["name", "like", "%INV%"]]
filters = [["name", "not like", "%TEST%"]]
# List membership
filters = [["status", "in", ["Open", "Pending"]]]
filters = [["status", "not in", ["Cancelled", "Closed"]]]
# NULL checks
filters = [["reference", "is", "set"]] # NOT NULL
filters = [["reference", "is", "not set"]] # IS NULL
# Range
filters = [["date", "between", ["2024-01-01", "2024-12-31"]]]Full operator list: =, !=, >, <, >=, <=, like, not like, in, not in, is, between.
OR Filters
GET /api/resource/Customer?or_filters=[
["customer_group","=","Commercial"],
["customer_group","=","Individual"]
]Example: Filtered + Paginated List
GET /api/resource/Sales Invoice
?fields=["name","customer","grand_total","status"]
&filters=[["status","=","Paid"],["grand_total",">",1000]]
&order_by=posting_date desc
&limit_page_length=50
&limit_start=0Response:
{
"data": [
{"name": "SINV-00001", "customer": "Customer A", "grand_total": 1500.00, "status": "Paid"}
]
}Python Example
import requests, json
params = {
'fields': json.dumps(["name", "customer", "grand_total"]),
'filters': json.dumps([["status", "=", "Paid"]]),
'limit_page_length': 100,
'order_by': 'modified desc'
}
response = requests.get(f'{BASE_URL}/api/resource/Sales Invoice',
params=params, headers=headers)
data = response.json()['data']---
Read Document (GET)
GET /api/resource/{doctype}/{name}Response includes all fields and child table data:
{
"data": {
"name": "CUST-00001",
"customer_name": "Test Customer",
"items": [...]
}
}---
Create Document (POST)
POST /api/resource/{doctype}
Content-Type: application/jsonBody: JSON object with field values.
With Child Table
requests.post(f'{BASE_URL}/api/resource/Sales Order', json={
"customer": "CUST-001",
"delivery_date": "2024-02-01",
"items": [
{"item_code": "ITEM-001", "qty": 5, "rate": 100},
{"item_code": "ITEM-002", "qty": 2, "rate": 250}
]
}, headers=headers)Response: full document with generated name, owner, creation fields.
---
Update Document (PUT)
PUT /api/resource/{doctype}/{name}Only specified fields are changed (PATCH-like behavior):
requests.put(f'{BASE_URL}/api/resource/Customer/CUST-001',
json={"customer_group": "Premium"}, headers=headers)Update Child Table
# Replace entire child table
{"items": [{"item_code": "ITEM-001", "qty": 10}]} # All others removed
# Update specific child row (by row name)
{"items": [{"name": "row_id_abc", "qty": 10}]} # Only this row updated---
Delete Document (DELETE)
DELETE /api/resource/{doctype}/{name}Response: {"message": "ok"}
---
Pagination Pattern
def get_all_records(doctype, base_url, headers, filters=None, page_size=100):
all_data, offset = [], 0
while True:
params = {
'filters': json.dumps(filters or []),
'limit_start': offset,
'limit_page_length': page_size
}
response = requests.get(f'{base_url}/api/resource/{doctype}',
params=params, headers=headers)
data = response.json().get('data', [])
if not data:
break
all_data.extend(data)
if len(data) < page_size:
break # Last page
offset += page_size
return all_data---
File Upload
files = {'file': ('document.pdf', open('doc.pdf', 'rb'), 'application/pdf')}
data = {'doctype': 'Customer', 'docname': 'CUST-001', 'is_private': 1}
# NOTE: Do NOT set Content-Type header — requests handles multipart boundary
response = requests.post(f'{BASE_URL}/api/method/upload_file',
files=files, data=data,
headers={'Authorization': 'token api_key:api_secret'})Response:
{"message": {"name": "file_hash.pdf", "file_url": "/private/files/file_hash.pdf", "is_private": 1}}---
v2 API Endpoints [v15+]
| Operation | Method | Endpoint |
|---|---|---|
| List | GET | /api/v2/document/{doctype} |
| Create | POST | /api/v2/document/{doctype} |
| Read | GET | /api/v2/document/{doctype}/{name} |
| Update | PATCH | /api/v2/document/{doctype}/{name} |
| Delete | DELETE | /api/v2/document/{doctype}/{name} |
| Copy | GET | /api/v2/document/{doctype}/{name}/copy |
| Doc Method | POST | /api/v2/document/{doctype}/{name}/method/{method} |
| Metadata | GET | /api/v2/doctype/{doctype}/meta |
| Count | GET | /api/v2/doctype/{doctype}/count |
---
Standard Response Fields
| Field | Description |
|---|---|
name | Document identifier |
doctype | Document type |
docstatus | 0=Draft, 1=Submitted, 2=Cancelled |
owner | Created by user |
creation | Creation datetime |
modified | Last modified datetime |
modified_by | Last modified by user |
---
Debug Mode
GET /api/resource/Customer?debug=True&limit=5Response includes SQL query and execution time in exc field.
RPC API Reference
Complete reference for Frappe Remote Procedure Calls via whitelisted methods.
---
Endpoint Structure
GET/POST /api/method/{dotted.path.to.function}The function MUST be decorated with @frappe.whitelist().
---
HTTP Methods
| Method | Use For | Auto Commit |
|---|---|---|
| GET | Read-only operations | No |
| POST | State-changing operations | Yes |
ALWAYS use GET for queries, POST for mutations.
---
Writing Whitelisted Methods
Basic Pattern
import frappe
@frappe.whitelist()
def get_customer_balance(customer):
"""GET /api/method/myapp.api.get_customer_balance?customer=CUST-001"""
balance = frappe.db.get_value("Sales Invoice",
{"customer": customer, "docstatus": 1},
"sum(outstanding_amount)") or 0
return {"customer": customer, "balance": balance}With Input Validation
@frappe.whitelist(methods=["POST"])
def create_payment(customer: str, amount: float, payment_type: str = "Receive"):
if not customer:
frappe.throw(_("Customer is required"))
if not frappe.db.exists("Customer", customer):
frappe.throw(_("Customer {0} does not exist").format(customer))
amount = float(amount)
if amount <= 0:
frappe.throw(_("Amount must be positive"))
if not frappe.has_permission("Payment Entry", "create"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
pe = frappe.new_doc("Payment Entry")
pe.payment_type = payment_type
pe.party_type = "Customer"
pe.party = customer
pe.paid_amount = amount
pe.insert()
return pe.name---
Decorator Options
| Option | Effect | Version |
|---|---|---|
@frappe.whitelist() | Requires authentication | All |
allow_guest=True | No authentication needed | All |
methods=["POST"] | Restrict HTTP methods | [v14+] |
methods=["GET", "POST"] | Allow specific methods | [v14+] |
xss_safe=True | Skip XSS escaping on response | All |
---
API Calls
Via cURL
# GET for read-only
curl -X GET "https://erp.example.com/api/method/myapp.api.get_customer_balance?customer=CUST-001" \
-H "Authorization: token api_key:api_secret" \
-H "Accept: application/json"
# POST for state-changing
curl -X POST "https://erp.example.com/api/method/myapp.api.create_payment" \
-H "Authorization: token api_key:api_secret" \
-H "Content-Type: application/json" \
-d '{"customer": "CUST-001", "amount": 500}'Via Python
# GET
response = requests.get(
f'{BASE_URL}/api/method/myapp.api.get_customer_balance',
params={'customer': 'CUST-001'}, headers=headers)
# POST
response = requests.post(
f'{BASE_URL}/api/method/myapp.api.create_payment',
json={'customer': 'CUST-001', 'amount': 500}, headers=headers)---
Response Structure
Success
{"message": "return_value_from_function"}The return value is ALWAYS wrapped in a message key. Can be string, dict, list, or number.
Error
{
"exc_type": "ValidationError",
"exc": "Traceback...",
"_server_messages": "[{\"message\": \"Error details\"}]"
}---
Client-Side Calls (JavaScript)
frappe.xcall (RECOMMENDED)
// Async/await — cleanest syntax
const result = await frappe.xcall('myapp.api.get_customer_balance', {
customer: 'CUST-001'
});
console.log(result.balance);
// With error handling
try {
const name = await frappe.xcall('myapp.api.create_payment', {
customer: 'CUST-001', amount: 500
});
frappe.show_alert(__('Payment created: {0}', [name]));
} catch (e) {
frappe.msgprint(__('Payment failed'));
}frappe.call (Callback/Promise)
// Promise pattern
frappe.call({
method: 'myapp.api.get_customer_balance',
args: {customer: 'CUST-001'},
freeze: true,
freeze_message: __('Loading...')
}).then(r => console.log(r.message));| Option | Type | Description |
|---|---|---|
method | string | Python method dotted path |
args | object | Arguments to pass |
callback | function | Success callback |
error | function | Error callback |
async | bool | Async call (default: true) |
freeze | bool | Freeze UI during call |
freeze_message | string | Message shown during freeze |
btn | jQuery | Button to disable during call |
frm.call (Document Context)
frm.call('get_linked_doc', {throw_if_missing: true})
.then(r => console.log(r.message));Requires controller method with @frappe.whitelist():
class MyDocType(Document):
@frappe.whitelist()
def get_linked_doc(self, throw_if_missing=False):
return frappe.get_doc(self.reference_type, self.reference_name)---
Standard frappe.client Methods
These built-in methods provide CRUD without writing custom endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
frappe.client.get_value | POST | Get single field value |
frappe.client.get_list | POST | List with filters and fields |
frappe.client.get | POST | Get full document |
frappe.client.insert | POST | Create new document |
frappe.client.save | POST | Update existing document |
frappe.client.delete | POST | Delete document |
frappe.client.submit | POST | Submit document |
frappe.client.cancel | POST | Cancel document |
frappe.client.get_count | POST | Count documents |
Examples
# Get value
POST /api/method/frappe.client.get_value
{"doctype": "Customer", "filters": {"name": "CUST-001"}, "fieldname": "customer_name"}
# Get count
POST /api/method/frappe.client.get_count
{"doctype": "Sales Order", "filters": {"status": "Draft"}}
# Insert
POST /api/method/frappe.client.insert
{"doc": {"doctype": "Customer", "customer_name": "New", "customer_type": "Company"}}
# Submit
POST /api/method/frappe.client.submit
{"doc": {"doctype": "Sales Order", "name": "SO-00001"}}---
Run Document Method
Execute a whitelisted method on a specific document instance:
POST /api/method/run_doc_method
{"dt": "Sales Order", "dn": "SO-00001", "method": "get_taxes_and_charges"}[v15+] Also available via v2 API:
POST /api/v2/document/Sales Order/SO-00001/method/get_taxes_and_charges---
Server Script API Type
Alternative to whitelisted Python methods — configured via UI:
1. Server Script > New > Script Type: "API" 2. API Method: myapp.my_endpoint 3. Becomes /api/method/myapp.my_endpoint 4. Enable Rate Limit (optional) [v15+]
# In Server Script body
response = {"customer": frappe.form_dict.customer}
frappe.response["message"] = response---
Error Handling Pattern
@frappe.whitelist()
def safe_operation(docname):
try:
doc = frappe.get_doc("Sales Order", docname)
doc.check_permission("write")
doc.submit()
return {"success": True, "name": doc.name}
except frappe.DoesNotExistError:
frappe.throw(_("Document not found"), frappe.DoesNotExistError)
except frappe.PermissionError:
raise # Re-raise permission errors as-is
except Exception:
frappe.log_error(title="API Error")
frappe.throw(_("Operation failed. Please try again."))---
Permission Checks
ALWAYS check permissions in whitelisted methods:
@frappe.whitelist()
def get_salary(employee):
if not frappe.has_permission("Salary Slip", "read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
return frappe.db.get_value("Salary Slip", {"employee": employee}, "gross_pay")The @frappe.whitelist() decorator only ensures the user is authenticated — it does NOT check DocType permissions.
Webhooks Reference
Complete reference for Frappe webhook configuration, security, and handling.
---
Overview
Webhooks are user-defined HTTP callbacks that trigger on document events, sending HTTP requests to configured URLs.
---
Configuration (via UI)
1. Webhook DocType > New 2. Select DocType (e.g., "Sales Order") 3. Select Doc Event 4. Enter Request URL 5. Optional: Add HTTP Headers (API keys, auth tokens) 6. Optional: Set Conditions (Jinja2 syntax) 7. Optional: Set Webhook Secret for HMAC verification
---
Available Events
| Event | Trigger Moment |
|---|---|
after_insert | After new document is created and saved |
on_update | After every save operation |
on_submit | After document submit (docstatus: 0 > 1) |
on_cancel | After document cancel (docstatus: 1 > 2) |
on_trash | Before document deletion |
on_update_after_submit | After amendment to submitted doc |
on_change | On every change (catch-all) |
---
Request Structure
Frappe sends automatically:
POST {webhook_url}
Content-Type: application/json
{
"doctype": "Sales Order",
"name": "SO-00001",
"data": {
"name": "SO-00001",
"customer": "Customer A",
"grand_total": 1500.00,
"status": "Draft",
...all document fields...
}
}---
Webhook Conditions
Conditions use Jinja2 syntax. Webhook only triggers when condition evaluates to True:
{# Only for large orders #}
{{ doc.grand_total > 10000 }}
{# Only premium customers #}
{{ doc.customer_group == "Premium" }}
{# Specific statuses #}
{{ doc.status in ["Submitted", "Paid"] }}
{# Combination #}
{{ doc.grand_total > 5000 and doc.customer_group == "Premium" }}---
Data Format Options
Form-Based
Configure fields individually in Webhook Data table:
| Fieldname | Key |
|---|---|
customer | customer |
grand_total | amount |
Sent as form-encoded: customer=Customer%20A&amount=1500
JSON-Based (with Jinja)
Select "JSON" as Request Structure and write a Jinja template:
{
"order_id": "{{ doc.name }}",
"customer": "{{ doc.customer }}",
"total": {{ doc.grand_total }},
"items": [
{% for item in doc.items %}
{
"item_code": "{{ item.item_code }}",
"qty": {{ item.qty }}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
}---
Webhook Security — HMAC Signature
When "Webhook Secret" is configured, Frappe adds a signature header:
X-Frappe-Webhook-Signature: base64_encoded_hmac_sha256_of_payloadPython Verification
import hmac
import hashlib
import base64
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = base64.b64encode(
hmac.new(secret.encode(), payload, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, signature)Complete Handler Example (Flask)
from flask import Flask, request, jsonify
import hmac, hashlib, base64, logging, os
app = Flask(__name__)
logger = logging.getLogger(__name__)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET')
def verify_signature(payload: bytes, signature: str) -> bool:
expected = base64.b64encode(
hmac.new(WEBHOOK_SECRET.encode(), payload, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, signature)
@app.route('/webhook/order', methods=['POST'])
def handle_order_webhook():
# 1. Verify signature
signature = request.headers.get('X-Frappe-Webhook-Signature')
if signature and not verify_signature(request.data, signature):
logger.warning('Invalid webhook signature')
return jsonify({'error': 'Invalid signature'}), 401
# 2. Parse data
try:
data = request.json
doctype = data.get('doctype')
docname = data.get('name')
doc_data = data.get('data', {})
except Exception as e:
logger.error(f'Failed to parse: {e}')
return jsonify({'error': 'Invalid payload'}), 400
# 3. Process (keep fast — queue long operations)
logger.info(f'Webhook: {doctype}/{docname}')
try:
if doctype == 'Sales Order':
process_sales_order(docname, doc_data)
except Exception as e:
logger.error(f'Processing failed: {e}')
# Return 200 anyway to prevent endless retries
# 4. Return quickly
return jsonify({'status': 'received'}), 200---
Best Practices
1. ALWAYS set a Webhook Secret and verify HMAC signatures 2. ALWAYS return quickly (< 30 seconds) — queue long operations 3. ALWAYS return HTTP 200 even on processing errors (prevents endless retries) 4. ALWAYS implement idempotent operations (same webhook may arrive multiple times) 5. ALWAYS log webhook payloads for debugging 6. NEVER put sensitive data in webhook payloads without encryption 7. NEVER rely on webhook delivery order 8. NEVER perform synchronous heavy operations in webhook handlers
---
Webhook Debugging
In Frappe
1. Webhook Request Log: Shows all sent webhooks with request/response details 2. Error Log: Shows failed webhook requests 3. Enable/disable individual webhooks without deleting configuration
Testing Locally
# Expose local server with ngrok
ngrok http 5000
# Simulate webhook
curl -X POST "http://localhost:5000/webhook/order" \
-H "Content-Type: application/json" \
-d '{"doctype":"Sales Order","name":"SO-00001","data":{"status":"Draft"}}'