Event feed
The event feed lets your system pull changes to customers, customer locations, jobs and members, in order, without running a public endpoint. This guide shows how to build a reliable sync with it. For every parameter and error, see the reference for GET /public/v1/events.
The event feed is part of a review contract that hasn't been released. FieldTask enables it, and enrolls your organization for each record family, separately from the rest of the API.
Before you start
- The feed enabled for your organization. FieldTask enables change sync and enrolls your organization for each family: customers, customer locations, jobs and members. Ask FieldTask which families you have.
- A REST credential with read permission for each family:
customers:read,customer_locations:read(withcustomers:read),jobs:readormembers:read. See Authentication. - Somewhere durable to store your copy of the records, each record's revision, and the feed cursor.
How it works
The feed is an ordered list of events. A cursor marks your position in it. Each request returns the events after your cursor and a new cursor to use next time.
Events say which record changed and which fields changed, not the new values. Your sync reads the current record whenever an event arrives.
The sync has four parts:
- Bootstrap: get a cursor for the current end of the feed.
- Backfill: copy every record you need.
- Poll: apply events after the cursor, repeatedly.
- Recover: start again when the cursor can't be used.
1. Bootstrap
Request a cursor with after=now, naming the families you want:
curl --get "$FIELDTASK_API_ORIGIN/public/v1/events" \
--header "Authorization: Bearer $FIELDTASK_API_TOKEN" \
--data-urlencode "after=now" \
--data-urlencode "resources=customers,customer_locations,jobs"
{
"data": [],
"page": {
"next_cursor": "EXAMPLE_OPAQUE_CURSOR_1",
"has_more": false,
"retry_after_seconds": 30
}
}
Save page.next_cursor. Treat it as an opaque string: don't parse or change it, and URL-encode it when you send it.
Always send the same resources value with a cursor. The cursor only works with the selection it was issued for. Sending resources explicitly also protects you when FieldTask enrolls another family later: a cursor issued without resources stops working when the set of available families changes.
2. Backfill
After you have the cursor, read every record you need with the list endpoints:
| Family | List endpoint |
|---|---|
| Customers | GET /public/v1/customers |
| Customer locations | GET /public/v1/customers/{customer_id}/locations, for each customer |
| Jobs | GET /public/v1/jobs |
| Members | GET /public/v1/members |
Store each record with its revision. See Pagination for paging through the lists.
The order matters. Because you took the cursor first, any change made while you backfill is also in the feed after your cursor, so nothing falls between the two. Some events will describe changes your backfill already has; the revision check below skips them.
3. Poll
Request the events after your saved cursor:
curl --get "$FIELDTASK_API_ORIGIN/public/v1/events" \
--header "Authorization: Bearer $FIELDTASK_API_TOKEN" \
--data-urlencode "after=$SAVED_CURSOR" \
--data-urlencode "resources=customers,customer_locations,jobs" \
--data-urlencode "limit=100"
For each page:
- Apply each event in
data, in order. - Save
page.next_cursor, in the same transaction as the changes you applied if you can. - Wait
page.retry_after_seconds, then request again with the new cursor.
retry_after_seconds is short while has_more is true and longer once you've caught up. Use the value returned rather than a fixed interval. Every request counts against your organization's daily event-feed allowance, including empty pages, so polling faster doesn't get you more data.
Keep reading while has_more is true, even if a page has fewer events than limit or none at all. The feed skips events you can't receive, such as events for families you didn't select, but the cursor still moves past them.
Apply an event
import os
import requests # pip install requests
API_ORIGIN = os.environ["FIELDTASK_API_ORIGIN"] # placeholder: https://api.fieldtask.example
HEADERS = {"Authorization": f"Bearer {os.environ['FIELDTASK_API_TOKEN']}"}
PATHS = {
"customers": "/public/v1/customers/",
"customer_locations": "/public/v1/customer-locations/",
"jobs": "/public/v1/jobs/",
"members": "/public/v1/members/",
}
def apply(event, store):
"""store is your database: revision(type, id), save(type, record), remove(type, id)."""
kind, record_id = event["resource"]["type"], event["resource"]["id"]
stored = store.revision(kind, record_id)
if stored is not None and event["resource"]["revision"] <= stored:
return # already have this change or a newer one
if event["tombstone"]:
store.remove(kind, record_id)
return
response = requests.get(API_ORIGIN + PATHS[kind] + record_id, headers=HEADERS, timeout=30)
if response.status_code == 404:
store.remove(kind, record_id) # deleted since; its deleted event follows
return
response.raise_for_status()
store.save(kind, response.json()["data"]) # includes the record's current revision
- Compare revisions, not arrival order. A record's revision increases with every change. Ignore events whose revision isn't newer than the one you stored, and store the revision from the record you read.
- Expect to apply an event twice. If your process stops after applying events but before saving the cursor, the next request returns the same events. The revision check makes that harmless.
- Reading records uses your read allowance. Several events for the same record can arrive together; reading the record once after the last of them is enough.
The full polling loop is in the code samples on the reference page.
4. Recover
Some problems mean your cursor can never be used again. Their response includes "resync_required": true:
| Problem | Cause |
|---|---|
410 history_expired | The cursor is older than 30 days, or the changes after it are no longer kept. |
409 access_context_changed | The connection's permissions or access changed since the cursor was issued, or FieldTask changed what the feed includes for your organization. Any permission change counts, even adding an unrelated permission. |
Retrying the same cursor won't help. Instead:
- Bootstrap a new cursor with
after=now. - Backfill again, replacing your copy. Remove records that no longer appear in the lists.
- Poll with the new cursor.
Keep your cursor fresh. Each response gives you a new cursor, and a cursor expires 30 days after it was issued, so a sync that polls regularly never reaches that limit.
Other errors don't need a resync:
| Problem | What to do |
|---|---|
400 invalid_cursor | The cursor was changed, belongs to another connection, or was sent with different resources. Send the saved cursor with its own selection. If you've lost it, recover as above. |
429 rate_limit_exceeded, 429 daily_quota_exceeded | Wait for Retry-After seconds, then retry with the same cursor. |
503 sync_unavailable | Retry with backoff using the same cursor. If it continues, ask FieldTask to check that sync is enabled for your organization. |
403 insufficient_scope | A family in resources isn't enabled, enrolled or permitted for this connection. Fix the permissions or remove the family. |
Checklist
- Take the cursor before you backfill.
- Send the same explicit
resourceswith every cursor. - Save the cursor only after applying its page, ideally in the same transaction.
- Use
revisionto skip stale and repeated events. - Wait
retry_after_secondsbetween requests. - Handle
resync_requiredby bootstrapping and backfilling again.