Audit & Incremental Sync
Replay the ledger update feed for audit and keep a local copy current with ledger offsets.
BetaCanton Data Services is in Beta. Endpoints and response fields can change as the product evolves.
Real-time delivery is planned and is not available yet, so this guide uses polling.
Canton Data Services exposes the ledger as an ordered feed of updates. You can read it in ledger order, and resume from the last ledger offset you processed. That supports audit and reconciliation, and lets you keep your own systems in step with the ledger.
This guide uses GET /native/updates and GET /native/contracts. Both are in the native tier, which works against any Canton participant and needs no Daml packages.
The update feed
GET /native/updates returns every ledger update across all record types. The typed endpoints under /token and /dtcc are narrower views over the same events. Use this endpoint for audit and for incremental synchronisation.
A contract creation appears as an event with status: open. Its archival appears later as a separate event with status: closed. Each event carries these fields:
| Field | Meaning |
|---|---|
ledgerOffset | The event's position in the ledger. It is an opaque, increasing cursor. |
contractId | The contract the event concerns. |
templateId | The fully-qualified Daml template. It is always present and is the authoritative type of the event. |
interfaceId | An interface the record implements, when it implements one this API recognises. |
packageName | The Daml package that defines the record. |
instrumentId, amount | Present only on events that carry a quantity. |
signatoryPartyIds | The parties whose authorisation the event required. |
occurredAt | When the ledger committed the event. |
Events are returned oldest first. If you pass sort, the only accepted value is createdAt:asc. Otherwise omit it.
Filter before you read
This feed is tier-agnostic. It returns events from every Daml application on the participant, not only the ones the other tiers model. A large share of those events is routine Canton Coin housekeeping, so an unfiltered read is mostly noise.
Filter on packageName first. It is the cheapest and most effective filter. Use templateId when you need one exact template, and interfaceId when you want every implementation of an interface. You can repeat each of these parameters.
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/native/updates?packageName=<PACKAGE_NAME>&partyId=<PARTY_ID>"result = get(
"/native/updates",
packageName="<PACKAGE_NAME>",
partyId="<PARTY_ID>",
)
print(result)const result = await get("/native/updates", {
packageName: "<PACKAGE_NAME>",
partyId: "<PARTY_ID>",
});
console.log(JSON.stringify(result, null, 2));Other filters are partyId, instrumentId, and an occurredAt[gte] and occurredAt[lte] window.
There is no event type field
The API has no normalised eventType. Canton has no such concept, and a new application on the participant produces templates this API has never seen. The type of an event is its templateId, and its interfaceId when present. Two applications can define an entity with the same name, so compare templateId and never the entity name alone.
For orientation, these are the record families you can expect to see:
| Package | Template families |
|---|---|
| DTCC registry | Holdings, transfer offers and executed transfers, mint and burn requests and offers, rejection records, compliance and registrar delegations, allow-list entries, party credentials, and onboarding requests. |
| Canton Token Standard | The Holding and TransferInstruction interface views. |
| Canton Coin | Coin holdings, transfer instructions and pre-approvals, validator licences, validator reward coupons and rights, and wallet application records. |
This table is for recognition only. Filter on packageName, templateId or interfaceId, not on this list.
Keep a local copy current
ledgerOffset is the cursor for incremental synchronisation. Persist the highest offset you have processed, and pass it as ledgerOffset[gt] on the next run. This is more reliable than filtering on a timestamp, because two events can share one commit time.
- Backfill. Request the feed with your filters and no
ledgerOffset[gt]. Followpagination.nextPageTokenuntil it is absent. - Checkpoint. Keep the highest
ledgerOffsetyou handled. Compare offsets as whole numbers, and do not do any other arithmetic on them. - Resume. On the next run, send the same filters with
ledgerOffset[gt]set to your checkpoint. - Handle each event idempotently. Key your writes on
contractIdandstatus, so replaying a page after a failure does no harm.
def sync(checkpoint, handle, **filters):
"""Handle every event after `checkpoint` and return the new checkpoint.
`handle` must be idempotent. Pass the same `filters` on every run.
"""
params = dict(filters)
if checkpoint is not None:
params["ledgerOffset[gt]"] = checkpoint
highest = checkpoint
for event in pages("/native/updates", **params):
handle(event)
if highest is None or int(event["ledgerOffset"]) > int(highest):
highest = event["ledgerOffset"]
return highestexport async function sync(
checkpoint: string | null,
handle: (event: any) => Promise<void> | void,
filters: Params = {},
): Promise<string | null> {
const params: Params = { ...filters };
if (checkpoint !== null) {
params["ledgerOffset[gt]"] = checkpoint;
}
let highest = checkpoint;
for await (const event of pages("/native/updates", params)) {
await handle(event);
if (highest === null || BigInt(event.ledgerOffset) > BigInt(highest)) {
highest = event.ledgerOffset;
}
}
return highest;
}Save the returned checkpoint only after handle has durably stored every event. If a run fails part-way, the old checkpoint stays in place and the next run replays the events.
Within one run, follow pageToken from page to page. If you resume with only ledgerOffset[gt] and a broad filter on a large participant, the API still has to read the matching events and discard those at or before your checkpoint, so the first page can be slow. Keep the filters narrow, starting with packageName.
Know when you have caught up
GET /native/health returns ledgerOffset, the position of the newest event available to query. When your checkpoint reaches that offset, you have processed everything that is currently available. Compare the offsets as whole numbers.
Poll until real-time delivery arrives
Real-time delivery of ledger events is planned. Until it is available, repeat sync on the schedule your use case needs. A narrow filter makes each poll cheap.
Reconcile with a position
GET /token/positions returns the offset a position is as of, in the X-Ledger-Offset header and as asOfOffset on each row. Pass that value as ledgerOffset[gt] to GET /native/updates to fetch everything that has happened since the position was computed. This is how you explain a difference between two readings of the same position.
curl -s -g -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/native/updates?packageName=<PACKAGE_NAME>&partyId=<PARTY_ID>&ledgerOffset[gt]=1491621"result = get(
"/native/updates",
packageName="<PACKAGE_NAME>",
partyId="<PARTY_ID>",
**{"ledgerOffset[gt]": "1491621"},
)
print(result)const result = await get("/native/updates", {
packageName: "<PACKAGE_NAME>",
partyId: "<PARTY_ID>",
"ledgerOffset[gt]": "1491621",
});
console.log(JSON.stringify(result, null, 2));Quote the offset whenever you report a discrepancy. Two responses with the same offset are directly comparable, and a higher offset is strictly newer.
Read contracts as they stand
The update feed is a sequence of events. GET /native/contracts is state: one record per contract, for any Daml template the caller's party scope can see, with the contract's create arguments passed through unchanged as payload. This is how you read your own Daml templates, which the typed endpoints do not model.
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE_URL/native/contracts?templateId=<TEMPLATE_ID>&partyId=<PARTY_ID>"result = get(
"/native/contracts",
templateId="<TEMPLATE_ID>",
partyId="<PARTY_ID>",
)
print(result)const result = await get("/native/contracts", {
templateId: "<TEMPLATE_ID>",
partyId: "<PARTY_ID>",
});
console.log(JSON.stringify(result, null, 2));status: openmeans the contract is active.status: closedmeans it was archived, and appears only withincludeArchived=true.- A record does not say which choice archived a contract. For the sequence of creates and exercises, use the update feed.
payloadis in Daml-LF JSON encoding. Decimals are strings, timestamps are ISO 8601 strings, parties are full party identifiers, and optional fields are absent rather thannull. The template defines its shape, not this API.- Always filter on
packageNameortemplateId. Without a filter the query spans every template on the participant. sortacceptscreatedAt:ascorcreatedAt:desc.
A common pattern is to poll this endpoint for the templates you care about and join the records on an application-level key carried in payload, such as a trade identifier.
Audit recipes
| Question | Request |
|---|---|
| What happened to one party? | GET /native/updates with partyId. |
| What happened to one instrument? | GET /native/updates with instrumentId. |
| What happened in a period? | GET /native/updates with occurredAt[gte] and occurredAt[lte]. |
| What has happened since I last looked? | GET /native/updates with ledgerOffset[gt] set to your checkpoint. |
| What is the state of my own contracts? | GET /native/contracts with templateId. Add includeArchived=true for closed ones. |
| Is the dataset current enough to trust? | GET /native/health. |
Next steps
Updated about 3 hours ago
