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

An event tells your plugin that something happened in the store. Commera records the event, commits, and then calls your handler in a background job. A handler cannot stop or change the action that caused the event. To stop an action, use a [hook](/commera/plugin-reference/hooks).

## Register a handler

Add a `commera_events` dict to `hooks.py`. Each key is an event name, and each value is a list of handlers.

```python
commera_events = {
	"order_paid": ["my_app.orders.on_order_paid"],
	"order_refunded": ["my_app.orders.on_order_refunded"],
}
```

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

The handler gets one argument, a `CommeraEvent`. Commera ignores its return value.

```python
import frappe
from commera.sdk.events import CommeraEvent


def on_order_paid(event: CommeraEvent):
	if frappe.db.exists("My Job", {"event_id": event.id}):
		return
	frappe.get_doc({"doctype": "My Job", "event_id": event.id, "sales_order": event.reference_name}).insert()
```

The first part of the dotted path must be your app's name. Commera uses it to find the plugin that owns the handler.

## The CommeraEvent object

`commera.sdk.events.CommeraEvent` is a frozen dataclass.

| Field | Type | Value |
| --- | --- | --- |
| `id` | `str` | A stable ID. It is the same on each retry and on each repeat delivery |
| `name` | `str` | The event name, for example `order_paid` |
| `reference_doctype` | `str` | `Sales Order` for order events, `Item` for product and stock events |
| `reference_name` | `str` | The name of the reference document |
| `data` | `dict` | The event data, see below. `{}` when there is no data |
| `created_at` | `datetime` | When Commera recorded the event |

The `id` of an order event is `<order>-<event>`, for example `SAL-ORD-2026-00010-order_placed`. An order event that can occur more than one time adds a key: `<order>-<event>-<key>`. A product or stock event is `<event>-<key>`. The item code is not in the `id`, so use `event.reference_name`.

## Order events

Commera fires order events only for store orders. A store order is a Sales Order with **Order Type** set to `Shopping Cart` (`commera.sdk.STORE_ORDER_TYPE`). An order that staff make in Desk is not a store order.

| Event | When it fires | How often |
| --- | --- | --- |
| `order_placed` | The shopper places the order. A cash on delivery (COD) order is still a draft Sales Order | Once |
| `order_paid` | Gateway orders: right after `order_placed`. COD orders: when the order is fully billed and every invoice is paid | Once |
| `order_cancelled` | The Sales Order is cancelled, from the storefront, the dashboard or Desk | Once |
| `order_fulfilled` | The order status changes to Shipped, Delivered, Partially Returned or Returned, whichever comes first | Once |
| `order_delivered` | The order status changes to Delivered | Once |
| `order_returned` | The order status changes to Partially Returned or Returned | Once per status |
| `order_refunded` | A refund Payment Entry for the order is submitted | Once per Payment Entry |

:::note[A free order is never paid]

Commera fires `order_paid` at placement only when the gateway charged an amount above zero. An order with a grand total of 0 gets `order_placed` and no `order_paid`. If your plugin must act on every order, handle `order_placed` too.

:::

### Order data

Every order event carries a snapshot of the order, taken when the event fires.

| Key | Value |
| --- | --- |
| `customer` | The Customer name |
| `currency` | The order currency |
| `grand_total` | The order grand total |
| `payment_mode` | The checkout payment mode, for example `COD` |
| `docstatus` | `0` draft, `1` submitted, `2` cancelled, when the event fired |
| `shipping_address` | The address the order ships to: its shipping address, else its billing address. `None` when it has neither |

