Framework Git

Framework Git

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Webhooks

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, or DELETE.
  • Request Structure: JSON or Form 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_transition is 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

Last updated 2 hours ago
Was this helpful?
Thanks!