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 deprecatedThe 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.
| Parameter | Type | Meaning |
|---|---|---|
first | Int | Rows per page, 1–1000. Defaults to 100; values above 1000 are capped. |
after | String | Opaque cursor. Pass the previous page's endCursor. |
since | String | ISO 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.
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.
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
| Status | Meaning |
|---|---|
400 | A parameter could not be read, including a cursor that will not decode. |
401 | Missing or invalid Authorization header. |
403 | The key belongs to a customer-role account, which cannot read workspace activity. |
500 | The 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.