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 ​

http
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: Bearer header. A token in the URL is ignored.
  • Send Accept-Encoding: gzip to receive compressed pages.

Query parameters ​

ParameterValueDefault
cursorThe cursor from the previous response. When present, start_time is ignored.none
start_timeRFC 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
limitEvents 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 ​

json
{ "events": [ … ], "cursor": "…", "hasMore": true }
  • events: up to limit events, 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: true when more events are waiting. Request again immediately.

Polling ​

  1. On the very first run, call without a cursor (start from now), or once with start_time (backfill).
  2. Write the page's events to the SIEM.
  3. Store the response's cursor, only after the events are written.
  4. If hasMore is true, 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, then id.
  • At least once: sending the same cursor again returns the same page. Deduplicate on id.

Errors and limits ​

Every error body is {"error": "<code>"}.

StatuserrorWhat to do
401unauthorizedThe 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.
404noneThe feed is not enabled for this GrantFlow environment, or the URL is wrong.
422invalid_limitlimit is not a whole number from 1 to 1000.
422invalid_start_timestart_time is not RFC 3339.
422cursor_rejectedThe 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.
429rate_limitedWait for the number of seconds in Retry-After, then retry.
503unavailable or feed_initialisingTemporary. 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.

FieldContent
schemaVersion"1"
idUnique event ID. Use it to deduplicate.
timestampWhen the action happened.
recordedAtWhen GrantFlow stored the event. The feed's order.
tenantIdYour GrantFlow tenant ID.
actionWhat happened, for example activation.approve, role.update, audit_feed_token.revoke.
categorychange, read, auth or system.
outcomesuccess, failure, denied, pending or throttled.
actorWho acted: id, type (user, application, system, anonymous or audit_feed_token), and for users upn, displayName and onPremisesSid where known.
resourceWhat it acted on: type and id.
channelWhere the request came from, for example rest (portal and CLI) or mcp.
clientip, userAgent, geo (countryCode, region, city), asnOrg, asnType, and isProxy when the address is a known proxy.
authHow 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.
correlationIdTies together the events of one request.
detailsAction-specific values, such as a role ID, a justification or a duration. Always an object.
detailsRedactedNames 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:

json
{
  "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.

python
#!/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:

bash
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.jsonl

flock 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.