Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.canton.network/llms.txt

Use this file to discover all available pages before exploring further.

The backend is the layer between your frontend and the Canton ledger. It submits commands, reads transactions, and queries contract state. This page uses cn-quickstart as a running example. cn-quickstart is a full-stack reference application that implements a software licensing workflow on Canton Network. It includes a Spring Boot backend, a React frontend, Daml smart contracts, and all the configuration needed to run locally or deploy to DevNet. The patterns shown here — connecting to the Ledger API, submitting commands, querying PQS, handling errors — apply to any Canton backend, but the code samples are drawn directly from the cn-quickstart backend so you can see them in a working context.

Backend Language

cn-quickstart uses a Java backend built on Spring Boot. Java has first-class code generation support (dpm codegen-java). TypeScript is also supported through dpm codegen-js. A TypeScript backend uses the same Ledger API (via gRPC-js or the JSON API) and can be a good choice if your team prefers a single language across frontend and backend.

Connecting to the Ledger API

The Ledger API is a service exposed by the validator’s participant node. Your backend connects to it as an authenticated client, acting on behalf of one or more parties. In cn-quickstart, LedgerApi.java manages this connection. The constructor builds a gRPC channel with an authentication interceptor that attaches a bearer token to every call:
ManagedChannelBuilder<?> builder = ManagedChannelBuilder
        .forAddress(ledgerConfig.getHost(), ledgerConfig.getPort())
        .usePlaintext();
builder.intercept(new Interceptor(tokenProvider));
ManagedChannel channel = builder.build();

submission = CommandSubmissionServiceGrpc.newFutureStub(channel);
commands = CommandServiceGrpc.newFutureStub(channel);
The Interceptor class attaches the bearer token to gRPC metadata on every call:
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
        MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
    ClientCall<ReqT, RespT> clientCall = next.newCall(method, callOptions);
    return new ForwardingClientCall.SimpleForwardingClientCall<>(clientCall) {
        @Override
        public void start(Listener<RespT> responseListener, Metadata headers) {
            headers.put(AUTHORIZATION_HEADER, "Bearer " + tokenProvider.getToken());
            super.start(responseListener, headers);
        }
    };
}
On LocalNet, the token comes from Keycloak. In production, it comes from your OAuth2 provider.

Command Submission

Commands are how you write to the ledger. There are two primary operations: creating a contract and exercising a choice.

Creating a contract

To create a contract, build a Create command with the template identifier and payload, then submit it. In cn-quickstart, the LedgerApi.create() method handles this:
public <T extends Template> CompletableFuture<Void> create(T entity, String commandId) {
    CommandsOuterClass.Command.Builder command = CommandsOuterClass.Command.newBuilder();
    ValueOuterClass.Value payload = dto2Proto.template(entity.templateId()).convert(entity);
    command.getCreateBuilder()
        .setTemplateId(toIdentifier(entity.templateId()))
        .setCreateArguments(payload.getRecord());
    return submitCommands(List.of(command.build()), commandId)
        .thenApply(submitResponse -> null);
}
The dto2Proto converter translates generated Java classes into Protobuf values. The commandId is a UUID that the Ledger API uses for deduplication.

Exercising a choice

Exercising a choice requires the contract ID and the choice arguments. The exerciseAndGetResult() method builds an Exercise command and submits it through the CommandService, which waits for the transaction result:
CommandsOuterClass.Command.Builder cmdBuilder = CommandsOuterClass.Command.newBuilder();
ValueOuterClass.Value payload =
    dto2Proto.choiceArgument(choice.templateId(), choice.choiceName()).convert(choice);

cmdBuilder.getExerciseBuilder()
    .setTemplateId(toIdentifier(choice.templateId()))
    .setContractId(contractId.getContractId)
    .setChoice(choice.choiceName())
    .setChoiceArgument(payload);
A REST endpoint that exercises a choice ties these pieces together. Here’s how LicenseApiImpl.java expires a license:
@Override
public CompletableFuture<ResponseEntity<String>> expireLicense(
        String contractId, String commandId, LicenseExpireRequest request) {
    return auth.asAuthenticatedParty(party -> {
        return damlRepository.findLicenseById(contractId).thenCompose(optContract -> {
            var license = ensurePresent(optContract,
                "License not found for contract %s", contractId);
            License_Expire choice = new License_Expire(
                new Party(auth.getAppProviderPartyId()),
                toTokenStandardMetadata(request.getMeta().getData()));
            return ledger.exerciseAndGetResult(license.contractId, choice, commandId)
                .thenApply(result -> ResponseEntity.ok("License expired successfully"));
        });
    });
}
The pattern is: look up the contract (from PQS), build the choice object (from generated code), and submit it through the Ledger API.

