# Approval Workflow for Odoo Community

> Multi-step approval workflow for Odoo Community, now with rules on any model: a condition picks the records, the ladder decides, a gate holds the confirm button, and the decision sets a field, schedules an activity, sends a template, runs a server action or calls a signed webhook. SLA clocks, parallel steps, a decision trail nobody can edit.

- **What it is:** Multi-step approval workflow for Odoo Community, now with rules on any model: a condition picks the records, the ladder decides, a gate holds the confirm button, and the decision sets a field, schedules an activity, sends a template, runs a server action or calls a signed webhook. SLA clocks, parallel steps, a decision trail nobody can edit.
- **For:** Human Resources
- **Odoo versions:** 16.0, 17.0, 18.0, 19.0, 20.0 - Community and Enterprise
- **Runs on:** Odoo.sh or Odoo you host yourself - not Odoo Online, which accepts no third-party apps
- **Price:** 89 EUR, one-time, on the Odoo Apps Store
- **Technical name:** bambooforge_approvals
- **Not for:** Said before you buy, because it is the half that decides whether the app fits:; Approve anything by itself: a request that no step applies to is refused, not passed.; Let a requester approve their own request - not as a member of the group, not as their own manager, not through a delegation.; Watch records on its own: a rule raises a request from the Action menu or from the button it blocks, not from a save. A record edited after its approval is not re-approved unless you raise it again.
- **Manual:** https://docs.bambooforge.dev/bambooforge_approvals/
- **Live demo:** https://demo20.bambooforge.dev/odoo/action-bambooforge_approvals.action_bf_approval_todo
- **Store page:** https://apps.odoo.com/apps/modules/20.0/bambooforge_approvals
- **Manual last updated:** 2026-09-25

---
# Approval Workflow for Odoo Community

Odoo Community has no approvals app. What people do instead is an email
thread — and six weeks later nobody can say who agreed to the fifteen
thousand, or whether the number they agreed to is the number that was
spent.

This is multi-step approvals for Community, built so that the trail it
leaves is worth reading a year later.

## A type, and the ladder under it

**Approvals › Configuration › Approval Types.**

A type is a kind of thing that needs signing off — spending, time off,
a new starter, access to a system. It says what its form asks for
(a period, an amount, a quantity, a contact, a reference, an
attachment), and carries the ladder of approvers underneath.

A **step** is a group, some named people, the requester's manager, or
all three at once — plus how many of them have to say yes. Steps run in
order: the second opens when the first is satisfied. One refusal at any
step refuses the request, with a reason that the person who raised it
reads.

Two worked examples are installed, built on groups every database
already has, so they do something on the first run.

## The amount matrix

A step can carry a floor: **From amount**. The manager signs everything,
finance from 2,000, the board from 20,000 - three steps on one type,
not three types. A request below a floor skips that rung, and the rung
stays on the trail marked *Skipped*, with the floor written next to it,
so "why did finance not sign this" has an answer six months later.

The floor is read in the company currency, converted on the day the
request is submitted: a rate that moves while the request waits must
not move the request between ladders. A request that no step applies
to cannot be submitted - a ladder nobody has to climb would come out
the other end approved, and the module says so instead. A floor on a
type that never asks for an amount is refused for the same reason: it
would skip its step on every request, silently, for ever.

## The mailbox

When a step opens, everybody who can clear it gets the activity - and,
if the type says **Email approvers**, a mail with an **Approve** button
in it. The button is a signed link: it is bound to that person, that
step and the word *approve*, it asks them to log in as themselves, and
it stops working the moment the step is decided. Forwarding the mail
gives the next reader a link that answers "this was sent to somebody
else". Refusing needs a reason, so the mail's second link opens the
request instead.

## Nobody approves their own request

Not by being in the approving group, not by being their own manager,
not through a delegation, and not by being added by hand afterwards.
The requester is removed from every step's approvers when the ladder is
built, and the same rule is repeated as a database constraint — because
a rule enforced only where the buttons are is a rule that holds until
somebody imports a file.

If removing the requester leaves a step with nobody on it, the request
cannot be submitted and says so. That is better than the alternative,
which is a request that sits in a queue nobody can clear.

## The ladder is frozen when it is submitted

Each step is copied onto the request at submission, with the people it
resolved to at that moment. A reorganisation, somebody leaving the
approving group, or an administrator editing the type afterwards does
not rewrite who was asked.

An approval trail that changes retrospectively is not a trail. It is a
report of what the org chart looks like today.

## What was approved cannot change under the approval

Once anybody has said yes, the amount, the currency, the dates, the
quantity, the contact and the reference are locked. Change them and the
approval that was granted quietly becomes an approval of something
else — which is the single most common way one of these systems turns
out to have been decorative.

To change them, withdraw the request and raise it again. That costs
somebody thirty seconds and leaves a trail that is true.

