skills/source/errors/frappe-errors-api/SKILL.md
Use when debugging or handling API errors in Frappe/ERPNext v14/v15/v16. Prevents silent failures and wrong HTTP status codes in REST endpoints. Covers 401 Unauthorized (wrong token format, expired OAuth), 403 Forbidden (missing @whitelist, allow_guest needed), 404 Not Found (wrong endpoint URL), 417 Expectation Failed (validation via frappe.throw), 500 Internal Server Error, CORS issues, CSRF token missing/invalid, rate limit exceeded (429), file upload failures, JSON parse errors in request/response, webhook delivery failures, and timeout on long operations. Keywords: API error, 401, 403, 404, 417, 429, 500, CSRF, CORS, REST,, API call fails, 403 forbidden, CORS error, token expired, endpoint not found, webhook not received. whitelist, webhook, rate limit, file upload, authentication token.
npx skillsauth add OpenAEC-Foundation/ERPNext_Anthropic_Claude_Development_Skill_Package frappe-errors-apiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
For API implementation patterns see frappe-core-api. For permission errors see frappe-errors-permissions.
| Code | Frappe Exception | When It Happens | Fix |
|------|-----------------|-----------------|-----|
| 200 | — | Success | — |
| 401 | AuthenticationError | Bad/expired token, wrong format | Check Authorization: token key:secret or Bearer access_token |
| 403 | PermissionError | Missing @whitelist, no role, no allow_guest | Add decorator or grant permission |
| 404 | DoesNotExistError | Wrong URL, doc not found, typo in endpoint path | Verify /api/resource/:doctype/:name or /api/method/dotted.path |
| 409 | DuplicateEntryError | Unique constraint violated | Check existing records before insert |
| 417 | ValidationError | frappe.throw() called | Fix validation logic or input data |
| 429 | RateLimitExceededError | Too many requests | Respect Retry-After header; throttle requests |
| 500 | Exception (unhandled) | Unhandled server error | Check Error Log; wrap in try/except |
| 503 | — | Server overloaded / maintenance | Retry with exponential backoff |
Error: HTTP 401 Unauthorized
Cause: Using "Bearer api_key:api_secret" instead of "token api_key:api_secret"
Frappe uses TWO authentication formats — NEVER mix them:
| Method | Header Format | When to Use |
|--------|--------------|-------------|
| API Key/Secret | Authorization: token api_key:api_secret | Server-to-server, scripts |
| OAuth Bearer | Authorization: Bearer access_token | OAuth 2.0 flows |
| Session Cookie | Cookie from /api/method/login | Browser-based apps |
# WRONG — Bearer with API key:secret
headers = {"Authorization": f"Bearer {api_key}:{api_secret}"}
# CORRECT — token keyword for API key:secret
headers = {"Authorization": f"token {api_key}:{api_secret}"}
# CORRECT — Bearer for OAuth access tokens only
headers = {"Authorization": f"Bearer {oauth_access_token}"}
Error: HTTP 401 after token was working
Cause: OAuth access_token expired
Fix: Use refresh_token to get new access_token
def get_fresh_token(settings):
"""ALWAYS implement token refresh for OAuth integrations."""
if is_token_expired(settings.token_expiry):
response = requests.post(f"{settings.base_url}/api/method/frappe.integrations.oauth2.get_token", data={
"grant_type": "refresh_token",
"refresh_token": settings.get_password("refresh_token"),
"client_id": settings.client_id,
})
if response.status_code == 200:
data = response.json()
settings.access_token = data["access_token"]
settings.token_expiry = frappe.utils.add_to_date(None, seconds=data["expires_in"])
settings.save(ignore_permissions=True)
else:
frappe.throw(_("OAuth token refresh failed"), exc=frappe.AuthenticationError)
return settings.access_token
Error: HTTP 403 on /api/method/myapp.api.my_function
Cause: Function exists but lacks @frappe.whitelist() decorator
Fix: Add decorator — without it, NO external call is allowed
# WRONG — Callable internally but returns 403 via REST
def my_function(name):
return frappe.get_doc("Item", name)
# CORRECT — Exposed to authenticated users
@frappe.whitelist()
def my_function(name):
return frappe.get_doc("Item", name)
# CORRECT — Exposed to everyone including unauthenticated
@frappe.whitelist(allow_guest=True)
def public_function():
return {"status": "ok"}
Error: HTTP 403 for unauthenticated requests
Cause: @frappe.whitelist() without allow_guest=True
Fix: Add allow_guest=True — but ALWAYS validate inputs
NEVER use allow_guest=True without input validation — these endpoints are exposed to the internet.
| Wrong URL | Correct URL | Issue |
|-----------|-------------|-------|
| /api/resource/SalesOrder/SO-001 | /api/resource/Sales Order/SO-001 | Space in DocType name |
| /api/method/myapp.my_function | /api/method/myapp.api.my_function | Missing module path |
| /api/resource/sales_order | /api/resource/Sales Order | Wrong case / underscore |
| /api/v2/document/Item/ITEM-001 [v14] | /api/resource/Item/ITEM-001 | v2 API only in v15+ |
# ALWAYS URL-encode DocType names with spaces
import urllib.parse
url = f"/api/resource/{urllib.parse.quote('Sales Order')}/{name}"
Every frappe.throw() call returns HTTP 417 by default (unless a specific exception class is provided).
# Returns 417 — generic validation error
frappe.throw(_("Amount must be positive"))
# Returns 417 — with explicit ValidationError type
frappe.throw(_("Amount must be positive"), exc=frappe.ValidationError)
# Returns 403 — PermissionError overrides to 403
frappe.throw(_("Access denied"), exc=frappe.PermissionError)
# Returns 404 — DoesNotExistError overrides to 404
frappe.throw(_("Not found"), exc=frappe.DoesNotExistError)
ALWAYS use the specific exception class so clients can handle error types correctly:
# WRONG — all errors look the same to the client
frappe.throw(_("Customer not found")) # 417, generic
# CORRECT — client can distinguish 404 from validation error
frappe.throw(_("Customer not found"), exc=frappe.DoesNotExistError) # 404
Error: HTTP 403 "CSRF token missing or invalid"
Cause: POST/PUT/DELETE request without X-Frappe-CSRF-Token header
Rules:
X-Frappe-CSRF-Token header for session-based (cookie) auth.Authorization: token ...) does NOT require CSRF token.frappe.csrf_token in JavaScript or embedded as window.CSRF_TOKEN.// Browser-side: ALWAYS include CSRF for session-based requests
fetch("/api/method/myapp.api.update", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Frappe-CSRF-Token": frappe.csrf_token
},
body: JSON.stringify({data: "value"})
});
Error: "Access-Control-Allow-Origin" header missing
Cause: Cross-origin request not configured in site_config.json
// site_config.json — NEVER use "*" in production
{
"allow_cors": "https://your-frontend.example.com"
}
For multiple origins [v15+]:
{
"allow_cors": ["https://app1.example.com", "https://app2.example.com"]
}
Error: HTTP 429 Too Many Requests
Cause: Exceeded rate limit configured in site_config.json or hooks.py
# hooks.py — rate limiting on whitelisted methods [v14+]
rate_limit = {"myapp.api.heavy_endpoint": {"limit": 10, "seconds": 60}}
ALWAYS handle 429 in external API calls:
def call_with_rate_limit(url, data):
response = requests.post(url, json=data, timeout=30)
if response.status_code == 429:
wait = int(response.headers.get("Retry-After", 60))
time.sleep(min(wait, 120)) # Cap at 2 minutes
response = requests.post(url, json=data, timeout=30)
response.raise_for_status()
return response.json()
Error: HTTP 500 on /api/method/upload_file
Cause: Wrong content type, file too large, or missing file field
# CORRECT file upload via REST API
import requests
response = requests.post(
f"{base_url}/api/method/upload_file",
headers={"Authorization": f"token {api_key}:{api_secret}"},
files={"file": ("document.pdf", open("document.pdf", "rb"), "application/pdf")},
data={
"doctype": "Sales Invoice",
"docname": "SINV-001",
"is_private": 1 # 1 = private, 0 = public
},
timeout=60 # ALWAYS set timeout for uploads
)
Common upload failures:
Content-Type must be multipart/form-data (set automatically by files= param)Content-Type: application/json for file uploadsmax_file_size in site_config.json (default 10MB)allowed_file_extensions restricts file typesError: "Failed to decode JSON" or unexpected behavior
Cause: API arguments sent as JSON string instead of parsed object
@frappe.whitelist()
def update_items(items):
# ALWAYS handle both string and parsed input
if isinstance(items, str):
try:
items = frappe.parse_json(items)
except Exception:
frappe.throw(_("Invalid JSON format"), exc=frappe.ValidationError)
if not isinstance(items, (list, dict)):
frappe.throw(_("Expected list or dict"), exc=frappe.ValidationError)
Error: Webhook not firing or returning errors
Cause: Target URL unreachable, wrong format, or timeout
Debug checklist:
# Custom webhook with error handling
@frappe.whitelist(allow_guest=True)
def incoming_webhook():
"""Handle incoming webhook with validation."""
payload = frappe.request.data
signature = frappe.request.headers.get("X-Webhook-Signature")
if not verify_signature(payload, signature):
frappe.local.response["http_status_code"] = 401
return {"error": "Invalid signature"}
try:
data = frappe.parse_json(payload)
except Exception:
frappe.local.response["http_status_code"] = 400
return {"error": "Invalid JSON payload"}
# ALWAYS return 200 quickly to prevent sender retries
frappe.enqueue(process_webhook_data, data=data, queue="short")
return {"status": "accepted"}
Error: HTTP 504 Gateway Timeout or connection reset
Cause: Operation takes longer than proxy/server timeout (typically 60s)
Fix: Use background jobs for long operations:
@frappe.whitelist()
def start_long_operation(filters):
"""NEVER run long operations synchronously in API calls."""
job_id = frappe.generate_hash(length=10)
frappe.enqueue(
"myapp.tasks.run_long_operation",
queue="long",
timeout=600,
job_id=job_id,
filters=filters
)
return {"status": "queued", "job_id": job_id}
@frappe.whitelist()
def check_job_status(job_id):
"""Poll for job completion."""
from frappe.utils.background_jobs import get_info
jobs = get_info()
for job in jobs:
if job.get("job_id") == job_id:
return {"status": job.get("status", "unknown")}
return {"status": "completed"}
@frappe.whitelist()
def safe_api_endpoint(docname, action):
"""ALWAYS follow: validate -> check permission -> execute -> handle errors."""
# 1. Validate input
if not docname:
frappe.throw(_("Document name required"), exc=frappe.ValidationError)
# 2. Check existence
if not frappe.db.exists("My DocType", docname):
frappe.throw(_("Document not found"), exc=frappe.DoesNotExistError)
# 3. Check permission
frappe.has_permission("My DocType", "write", docname, throw=True)
# 4. Execute with error handling
try:
doc = frappe.get_doc("My DocType", docname)
result = doc.run_method(action)
return {"status": "success", "data": result}
except frappe.ValidationError:
raise # Let Frappe handle — returns 417
except frappe.PermissionError:
raise # Let Frappe handle — returns 403
except Exception:
frappe.log_error(frappe.get_traceback(), f"API Error: {docname}")
frappe.throw(_("Operation failed. Please try again."))
// ALWAYS handle errors in frappe.call
frappe.call({
method: "myapp.api.safe_api_endpoint",
args: {docname: "DOC-001", action: "approve"},
freeze: true,
freeze_message: __("Processing..."),
callback: function(r) {
if (r.message && r.message.status === "success") {
frappe.show_alert({message: __("Done"), indicator: "green"});
}
},
error: function(r) {
// ALWAYS check exc_type for specific handling
if (r.exc_type === "PermissionError") {
frappe.msgprint(__("You lack permission for this action."));
} else if (r.exc_type === "DoesNotExistError") {
frappe.msgprint(__("Record not found."));
} else if (!r.status) {
frappe.msgprint(__("Network error. Check your connection."));
}
}
});
frappe.throw() — enables correct HTTP status codesrequests.get(url, timeout=30)frappe.log_error() then frappe.throw()frappe.call() — silent failures confuse userstoken key:secret vs Bearer oauth_tokensettings.get_password("field") from a DocType| File | Contents |
|------|----------|
| references/patterns.md | Complete whitelisted method, webhook, external API patterns |
| references/examples.md | Full working API module, client integration, external API client |
| references/anti-patterns.md | 15 common API error handling mistakes |
frappe-core-api — API implementation patternsfrappe-errors-permissions — Permission error handling (403 deep dive)frappe-syntax-whitelisted — Whitelisted method syntaxfrappe-errors-serverscripts — Server Script error handlingtools
Use when implementing OAuth providers, Connected Apps, Webhooks, Payment Gateways, or Data Import/Export in Frappe. Prevents authentication failures from wrong OAuth flow, missed webhook deliveries, and data corruption during bulk imports. Covers OAuth2 provider/client, Connected App DocType, Webhook DocType, Payment Gateway integration, Data Import, Data Export, frappe.integrations module. Keywords: OAuth, Connected App, Webhook, Payment Gateway, Data Import, Data Export, integration, API key, OAuth2, webhook trigger, connect to external service, OAuth setup, webhook configuration, import data, export data..
development
Use when implementing hooks.py configurations in a Frappe custom app. Covers step-by-step workflows for doc_events, scheduler_events, override/extend_doctype_class, permission hooks, extend_bootinfo, fixtures, asset injection, website hooks, and doctype_js. Prevents broken transactions, missed migrations, and multi-app conflicts. Keywords: hooks.py, doc_events, scheduler_events, override doctype,, how to add hook, when to use doc_events, scheduler setup, override existing behavior. extend doctype class, permission hook, scheduler job, fixtures, doctype_js, extend_bootinfo, website hooks.
development
Use when building a custom Frappe app from scratch. Covers bench new-app walkthrough, app structure decisions, adding DocTypes, hooks, patches, fixtures management, development workflow (bench migrate, build, clear-cache), testing, packaging, installing on another site, version management, and app dependencies for v14/v15/v16. Keywords: create custom app, new frappe app, bench new-app, app structure, module creation, doctype creation, fixtures, patches, deployment, packaging, data migration, patch file, patches.txt, migrate data between DocTypes, create new app from scratch.
development
Use when building Document Controllers in a custom Frappe app: file creation, lifecycle hooks, validation, autoname, submittable workflows, controller override, child table controllers, flags system, migration from hooks.py and Server Scripts. Keywords: how to implement controller, which hook to use, validate vs on_update, override controller, submittable document, autoname, flags, extend_doctype_class, controller testing, child table controller, which hook to use, when does validate run, how to override save, document lifecycle.