Handling Contract IDs

Every active contract has a unique contract ID. To exercise a choice, you need the target contract’s ID. Your backend obtains contract IDs by querying PQS or reading them from the transaction stream. The cn-quickstart REST API passes contract IDs in URL paths (e.g., POST /api/licenses/{contractId}/expire). Contract IDs are opaque strings. Do not parse or construct them manually.

Querying PQS

For read operations, the cn-quickstart backend queries PQS rather than the Ledger API. PQS maintains a PostgreSQL database that mirrors the ledger state visible to the validator’s hosted parties.

The Pqs adapter

Pqs.java wraps Spring’s JdbcTemplate and uses PQS’s active() table-valued function to query active contracts:
public <T extends Template> CompletableFuture<List<Contract<T>>> active(Class<T> clazz) {
    Identifier identifier = Utils.getTemplateIdByClass(clazz);
    String sql = "select contract_id, payload from active(?)";
    return runAndTraceAsync(ctx, () ->
        jdbcTemplate.query(sql, new PqsContractRowMapper<>(identifier),
            identifier.qualifiedName())
    );
}
The active() function takes a qualified template name (e.g., quickstart_licensing:Licensing.License:License) and returns all active contracts of that type. Each row contains a contract_id and a JSON payload. For filtered queries, activeWhere() appends a WHERE clause:
public <T extends Template> CompletableFuture<List<Contract<T>>> activeWhere(
        Class<T> clazz, String whereClause, Object... params) {
    Identifier identifier = Utils.getTemplateIdByClass(clazz);
    String sql = "select contract_id, payload from active(?) where " + whereClause;
    return runAndTraceAsync(ctx, () ->
        jdbcTemplate.query(sql, new PqsContractRowMapper<>(identifier),
            combineParams(identifier.qualifiedName(), params))
    );
}

Domain-specific queries

DamlRepository.java builds higher-level queries. For example, findActiveLicenses() joins licenses with their renewal requests and allocation contracts in a single SQL query:
SELECT license.contract_id    AS license_contract_id,
       license.payload        AS license_payload,
       renewal.contract_id    AS renewal_contract_id,
       renewal.payload        AS renewal_payload,
       allocation.contract_id AS allocation_contract_id
FROM active(?) license
LEFT JOIN active(?) renewal ON
    license.payload->>'licenseNum' = renewal.payload->>'licenseNum'
    AND license.payload->>'user' = renewal.payload->>'user'
LEFT JOIN active(?) allocation ON
    renewal.payload->>'requestId' =
        allocation.payload->'allocation'->'settlement'->'settlementRef'->>'id'
WHERE license.payload->>'user' = ? OR license.payload->>'provider' = ?
ORDER BY license.contract_id
PQS stores contract payloads as JSONB, so you use PostgreSQL’s -> and ->> operators to filter and join on contract fields. You can create additional PostgreSQL indexes on frequently queried JSON paths for performance.

Reading Transactions

The Ledger API also exposes a transaction stream that your backend can subscribe to. Each transaction includes the created and archived contracts visible to your party. This is useful when you need real-time event processing rather than polling PQS. A transaction stream subscription uses gRPC server streaming. Here’s the pattern from the docs-website quickstart:
UpdateServiceGrpc.UpdateServiceStub updateServiceStub = UpdateServiceGrpc.newStub(channel);
updateServiceStub.getUpdates(getUpdatesRequest.toProto(), new StreamObserver<>() {
    public void onNext(UpdateServiceOuterClass.GetUpdatesResponse r) {
        GetUpdatesResponse response = GetUpdatesResponse.fromProto(r);
        response.getTransaction().ifPresent(transaction -> {
            for (Event event : transaction.getEvents()) {
                if (event instanceof CreatedEvent createdEvent) {
                    Iou.Contract contract = Iou.Contract.fromCreatedEvent(createdEvent);
                    // update local cache or trigger side effects
                } else if (event instanceof ArchivedEvent archivedEvent) {
                    // remove from local cache
                }
            }
        });
    }
    public void onError(Throwable throwable) { /* handle error */ }
    public void onCompleted() { /* stream ended */ }
});
In cn-quickstart, PQS handles read-side projection, so the backend does not maintain its own event-sourced state. If you are not using PQS, streaming transactions and maintaining your own projections is the alternative.

