Send your audit log to Splunk or Microsoft Sentinel
On the Enterprise plan, an owner makes an API token, and your security tool reads your audit log with it: oldest first, with a cursor, so it never misses a record or reads one twice.
Your audit log
Who changed what, and when
An API token
Made by an owner
Splunk or Sentinel
Pulls the new records
An owner makes the token on your workspace's "API and integrations" page, and your security tool sends it to https://trilly.io/api/v1/workspace/audit. It's built for Splunk and Microsoft Sentinel. We've tested it with our own scripts, not yet with a customer's Splunk or Sentinel.
- What's in it: what your audit log page shows. Who changed what in your workspace, and when: people, roles, seats, the skill list, rules, sign-in and billing settings.
- What's never in it: what anyone asked their AI or wrote. Notes and reasons people typed stay on Trilly.
- Changes by Trilly's staff read "Trilly team", with no name, email or address, as on your audit log page.
How the API works
- Send
GET /api/v1/workspace/auditwith the headerAuthorization: Bearer tri_api_…. - The answer has up to 100 records in
data, oldest first, thenhasMoreandnextCursor. - Ask again with
cursorset to the lastnextCursor. You get only newer records. With nothing new, you get the same cursor back, so keep it. - A change shows about five minutes after it happens. That way a slower change can't slip behind your cursor.
cursor- Where to carry on from: the last
nextCursor, as it is. since,until- Only records from
sinceand beforeuntil. A date and time with its zone, like2026-10-21T00:00:00Z. type- Only these actions, separated by commas: a full code like
workspace.member.added, or everything under a prefix likeworkspace.member.(up to 10). limit- Records a page, 1 to 1000. 100 if you leave it out.
Each token can make 60 requests a minute. Past that the answer is 429 with Retry-After; wait, then carry on from your cursor. Errors are JSON with error and message: 401 invalid_token, 403 insufficient_scope or not_available (not on the Enterprise plan now), 400 invalid_parameter.
A token stops working when it's revoked, when it reaches its end date, and when the owner who made it stops being an owner of the workspace: removed, their last day, or taken out by your identity provider through provisioning. The answer is then 401. If provisioning isn't on, revoke an owner's tokens when they leave: Trilly can't tell that your identity provider turned them off. Every token made or revoked is in your audit log.
Every response says "apiVersion": "2026-10-21". Within this version we only add fields. If we ever need to change or remove one, it comes as a new version, and we tell owners who use this one first.
A sample answer
Example people and ids. summary is the sentence your audit log page shows. details holds the change's settings and ids, never anything a person typed.
{
"apiVersion": "2026-10-21",
"workspace": "example-co",
"data": [
{
"id": "48213",
"time": "2026-10-21T03:04:05.123Z",
"type": "workspace.member.role_changed",
"category": "people",
"summary": "Priya Patel changed Sam Lee's role from member to admin.",
"actor": { "type": "person", "userId": "1201", "name": "Priya Patel", "email": "priya@example.com", "ip": "203.0.113.0" },
"subjects": { "userIds": ["1377"], "skillIds": [], "licenceIds": [], "groupIds": [], "requestIds": [] },
"details": {
"before": { "userId": 1377, "role": "member", "breakGlass": false },
"after": { "userId": 1377, "role": "admin", "ownerConfirmedByEmail": false },
"data": {}
}
},
{
"id": "48217",
"time": "2026-10-21T03:06:41.508Z",
"type": "workspace.scim.user_deleted",
"category": "people",
"summary": "Your identity provider took sam@example.com out.",
"actor": { "type": "identity_provider", "userId": null, "name": "Your identity provider", "email": null, "ip": "198.51.100.0" },
"subjects": { "userIds": ["1377"], "skillIds": [], "licenceIds": [], "groupIds": [], "requestIds": [] },
"details": { "before": {}, "after": {}, "data": { "userId": 1377, "userName": "sam@example.com", "via": "scim", "seatsFreed": 2 } }
}
],
"hasMore": false,
"nextCursor": "c1.CN8vIFSfOkAAAAAAAAC8WQ"
}
Step by step
Splunk
- In Splunk, go to Settings, then Data inputs, then HTTP Event Collector, and make a token for the index you want, for example
trilly. - Make an API token in Trilly. On a server that can reach both, set
TRILLY_API_TOKENandSPLUNK_HEC_TOKEN, and save this script with your Splunk address inHEC. It needs Python 3 and nothing else. - Run it every 5 minutes, with cron or Task Scheduler. It keeps its place in
trilly-cursor.txtand moves it on only after Splunk has the records, so a failed run sends them again next time. - Search with
sourcetype="trilly:audit". Each event's time is the time of the change.
#!/usr/bin/env python3
# Sends Trilly's audit log to Splunk's HTTP Event Collector. Run it every 5 minutes.
# It keeps its place in trilly-cursor.txt, so each record is sent once it's in Trilly's log.
import json, os, urllib.request
from datetime import datetime, timezone
TRILLY = "https://trilly.io/api/v1/workspace/audit"
TRILLY_TOKEN = os.environ["TRILLY_API_TOKEN"] # tri_api_... from API and integrations
HEC = "https://splunk.example.com:8088/services/collector/event"
HEC_TOKEN = os.environ["SPLUNK_HEC_TOKEN"]
CURSOR_FILE = "trilly-cursor.txt"
def epoch(t):
return datetime.strptime(t, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc).timestamp()
cursor = open(CURSOR_FILE).read().strip() if os.path.exists(CURSOR_FILE) else ""
while True:
url = TRILLY + "?limit=500" + ("&cursor=" + cursor if cursor else "")
request = urllib.request.Request(url, headers={"Authorization": "Bearer " + TRILLY_TOKEN})
with urllib.request.urlopen(request, timeout=60) as response:
page = json.load(response)
events = "".join(json.dumps({"time": epoch(r["time"]), "source": "trilly", "sourcetype": "trilly:audit", "event": r})
for r in page["data"])
if events:
send = urllib.request.Request(HEC, data=events.encode("utf-8"), method="POST",
headers={"Authorization": "Splunk " + HEC_TOKEN})
urllib.request.urlopen(send, timeout=60).close()
if page["nextCursor"]:
cursor = page["nextCursor"]
with open(CURSOR_FILE, "w") as f:
f.write(cursor)
if not page["hasMore"]:
break
Microsoft Sentinel
- Build a codeless connector (Codeless Connector Framework) with Microsoft's guide, "Create a codeless connector for Microsoft Sentinel". It has you make a custom table, a data collection rule and the connector's page.
- For the table, use the record's fields. In the data collection rule's transform, set the time from the record:
source | extend TimeGenerated = todatetime(['time']). - Use this connection. It polls every 5 minutes and pages with the cursor (
PersistentToken), so Sentinel carries on from where it stopped. Don't page by time window: a change that commits a moment late could fall between two windows. - Put the Trilly API token in the connector's
apikeybox. Sentinel sends it asAuthorization: Bearer.
{
"kind": "RestApiPoller",
"properties": {
"connectorDefinitionName": "TrillyAuditLog",
"dataType": "TrillyAudit_CL",
"dcrConfig": {
"streamName": "Custom-TrillyAudit",
"dataCollectionEndpoint": "https://your-endpoint.your-region.ingest.monitor.azure.com",
"dataCollectionRuleImmutableId": "dcr-your-rule-id"
},
"auth": {
"type": "APIKey",
"ApiKey": "[[parameters('apikey')]",
"ApiKeyName": "Authorization",
"ApiKeyIdentifier": "Bearer"
},
"request": {
"apiEndpoint": "https://trilly.io/api/v1/workspace/audit",
"httpMethod": "GET",
"queryWindowInMin": 5,
"rateLimitQPS": 1,
"paginatedCallsPerSecond": 1,
"retryCount": 3,
"timeoutInSeconds": 60,
"headers": { "Accept": "application/json" }
},
"paging": {
"pagingType": "PersistentToken",
"nextPageParaName": "cursor",
"nextPageTokenJsonPath": "$.nextCursor",
"hasNextFlagJsonPath": "$.hasMore",
"pageSize": 500,
"pageSizeParameterName": "limit"
},
"response": {
"eventsJsonPaths": ["$.data"],
"format": "json"
}
}
}
Webhooks
An owner adds an https address on the "API and integrations" page and picks what to send. Trilly sends a POST with a JSON body to it, about five minutes after it happens:
person.added- Someone joins the workspace
person.removed- Someone leaves or is taken out
request.made- Someone asks for a skill
skill.approved- A skill is added to your team's list
skill.update_asks_for_more- An update to one of your skills asks for more
ping- A test, when an owner presses "Send a test"
- The body is
id,type,createdAt,apiVersion,workspaceanddata. For the first four events,datais the audit log record above. For an update that asks for more, it's the skill, its new version and what it asks for. - Headers:
Trilly-Signature: t=…,v1=…,Trilly-Event-IdandTrilly-Event-Type. The event id stays the same on every try, so you can drop a repeat. - Check every request: HMAC-SHA256 with your secret over the time, a full stop and the raw body. Refuse it if the time is more than 5 minutes from your clock.
- Answer with any 2xx within 10 seconds. Anything else is tried again after 1, 5 and 15 minutes, then 1, 3, 6, 12 and 24 hours. A webhook that fails for more than a day is switched off, and the workspace's owners get an email.
- Trilly only sends to public addresses on port 443, and doesn't follow redirects.
- A webhook stops when the owner who added it stops being an owner of the workspace, as a token does. Add it again from an owner's account, then delete the old one.
- To change the secret, press "Make a new secret". For the next 24 hours each request carries two
v1values, one for the new secret and one for the old, so the sample below accepts either while you change it. If the old secret leaked, stop accepting it in your system now: anyone who has it can sign requests of their own.
{
"id": "evt_4f1c2a9e0b7d4e33a6c1f2d5e8b90a17",
"type": "person.added",
"createdAt": "2026-10-21T03:04:05.123Z",
"apiVersion": "2026-10-21",
"workspace": "example-co",
"data": {
"id": "48222",
"time": "2026-10-21T03:04:05.123Z",
"type": "workspace.member.added",
"category": "people",
"summary": "Priya Patel added Sam Lee as a member.",
"actor": { "type": "person", "userId": "1201", "name": "Priya Patel", "email": "priya@example.com", "ip": "203.0.113.0" },
"subjects": { "userIds": ["1377"], "skillIds": [], "licenceIds": [], "groupIds": [], "requestIds": [] },
"details": { "before": {}, "after": { "userId": 1377, "role": "member", "source": "invite" }, "data": {} }
}
}
import hashlib, hmac, time
def from_trilly(secret, header, body):
# secret: tri_whsec_... from API and integrations. header: Trilly-Signature. body: the raw request body, as bytes.
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > 300:
return False # more than 5 minutes old or ahead: maybe a replay
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Ask us
Send us a message and we'll reply. Tell us which tool you use and what it said. Contact us about your team
Setting up company sign-in and provisioning: For your IT team. Security questions: Security at Trilly.