events
Returns the store's change journal, oldest first, paginated by a cursor. Events cover content changes plus lifecycle events for content releases, schedules, and A/B tests. See Polling for changes below for how to consume it.
Arguments
- Name
first- Type
- Int
- Description
Returns up to the first
nevents. Defaults to50, maximum100.
- Name
after- Type
- String
- Description
Returns the events that come after the specified cursor.
- Name
filter- Type
- EventFilterInput
- Description
An object.
types:[String!]Full event types, such ascontent.page.published.subjectTypes:[EventSubjectType!]page,section,article,blog,product,collection,template,siteSettings,contentRelease,schedule, ortest.actions:[EventAction!]created,updated,deleted,restored,published,unpublished,started,paused,ended,archived,scheduled,unscheduled,succeeded, orfailed.since:DateTimeA starting point for a new consumer with no cursor. Treat it as a hint: older events can be included.excludeSystemActor:BooleanDrop events recorded by Pack's system actor.
Returns
- Name
EventConnection.edges- Type
- [EventEdge!]!
- Description
A list of edges. Each edge has a
cursorand anode.
- Name
EventConnection.nodes- Type
- [Event!]!
- Description
A list of events. Each has
id: ID!,storeId: ID!,contentEnvironmentId: ID,releaseId: ID,type: String!,subjectType: String!,subjectId: ID!,action: String!,actorUserId: ID,occurredAt: DateTime!, anddata: JSON. See the Event object.
- Name
EventConnection.pageInfo- Type
- PageInfo!
- Description
Information to aid in pagination.
- Name
EventConnection.recommendedPollAfterMs- Type
- Int!
- Description
How long to wait, in milliseconds, before the next poll once you have caught up.
Request
import { PackClient } from '@pack/client'
const packClient = new PackClient({
token: 'YOUR-PACK-TOKEN',
})
const query = `
query PollEvents($first: Int!, $after: String, $filter: EventFilterInput) {
events(first: $first, after: $after, filter: $filter) {
edges {
cursor
node {
id
type
subjectType
subjectId
action
releaseId
occurredAt
data
}
}
pageInfo {
hasNextPage
endCursor
}
recommendedPollAfterMs
}
}
`
const variables = {
first: 100,
after: 'saved-cursor',
filter: {
subjectTypes: [
'page',
'section',
],
},
}
const response = await packClient.fetch(query, { variables })
console.log(response.data)
Response
{
"data": {
"events": {
"edges": [
{
"cursor": "next-cursor",
"node": {
"id": "event-id",
"type": "content.page.published",
"subjectType": "page",
"subjectId": "page-id",
"action": "published",
"releaseId": null,
"occurredAt": "2026-09-20T15:04:12.000Z",
"data": null
}
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": "next-cursor"
},
"recommendedPollAfterMs": 5000
}
}
}
Polling for changes
Use events as a pull-based alternative to webhooks: store a cursor, ask for everything after it, and process events in order.
Event types
Each event's type is <subject>.<action>. subjectType and action are also returned separately.
| Subject | subjectType | type prefix | Actions |
|---|---|---|---|
| Content | page, section, article, blog, product, collection, template, siteSettings | content.<subjectType> (content.site_settings for site settings) | created, updated, deleted, restored, published, unpublished |
| Content release | contentRelease | content.release | created, updated, scheduled, unscheduled, published, archived, failed |
| Schedule | schedule | schedule | created, updated, scheduled, unscheduled, deleted, succeeded, failed |
| A/B test | test | experiment | created, updated, scheduled, unscheduled, started, paused, ended, deleted, failed |
For example, content.page.published, content.release.scheduled, or experiment.started.
updatedevents includedata.changedFields, the fields that changed.- Content events are recorded against the release they were made in, so
releaseIdis set for changes staged in a release. - Release, schedule, and test events always have a
nullreleaseId. Poll without theX-Pack-Release-Idheader to receive them. - Schedule events are only returned to stores whose plan includes content scheduling.
- A pending scheduled run ends with
unscheduled(including when archiving a release cancels its schedule, withdata.reasonset toarchived), the run itself (such ascontent.release.publishedorschedule.succeeded),failed, or the subject'sdeletedevent.
Treat subjectType and action as open-ended strings so a new value added later does not break your consumer.
Filtering
filter.typestakes fulltypestrings. An unknown type, such asschedule.paused, is rejected with aninvalid_inputerror.filter.subjectTypesandfilter.actionstake theEventSubjectTypeandEventActionenums.- Each list accepts up to 50 values.
filter.excludeSystemActordrops events recorded by Pack's system actor.
Cursors and checkpoints
Events are scoped to the store, the X-Environment content environment, and the optional X-Pack-Release-Id release. Keep a separate checkpoint for each combination and each filter you use.
- Request up to 100 events per page.
- Process each event, then save its edge
cursor. Only advance the checkpoint after an event is processed successfully. - Make your handler idempotent using the event
id. - When a poll returns no new events,
pageInfo.endCursorechoes theaftercursor you sent. An initial empty poll returns an empty string.
Adaptive polling
- While
pageInfo.hasNextPageistrue, fetch the next page immediately. - Once caught up, wait at least
recommendedPollAfterMs(currently 5 seconds), plus some jitter. - Back off after consecutive empty polls, for example 5, 10, 20, then 30 seconds. Reset when an event arrives.
- On transient errors, keep the previous cursor and retry with exponential backoff. A poll can return a transient
api_errorwhile an earlier write is still committing, so retry rather than skipping ahead.
Rate limits
Each credential gets a short burst allowance that refills at one request per second, and a separate store-wide budget applies across all credentials. When a request is limited, the error code is RATE_LIMITED. Wait for retryAfterSeconds in the GraphQL error, or the HTTP Retry-After header, before retrying. Following recommendedPollAfterMs keeps you well under the limits.
Retention and initial sync
Events are kept for 30 days. If a consumer has not polled successfully in that window, run a full content resync. An expired cursor does not return a distinct error.
To bootstrap a new consumer:
- Record the current time.
- Read the content you need, such as with pages and sections.
- Poll
eventswithfilter.sinceset to the recorded time and reconcile the results. - Continue with the cursor loop above.
since is a lower-bound hint. The first page can include slightly older events, so process them idempotently.
Polling loop
import { PackClient } from '@pack/client'
const packClient = new PackClient({ token: 'YOUR-PACK-TOKEN' })
const query = `
query PollEvents($first: Int!, $after: String, $filter: EventFilterInput) {
events(first: $first, after: $after, filter: $filter) {
edges {
cursor
node {
id
type
subjectId
occurredAt
data
}
}
pageInfo {
hasNextPage
endCursor
}
recommendedPollAfterMs
}
}
`
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function poll(cursor, handleEvent, saveCursor) {
const idleDelays = [5000, 10000, 20000, 30000]
let emptyPolls = 0
while (true) {
const response = await packClient.fetch(query, {
variables: { first: 100, after: cursor },
})
const { edges, pageInfo, recommendedPollAfterMs } = response.data.events
for (const edge of edges) {
await handleEvent(edge.node) // idempotent on edge.node.id
cursor = edge.cursor
await saveCursor(cursor)
}
if (pageInfo.hasNextPage) continue
emptyPolls = edges.length ? 0 : emptyPolls + 1
const backoff = emptyPolls
? idleDelays[Math.min(emptyPolls - 1, idleDelays.length - 1)]
: 0
await sleep(Math.max(recommendedPollAfterMs, backoff) + Math.random() * 1000)
}
}