Skip to content

Instantly share code, notes, and snippets.

@odony
Last active May 6, 2026 18:35
Show Gist options
  • Select an option

  • Save odony/71304a91fadf782e6d88522e804ddf2b to your computer and use it in GitHub Desktop.

Select an option

Save odony/71304a91fadf782e6d88522e804ddf2b to your computer and use it in GitHub Desktop.

Rotating your Odoo API key programmatically

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.

Prerequisites

  1. 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.
  2. The administrator has enabled programmatic key management. They must set the system parameter base.enable_programmatic_api_keys to True (Settings → Technical → System Parameters). Without this, only system administrators can call the methods below and you'll get UserError: "Programmatic API keys are not enabled".
  3. You know your Odoo URL.

How rotation works

There is no atomic "rotate" call. The flow is:

  1. Generate a new key, authenticating with the current key.
  2. Store the new key somewhere safe. It is returned exactly once and cannot be retrieved again.
  3. Switch your application to use the new key.
  4. Revoke the old key.

If step 2 or 3 fails, the old key is still valid, so you can retry safely.

API surface

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.

generate(key, scope, name, expiration_date) → str

  • 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 the Authorization header) and be non-expired.
  • scope (str | null): scope of the new key (e.g. "rpc"). null means 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_duration on res.groups).

The response body is the new key as a JSON string. This is the only time you will see it.

revoke(key) → true

  • 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.

Limits and constraints

  • Cap per user: by default, 10 active (non-expired) keys per user. generate will fail with UserError once 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.

Example: Python with requests

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)

Example: curl

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\"}"

Common errors

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 invalid Authorization: Bearer header. Check the key value and that the user is active.
  • UserError: "Programmatic API keys are not enabled": the admin has not set base.enable_programmatic_api_keys.
  • AccessDenied: "The provided API key is invalid or does not belong to the current user.": the key argument 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's api_key_duration.
  • UserError: "Limit of 10 API keys is reached for programmatic creation": revoke before generating, or ask an admin to raise base.programmatic_api_keys_limit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment