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.
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.
1PAYCUX_API_KEY=sk_example_7f4c1b9ad2e84c6f2PAYCUX_AUDIT_STREAM=none3PAYCUX_WEBHOOK_SECRET=whsec_example_2b6d9f31
Then, in the dashboard:
- 1Open Audit Logs and register your event schema: the action name, the actor types and the target types each action can carry.
- 2Set the retention period for the environment, and check it against what your enterprise contracts promise.
- 3Enable the export formats your customers ask for, usually CSV and JSON Lines.
- 4If a customer wants a live stream, configure their destination against their organization rather than globally.
- 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.
1import { Paycux } from "@paycux/node";23const paycux = new Paycux(process.env.PAYCUX_API_KEY);45type 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};1516export 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.
1import { audit } from "@/lib/audit";2import { requirePermission } from "@/lib/authz";34export async function POST(request: Request) {5 const { user, organizationId } = await requirePermission("members:manage");6 const { memberId } = await request.json();78 const removed = await removeMember(organizationId, memberId);910 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 });1920 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.
1import { Paycux } from "@paycux/node";2import { requirePermission } from "@/lib/authz";34const paycux = new Paycux(process.env.PAYCUX_API_KEY);56export async function POST(request: Request) {7 const { organizationId } = await requirePermission("audit:read");8 const { rangeStart, rangeEnd, actions } = await request.json();910 const exportJob = await paycux.auditLogs.createExport({11 organizationId,12 rangeStart,13 rangeEnd,14 actions,15 format: "csv",16 });1718 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.
- 1Perform a real action in your product and confirm the event appears with the right actor, target and time.
- 2Read the event in the dashboard and check that a person outside your team could tell what happened from it alone.
- 3Search for the event by action and by actor, and confirm both filters return it.
- 4Grep the event body for anything that should not be there: tokens, passwords, full personal records.
- 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.
| Method | Path | What it does |
|---|---|---|
| POST | /audit_logs/events | Emit one audit event for an organization. |
| GET | /audit_logs/events | Search events by organization, action, actor or range. |
| GET | /audit_logs/actions | List the registered action names and their schemas. |
| POST | /audit_logs/actions | Register a new action name and its target types. |
| POST | /audit_logs/exports | Start an export over a time range and filter set. |
| GET | /audit_logs/exports/:id | Check an export's state and collect its download URL. |
| GET | /audit_logs/streams | List live stream destinations configured per organization. |