`shipping_address` is a dict with the Address `name` and `address_title`, `address_line1`, `address_line2`, `city`, `state`, `pincode`, `country` and `phone`. [`get_order`](/commera/plugin-reference/sdk#get_order) returns the same dict.

`order_refunded` adds `payment_entry` (the refund Payment Entry, also the key in `event.id`) and `amount` (the part of this refund that goes to this order, in the order currency, with tax and shipping).

`order_returned` adds `status` (`Partially Returned` or `Returned`) and `partial` (`True` for Partially Returned).

The snapshot can be old when your handler runs. For current values, call `get_order`.

### When fulfilment events fire

Commera sets the order status from the order's Delivery Notes and Shipping Requests.

- A shipment status of Pickup Scheduled, In Transit, Out For Delivery, Undelivered or Lost sets Shipped. Commera fires `order_fulfilled`.
- A shipment status of Delivered sets Delivered. Commera fires `order_fulfilled`, then `order_delivered`.
- With no Shipping Request, a submitted Delivery Note sets Delivered.
- A drop-ship line (**Supplier delivers to Customer**) counts as delivered when its Purchase Order is received. An order with both kinds of lines moves only as far as its slower part.
- A return Delivery Note for part of the delivered quantity sets Partially Returned. For all of it, Returned. Commera fires `order_returned`.
- A shipment status of RTO sets Returned, and `order_returned` has `partial` set to `False`.

To move an order to Shipped from your plugin, call [`record_shipment`](/commera/plugin-reference/sdk#record_shipment).

### When order_refunded fires

Commera fires `order_refunded` for each submitted Payment Entry of type Pay that it can link to a store order: a refund from the dashboard, the refund that a cancel pays out, or a Payment Entry that references the order, one of its invoices or its credit note. When one Payment Entry refunds more than one order, each order gets its own event.

## Product and stock events

These events fire only for items that are on the storefront.

| Event | When it fires | `event.data` |
| --- | --- | --- |
| `product_updated` | A product changes. One event per product for each transaction | `item_code` (the template Item), `changed` (a sorted list) |
| `inventory_changed` | Stock changes in the store warehouse (**Commera Settings > Ecommerce Warehouse**) | `warehouse`, `actual_qty`, `available_qty` |

`changed` holds one or more of these values:

| Value | What changed |
| --- | --- |
| `details` | The product Item or one of its size Items was saved |
| `options` | An option was saved without a publish change, or sizes were added or removed |
| `price` | A selling Item Price of the product or a size was added, changed or deleted |
| `published` | An option was published or unpublished |

A new product fires nothing until you publish it. `changed` tells you what to read again, not the new values.

Commera records these events only when at least one plugin handles them.

## Delivery rules

- **After commit.** Commera queues the delivery after the transaction commits. If the action rolls back, the event does not exist.
- **Background job.** Each delivery runs on the `default` queue. Do not use `frappe.local.request`, a shopper session or a cart cookie.
- **User.** The handler runs as `commera-plugins@commera.local`. This user has every desk role, so permission checks pass. Documents that your handler makes show this user as the owner.
- **Lanes.** Deliveries in one lane run one at a time, oldest first. For order events, each plugin has one lane per order: your `order_placed` handler finishes before your `order_paid` handler starts. For item events, each plugin has one lane per item and event. Plugins never share a lane.
- **At least once.** Commera never fires the same `event.id` twice, but it can deliver it twice. A worker can stop after your handler commits and before Commera marks the delivery done.
- **Commits.** Commera commits after each handler that succeeds. Writes that you commit stay, even if the handler raises later. Commera rolls back the rest.

:::caution[Make every handler idempotent]

Store `event.id` on the record that you make, and check it first. For a call to a remote API, send a stable ID, such as the order name or the Sales Order Item name.

:::

## Retries

When a handler raises, Commera rolls back the uncommitted writes, writes an Error Log with the title `commera_events["<event>"] hook failed: <handler>`, and tries again later. The delays are 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After the sixth failed attempt, the delivery is Failed.

A scheduled job starts the deliveries that are due, on each scheduler tick. The tick is 4 minutes by default, so a retry runs up to one tick after its delay. Make sure that the scheduler is enabled on the site, or retries do not run. A delivery that stays Running for more than 30 minutes counts as a failed attempt.

While a delivery waits for a retry, the later deliveries in its lane wait too. When it is Failed, they run.

## See deliveries

- **Settings > Plugins**, then **Open** on your plugin: the **Recent deliveries** list shows each delivery with its event, order, status and time. The statuses are Queued, Sending, Retrying, Sent and Failed. Only a System Manager sees this list.
- On the order page in `/commera`, a red alert says that your plugin couldn't process the order when one of its deliveries for that order is Failed.
- In Desk, the **Commera Event** record of the event lists its deliveries with the status Queued, Running, Done or Failed.

A System Manager sees a **Retry** button on a Failed delivery, in the list and on the order page alert.

## Retention

Commera keeps order events, because their rows stop an order event from firing twice. **Log Settings** deletes `product_updated` and `inventory_changed` events after 14 days, unless a delivery of the event is still waiting.

## Next steps

- [Hooks](/commera/plugin-reference/hooks)
- [SDK](/commera/plugin-reference/sdk)
- [Testing](/commera/plugin-reference/testing)
