# Audit log Find out what happened in your tenant — who did it, to whom, from where, and when. Read the audit log in the dashboard, and download the whole matching set as CSV or NDJSON. The list and the export are also management API operations, described below for reference. Start with the [dashboard workflow](https://www.udibo.com/docs/identity/audit-log#reading-it-in-the-dashboard) when investigating an account or downloading a filtered file. Use [retention](https://www.udibo.com/docs/identity/audit-log#retention) and [limits today](https://www.udibo.com/docs/identity/audit-log#limits-today) when deciding whether the log meets your operational needs. The log is **append-only in the strict sense**: the service writes events, and nothing edits or deletes one. There is no update or delete verb on the resource and no "clear log" button, in the dashboard or the API. The only thing that ever removes a row is the retention purge described at the end of this page. ## Reading it in the dashboard **Tenant** → **Audit log** lists the tenant's events newest first, with the filters below and an infinite-scrolling table. Viewing requires **View the audit log**. Downloading also requires **Export the audit log**; Owner and Admin carry both, while Support can read without exporting. When you have export access, **Export CSV** and **Export NDJSON** download every event matching the applied filters, including rows you have not scrolled to. A user's own page in the dashboard carries the same log narrowed with `involving` — everything that person did **or** had done to them, which is why an admin action on their account shows up there too. 1. Open the correct tenant's **Audit log**. For one person's history, open their page under **Users**, or enter their user ID in **Actor or target**, which matches the id on either side of an event. 2. Choose an **Event type**, open **More filters** and set **From (UTC)** and **To (UTC)** for the incident's dates, then choose **Apply**. Each date is a whole UTC day, both ends included, matching the **When** column. **More filters** also holds **Target type**, and stays open while any filter in it is applied. A link that narrows by `actorId` or `targetId` alone shows an **Actor** or **Target** field there while it applies. 3. Check the event type, time, actor, target, and details before changing the account. The actor and target show a name beside each id where this tenant holds one — a user's display name or username, an application's or organization's name — marked "(deleted)" when it has since been deleted. A Udibo administrator who acted here is named while they still appear on the tenant's **Administrators** page, support access included, and while their Udibo account exists, and only if you may view that page. An id with no name beside it belongs to no user, application or organization in this tenant you may view, such as an administrator whose access has since been revoked. 4. Read a sign-in as two events. **Sign-in: first factor verified** (`auth.sign_in.succeeded`) records a verified first factor; MFA may still be pending. **Sign-in: session started** (`auth.sign_in.completed`) records creation of the tenant session, before application consent or your callback succeeds. 5. If you need a file, choose **Export CSV** or **Export NDJSON** after applying the filters. Keep the file under your access controls: it contains account activity and can include IP addresses and user agents. An empty result is a reason to check the tenant, IDs, dates, filters, and retention. Some event capture is best effort, so absence alone does not prove an action never happened. Compare with your application's own request records. Your end users see a redacted slice of their own account's events on their `/security` page, on your tenant's own host. That view is a different, narrower surface with its own allowlist of event types, and what it redacts is **an administrator's identity and context** — an admin action shows up as having happened without naming who did it, and with no IP address or user agent attached. The system's own actions are redacted the same way. It does **not** redact the person's own context: their own actions, and anonymous attempts against their account, come back with the IP address and user agent intact. That is deliberate — those are the rows someone reads to recognise a sign-in that was not theirs. ## Filters Every filter below works identically on the list, the export and the dashboard view. | Query | Takes | Repeatable | | --------------- | ------------------------------------------------------------------ | ---------- | | `eventType` | An exact name (`auth.sign_in.failed`) or a whole category (`auth`) | Yes | | `actorId` | A user id | No | | `targetId` | The id of the thing acted on | No | | `involving` | An id that must appear as **either** the actor or the target | No | | `targetType` | The kind of thing acted on | No | | `createdAfter` | An ISO instant, or a bare `YYYY-MM-DD` meaning that whole UTC day | No | | `createdBefore` | The same | No | Filters combine with **AND**. Repeated `eventType` values are the exception — they OR with each other, so naming three types returns all three. **Check how each filter handles an invalid value before relying on the result.** - **`eventType`, `actorId`, `targetId` and `involving` fail closed.** An unknown event type, or a subject id that is not a UUID, is a `400` that streams nothing. - **`createdAfter` and `createdBefore` fail closed too.** A date the parser cannot read is a `400` naming the field, and an export refused this way streams nothing. An empty value is treated as absent. - **`targetType` is not validated against anything.** A value matching no rows returns an empty result rather than an error, so a typo here reads as "nothing happened". The dashboard treats a refused filter differently from the API, deliberately: a bad value in the browser's URL strips that filter and reloads the page telling you which one it dropped, rather than showing you an error page. **The dashboard's download link does not do that** — it is the raw surface, so a bad id there is the same `400` the API gives. ## What one event carries | Field | What it holds | | ------------ | ---------------------------------------------------------------- | | `id` | The event's own id | | `createdAt` | When it was recorded | | `eventType` | Its name in the event taxonomy, e.g. `auth.sign_in.failed` | | `actorType` | One of `user`, `client`, `bot`, `system`, or `anonymous` (below) | | `actorId` | Who acted, when there is a who | | `targetType` | What kind of thing was acted on | | `targetId` | Which one | | `ipAddress` | The request's client address, where one was captured | | `userAgent` | The request's user agent, where one was captured | | `metadata` | A per-event-type object of details, or `null` | `actorId`, `targetType`, `targetId`, `ipAddress`, `userAgent` and `metadata` are **nullable, not optional**. An event with no such thing — a scheduled job has no user agent, a system event has no actor id, and an event with nothing extra to say stores `metadata` as `null` rather than `{}` — carries the key with a `null` value. So `"actorId" in event` is true on every event and tells you nothing; check the value. Type them nullable and a `metadata?.someField` reader will not throw on the events that have none. `actorType` says who acted. `user` is a signed-in person, with their id as `actorId`. `client` is a machine token acting for the application it was issued to: `actorId` is `null` and `metadata.clientId` names the application, so a machine mutation is never attributed to a person who did not make the call. `bot` is a machine token whose subject is a bot principal — a user of `type: "bot"` registered to stand for an application — and carries the bot's id as `actorId`, so the `actorId` and `involving` filters find its trail. A stored `client` event is never re-read as `bot`. `system` is the platform itself (a scheduled job, a flow-triggered revocation) and `anonymous` an unauthenticated request (a failed sign-in, a reset request). ## Application-requested reauthentication When an application request starts a hosted reauthentication interaction, `auth.reauthentication.requested` names the application and records its normalized prompt, assurance preferences and maximum age. Its metadata `requestId` is a hash of the transient flow nonce; the raw nonce, OAuth state and callback URL are not recorded. The first eligible sign-in completion carries `metadata.reauthentication` on `auth.sign_in.completed`, with the same request and client ids, achieved authentication claims, and `optionalMfaDeclined`. That last field is true only when the person explicitly declined a valid optional-enrollment offer. Completion means sign-in finished, before consent or code issuance; it does not mean the application accepted the achieved assurance. An abandoned interaction has no matching completion, and a request satisfied by an existing session does not start this interaction. These events use the same best-effort capture and retention policy as the rest of the log. ## Retention Audit events are kept for **365 days** and then hard-deleted by a purge that runs daily. That number is configuration and can move; what is promised is the floor beneath it — **at least 90 days**, which the purge is not permitted to go below. The dashboard states the floor rather than the configured window, so build anything you depend on against 90 days and treat the rest as headroom. Purged means gone: these rows are deleted outright, not soft-deleted, so nothing brings them back. **If you need events for longer than the window, export them on a schedule** — the export is the archival path, and there is no separate archive we keep for you. ## Limits today - **No customer credential for the API.** See the note above; a scheduled export today is a download you start from the dashboard, not an unattended service. - **No streaming-out integration.** There is no log drain, no S3 destination and no SIEM connector; the export is a pull. - **No `orderBy` and no full-text search.** Newest-first with the filters above is the whole query surface. - **No JSON export format.** The list returns JSON but is paginated; the export is CSV or NDJSON. - **The export is not rate-limited**, which also means nothing throttles a runaway loop of them on your behalf. - **`usage.*` events are the usage-alert ladder**: for each meter and period, one event when usage reaches half of the limit or allowance, one at your alert threshold (80% by default), and one at the limit itself; they are what a webhook subscribed to your limits receives, and the `email.cap_reached` event beside them is the send path's own record of a refusal. Both carry the meter and window as `targetType` (`emails:month`, `emails:day`, `own_sender_emails:month`, `own_sender_emails:day`, `users:month`) with the tenant as `targetId`, so `targetType` filters one meter; the count (`used`), the bound and the alert line are in `metadata`. ## Reading it through the API | Method | Path | Scope | | ------ | ---------------------------------------------- | ---------------------------- | | `GET` | `/api/identity/{tenantId}/audit-events` | `identity:audit-events:read` | | `GET` | `/api/identity/{tenantId}/audit-events/export` | `identity:audit-events:read` | Both take the same filters. The difference is shape, not scope: the list answers one page of JSON, the export streams every matching event as an attachment. **Dashboard only today.** These management API operations are listed for reference: they accept only the dashboard's own session or a token from Udibo's own sign-in, which you cannot get outside the dashboard. A token your tenant issues, including a client-credentials token, is refused with `401`; see [the management API](https://www.udibo.com/docs/identity/apis#the-management-api). ### The list Newest first, cursor-paginated. The default page is **20 events** and the largest is **100**. A requested `limit` must be a whole number of at least 1; zero, negative, or fractional values are refused with `400`. Values above 100 are clamped to 100. ```ts interface AuditEvent { id: string; createdAt: string; eventType: string; actorType: string; actorId: string | null; targetType: string | null; targetId: string | null; ipAddress: string | null; userAgent: string | null; metadata: Record | null; } interface AuditSubject { type: "user" | "application" | "organization"; name: string; deleted: boolean; } interface AuditPage { data: AuditEvent[]; cursors: { next: string | null; prev: string | null }; hasMore: boolean; subjects: Record; } async function signInFailures( tenantId: string, token: string, ): Promise { const found: AuditEvent[] = []; let cursor: string | null = null; do { const url = new URL( `https://www.udibo.com/api/identity/${tenantId}/audit-events`, ); url.searchParams.set("eventType", "auth.sign_in.failed"); url.searchParams.set("limit", "100"); if (cursor) url.searchParams.set("cursor", cursor); const response = await fetch(url, { headers: { authorization: `Bearer ${token}` }, }); if (!response.ok) throw new Error(`list failed: ${response.status}`); const page = await response.json() as AuditPage; found.push(...page.data); cursor = page.hasMore ? page.cursors.next : null; } while (cursor); return found; } ``` **Stop a forward walk when `hasMore` is false.** A non-empty page can carry a `next` cursor even when no further rows exist. Because this log is newest first, `cursors.next` continues toward **older** events; it is not a checkpoint for polling newly recorded activity. Only an empty page returns `next: null`. A loop that runs until the cursor goes null does terminate — the page after the last one is empty and nulls it — but it always pays one extra request to find that out. Reading `hasMore` saves the round trip. To check for recent activity, start a new newest-first query with your intended time filter and deduplicate by event ID. A timestamp alone is not a unique position: multiple events can share it. For customers today, refresh the dashboard's filtered view; the management API still has the credential restriction described above. The cursor carries its own direction, so there is no separate direction parameter, and ordering is fixed at newest-first — there is no `orderBy`. **Read names from `subjects`.** Every page, not only the first, carries `subjects`: a readable name for the page's actor and target ids, keyed by id. A user actor, and a `user`, `application` or `organization` target, is named from this tenant's own records — a user's display name or username, otherwise the record's name — with `deleted: true` when the record has since been deleted. A family is named only when the credential could list it directly, holding its read scope (`identity:users:read`, `identity:applications:read`, `identity:organizations:read`) and permission; a credential with the audit scope alone gets an empty map. An administrator who acted in the tenant from a Udibo account is named as a `user` while they appear in `GET /api/identity/{tenantId}/administrators` — a direct seat, pending or accepted and support included, or membership in an organization holding a grant over the tenant — while their Udibo account exists, and only to a credential that could read that list (`identity:tenants:read` and the `identity.grants.read` permission). A machine credential never gets these names. An id that resolves to nothing in this tenant, such as an administrator whose seat has been revoked or a purged record, is absent, so treat a missing key as "no name", not as "deleted". The export carries ids only: names change, and the file records what the event recorded. **Read the size of a filtered view from its first page.** A request without a `cursor` also carries `total`, the number of events the filters match, and `totalCap`, where counting stops: the log is unbounded, so a `total` equal to `totalCap` (10,000) means at least that many. With a filter applied, the same page carries `unfilteredTotal`, the count with no filter, capped the same way. A later page carries none of them, so walking the log never recounts it. The dashboard shows the same numbers above the table. ### The export `GET …/audit-events/export` streams **every** event matching the filters, newest first, as a downloadable attachment. It is a streamed response rather than a built-then-sent file, so a large window starts arriving immediately instead of buffering server-side. | Query | Values | Default | | -------- | ----------------- | ------- | | `format` | `csv` or `ndjson` | `csv` | An unrecognized `format` is a `400` naming the two it accepts, and it is refused **before** anything streams — so a typo costs you nothing and records nothing. - **`csv`** — `text/csv; charset=utf-8` - **`ndjson`** — `application/x-ndjson` Both arrive as `content-disposition: attachment; filename="audit-events-YYYY-MM-DD."`, dated in UTC at export time, with `cache-control: no-store`. **`limit` is ignored here.** The export is defined by its filters, not by a page size; passing one changes nothing. `cursor` **is** honoured, and it is the way to resume: hand it a previous list page's `cursors.next` and the export starts below that position. **There is no cap on how many events an export returns**, no truncation, and no row-limit constant — a full retention window comes down whole. There is also no queued-job, signed-URL or emailed-file variant: the request streams the answer synchronously, so budget the connection time for a large window rather than expecting a job id back. #### The CSV Ten columns, in this fixed order, with a header row and CRLF line endings: ``` id,createdAt,eventType,actorType,actorId,targetType,targetId,ipAddress,userAgent,metadata ``` Absent values are empty fields, `createdAt` is ISO 8601, and `metadata` is its JSON serialization in a single field. Fields are quoted per RFC 4180 when they contain a comma, a quote or a line break, and any field beginning with `=`, `+`, `-`, `@`, a tab or a carriage return is prefixed with a single quote so a spreadsheet reads it as text rather than a formula — the log records attacker-supplied strings like `userAgent`, and this is what keeps opening the file safe. **The CSV has no `tenantId` column.** You asked for one tenant by id, so every row is that tenant's. #### The NDJSON One JSON object per line, `\n`-separated, no header. The keys are the fields in the table above **plus `tenantId`**, which NDJSON carries as provenance because these files get concatenated and moved around in a way a spreadsheet does not. ```ts async function* exportedEvents( tenantId: string, token: string, since: string, ): AsyncGenerator> { const url = new URL( `https://www.udibo.com/api/identity/${tenantId}/audit-events/export`, ); url.searchParams.set("format", "ndjson"); url.searchParams.set("createdAfter", since); const response = await fetch(url, { headers: { authorization: `Bearer ${token}` }, }); if (!response.ok) throw new Error(`export failed: ${response.status}`); if (!response.body) throw new Error("export returned no body"); const decoder = new TextDecoder(); let buffered = ""; for await (const chunk of response.body) { buffered += decoder.decode(chunk, { stream: true }); const lines = buffered.split("\n"); buffered = lines.pop() ?? ""; for (const line of lines) { if (line) yield JSON.parse(line) as Record; } } if (buffered) yield JSON.parse(buffered) as Record; } ``` Read the records by key. **Key order is not part of the contract** — nothing pins it, so parse each line as an object rather than positionally. ## Exporting is itself audited Every export writes an `admin.audit_events.exported` event **into the log it exported**, carrying who ran it, the format, and the filters. Reading tenant B's log is something that happened to B, so that is where the record lands — the acting administrator's own tenant is not where you look for it. **That record is the query string, not the query that ran**, and the difference matters if anyone audits it. The recorded `filters` are the raw values from the request URL for each recognized key — `eventType`, `actorId`, `targetId`, `targetType`, `involving`, `createdAfter`, `createdBefore`, plus `limit` and `cursor`. They are recorded as written, not as the parser normalized them: a bare `YYYY-MM-DD` stays a date rather than the instant it resolved to, a `targetType` that matched nothing reads like one that did, and `limit` is recorded on exports too, where it does nothing. **Reconcile an export's scope against the events it returned, not against this record's filters.** Values are capped for the record's own safety — at most 25 per key and 256 characters each — so a very long filter can be truncated in the record while having applied in full. A refused export writes nothing. The format check and the filter validation both run before the event is recorded, so a `400` leaves no trace and no partial file. _Last verified 2026-10-25._