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.

How your audit log reaches your security tool.

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

  1. Send GET /api/v1/workspace/audit with the header Authorization: Bearer tri_api_….
  2. The answer has up to 100 records in data, oldest first, then hasMore and nextCursor.
  3. Ask again with cursor set to the last nextCursor. You get only newer records. With nothing new, you get the same cursor back, so keep it.
  4. 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 since and before until. A date and time with its zone, like 2026-10-21T00:00:00Z.
type
Only these actions, separated by commas: a full code like workspace.member.added, or everything under a prefix like workspace.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
  1. In Splunk, go to Settings, then Data inputs, then HTTP Event Collector, and make a token for the index you want, for example trilly.
  2. Make an API token in Trilly. On a server that can reach both, set TRILLY_API_TOKEN and SPLUNK_HEC_TOKEN, and save this script with your Splunk address in HEC. It needs Python 3 and nothing else.
  3. Run it every 5 minutes, with cron or Task Scheduler. It keeps its place in trilly-cursor.txt and moves it on only after Splunk has the records, so a failed run sends them again next time.
  4. 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
  1. 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.
  2. 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']).
  3. 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.
  4. Put the Trilly API token in the connector's apikey box. Sentinel sends it as Authorization: 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, workspace and data. For the first four events, data is 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-Id and Trilly-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 v1 values, 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.