Skip to main content

Activity export

GET /1/analytics/activities returns every activity event in your workspace — page views, resource views, step completions, invitations — as a cursor-paginated list you can poll on a schedule and load into your own systems.

It replaces the GraphQL activitiesConnection query, which is deprecated. Activity now lives in a separate database owned by a separate service, so it is served over REST rather than GraphQL. Everything else is the same: the same host, the same Authorization header, the same field names.

activitiesConnection is deprecated

The GraphQL activity and activitiesConnection queries still answer today and will keep answering through an announced migration window. They will then be removed. Move to this endpoint at your convenience; the changelog will carry the removal date well ahead of time.

The request

curl -G https://api.inaccord.com/1/analytics/activities \
-H "Authorization: Bearer YOUR_API_KEY" \
--data-urlencode "first=1000" \
--data-urlencode "since=2026-09-01T00:00:00Z"

Same API key as /graphql, same Bearer scheme — see Authentication. The key is scoped to one workspace, and the endpoint returns that workspace's activity and nothing else. There is no workspaceId parameter.

ParameterTypeMeaning
firstIntRows per page, 1–1000. Defaults to 100; values above 1000 are capped.
afterStringOpaque cursor. Pass the previous page's endCursor.
sinceStringISO 8601 instant with an offset, e.g. 2026-09-01T00:00:00Z or 2026-09-01T02:00:00+02:00. Returns rows whose updatedAt is at or after it.

Unrecognised parameters are ignored, and so is one sent empty — ?first=&since= is read as if neither were there, which is what a URL builder with an unset optional emits.

A malformed first, since or after returns 400. first must be a whole number of at least 1 — first=0 is a 400, while anything above 1000 is capped rather than refused. since must carry an explicit offset (Z or ±HH:MM); a zoneless or loosely-shaped value such as 2026-09-01 or Sep 2026 is a 400 rather than being guessed at.

There is deliberately no filter by event name or by accord. Those columns are not in the index that orders this endpoint, so filtering on them would turn every page into a scan of the whole workspace: on a large workspace, tens of times the work for the same page. Pull the stream and filter on your side. If you need one server-side, ask: adding a parameter with an index behind it is straightforward, and we would rather do that than publish one that quietly degrades as your workspace grows.

The response

{
"success": true,
"data": {
"edges": [
{
"cursor": "WyIyMDI2LTA5LTAxVDAwOjAwOjAwLjAwMDAwMFoiLCJhY3QtMSJd",
"node": {
"id": "f1e2d3c4-...",
"workspaceId": "3b28635f-...",
"event": "Resource Viewed",
"properties": { "resourceName": "Security review.pdf" },
"duration": 42,
"accordId": "a1b2c3d4-...",
"stepId": null,
"resourceId": "r1s2t3u4-...",
"sessionId": "s1e2s3s4-...",
"workspaceAccountId": "w1a2c3c4-...",
"targetWorkspaceAccountId": null,
"anonymousId": null,
"createdAt": "2026-09-01T00:00:00.000000Z",
"updatedAt": "2026-09-01T00:00:00.000000Z"
}
}
],
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "WyIyMDI2...",
"endCursor": "WyIyMDI2..."
}
}
}

Every node field carries the name and the value the GraphQL Activity type returned, including properties and anonymousId. Nothing is renamed, reshaped, or filtered.

Timestamps carry microseconds

createdAt and updatedAt are always rendered with six fractional digits — 2026-09-01T00:00:00.000000Z — because that is the precision the database stores, and the sort key has to round-trip through a cursor without losing any of it.

Parse them with something that accepts a six-digit fraction. A format string pinned to three — a literal SSS, or a hand-written regex built from a millisecond sample — will not match.

updatedAt is also what you keep as a high-water mark and send back as since, so a parser that truncates it to milliseconds moves your mark backwards and re-delivers rows on every poll.

properties is a free-form JSON object whose keys vary by event, and it can contain personal data such as an invited member's email address. It is returned exactly as recorded, which is what activitiesConnection did; treat it as you would any other field carrying customer data.

Paging

Ask for a page, then pass its endCursor back as after until hasNextPage is false:

# First page
curl -G https://api.inaccord.com/1/analytics/activities \
-H "Authorization: Bearer YOUR_API_KEY" \
--data-urlencode "first=1000"

# Next page
curl -G https://api.inaccord.com/1/analytics/activities \
-H "Authorization: Bearer YOUR_API_KEY" \
--data-urlencode "first=1000" \
--data-urlencode "after=WyIyMDI2..."

A cursor encodes a position in the sort order, not an offset, so a page stays correct while rows are being written behind it. Cursors are opaque: store them, don't parse them.

Incremental sync

Rows are ordered by updatedAt ascending, and since filters on updatedAt. Keep a high-water mark of the last updatedAt you processed and resume from it:

GET /1/analytics/activities?first=1000&since=<high-water mark>

