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.
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.
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
actorIdortargetIdalone shows an Actor or Target field there while it applies.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.
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.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,targetIdandinvolvingfail closed. An unknown event type, or a subject id that is not a UUID, is a400that streams nothing.createdAfterandcreatedBeforefail closed too. A date the parser cannot read is a400naming the field, and an export refused this way streams nothing. An empty value is treated as absent.targetTypeis 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
orderByand 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 theemail.cap_reachedevent beside them is the send path's own record of a refusal. Both carry the meter and window astargetType(emails:month,emails:day,own_sender_emails:month,own_sender_emails:day,users:month) with the tenant astargetId, sotargetTypefilters one meter; the count (used), the bound and the alert line are inmetadata.
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.
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.
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.
| 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-8ndjson—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,metadataAbsent 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.
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.