## The decision log is read-only to everybody

Including the approvers. Nobody has write access to `bf.approval.entry`
at all; every change goes through the workflow, which writes it under
`sudo`.

An approval log that approvers can edit answers "who agreed to this?"
with whatever suits today.

Each decision records **who was asked** and **who actually clicked** —
they differ when somebody was standing in.

## Delegation ends, and does not pass on

**Approvals › Delegation.** Hand your approvals to somebody while you
are away. It has an end date, because a delegation without one is how
somebody is still signing things off two years after the holiday.

It cannot be chained. If B is covering for A and then B goes away and
hands it to C, C ends up with A's say — and nobody chose C. That is
refused when it is set up, not discovered afterwards.

A stand-in never inherits the right to approve their own request
either.

## Confidential types

A confidential type is seen only by the person who raised it, the
people it is waiting on, and an approval administrator. Use it for
anything about money or people.

A confidential type **cannot be delegated**, and that is a deliberate
pairing rather than a missing feature: lending somebody your say would
lend them the reading of it, to somebody nobody chose.

## It chases

Give a type a number of days and any step that has been waiting longer
puts an activity on everybody who can clear it — an activity, not an
email, because an activity is the only thing in Odoo that shows up as a
number somebody has to clear.

An approval queue nobody is reminded of is a drawer.

## Rules: any record climbs a ladder

A type is a ladder. A **rule** (*Configuration → Approval Rules*) says
which records climb it:

- the **model** - sales orders, purchase orders, invoices, transfers, or
  anything with a form;
- the **condition**, written in the same editor as a filter: amount
  above 5,000, margin below 20 %, a tag on the customer, a company;
- the **ladder** it climbs, and the **amount field** on the record that
  the steps' floors are compared with, the **contact field** that names
  the contact on the request;
- what the record's own button is called, when the approval should
  stand in front of it (*Blocks*, below).

Every model with an active rule gets **Request approval** in its Action
menu. Pick the records, choose it, and a request is raised for each one
on every rule that matches - named after the type and the record,
carrying the amount and the contact, linked both ways: the request has a
*Record* button, the record's chatter says it was sent. A record with a
request already open is left alone.

## What the decision does

On the rule, two lists: *When approved* and *When refused*. Each line is
one of:

- **Set a field on the record** - a stage, a flag, a text; the value is
  typed as text and coerced to the field.
- **Schedule an activity** for a named user, with a summary.
- **Send a mail template** of the record's model.
- **Run a server action** of the record's model.
- **Post a message** on the record.
- **Call a webhook**: a JSON body with the request, the record and the
  rule, signed with HMAC-SHA256 in `X-BF-Signature` when a secret is
  set, so the receiver can check it is yours.

They run in order, the moment the request is decided. One that fails -
a webhook that is down, a template that errors - is written to the
request's chatter and never undoes the decision it follows.

## The gate in front of a button

A rule whose *Blocks* names the record's own button - `action_confirm`
on a sales order, `button_confirm` on a purchase, `action_post` on
an invoice, `button_validate` on a transfer - stands in front of that
button:

- the first press raises the request and stops, saying so and naming the
  step it went to;
- while the request waits, the button stops and names the step;
- once it is approved, the button works;
- a refused request is named when somebody tries again.

The request is written before the button is stopped, on a transaction of
its own, so the stop cannot roll it back. Four small modules ship
alongside this app and install by themselves when the other app is
there: the gates for Sales, Purchase, Invoicing and Inventory. No
context flag skips a gate - a browser can send any flag with a call.
The seed runner that writes demo history carries a server-side marker
instead (`SEEDING` in `bf_approval_rule`, an object no request can
produce), and the gate steps aside for that alone.

## A type asks its own questions

Period, amount, quantity, contact, reference and attachment are the
standard fields a type can ask for. On the type's **Questions** tab you
add the rest: a cost centre as a choice, a laptop model as text, a start
date, a yes/no, a number, a contact, a user. Each question has a label,
a type, whether it is required, and a hint.

A request of that type carries one row per question, right above the
details, typed by the question: a date picker for a date, a dropdown for
a choice, a contact picker for a contact. A required question stops the
request being submitted until it is answered - the message names the
question. The answers travel with the request, are read-only once it is
submitted, and are on the decision trail with everything else. From
code, `request._bf_answers()` gives them as a dictionary of label to
text, for a mail template or a webhook.

No model is created and no view is generated: the questions are rows,
which is why they need no upgrade, no Studio, and no developer. What
this is not: a form for people without an Odoo login. Requests are
raised in Odoo, and Odoo's own screens work on a phone.

## SLA clocks and parallel rungs

