Audit log
Purpose: the append-only record of what happened in your tenant — who did it, to whom, from where, and when. Read it in the dashboard, page it through the management API, or pull the whole matching set down as a CSV or NDJSON attachment.
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.
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 (a machine token), system, or anonymous |
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.
Reading it in the dashboard
Tenant → Audit log lists the tenant's events newest first, with the
filters below, an infinite-scrolling table, and a CSV and an NDJSON
download button that apply whatever filters are on screen. 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.
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.
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.
Administrator credentials required. These management calls use the
credential associated with an authorized Udibo dashboard session. A machine
credential issued to an application in your own tenant is not accepted on
/api/identity/…. Use the dashboard or an approved administrator workflow; keep
administrator credentials out of your application integration.
The list
Newest first, cursor-paginated. The default page is 20 events and the
largest you may ask for is 100; a limit outside that range is clamped
rather than refused.
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 AuditPage {
data: AuditEvent[];
cursors: { next: string | null; prev: string | null };
hasMore: boolean;
}
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 on hasMore, never on cursors.next. A page that returned rows
carries a next cursor even when it is the last page — that is on purpose, so a
poller can hold the cursor and ask again later for events that have not happened
yet. Only a page with no rows at all comes back with 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. The live cursor is not a
mistake to work around: hold it and poll it, and it answers with the events
recorded since, which is the cheap way to tail the log.
The cursor carries its own direction, so there is no separate direction
parameter, and ordering is fixed at newest-first — there is no orderBy.
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.
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.
Two filters refuse and two do not, and the difference will surprise you.
eventType,actorId,targetIdandinvolvingfail closed. An unknown event type, or a subject id that is not a UUID, is a400that streams nothing.createdAfterandcreatedBeforefail open. A date the parser cannot read is dropped rather than refused, and the request answers as though you had not sent it — which on an export means a wider result than you asked for, not an error. Send an ISO instant or a bareYYYY-MM-DD, and check the earliestcreatedAtyou got back before trusting a window.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.
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. It records them whether or not they took effect, so the two behaviours
above show up here as a trap: a createdAfter the parser silently dropped is
still written to the record, and a reviewer reading createdAfter: ["lastweek"]
would conclude a one-week export when the full retention window actually
streamed. 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.
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-mintable machine credential. See the caveat above; scheduled exports are a dashboard-credential script today, 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, one per step per meter per window per line (services/udibo/billing/limit-alerts.ts); 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.
Last verified 2026-09-08.

