Skip to content
Udibo
On this page
View Markdown

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 when investigating an account or downloading a filtered file. Use retention and 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.

QueryTakesRepeatable
eventTypeAn exact name (auth.sign_in.failed) or a whole category (auth)Yes
actorIdA user idNo
targetIdThe id of the thing acted onNo
involvingAn id that must appear as either the actor or the targetNo
targetTypeThe kind of thing acted onNo
createdAfterAn ISO instant, or a bare YYYY-MM-DD meaning that whole UTC dayNo
createdBeforeThe sameNo

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

FieldWhat it holds
idThe event's own id
createdAtWhen it was recorded
eventTypeIts name in the event taxonomy, e.g. auth.sign_in.failed
actorTypeOne of user, client, bot, system, or anonymous (below)
actorIdWho acted, when there is a who
targetTypeWhat kind of thing was acted on
targetIdWhich one
ipAddressThe request's client address, where one was captured
userAgentThe request's user agent, where one was captured
metadataA 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

MethodPathScope
GET/api/identity/{tenantId}/audit-eventsidentity:audit-events:read
GET/api/identity/{tenantId}/audit-events/exportidentity: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.

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.

TypeScript
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<string, unknown> | 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<string, AuditSubject>;
}

async function signInFailures(
  tenantId: string,
  token: string,
): Promise<AuditEvent[]> {
  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.

QueryValuesDefault
formatcsv or ndjsoncsv

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.<format>", 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.

TypeScript
async function* exportedEvents(
  tenantId: string,
  token: string,
  since: string,
): AsyncGenerator<Record<string, unknown>> {
  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<string, unknown>;
    }
  }
  if (buffered) yield JSON.parse(buffered) as Record<string, unknown>;
}

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.