SIEM Integration: Any Other SIEM
This page is the reference for the GrantFlow audit feed: how to poll it and what each event contains. Use it to connect a SIEM that has no dedicated guide, or to build your own collector. Create a feed token first; see SIEM Integration.
The endpoint
GET https://api.grantflow.cloud/api/v1/audit-feed/events
Authorization: Bearer <feed-token>Copy the exact endpoint from Audit & Compliance → SIEM Integration → Connect your SIEM: it is the API address of your GrantFlow environment.
- Send the token only in the
Authorization: Bearerheader. A token in the URL is ignored. - Send
Accept-Encoding: gzipto receive compressed pages.
Query parameters
| Parameter | Value | Default |
|---|---|---|
cursor | The cursor from the previous response. When present, start_time is ignored. | none |
start_time | RFC 3339 time, for example 2026-10-01T00:00:00Z. Starts at that time, inclusive. Use it once, for a backfill. A time less than 60 seconds ago starts from now. | none |
limit | Events per page, 1 to 1000. | 500 |
Without cursor and start_time, the feed returns no events and a cursor positioned at "now": the SIEM receives everything from that moment on.
Response
{ "events": [ … ], "cursor": "…", "hasMore": true }events: up tolimitevents, oldest first. A page is also capped at 2 MiB, and then has fewer events.cursor: always present. Store it and send it with the next request.hasMore:truewhen more events are waiting. Request again immediately.
Polling
- On the very first run, call without a cursor (start from now), or once with
start_time(backfill). - Write the page's events to the SIEM.
- Store the response's
cursor, only after the events are written. - If
hasMoreistrue, request the next page at once with the new cursor. Otherwise wait for the next poll interval. One to five minutes is a good interval.
Your collector only needs to persist the cursor. The cursor belongs to your tenant, not to the token, so you can replace the token and keep the cursor.
- Delay: events appear about 60 seconds after they happen. The delay guarantees that a slow write is never skipped.
- Order: ascending by
recordedAt, thenid. - At least once: sending the same cursor again returns the same page. Deduplicate on
id.
Errors and limits
Every error body is {"error": "<code>"}.
| Status | error | What to do |
|---|---|---|
401 | unauthorized | The token is wrong, revoked or expired, or the tenant is suspended. Every token failure looks the same, on purpose. Check the token's card in GrantFlow. |
404 | none | The feed is not enabled for this GrantFlow environment, or the URL is wrong. |
422 | invalid_limit | limit is not a whole number from 1 to 1000. |
422 | invalid_start_time | start_time is not RFC 3339. |
422 | cursor_rejected | The cursor is damaged, belongs to another tenant, or is no longer valid. Do not retry blindly: alert, and restart from a start_time just before the last event you received. |
429 | rate_limited | Wait for the number of seconds in Retry-After, then retry. |
503 | unavailable or feed_initialising | Temporary. Wait for Retry-After, then retry. |
Plan for one request per second per token, with bursts of up to 10, and 2 pages at a time; the actual ceiling can be higher. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. A collector that polls every minute and follows hasMore stays far below these limits.
Event format
Every event is a JSON object in format version "1". Times are UTC with milliseconds. A field without a value is left out, never null.
| Field | Content |
|---|---|
schemaVersion | "1" |
id | Unique event ID. Use it to deduplicate. |
timestamp | When the action happened. |
recordedAt | When GrantFlow stored the event. The feed's order. |
tenantId | Your GrantFlow tenant ID. |
action | What happened, for example activation.approve, role.update, audit_feed_token.revoke. |
category | change, read, auth or system. |
outcome | success, failure, denied, pending or throttled. |
actor | Who acted: id, type (user, application, system, anonymous or audit_feed_token), and for users upn, displayName and onPremisesSid where known. |
resource | What it acted on: type and id. |
channel | Where the request came from, for example rest (portal and CLI) or mcp. |
client | ip, userAgent, geo (countryCode, region, city), asnOrg, asnType, and isProxy when the address is a known proxy. |
auth | How the actor signed in: methods (the Entra ID amr values), strength (unknown, single_factor, mfa, strong_mfa or phishing_resistant), authContext, contextClasses (the Conditional Access authentication contexts the token carried), sessionId, tokenId, clientAppId, isAppOnly, deviceId, entraIp. |
correlationId | Ties together the events of one request. |
details | Action-specific values, such as a role ID, a justification or a duration. Always an object. |
detailsRedacted | Names of details values GrantFlow withheld, when any were. |
details carries only values GrantFlow has classified for export. Identifiers, counts, times and closed sets of codes appear as they are. Text that users type, such as justifications and names, is cleaned of control characters and length-limited. Values that describe GrantFlow's own infrastructure are never exported. Each event's details is limited to 8 KiB; anything withheld is named in detailsRedacted.
An example event:
{
"schemaVersion": "1",
"id": "0192f0c4-0000-7000-8000-000000000001",
"timestamp": "2026-10-17T09:12:44.512Z",
"recordedAt": "2026-10-17T09:12:44.731Z",
"tenantId": "00000000-0000-0000-0000-000000000000",
"action": "activation.approve",
"category": "change",
"outcome": "success",
"actor": {
"id": "11111111-1111-1111-1111-111111111111",
"type": "user",
"upn": "anna@contoso.com",
"displayName": "Anna Admin"
},
"resource": { "type": "activation", "id": "act-1" },
"channel": "rest",
"client": {
"ip": "203.0.113.7",
"userAgent": "Mozilla/5.0",
"geo": { "countryCode": "DE", "region": "Hesse", "city": "Frankfurt" },
"asnOrg": "Contoso AG",
"asnType": "ENTERPRISE"
},
"auth": {
"methods": ["fido", "mfa"],
"strength": "phishing_resistant",
"contextClasses": ["c1"]
},
"correlationId": "corr-1",
"details": { "approverCount": 2, "justification": "incident 42", "roleId": "role-1" }
}Reference poller
This Python script (3.8 or later, standard library only) runs one polling cycle: it prints every new event as one JSON line, keeps the cursor in a file, follows hasMore, and waits out 429 and 503. Run it on a schedule and forward its output to your SIEM.
#!/usr/bin/env python3
"""Pull the GrantFlow audit feed and print one JSON event per line.
Environment:
GRANTFLOW_FEED_URL the endpoint from Audit & Compliance > SIEM Integration
GRANTFLOW_FEED_TOKEN the feed token (it starts with gfa_)
GRANTFLOW_CURSOR_FILE where the cursor is kept (default ./grantflow.cursor)
GRANTFLOW_START_TIME RFC 3339 start for the very first run only (optional)
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
URL = os.environ["GRANTFLOW_FEED_URL"]
TOKEN = os.environ["GRANTFLOW_FEED_TOKEN"]
CURSOR_FILE = os.environ.get("GRANTFLOW_CURSOR_FILE", "grantflow.cursor")
START_TIME = os.environ.get("GRANTFLOW_START_TIME", "")
def read_cursor():
try:
with open(CURSOR_FILE) as f:
return f.read().strip()
except FileNotFoundError:
return ""
def write_cursor(cursor):
tmp = CURSOR_FILE + ".tmp"
with open(tmp, "w") as f:
f.write(cursor)
os.replace(tmp, CURSOR_FILE)
def fetch(params):
req = urllib.request.Request(
URL + "?" + urllib.parse.urlencode(params),
headers={"Authorization": "Bearer " + TOKEN, "Accept": "application/json"},
)
while True:
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
if e.code in (429, 503):
time.sleep(int(e.headers.get("Retry-After", "10")))
continue
body = e.read().decode(errors="replace")
sys.exit(f"grantflow feed: HTTP {e.code} {body}")
def main():
cursor = read_cursor()
while True:
if cursor:
params = {"cursor": cursor, "limit": 1000}
elif START_TIME:
params = {"start_time": START_TIME, "limit": 1000}
else:
params = {"limit": 1000}
page = fetch(params)
for event in page["events"]:
print(json.dumps(event, separators=(",", ":")))
sys.stdout.flush()
# Persist the cursor only after the page's events are written.
cursor = page["cursor"]
write_cursor(cursor)
if not page["hasMore"]:
return
if __name__ == "__main__":
main()Run it every minute, for example from cron, and make sure only one copy runs at a time:
GRANTFLOW_FEED_URL="https://api.grantflow.cloud/api/v1/audit-feed/events" \
GRANTFLOW_FEED_TOKEN="$(cat /etc/grantflow/feed-token)" \
GRANTFLOW_CURSOR_FILE=/var/lib/grantflow/feed.cursor \
/usr/bin/flock -n /var/lib/grantflow/feed.lock \
python3 grantflow_feed.py >> /var/log/grantflow/audit.jsonlflock holds a lock for the whole polling cycle. Paging through a backlog or waiting out 429 and 503 can take longer than a minute, and two overlapping runs share the cursor file: one can overwrite a newer cursor with an older one. With the lock, a run that finds the previous one still busy exits and the next minute tries again. If your scheduler has its own way to prevent overlapping runs, use that instead.
The script exits with an error on 401, 404 and 422, so your scheduler can alert. Set GRANTFLOW_START_TIME on the first run to backfill from a point in time.