Error Handling

Canton uses structured error codes. When a command fails, the Ledger API returns a gRPC StatusRuntimeException with a Canton-specific error code. Common categories:
  • NOT_FOUND — The contract ID does not exist or is not visible to the submitting party
  • ALREADY_EXISTS — A duplicate command was submitted (commands are deduplicated by command ID)
  • INVALID_ARGUMENT — The command payload does not match the template or choice signature
  • FAILED_PRECONDITION — A contract was archived between the time you read it and the time you exercised a choice on it (contention)
In Java, catch StatusRuntimeException and inspect the status code:
import io.grpc.StatusRuntimeException;
import io.grpc.Status;

try {
    ledger.exerciseAndGetResult(contractId, choice, commandId).join();
} catch (CompletionException e) {
    if (e.getCause() instanceof StatusRuntimeException sre) {
        if (sre.getStatus().getCode() == Status.Code.NOT_FOUND) {
            // contract was archived — re-query PQS and retry
        }
    }
}
For contention errors, a retry strategy often resolves the issue: re-read the current contract ID from PQS, then resubmit the command.
Set a unique command ID on each new submission. The Ledger API deduplicates commands by this ID, which prevents double-submission if your backend retries a command that actually succeeded but whose response was lost in transit.

Backend Architecture in cn-quickstart

The cn-quickstart backend follows this module structure (under backend/src/main/java/com/digitalasset/quickstart/):
  • service/ — REST endpoint implementations. Each endpoint combines a PQS query or a Ledger API command.
  • ledger/ — The gRPC Ledger API client. Submits commands to the validator.
  • repository/ — Domain-specific PQS query methods.
  • pqs/ — Low-level SQL generation and PostgreSQL access.
  • utility/ — JSON configuration, tracing, and helper methods.
  • security/ — OAuth2 bearer token validation and party authentication.
  • config/ — Spring Boot configuration properties.
This structure cleanly separates read concerns (PQS) from write concerns (Ledger API) while keeping the REST layer thin.

Exercise: Add License Comments

This exercise walks you through adding a comment feature to cn-quickstart. Users will be able to post comments on licenses and view all comments for a given license. The feature touches every layer of the backend: Daml model, code generation, OpenAPI spec, PQS queries, and REST endpoints. By the end, you’ll have a working /licenses/{contractId}/comments API that creates LicenseComment contracts on the ledger and reads them back through PQS.

Step 1: Define the Daml Template

Create a new file quickstart/daml/licensing/daml/Licensing/LicenseComment.daml:
module Licensing.LicenseComment where

template LicenseComment
  with
    provider : Party
    user : Party
    licenseNum : Int
    commenter : Party
    body : Text
    createdAt : Time
  where
    signatory commenter
    observer provider, user
The commenter is the signatory — only the person writing the comment can create it. Both provider and user are observers so they can see comments on their licenses. The licenseNum field links the comment to a specific license without requiring a contract ID reference (which would break if the license is renewed or archived). Compare this with the License template in Licensing/License.daml, which uses dual signatories (signatory provider, user). A comment only needs the commenter’s authority, making it simpler to create — no multi-party workflow required.

Step 2: Build and Generate Java Bindings

Compile the new Daml module and regenerate the Java bindings. If you’re working outside cn-quickstart, use dpm directly:
dpm build
dpm codegen-java &lt;DAR-file&gt; -o &lt;output-dir&gt;
In cn-quickstart, the Gradle build handles both steps. Run the Daml build task, which compiles the .daml files into a DAR and then runs the transcode codegen plugin to produce Java classes:
./gradlew :daml:build
After this step, you’ll have a generated LicenseComment Java class under backend/build/generated-daml-bindings/ in the quickstart_licensing.licensing.licensecomment package. The generated class uses public final fields (getProvider, getUser, getLicenseNum, etc.) rather than getter methods — this is the transcode codegen style used throughout cn-quickstart.

Step 3: Add the OpenAPI Spec