Every step of a type can carry an **SLA in hours**. The clock starts
when the step opens; when it runs out, everybody who can act on the step
is chased with an activity, the request's chatter says the step passed
its SLA, and the request is flagged **Past its SLA** - a filter on the
list, so the overdue ones are one click.

A step ticked **Together with the step above** opens at the same time as
the one before it instead of after it. Both are asked at once; the
request moves on when both are done. Three steps - the team, legal in
parallel, then the board - is the shape most contracts want.

## Who can do what

- **Approvals › User** — everybody has it. Raise requests, act on the
  ones waiting on you, arrange your own cover.
- **Approvals › Administrator** — defines the types and the steps, sees
  every request including confidential ones, and can withdraw one on
  somebody's behalf.

An administrator cannot edit a decision. Nobody can.

## Nothing from Enterprise

The module depends on `mail` and nothing else. `hr` is used if it
happens to be installed, to find who somebody reports to.

Without it — and for anybody at the top of the tree, who reports to
nobody — "the requester's manager" resolves to nobody. A step is asked
to name somebody who is not the manager as well, and a step that ends
up with nobody on it stops the request from being submitted and says
why. Both examples shipped with the module are built that way.

## How we make sure it works

Before every release, **117 automated checks** run against this app on Odoo 16, 17, 18, 19 and 20.
Among them:

- One person cannot cast two votes on the same step.
- A type that is not offered is refused by the model.
- A request cannot be raised in somebody else's name.
- A type offered on the portal cannot require the unaskable.
- A visitor cannot name a contact that is not theirs.

The complete list is in the source you receive.

## What it does not do

Said before you buy, because it is the half that decides whether the app fits:

- Approve anything by itself: a request that no step applies to is refused, not passed.
- Let a requester approve their own request - not as a member of the group, not as their own manager, not through a delegation.
- Watch records on its own: a rule raises a request from the Action menu or from the button it blocks, not from a save. A record edited after its approval is not re-approved unless you raise it again.

## Install

1. Copy `bambooforge_approvals` into your addons path and update the app list, or install the ZIP from the Odoo Apps Store.
2. Install **Approval Workflow for Odoo Community**.
3. Open **Approvals** - its menus are Waiting on Me, All Requests, Delegation, Configuration.

## Questions people ask

****Which Odoo versions does it run on?****

Odoo 16.0, 17.0, 18.0, 19.0, 20.0, on the series you buy it for; each series is its own listing.

****Community or Enterprise?****

Both. It needs no Enterprise app.

****What does it need?****

The standard Discuss app. No extra Python package.

****Where does it show up?****

Under the **Approvals** menu (Waiting on Me, All Requests, Delegation, Configuration).

****Does anything leave my server?****

No. It makes no call outside your Odoo server: no telemetry, no licence check, no third-party service.

****What happens if I uninstall it?****

Odoo drops the app's own tables and fields; the standard records it worked on (contacts, orders, products) stay as they are.

## Raising one from the portal

The people who ask for things often have no Odoo login worth paying
for: a contractor asking for site access, a subcontractor asking for a
purchase order, a new starter asking for a laptop. Tick **Offered on
the portal** on a request type and it appears at `/my/approvals`, on
a phone as readily as a desk.

What the visitor gets:

- **The type's own form** - the standard fields it asks for (period,
  amount, quantity, contact, reference, attachment) and every question
  you put on it, in the order you put them.
- **Their own requests**, and nobody else's: what they raised, what it
  is waiting on, who has decided, and why if it was refused.
- **Their decision**, if the ladder waits on them - approve, or refuse
  with a reason. The refusal needs the reason; the person who asked
  reads it.
- **Withdraw**, while it is still theirs to withdraw.

What they never get: somebody else's request, a request they were not
named on, or a way to set a field the form does not show. Every route
hands the form to the model, which whitelists what a form may write,
forces the requester to whoever is logged in, and resolves each answer
against the questions that type actually asks. A contact question
offers the visitor their own company's contacts and nothing else.

Two things the portal cannot ask, and says so:

- a question whose kind is **User** - a visitor has no back end to pick
  an internal user in;
- a question you marked **Ask on the portal: no** - the cost centre the
  office fills in.

Make either of them required on a type that is offered on the portal
and the type refuses to save, rather than offering a form nobody can
submit.

The ladder, the SLA clock, the escalation activities and the trail are
the same as for a request raised in the back end. A portal request is
not a lesser request: it is the same record, raised by the person who
wanted the thing.

## What changed in 1.4

- **A portal form**: a request type offered on the portal is raised at
  `/my/approvals` by anybody with a portal login - the type's own
  questions, their own requests, approve or refuse from a phone, and
  nothing of anybody else's.

## What changed in 1.3

- **Questions on a request type**: text, number, date, yes/no, choice,
  contact, user - required or not - answered on the request, checked on
  submit, on the trail. No model, no generated view.
