Webhooks let Frappe push data out to another system when a document changes.
This is the inverse of the REST API, which pulls data in. You configure them entirely from
the Webhook DocType; no code required. When a matching document event fires,
Frappe sends an HTTP request to your URL in a background job.
Creating a webhook
Create a Webhook record (Desk: search "Webhook > New") and set:
- DocType: which DocType to watch (
webhook_doctype). - Doc Event: the lifecycle event that triggers it (
webhook_docevent). - Request URL: where to send the request (
request_url). - Request Method:
POST,PUT, orDELETE. - Request Structure:
JSONorForm URL-Encoded.
Trigger events
webhook_docevent is one of:
| Event | Fires when |
|---|---|
after_insert |
a new document is created |
on_update |
a document is saved |
on_submit |
a submittable document is submitted |
on_cancel |
a submitted document is cancelled |
on_trash |
a document is deleted |
on_update_after_submit |
a submitted document is edited |
on_change |
any change to the document |
workflow_transition |
see note below |
The submit/cancel events require the DocType to be submittable.
workflow_transition does not auto-dispatch like the other events; it is not in
the webhook module's supported_events set. Instead, attach the webhook as a
Webhook task on a Workflow Transition's Transition Tasks table
(frappe/model/workflow.py); it then runs whenever that transition happens.
Conditions
Use Condition to fire only for some documents. It's a Python expression
evaluated against the document as doc (with frappe.utils helpers available):
doc.grand_total > 1000 and doc.status == "Open"
If the expression is falsy, no request is sent.
Request body
You choose how the payload is built, based on Request Structure.
Mapped fields (default, Request Structure = Form URL-Encoded): add rows
under Data, each mapping a document fieldname to an outgoing key. The body is
a flat JSON object of those keys.
Custom JSON (Request Structure = JSON): write a Jinja template in
Webhook JSON, with the document available as doc:
{
"id": "{{ doc.name }}",
"customer": "{{ doc.customer_name }}",
"total": {{ doc.grand_total }}
}
The rendered template must be valid JSON. The request is always sent with a JSON
body (Content-Type is governed by your headers; the payload itself is serialized
as JSON).
Setting one clears the other: saving with Request Structure Form URL-Encoded
clears Webhook JSON, and saving with JSON clears the Data rows
(validate_request_body in webhook.py).
Custom headers
Add Webhook Headers rows (key/value) to send extra headers, for example an
Authorization or Content-Type header expected by the receiver.
Security: verifying the signature
Enable Enable Security and set a Webhook Secret. Frappe then signs each
request and sends the signature in the header:
X-Frappe-Webhook-Signature: <base64 HMAC-SHA256 of the JSON body>
The signature is base64(HMAC_SHA256(secret, body)) over the exact JSON body
bytes. Verify it on the receiver before trusting the payload:
import base64, hashlib, hmac
def is_valid(raw_body: bytes, signature: str, secret: str) -> bool:
expected = base64.b64encode(
hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
)
return hmac.compare_digest(expected, signature.encode("utf-8"))
Compute the HMAC over the raw request body exactly as received. Re-serializing
the parsed JSON may change byte ordering and break the comparison.
Delivery, retries and logs
- Webhooks run in a background job, so they don't block the triggering save.
Pick the queue with Background Jobs Queue; set a Timeout (default 5s). - A webhook is attempted up to 3 times total (the initial request plus up to
2 retries), with a backoff of about 1s then 4s between retries on a generic
error. A non-2xx response counts as a failure. - Every attempt is recorded in Webhook Request Log (URL, headers, body,
response, and any error). This is the first place to look when a delivery fails. workflow_transitionis special: if all retries fail, the error is re-raised so
the transition itself fails.
Dynamic URLs
Tick Is Dynamic URL to template the URL itself with Jinja, e.g. routing by a
field on the document:
https://hooks.example.com/{{ doc.company | lower }}/events
See also
- Hooks: for in-process reactions to document events
- Background jobs: how webhook delivery is queued
- Calling Methods: to pull data the other direction