Add the comment schema and endpoints to quickstart/common/openapi.yaml. First, define the schemas in the components/schemas section:
    LicenseComment:
      type: object
      required:
        - contractId
        - provider
        - user
        - licenseNum
        - commenter
        - body
        - createdAt
      properties:
        contractId:
          type: string
        provider:
          type: string
        user:
          type: string
        licenseNum:
          type: integer
        commenter:
          type: string
        body:
          type: string
        createdAt:
          type: string
          format: date-time

    AddCommentRequest:
      type: object
      required:
        - body
      properties:
        body:
          type: string
Then add two endpoints under paths:
  /licenses/{contractId}/comments:
    get:
      tags: [Licenses]
      summary: List comments for a license
      operationId: listLicenseComments
      parameters:
        - $ref: '#/components/parameters/ContractId'
      responses:
        '200':
          description: A list of comments
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/LicenseComment'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      tags: [Licenses]
      summary: Add a comment to a license
      operationId: addLicenseComment
      parameters:
        - $ref: '#/components/parameters/ContractId'
        - $ref: '#/components/parameters/CommandId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddCommentRequest'
      responses:
        '201':
          description: Comment created
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
The GET endpoint takes only the contract ID (to look up the license’s licenseNum). The POST endpoint also takes a commandId for Ledger API deduplication, following the same convention as renewLicense and expireLicense. Use tags: [Licenses] so these endpoints are grouped with the existing license operations. The cn-quickstart OpenAPI generator produces a LicensesApi Java interface from all endpoints whose paths start with /licenses/, so your new methods will appear in the same interface that LicenseApiImpl already implements. After updating the spec, regenerate the Spring server stubs:
./gradlew openApiGenerate
This reads common/openapi.yaml and writes the generated interfaces and model classes to backend/build/generated-spring/. Your new listLicenseComments and addLicenseComment methods will appear in the LicensesApi interface, and the LicenseComment and AddCommentRequest model classes will be generated in org.openapitools.model.

Step 4: Add the PQS Query

Add a method to DamlRepository.java that finds comments by license number. This follows the same activeWhere() pattern used for filtering licenses:
public CompletableFuture<List<Contract<LicenseComment>>> findCommentsByLicenseNum(int licenseNum) {
    return pqs.activeWhere(
        LicenseComment.class,
        "payload->>'licenseNum' = ?",
        String.valueOf(licenseNum)
    );
}
PQS stores all contract payloads as JSON, so payload->>'licenseNum' extracts the field as text. The activeWhere() method in Pqs.java appends this WHERE clause to the active() table-valued function, returning only LicenseComment contracts whose licenseNum matches.

Step 5: Add the REST Endpoints

Add two methods to LicenseApiImpl.java. After the Gradle build regenerates LicensesApi from the updated OpenAPI spec, LicenseApiImpl won’t compile until you implement the two new interface methods. Both follow the existing pattern: authenticate, look up data, execute, and return a response. List comments — authenticate the caller, look up the license to get its licenseNum, query PQS for matching comments:
@Override
@WithSpan
public CompletableFuture<ResponseEntity<List<LicenseComment>>> listLicenseComments(
        String contractId) {
    var ctx = tracingCtx(logger, "listLicenseComments", "contractId", contractId);
    return auth.asAuthenticatedParty(party -> traceServiceCallAsync(ctx, () ->
            damlRepository.findLicenseById(contractId).thenCompose(optLicense -> {
                var license = ensurePresent(optLicense,
                    "License not found for contract %s", contractId);
                return damlRepository.findCommentsByLicenseNum(
                        license.payload.getLicenseNum.intValue())
                    .thenApply(comments -> {
                        var result = comments.stream()
                            .map(LicenseApiImpl::toLicenseCommentApi)
                            .toList();
                        return ResponseEntity.ok(result);
                    });
            })
    ));
}
Add a comment — authenticate the caller, look up the license, create a LicenseComment contract on the ledger:
@Override
@WithSpan
public CompletableFuture<ResponseEntity<Void>> addLicenseComment(
        String contractId, String commandId, AddCommentRequest request) {
    var ctx = tracingCtx(logger, "addLicenseComment",
            "contractId", contractId, "commandId", commandId);
    return auth.asAuthenticatedParty(party -> traceServiceCallAsync(ctx, () ->
            damlRepository.findLicenseById(contractId).thenCompose(optLicense -> {
                var license = ensurePresent(optLicense,
                    "License not found for contract %s", contractId);
                var now = Instant.now();
                var comment = new quickstart_licensing.licensing
                        .licensecomment.LicenseComment(
                    license.payload.getProvider,
                    license.payload.getUser,
                    license.payload.getLicenseNum,
                    new Party(party),
                    request.getBody(),
                    now
                );
                return ledger.create(comment, commandId)
                    .thenApply(v -> ResponseEntity.status(HttpStatus.CREATED)
                        .<Void>build());
            })
    ));
}
Note the fully-qualified quickstart_licensing.licensing.licensecomment.LicenseComment type name — this is the Daml-generated class, distinct from the OpenAPI-generated org.openapitools.model.LicenseComment that the REST response uses. The field accessors (license.payload.getProvider, license.payload.getLicenseNum) are public final fields on the transcode-generated classes, not getter methods. You’ll also need a converter method to map between the two LicenseComment types — from the Daml contract (read from PQS) to the API model (returned as JSON):
private static LicenseComment toLicenseCommentApi(
        Contract<quickstart_licensing.licensing.licensecomment.LicenseComment> contract) {
    var p = contract.payload;
    var api = new LicenseComment();
    api.setContractId(contract.contractId.getContractId);
    api.setProvider(p.getProvider.getParty);
    api.setUser(p.getUser.getParty);
    api.setLicenseNum(p.getLicenseNum.intValue());
    api.setCommenter(p.getCommenter.getParty);
    api.setBody(p.getBody);
    api.setCreatedAt(toOffsetDateTime(p.getCreatedAt));
    return api;
}
This follows the same pattern as toLicenseApi() already in the file. The write path uses ledger.create() — the same method shown in the Command Submission section above. The read path queries PQS through DamlRepository, keeping reads and writes separated.