since is inclusive, so the boundary row is returned again rather than skipped. Rows are keyed by id — upsert on it and a re-delivered row updates in place.

Overlap your high-water mark by 15 minutes
since = (last updatedAt you processed) - 15 minutes

Do this and dedupe on id. You re-receive 15 minutes of rows on each poll, which is cheap. Without it you will both miss rows and store stale dwell times. Two independent reasons:

Rows can become visible out of timestamp order. updatedAt is stamped when the writing request starts, not when it commits. A slow write that began at 12:00 and commits at 12:02 appears after a fast write stamped 12:01. If you polled at 12:01:30 and stored 12:01 as your high-water mark, the 12:00 row is already behind it when it arrives, and since=12:01 will never return it. The overlap is what closes that window, so it is required for completeness — not an optimisation.

duration is filled in after the fact. Dwell time is the gap to whatever event followed, so it is written onto the earlier row by a later request, and that write does not move updatedAt. The fill only reaches rows created in the last 15 minutes, so a duration is final 15 minutes after its row was created and never changes again. Without the overlap, rows you fetched within 15 minutes of their creation keep whatever duration they had at that moment — usually null.

updatedAt currently equals createdAt for every row, because nothing stamps it. Ordering on it rather than on createdAt is deliberate anyway: it is the key that stays correct if a future change does start stamping it, and your sync picks that up without a code change.

Deletions

A row that is deleted stops appearing. There is no tombstone and no deletedAt in the response — the same as activitiesConnection, which never returned deleted rows either. If your system needs to reflect deletions, reconcile with a periodic full sync (since omitted) rather than relying on the incremental feed.

Deleting an Accord hides its activity. Workspace-level events that belong to no Accord, such as Library Resource Viewed from the resource library, are not hidden by removing the member who generated them.

Errors

StatusMeaning
400A parameter could not be read, including a cursor that will not decode.
401Missing or invalid Authorization header.
403The key belongs to a customer-role account, which cannot read workspace activity.
500The request failed. Retry it.

A failed request never answers with an empty page. That distinction matters: an empty page means "no rows matched", and if a failure could produce one, your sync would advance its high-water mark past rows it never received. A 500 is safe to retry with the same cursor.

Migrating from activitiesConnection

The transport changes; the data does not.

Incremental sync with a createdAt filter
query ($cursor: Cursor) {
activitiesConnection(
first: 100
after: $cursor
filter: { createdAt: { greaterThan: "2026-09-01T00:00:00Z" } }
) {
edges { node { id accordId stepId resourceId event duration sessionId createdAt } }
pageInfo { hasNextPage }
}
}

becomes

GET /1/analytics/activities?first=100&since=2026-09-01T00:00:00Z&after=<cursor>

Read data.edges[].node instead of data.activitiesConnection.edges[].node; the field names inside are unchanged. Store data.pageInfo.endCursor as your after, and pass your existing createdAt high-water mark as since. updatedAt equals createdAt today, so the boundary is the same one you already keep. Then subtract the 15-minute overlap described under Incremental sync; your current query has the same out-of-order visibility gap, so this is worth adding rather than porting across.

Incremental sync already ordered by updatedAt
query ($cursor: Cursor) {
activitiesConnection(
first: 500
after: $cursor
orderBy: UPDATED_AT_ASC
filter: { updatedAt: { greaterThan: "2026-09-01T00:00:00Z" } }
) {
nodes { id event duration sessionId createdAt updatedAt }
pageInfo { hasNextPage }
}
}

becomes

GET /1/analytics/activities?first=500&since=2026-09-01T00:00:00Z&after=<cursor>

This is the closest match: the endpoint's ordering and since semantics are the ones you are already using. Two adjustments: read edges[].node instead of nodes[], and filter.updatedAt.greaterThan becomes since, which is inclusive rather than exclusive, so dedupe on id. Add the 15-minute overlap from Incremental sync while you are here; an exclusive greaterThan on the exact last value has been silently skipping rows that committed late.

Full dump with offset
query {
activitiesConnection(first: 1000, offset: 762000) {
nodes { id workspaceId event properties duration accordId stepId resourceId sessionId workspaceAccountId targetWorkspaceAccountId createdAt updatedAt }
}
}

becomes

GET /1/analytics/activities?first=1000
# then follow pageInfo.endCursor

There is no offset. Follow endCursor instead, which is both correct under concurrent writes and much faster — an offset of 762,000 makes the database walk 762,000 rows it then discards, which is why these requests are the slowest ones you issue today.

Once you are following cursors, keep the last one and switch to since=<last updatedAt> on subsequent runs rather than re-dumping the workspace.

Limits

first is capped at 1000 rows and each request is bounded server-side, so a page that cannot be produced in time fails rather than hanging. There is no published rate limit on this endpoint yet; poll on a schedule rather than continuously, and keep to one sync at a time per workspace.