Skip to content

Security audit log

GET /api/org/audit-log/ lets an org's admins read its security audit log: sign-ins, org switches, refused requests, record merges, paused webhooks, API token and calendar feed changes and the like. The view is SecurityAuditLogListView (backend/common/views/audit_log_views.py), routed in common/urls.py; the events are written by AuditLogger in common/audit_log.py. See Conventions for limit and offset, and Errors for the refusal shapes.

Who may read it

Org admins only (IsOrgAdmin: the ADMIN role, or a Django superuser's profile in the org). A member is refused with 403:

{"detail": "You must be an organization administrator to perform this action."}

It is read from a signed-in session and nothing else. /api/org/audit-log/ is on the credential deny-list in common/scopes.py, beside /api/webhooks/ and the token routes, so a personal access token or the organization API key is refused before the view runs, whatever scopes it carries (see Tokens and API keys). A leaked token cannot read what the org has noticed about it.

Only the caller's own org's rows are returned. The table has no row-level security policy on purpose (its org is empty for events such as a failed sign-in), so the view's org=request.profile.org filter is the barrier, and a row with no org never matches it.

Query parameters

Parameter Meaning
event_type One event type, by value (LOGIN_SUCCESS, WEBHOOK_PAUSED, ...). An unknown value is a 400.
include_token_refresh true to list TOKEN_REFRESH rows alongside everything else. Any other value, or none, leaves them out.
actor The user id of whoever the event is about. Not a UUID is a 400.
from, to Days, YYYY-MM-DD, inclusive, in the org's timezone. from after to is a 400.
limit, offset Paging: 25 rows by default, at most 100.

TOKEN_REFRESH rows (a signed-in client quietly renewing its session) are most of what the table holds, so they are left out of the list, and out of count, unless the caller asks for them: with include_token_refresh=true, or with event_type=TOKEN_REFRESH, which lists only those. They are still recorded either way.

Rows come newest first. For example, GET /api/org/audit-log/?event_type=LOGIN_SUCCESS&from=2026-09-01 lists this month's sign-ins to the org. LOGIN_FAILURE is accepted as a filter but lists nothing: see Failed sign-ins.

{"event_type": ["Unknown event type."]}

The response

The usual limit/offset envelope, plus the event types the log can hold, so a client can offer them as a filter without a list of its own:

{
  "count": 213,
  "next": "https://api.example.com/api/org/audit-log/?limit=25&offset=25",
  "previous": null,
  "results": [
    {
      "id": "7d0e...",
      "event_type": "WEBHOOK_PAUSED",
      "event_label": "Webhook Paused",
      "created_at": "2026-09-26T09:12:44.018+00:00",
      "success": true,
      "actor": {"id": "3b1f...", "name": "Dana Reyes", "email": "dana@example.com"},
      "ip_address": null,
      "user_agent": "",
      "request_method": "",
      "request_path": "",
      "details": {"endpoint_id": "91ac...", "creator_id": "3b1f...", "pause_reason": "..."}
    }
  ],
  "event_types": [{"value": "LOGIN_SUCCESS", "label": "Login Success"}]
}

actor is null for an event with no user.

What each row returns is chosen field by field rather than copied from the table:

  • No description. An ORG_SWITCH row's text names the org the user switched from, which is another tenant's name.
  • details holds only known-safe keys (SAFE_DETAILS: action, resource, deleted_count, endpoint_id, creator_id, pause_reason, previous_creator_id, changed). Those are ids, counts and sentences the server wrote. Everything else in the stored metadata can hold what a caller supplied (the email a failed login tried, an API key prefix, free-text details of suspicious activity) and is left out.
  • A merge row also carries both records (EVENT_DETAILS): a RECORD_MERGED row adds entity (lead, contact or account), kept_id, kept_name, merged_id and merged_name. The merged record is deleted, so this is the only place its name survives. Those keys are allowed on that event only; the same key on any other row is left out.
  • An API token row also names the token (EVENT_DETAILS): API_TOKEN_CREATED and API_TOKEN_REVOKED rows add token_id, token_prefix, token_name, scopes, owner_id and owner_name. See Credential changes. Allowed on those two events only.
  • A module change also carries its keys (EVENT_DETAILS): a MODULES_CHANGED row adds switched_off and switched_on (lists of module keys) and reordered (a boolean). See Module changes. Allowed on that event only.
  • An app change names the app (EVENT_DETAILS): APP_INSTALLED, APP_UPGRADED and APP_REMOVED rows add app_id and version. See App changes. Allowed on those three events only.
  • Paths under /api/public/ are cut to that prefix. Some public links carry a token in the URL (a satisfaction-survey link, for one), and the log should not repeat it.

Credential changes

From django-crm 1.13.0 the two credentials a member can mint for themselves are audited, because each one keeps working until someone takes it away. Every row is written against the acting org, so the org's admins see it here, with the caller's client IP, user agent, method and path like any other row.

event_type Label Written by actor
CALENDAR_FEED_ENABLED Calendar Feed Enabled POST /api/profile/calendar-feed/ when the member had no feed the member
CALENDAR_FEED_REGENERATED Calendar Feed Regenerated POST /api/profile/calendar-feed/ when it replaced a feed (the old URL stops working) the member
CALENDAR_FEED_DISABLED Calendar Feed Disabled DELETE /api/profile/calendar-feed/ when a feed existed the member
API_TOKEN_CREATED API Token Created POST /api/profile/tokens/ the owner
API_TOKEN_REVOKED API Token Revoked DELETE /api/profile/tokens/<id>/ (the owner) or DELETE /api/org/tokens/<id>/ (an admin) whoever revoked it

A calendar feed row carries no details. An API token row carries:

Key Value
token_id The token's id
token_prefix The display prefix the token list already shows (bcrm_pat_ and four characters)
token_name The name its owner gave it
scopes Its scopes; an empty list is a token with its owner's full access
owner_id, owner_name The user whose token it is, so an admin's revoke of someone else's token names both people

No row holds the feed URL, a raw token or a token's hash. A no-op writes nothing: disabling a feed that is already off, or revoking a token that is already revoked (on either endpoint), still answers 200 but adds no row, so the log holds one revoke per token. A refused call (a 400 create, a 403 member on /api/org/tokens/, a 404 token that is not yours or not in your org) writes nothing either. If the audit row cannot be written the action still succeeds; the failure goes to the security.audit logger, as for every other event.

Module changes

PUT /api/org/modules/ writes a MODULES_CHANGED row (label Modules Changed) when an admin switches a module off or on, or reorders the modules, with the admin as actor. A request that changes nothing, or is refused, writes nothing. See Modules.

Key Value
switched_off Keys of the modules this change switched off
switched_on Keys of the modules this change switched on
reordered true when the order changed

App changes

Installing, updating and removing an industry app each write a row, with the admin as actor. A refused call writes nothing.

event_type Label Written by
APP_INSTALLED App Installed POST /api/apps/<id>/install/, and POST /api/packs/<id>/apply/ when it installs
APP_UPGRADED App Updated POST /api/apps/<id>/upgrade/
APP_REMOVED App Removed POST /api/apps/<id>/remove/
Key Value
app_id The app's id, such as real-estate
version The app's version after the change

Failed sign-ins

From django-crm 1.13.0 a refused staff sign-in writes a LOGIN_FAILURE row (AuditLogger.login_failure, called from common/views/auth_views.py). The caller has no org yet, so the row has none, and this endpoint never returns it: failed sign-ins are for the operator, read from the security_audit_log table or the security.audit logger (security_audit.log), not from an org's viewer.

metadata holds the claimed email (cut to 254 characters, empty when the attempt named none) and a reason; user is set when that email belongs to a user. The token or code that was tried is never stored. The reasons:

reason When
google_unreachable The Google token exchange could not be reached
google_code_rejected Google refused the authorization code
google_invalid_id_token The code flow returned no ID token, or one that would not parse
google_invalid_token The phone's Google ID token failed verification
google_no_email The Google token carried no email
google_email_unverified Google does not vouch for the email
account_disabled The user exists but is deactivated (Google or email sign-in)
magic_link_invalid A sign-in link that is unknown, used or expired; the email is the link's own when it exists
code_not_found No unused, unexpired sign-in code for that email
code_wrong The code did not match
code_attempts_exhausted The code did not match and that was the last attempt, so the code is spent

Anonymous callers write these rows, so each client IP gets at most 20 per hour (LOGIN_FAILURE_ROWS_PER_IP_PER_HOUR in common/audit_log.py, counted in the default cache from the first failure). Attempts past the cap are refused exactly as before; they leave no row and no log line.