Odoo lets you rotate your own API keys over RPC, without touching the web UI. This is useful for portal users and service integrations that need to roll credentials on a schedule.
The examples below use the modern /json/2 HTTP endpoint (Odoo 19+). The
older /xmlrpc/2 and /jsonrpc endpoints are deprecated and will be removed
in a future version, so new integrations should target /json/2.
- You already have a valid, non-expired API key. Rotation re-authenticates with the current key — there's no other entry point. The very first key still has to be created in the UI (Preferences → Account Security → New API Key) or by an administrator.
- The administrator has enabled programmatic key management. They must set
the system parameter
base.enable_programmatic_api_keystoTrue(Settings → Technical → System Parameters). Without this, only system administrators can call the methods below and you'll getUserError: "Programmatic API keys are not enabled". - You know your Odoo URL.
There is no atomic "rotate" call. The flow is:
- Generate a new key, authenticating with the current key.
- Store the new key somewhere safe. It is returned exactly once and cannot be retrieved again.
- Switch your application to use the new key.
- Revoke the old key.
If step 2 or 3 fails, the old key is still valid, so you can retry safely.
Both methods live on the res.users.apikeys model and are called via the
JSON/2 RPC endpoint:
POST /json/2/res.users.apikeys/<method>
Authorization: Bearer <api_key_used_for_auth>
Content-Type: application/json
X-odoo-database: <db_name> # only required in multi-database setups
{ ...method kwargs as JSON... }
The bearer token itself identifies the calling user — there is no separate login step.
key(str): the current API key, used as the credential argument for this call. Must belong to the calling user (the same key used in theAuthorizationheader) and be non-expired.scope(str | null): scope of the new key (e.g."rpc").nullmeans a global key. A global current key can mint any scope; a scoped current key can only mint keys with the same scope.name(str): human-readable label shown in the UI.expiration_date(str):"YYYY-MM-DD HH:MM:SS"(UTC). Required for non-admin users. Cannot exceed the maximum duration allowed by your user group (api_key_durationonres.groups).
The response body is the new key as a JSON string. This is the only time you will see it.
key(str): the key to revoke.
The response body is true. Returns HTTP 401/403 with an AccessDenied error
if the key is invalid or already gone.
- Cap per user: by default, 10 active (non-expired) keys per user.
generatewill fail withUserErroronce you hit the cap, so revoke before you regenerate if you're close to the limit. - Scope compatibility: see above: a scoped key cannot escalate to a different scope.
import requests
from datetime import datetime, timedelta, timezone
URL = "https://example.odoo.com"
CURRENT_KEY = "...existing api key..."
def call(method, payload, bearer):
r = requests.post(
f"{URL}/json/2/res.users.apikeys/{method}",
headers={
"Authorization": f"Bearer {bearer}",
"Content-Type": "application/json",
},
json=payload,
timeout=30,
)
r.raise_for_status()
return r.json()
# 1. Generate the new key, authenticating with the current one.
expires = (datetime.now(timezone.utc) + timedelta(days=90)).strftime("%Y-%m-%d %H:%M:%S")
new_key = call(
"generate",
{
"key": CURRENT_KEY,
"scope": "rpc",
"name": "rotated-2026-05",
"expiration_date": expires,
},
bearer=CURRENT_KEY,
)
# 2. Persist `new_key` to your secret store BEFORE proceeding.
save_secret(new_key)
# 3. Revoke the old key, authenticating with the new one.
call("revoke", {"key": CURRENT_KEY}, bearer=new_key)URL="https://example.odoo.com"
CURRENT_KEY="...existing api key..."
NEW_KEY=$(curl -sS -X POST "$URL/json/2/res.users.apikeys/generate" \
-H "Authorization: Bearer $CURRENT_KEY" \
-H "Content-Type: application/json" \
-d "{
\"key\": \"$CURRENT_KEY\",
\"scope\": \"rpc\",
\"name\": \"rotated-2026-05\",
\"expiration_date\": \"2026-08-06 00:00:00\"
}" | jq -r '.')
# Save $NEW_KEY before this line.
curl -sS -X POST "$URL/json/2/res.users.apikeys/revoke" \
-H "Authorization: Bearer $NEW_KEY" \
-H "Content-Type: application/json" \
-d "{\"key\": \"$CURRENT_KEY\"}"JSON/2 returns errors as a JSON object with name, message, arguments,
timestamp, context, debug, and a matching HTTP status code.
- HTTP 401,
werkzeug.exceptions.Unauthorized— missing or invalidAuthorization: Bearerheader. Check the key value and that the user is active. UserError: "Programmatic API keys are not enabled": the admin has not setbase.enable_programmatic_api_keys.AccessDenied: "The provided API key is invalid or does not belong to the current user.": thekeyargument doesn't match an active key for the calling user, or the requested scope is incompatible with the credential's scope.ValidationError: "The API key must have an expiration date"/"You cannot exceed N days.": expiration is required and bounded by your group'sapi_key_duration.UserError: "Limit of 10 API keys is reached for programmatic creation": revoke before generating, or ask an admin to raisebase.programmatic_api_keys_limit.