Skip to content

Integrations

Integrate Audit Logs

Audit Logs is the feature your customer's security team asks for by name. You emit events; they search, export and stream them. The integration is small — the part worth thinking about is which events you emit and what you put in them.

Before you begin

  • A schema decided before the first event. Renaming an action after it is in a customer's SIEM is not a rename, it is a migration on their side.
  • An organization on every event. Audit data is read by the customer it belongs to, so a missing organization ID makes an event invisible.
  • A view on what belongs in metadata. Enough to answer a question during an incident, never a password, token or full record.
  • A clock you trust. Events carry the time the action happened, not the time you got round to sending it.

Install

Every example on this page uses the Node.js client. The same calls exist in the Python, Go, Ruby, PHP and Java SDKs, following the naming conventions of each language.

terminal
1npm i @paycux/node

Configure

Events are written with your ordinary API key. The stream target below is only needed if you want deliveries pushed into your own sink as well as the customer's.

.env.local
1PAYCUX_API_KEY=sk_example_7f4c1b9ad2e84c6f
2PAYCUX_AUDIT_STREAM=none
3PAYCUX_WEBHOOK_SECRET=whsec_example_2b6d9f31

Then, in the dashboard:

  1. 1Open Audit Logs and register your event schema: the action name, the actor types and the target types each action can carry.
  2. 2Set the retention period for the environment, and check it against what your enterprise contracts promise.
  3. 3Enable the export formats your customers ask for, usually CSV and JSON Lines.
  4. 4If a customer wants a live stream, configure their destination against their organization rather than globally.
  5. 5Give organization admins access to the log view in your product, so the answer to a security question is a link rather than a support ticket.

Implement

One helper, called from everywhere. Emitting audit events inline at each call site is how half of them end up with a different action name, so write the wrapper once and make it the only path.

Emit an event

Actor, action, targets, time. Everything else is context. Keep the action name in the past tense and stable forever, because it is a public identifier the moment a customer builds an alert on it.

lib/audit.ts
1import { Paycux } from "@paycux/node";
2
3const paycux = new Paycux(process.env.PAYCUX_API_KEY);
4
5type AuditInput = {
6 organizationId: string;
7 action: string;
8 actorId: string;
9 actorName: string;
10 targets: { id: string; type: string; name?: string }[];
11 context?: Record<string, string | number | boolean>;
12 ip?: string;
13 userAgent?: string;
14};
15
16export async function audit(input: AuditInput) {
17 await paycux.auditLogs.createEvent({
18 organizationId: input.organizationId,
19 action: input.action,
20 occurredAt: new Date().toISOString(),
21 actor: { id: input.actorId, name: input.actorName, type: "user" },
22 targets: input.targets,
23 context: { location: input.ip, userAgent: input.userAgent },
24 metadata: input.context,
25 });
26}

Call it from an action

Emit after the action succeeds, not before. An audit trail that records attempts as if they were changes is worse than none, because it is believed.

app/api/members/remove/route.ts
1import { audit } from "@/lib/audit";
2import { requirePermission } from "@/lib/authz";
3
4export async function POST(request: Request) {
5 const { user, organizationId } = await requirePermission("members:manage");
6 const { memberId } = await request.json();
7
8 const removed = await removeMember(organizationId, memberId);
9
10 await audit({
11 organizationId,
12 action: "organization.member_removed",
13 actorId: user.id,
14 actorName: user.email,
15 targets: [{ id: removed.id, type: "user", name: removed.email }],
16 ip: request.headers.get("x-forwarded-for") ?? undefined,
17 userAgent: request.headers.get("user-agent") ?? undefined,
18 });
19
20 return Response.json({ ok: true });
21}

Export on demand

Exports are generated asynchronously because a year of events is not a synchronous response. Create the export, then poll or wait for the webhook that says it is ready.

app/api/audit/export/route.ts
1import { Paycux } from "@paycux/node";
2import { requirePermission } from "@/lib/authz";
3
4const paycux = new Paycux(process.env.PAYCUX_API_KEY);
5
6export async function POST(request: Request) {
7 const { organizationId } = await requirePermission("audit:read");
8 const { rangeStart, rangeEnd, actions } = await request.json();
9
10 const exportJob = await paycux.auditLogs.createExport({
11 organizationId,
12 rangeStart,
13 rangeEnd,
14 actions,
15 format: "csv",
16 });
17
18 return Response.json({ id: exportJob.id, state: exportJob.state });
19}

Verify

Check the event as your customer will read it, not as you wrote it. A field that made sense in code often reads as nonsense in a log viewer.

  1. 1Perform a real action in your product and confirm the event appears with the right actor, target and time.
  2. 2Read the event in the dashboard and check that a person outside your team could tell what happened from it alone.
  3. 3Search for the event by action and by actor, and confirm both filters return it.
  4. 4Grep the event body for anything that should not be there: tokens, passwords, full personal records.
  5. 5Create an export over a range that includes the event, download it and confirm the file parses and the row is present.

Reference

Request and response shapes for each of these live in the API reference. Paths are relative to https://api.paycux.com.

MethodPathWhat it does
POST/audit_logs/eventsEmit one audit event for an organization.
GET/audit_logs/eventsSearch events by organization, action, actor or range.
GET/audit_logs/actionsList the registered action names and their schemas.
POST/audit_logs/actionsRegister a new action name and its target types.
POST/audit_logs/exportsStart an export over a time range and filter set.
GET/audit_logs/exports/:idCheck an export's state and collect its download URL.
GET/audit_logs/streamsList live stream destinations configured per organization.

Next steps