Audit & Incremental Sync

Replay the ledger update feed for audit and keep a local copy current with ledger offsets.

🚧

Beta

Canton 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:

FieldMeaning
ledgerOffsetThe event's position in the ledger. It is an opaque, increasing cursor.
contractIdThe contract the event concerns.
templateIdThe fully-qualified Daml template. It is always present and is the authoritative type of the event.
interfaceIdAn interface the record implements, when it implements one this API recognises.
packageNameThe Daml package that defines the record.
instrumentId, amountPresent only on events that carry a quantity.
signatoryPartyIdsThe parties whose authorisation the event required.
occurredAtWhen 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:

PackageTemplate families
DTCC registryHoldings, 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 StandardThe Holding and TransferInstruction interface views.
Canton CoinCoin 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.

  1. Backfill. Request the feed with your filters and no ledgerOffset[gt]. Follow pagination.nextPageToken until it is absent.
  2. Checkpoint. Keep the highest ledgerOffset you handled. Compare offsets as whole numbers, and do not do any other arithmetic on them.
  3. Resume. On the next run, send the same filters with ledgerOffset[gt] set to your checkpoint.
  4. Handle each event idempotently. Key your writes on contractId and status, 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 highest
export 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: open means the contract is active. status: closed means it was archived, and appears only with includeArchived=true.
  • A record does not say which choice archived a contract. For the sequence of creates and exercises, use the update feed.
  • payload is in Daml-LF JSON encoding. Decimals are strings, timestamps are ISO 8601 strings, parties are full party identifiers, and optional fields are absent rather than null. The template defines its shape, not this API.
  • Always filter on packageName or templateId. Without a filter the query spans every template on the participant.
  • sort accepts createdAt:asc or createdAt: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

QuestionRequest
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


Did this page help you?