Step 6: Test It

Since you changed the Daml model, you need a clean environment — the new DAR won’t upload over an existing one. Stop and reset LocalNet, then rebuild and start fresh:
make clean-docker
make build
make start
Wait for all services to come up. The onboarding containers will automatically register the app-user tenant. Get an auth token. The backend uses OAuth2 via Keycloak. Obtain a token using the client credentials grant with the backend service account:
TOKEN=$(curl -s -X POST \
  "http://localhost:8082/realms/AppProvider/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=app-provider-backend" \
  -d "client_secret=05dmL9DAUmDnIlfoZ5EQ7pKskWmhBlNz" \
  -d "grant_type=client_credentials" \
  -d "scope=openid" | jq -r .access_token)
These credentials are from docker/backend-service/onboarding/env/oauth2.env and are only valid for the local development environment. Create a license to test with. The quickstart doesn’t create licenses automatically — they’re created by the app provider through the AppInstall workflow. First, create an AppInstallRequest from the app-user participant:
make create-app-install-request
Then accept the request and create a license using the backend API:
# Get the AppInstallRequest contract ID
REQUEST_CID=$(curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:8080/app-install-requests | jq -r '.[0].contractId')

# Accept the install request
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"installMeta":{"data":{}},"meta":{"data":{}}}' \
  "http://localhost:8080/app-install-requests/${REQUEST_CID}:accept?commandId=accept-$(date +%s)"

# Wait a moment for PQS to index, then get the AppInstall contract ID
sleep 3
INSTALL_CID=$(curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:8080/app-installs | jq -r '.[0].contractId')

# Create a license from the AppInstall
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"params":{"meta":{"data":{}}}}' \
  "http://localhost:8080/app-installs/${INSTALL_CID}:create-license?commandId=license-$(date +%s)"
Test the comment endpoints. List the active licenses and pick a contract ID:
LICENSE_CID=$(curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:8080/licenses | jq -r '.[0].contractId')
Now test the comment endpoints:
# Add a comment
curl -X POST "http://localhost:8080/licenses/${LICENSE_CID}/comments?commandId=$(uuidgen)" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"body": "Renewed for another quarter"}'

# Wait for PQS to index, then list comments
sleep 3
curl -s "http://localhost:8080/licenses/${LICENSE_CID}/comments" \
  -H "Authorization: Bearer $TOKEN" | jq .

Next Steps

Advanced Topics

  • Command Deduplication — Designing application command flows so an intended ledger change is executed exactly once, even under retries, crashes, and lost network messages.
  • Explicit Contract Disclosure — Submitting commands that read a contract you do not stakeholder by passing it as a disclosed contract on the Ledger API.