---
title: "Hooks"
space: "Commera"
url: "https://docs.bwh.tech/commera/plugin-reference/hooks"
updated: "2026-10-09"
---

Hooks run inside the shopper's request, before Commera commits. Today every hook runs at checkout. They can refuse a cart, add a fee, hide or reprice a delivery option, or hide a payment method.

Register them in one `commera_hooks` dict in `hooks.py`. Each key is a hook name, and each value is a list of handlers.

```python
commera_hooks = {
	"validate_cart": ["my_app.cart.get_cart_refusal"],
	"cart_fees": ["my_app.cart.get_cart_fees"],
}
```

`bench migrate` prints a warning and writes an Error Log for a key that is not a hook name, for example `cart_fee`.

| Key | Arguments | Returns | Effect |
| --- | --- | --- | --- |
| `validate_cart` | the cart `Quotation` | `str` or `None` | A string refuses checkout and shows the string to the shopper |
| `cart_fees` | the cart `Quotation` | `list[dict]` | Adds one charge row to the cart for each fee |
| `delivery_options` | the cart `Quotation`, the delivery options | `list[dict]` | Hides, reprices or renames delivery options |
| `payment_methods` | the cart `Quotation`, the payment method names | `list[str]` | Hides payment methods |

## Rules for every hook

- **Be fast.** Commera calls these hooks on cart changes, and the shopper waits. Do not call a remote API.
- **Do not change data.** Do not change or save the Quotation. The hooks can run many times for one cart, and the transaction can roll back. Do side effects in an [event handler](/commera/plugin-reference/events).
- **Translate messages.** The shopper sees the string that you return. Wrap it in `frappe._()`.
- **Do not raise to refuse.** An exception is a crash, not a refusal. See [When a hook raises](#when-a-hook-raises).

When more than one plugin has a hook, Commera calls them in the order of `frappe.get_hooks`. For `validate_cart`, the first string wins. For the other two list hooks, each handler gets the list that the handler before it returned.

## validate_cart

```python
commera_hooks = {"validate_cart": ["my_app.cart.get_cart_refusal"]}
```

```python
import frappe
from frappe import _


def get_cart_refusal(quotation) -> str | None:
	item_codes = [row.item_code for row in quotation.items]
	missing = frappe.get_all(
		"Item", filters={"name": ["in", item_codes], "my_app_sku": ["is", "not set"]}, pluck="name"
	)
	if missing:
		return _("{0} can't be ordered right now. Please remove it from your cart to check out.").format(
			missing[0]
		)
	return None
```

Commera calls the hook when the shopper starts checkout, for every payment mode, after it applies the delivery charge and the plugin fees. It calls it again when the shopper confirms a cash on delivery order. When the hook returns a string, no payment session opens and no order is made. Commera writes no Error Log for a refusal.

Return `None` to allow the cart. An empty value such as `""` or `False` also allows it. Any other value that is not a string counts as a crash.

:::note[The shopper sees the refusal at the last click]

Commera does not call this hook on the cart page or the address step. The shopper sees your message only when they click to place the order, as a toast. Write a message that names the item and says what to do.

:::

## cart_fees

```python
commera_hooks = {"cart_fees": ["my_app.cart.get_cart_fees"]}
```

```python
from frappe import _


def get_cart_fees(quotation) -> list[dict]:
	if not quotation.get("my_app_gift_wrap"):
		return []
	return [{"description": _("Gift wrap"), "amount": 50}]
```

| Key | Required | Value |
| --- | --- | --- |
| `description` | Yes | The label the shopper sees |
| `amount` | Yes | An `int` or a `float` above zero, in the cart currency |
| `account_head` | No | The account for the fee. The default is **Commera Settings > Charge Account Head** |

Commera calls the hook each time it prices the cart: a cart change, an address change, a delivery option change, and the start of checkout. Each time, it removes every plugin fee row and asks every plugin again, so a fee never adds up across calls.

Each fee becomes a row in the cart's taxes table with **Type** `Actual`, after all other rows, so no tax is calculated on it. The row has `commera_plugin_fee` set to `1`, and it goes into the Sales Order with the rest of the cart.

- An amount of `0` adds no row.
- A negative amount, a missing description or an amount that is not a number counts as a crash. A fee cannot be a discount.
- If you give no `account_head` and **Charge Account Head** is empty, checkout fails.
- Use an account that no rate-based tax row uses.

## delivery_options

```python
commera_hooks = {"delivery_options": ["my_app.delivery.get_delivery_options"]}
```

```python
def get_delivery_options(quotation, options: list[dict]) -> list[dict]:
	if not any(row.get("my_app_fragile") for row in quotation.items):
		return options
	return [option for option in options if option["title"] != "Economy"]
```

Each option is a copy with the keys `title`, `amount`, `description`, `provider`, `service_code`, `is_free` and `is_live_rate`. Return the options that the shopper can choose. Commera reads only these keys:

| Key | Value |
| --- | --- |
| `title` | Required. The `title` of an option that Commera gave you. You cannot change it |
| `amount` | Optional. A new price of `0` or more |
| `label` | Optional. The name that the shopper sees |
| `description` | Optional. A new description |

You cannot add an option or change the order. An added or renamed option, a duplicate `title` or a negative amount counts as a crash.

The hook runs only when `bwh_shipping` is installed, the cart has a shipping address and the order is not a store pickup. Commera calls it when it lists the options, when the shopper selects one, and at the start of checkout. If the hook hides the selected option at checkout, the shopper sees "Your delivery option is no longer available. Please choose another one."

## payment_methods

```python
commera_hooks = {"payment_methods": ["my_app.payments.get_payment_methods"]}
```

```python
from frappe.utils.data import flt


def get_payment_methods(quotation, methods: list[str]) -> list[str]:
	if flt(quotation.grand_total) <= 5000:
		return methods
	return [method for method in methods if method != "COD"]
```

`methods` holds the names of the enabled payment gateways, and `COD` when cash on delivery is on. You can only remove methods. Commera calls the hook on the checkout page of a signed-in shopper, at the start of checkout, and when the shopper confirms a COD order. A guest's checkout page shows every method, but the start of checkout still refuses a hidden one.

## When a hook raises

Commera writes an Error Log with the title `commera_hooks["<hook>"] hook failed: <handler>` and removes any message that your hook printed. Then:

- **On a cart edit, the options list or the checkout page:** Commera leaves out that handler's fees, options change or methods change. The cart still works.
- **At the start of checkout or a COD confirmation:** the shopper sees "Something went wrong, please try again." and cannot check out.

:::danger[A crash blocks every shopper]

While your hook raises at checkout, no shopper can check out, even with a cart that has nothing to do with your plugin. A `frappe.throw` inside the hook is also a crash: the shopper sees "Something went wrong", not your message. Return a string from `validate_cart` instead.

:::

The Error Log is written with a deferred insert, so it can appear a few minutes later.

## Refuse a cancel

There is no Commera hook to refuse a cancel. Use a Sales Order `before_cancel` doc event, and return early for orders that are not store orders:

```python
doc_events = {"Sales Order": {"before_cancel": "my_app.orders.before_order_cancel"}}
```

```python
import frappe
from frappe import _
from commera.sdk import STORE_ORDER_TYPE


def before_order_cancel(doc, method=None):
	if doc.order_type != STORE_ORDER_TYPE:
		return
	if frappe.db.exists("My Job", {"sales_order": doc.name, "status": "In Production"}):
		frappe.throw(_("This order is already in production, so it can't be cancelled."))
```

The refusal applies to the shopper, the dashboard and Desk. No `order_cancelled` event fires.

## Next steps

- [Events](/commera/plugin-reference/events)
- [SDK](/commera/plugin-reference/sdk)
- [Gotchas](/commera/plugin-reference/gotchas)
