Cove Wallet API
documentation
One HTTP surface for Canton-native digital assets: onboard self-custodied parties, move CIP-56 tokens, issue registry instruments, read the ledger, and stream events — without running a validator.
Base URL
https://walletapi.cove.qasara.ai
Authentication
Authorization: Bearer canton_sk_…
Conventions
The gateway holds no keys
Every ledger write is prepare → sign → submit. Cove builds an unsigned Canton interactive submission, you sign its hash with an Ed25519 key we never see, and you post the signature back. Signing happens on your machine, between the two calls. Every prepare response has this shape:
{
"commandId": "b2d1c8e0-…",
"preparedTransaction": "<base64 protobuf>",
"preparedTransactionHash": "<base64 hash — sign THIS>",
"hashingSchemeVersion": "HASHING_SCHEME_VERSION_V2",
"trafficCost": {
"requestBytes": 3412,
"responseBytes": 812,
"totalBytes": 4224,
"estimatedAt": "2026-08-20T09:41:02Z"
}
}trafficCost is the ledger’s own sequencer-traffic estimate in bytes — the real answer to “what will this submission cost”. It is absent on participants older than Canton 3.5.
Three submit endpoints, and which to use
| Endpoint | Real hash? | Use for |
|---|---|---|
| POST /v1/transfers/broadcast | No (legacy path) | CC / CIP-56 transfers, accept · reject · withdraw, Amulet preapproval proposals |
| POST /v1/canton/broadcast | No | submissions built by /v1/canton/prepare |
| POST /v1/interactive/execute | Yes | anything where the participant validates the hash — notably a receiver-signed Utility Registry preapproval |
If a submit fails with a hash-mismatch or authorization error on a legacy path, retry through /v1/interactive/execute, passing the preparedTransactionHash and hashingSchemeVersion from the prepare response.
Authentication
Every route except GET /v1/health, GET /v1/ready and /v1/x402/* requires a bearer key:
Authorization: Bearer canton_sk_<48 hex chars>
Content-Type: application/json # on POST onlyKeys are provisioned by us rather than self-served: ask for one, and it arrives scoped to an account, optionally pinned to an IP allowlist and an expiry date.
A missing header, or one not starting with Bearer canton_sk_, is 401 UNAUTHORIZED. Key validation is cached for 15 seconds, so a revoked key can still authenticate for up to 15 s. If your key carries an ipWhitelist, a request from any other address is 403 IP_NOT_WHITELISTED.
A few routes additionally require an admin header, X-Admin-Secret — those that run as the issuer, i.e. that create or move supply rather than acting for a client party. They are marked admin secret below, and the rest of the operator surface (key provisioning, the node registry) is documented separately for the accounts that hold one. The gate is fail-closed in production and runs before body validation, so an unauthorised caller cannot probe the schema.
Party scoping
Every handler that acts as a party checks that the party belongs to your account. An unknown or foreign party gets a uniform 403 FORBIDDEN — deliberately indistinguishable, so party existence does not leak.
Reads are not scoped (pass any party id), and neither is the issuer surface — party scoping is the wrong control there, which is why those routes are admin-gated instead. The two history reads are isolated by API key rather than account, so a second key on the same account cannot read the first key’s transfer log.
Multi-node routing
Many endpoints accept an optional node (body field or query param) naming a Canton participant to route to. Omit it unless we have given you a node name — the request then goes to the default node for your account, which is what almost every integration wants. Endpoints that accept one are marked node below; the rest have no such field and silently ignore one: transfers/accept · reject · withdraw · broadcast · context, all of canton/*, and both wallet preapproval actions.
Party IDs in paths
Canton party ids contain ::. Both raw and percent-encoded forms work in a path:
GET /v1/wallets/alice::1220abc…/balance
GET /v1/wallets/alice%3A%3A1220abc…/balanceUnder /v1/wallets/* only four action suffixes are routed — balance, contracts, preapproval, preapproval/prepare. Anything else is 404 NOT_FOUND.
Amounts, headers, bodies
- Amounts are decimal strings. All internal math is arbitrary-precision decimal — never send a JSON number or a float.
- Every response carries X-Request-Id. Quote it in bug reports.
- The JSON parser tolerates an empty body with a JSON content type, so POST /v1/auth/stream-token can be called with none.
System
Both routes are unauthenticated.
Liveness. Never touches the ledger, the database, or Redis.
{ "status": "healthy", "timestamp": "2026-08-20T09:41:02.114Z", "version": "1.0.0" }Readiness. Probes Canton, Postgres and Redis independently.
{ "status": "ready", "db": "connected", "redis": "connected", "canton": "connected" }Parties
External, self-custodied party onboarding: Cove derives the topology from a public key you supply, you sign the resulting hash, Cove allocates.
{ "publicKey": "<base64 raw Ed25519 public key>" }{
"partyId": "alice::1220abc…",
"publicKey": "<echoed base64>",
"topologyTransactions": ["<base64>", "…"],
"multiHash": "<base64 — sign THIS>",
"publicKeyFingerprint": "1220abc…"
}{
"signature": "<base64 Ed25519 signature over multiHash>",
"preparedParty": {
"partyId": "alice::1220abc…",
"publicKey": "<base64>",
"topologyTransactions": ["<base64>"],
"multiHash": "<base64>",
"publicKeyFingerprint": "1220abc…"
}
}preparedParty.publicKey is optional only if the party was prepared here — the handler falls back to the key stored at prepare time. Otherwise: 400 MISSING_PUBLIC_KEY.
On success the party is allocated and granted actAs / readAs for the gateway’s ledger user. The party stays self-custodied — Cove needs those rights only to read its ACS and build prepared submissions on its behalf. If the grant fails the party is allocated and the route returns 502 PARTY_RIGHTS_GRANT_FAILED: grant the rights, do not re-register.
Cursor pagination over parties owned by the calling account.
?limit=25&cursor=<id> — limit is 1–100 (default 25); nextCursor is the last item’s id.
{
"items": [
{ "id": "uuid", "cantonPartyId": "alice::1220abc…",
"publicKeyFingerprint": "1220abc…", "displayName": null,
"status": "allocated", "createdAt": "2026-08-01T12:00:00.000Z" }
],
"hasMore": false
}{
"id": "uuid",
"cantonPartyId": "alice::1220abc…",
"publicKey": "<base64>",
"publicKeyFingerprint": "1220abc…",
"displayName": null,
"status": "allocated",
"preApprovedInstruments": ["USDCx"],
"createdAt": "2026-08-01T12:00:00.000Z"
}404 NOT_FOUND if the gateway does not know the party.
Wallets
All reads here are unscoped: pass any party id.
Operator view — who holds this instrument?
?instrument=USDCx is required (400 VALIDATION_ERROR without it).
{
"instrument": "USDCx",
"totalBalance": "125000.5",
"holderCount": 3,
"partiesQueried": 42,
"queryErrors": 1,
"holders": [
{ "partyId": "alice::1220abc…", "displayName": null, "balance": "100000" },
{ "partyId": "bob::1220def…", "displayName": null, "balance": "25000.5" }
]
}Sorted largest first, zero balances dropped. queryErrors counts parties whose balance read failed; those are excluded from totalBalance, so a non-zero queryErrors means the total is a lower bound. Cost scales with the number of known parties.
{
"partyId": "alice::1220abc…",
"balance": "1234.5",
"instruments": [ { "id": "Amulet", "amount": "1200" }, { "id": "USDCx", "amount": "34.5" } ]
}Do not use this for an issuer or registrar party.
It sums holdings where the party is a stakeholder, and a registrar is a stakeholder on every holding of its own instruments — so the figure over-counts badly. For that case read the ACS with POST /v1/ledger/active-contracts and filter on the interface view’s owner yourself.?limit=25&cursor=<offset> — returns a bare array. cursor is an integer offset here, unlike the id cursors elsewhere.
[ { "contractId": "00abc…", "asset": "Amulet", "amount": "1200" } ]{ "partyId": "alice::1220abc…", "isPreApproved": true, "status": "active" }This reports the Amulet / Splice Wallet preapproval only. For a Utility Registry instrument, check the ACS for a TransferPreapproval contract instead.
Two request families, discriminated on registry.
Amulet / CC (the default when registry is omitted) — creates a Splice TransferPreapprovalProposal:
{ "registry": "amulet", "instrument": { "id": "Amulet", "admin": "dso::1220…" } }Utility Registry (USDCx, CBTC, …) — creates a receiver-initiated TransferPreapproval scoped to an operator and a registrar:
{
"registry": "utility",
"operatorPartyId": "operator::1220…",
"registrarPartyId": "issuer::1220…",
"instrumentAllowances": [ { "id": "USDCx" } ],
"templateId": "#package-name:Module:TransferPreapproval"
}Each allowance is { "id": "…" } and nothing else — the Daml template rejects an admin key; the instrument admin comes from registrarPartyId. An empty or omitted instrumentAllowances means all instruments from that registrar.
Transfers
The typed CC / CIP-56 money path. Every mutating call here is party-scoped.
{
"senderPartyId": "alice::1220abc…",
"receiverPartyId": "bob::1220def…",
"amount": "10.5",
"instrument": { "id": "Amulet", "admin": "dso::1220…" },
"expiryDate": "2026-08-27T00:00:00Z",
"memo": "invoice 4471",
"registryChoiceContext": { "factoryId": "00fac…", "choiceContext": {} }
}memo is at most 256 characters. Returns a prepared submission.
registryChoiceContext is required for any instrument Cove is not the registrar for.
This route does not resolve a foreign registry: it uses a caller-supplied context, then the choice-context cache, then the default registry. Fetch the factory from the owning registry yourself (POST {registryBase}/registry/transfer-instruction/v1/transfer-factory) and pass { factoryId, choiceContext }. choiceContext is a free-form record, so the registry’s disclosedContracts ride through untouched. For an instrument Cove is the registrar for, omitting it is fine — though a pre-fetched context still saves the lookup when batching.One prepared transaction paying many recipients.
{
"partyId": "alice::1220abc…",
"receivers": [
{ "recipient": "bob::1220def…", "amount": "10", "memo": "a" },
{ "recipient": "carol::1220ghi…", "amount": "5", "expiryDate": "2026-08-27T00:00:00Z" }
],
"instrument": { "id": "Amulet", "admin": "dso::1220…" }
}receivers is 1–50, on every tier.
Same body for all three; each returns a prepared submission to sign and broadcast.
{
"partyId": "bob::1220def…",
"transferContractId": "00inst…",
"instrument": { "id": "USDCx", "admin": "decentralized-usdc-interchain-rep::1220…" },
"synchronizerId": "global-domain::1220…",
"registryApiUrl": "https://api.utilities.digitalasset.com/api/token-standard/v0/registrars/<registrar>",
"registryChoiceContext": { "choiceContextData": { }, "disclosedContracts": [ ] }
}accept and reject are the receiver’s choices; withdraw is the sender’s. Everything except partyId and transferContractId is optional.
The choice context comes from the registry that owns the instrument, and Cove resolves that registry itself — you do not have to pass a URL, and there is no per-token configuration to maintain. Resolution order:
- registryChoiceContext — used as-is, no registry call.
- registryApiUrl — one call, fetched from there.
- an exact configured mapping for the instrument admin.
- derived — {host}/api/token-standard/v0/registrars/{admin} for each known host, in order, the winner remembered per admin. One host covers every instrument it serves.
- nothing configured — the default (Amulet) registry.
The instrument admin for steps 3–4 comes from instrument.admin when you send it, otherwise it is read off the TransferInstruction itself — so { partyId, transferContractId } alone is enough. Passing instrument just saves one ACS read.
synchronizerId matters when the registry’s disclosed contracts live on a domain other than the configured default — a mismatch surfaces as contract-not-found.
{
"partyId": "alice::1220abc…",
"signature": "<base64>",
"publicKey": "<base64>",
"preparedTransaction": {
"preparedTransaction": "<base64 from prepare>",
"preparedTransactionHash": "<base64 from prepare>",
"hashingSchemeVersion": "HASHING_SCHEME_VERSION_V2"
}
}Synchronous by default — waits for Canton and returns 200:
{ "status": "confirmed", "transactionId": "1220upd…",
"cantonUpdateId": "1220upd…", "commandId": "1220upd…" }status is the same terminal value the status endpoint will report — confirmed or failed, never submitted. The call resolves only once the ledger has accepted, and cantonUpdateId is the commit proof.
Asynchronous — send X-Async: true to enqueue and get 202 immediately:
{ "jobId": "412", "commandId": "b2d1c8e0-…", "status": "queued",
"statusUrl": "/v1/transfers/b2d1c8e0-…/status" }The worker retries 6 times with exponential backoff and reuses the same commandId as the ledger submission id, so a retry of the same signed transaction is an idempotent resubmission rather than a second execution. Job priority comes from the key’s tier.
A client re-post of the same body is NOT retryable.
The confirmation-request UUID is minted at /prepare time and travels inside preparedTransaction, so re-posting the same body yields DUPLICATE_CONFIRMATION_REQUEST_UUID with an expireAfter roughly 48 h out. To retry, call /prepare again for a fresh UUID and re-sign. The error means the UUID was consumed, not that the transaction failed — check whether the first attempt committed before resubmitting.Scoped to the calling API key. 404 if the command id belongs to another key.
{
"commandId": "b2d1c8e0-…",
"status": "confirmed",
"transactionId": "1220upd…",
"createdAt": "2026-08-20T09:41:02.114Z",
"completedAt": "2026-08-20T09:41:04.902Z"
}status is one of prepared, submitted, queued, processing, confirmed, failed, dead_letter. transactionId is the Canton update id, null until confirmed. A sync broadcast is already terminal on the first poll; async broadcasts walk queued → processing → confirmed / failed / dead_letter.
Live read of open TransferInstruction contracts.
?partyId=… — returns a bare array.
[ { "transferContractId": "00inst…", "sender": "alice::1220abc…",
"receiver": "bob::1220def…", "amount": "10.5", "status": "pending" } ]Cove's own transaction log — not a ledger read.
It contains only transfers this API key broadcast through Cove.
| Query | Notes |
|---|---|
| partyId | required |
| status | pending · confirmed · failed |
| asset, counterparty | exact match |
| from, to | ISO 8601 datetimes, filter on createdAt |
| sort | asc · desc (default desc) |
| limit, cursor | 1–100, default 25; id cursor |
Returns { items, hasMore, nextCursor }.
Fetch the transfer-factory choice context without preparing anything.
Feed the result back as registryChoiceContext, or use it to build a custom multi-leg command via /v1/canton/prepare. The body is the same as prepare minus registryChoiceContext and node.
Always 501 NOT_IMPLEMENTED.
The real traffic estimate already rides on every prepare response as trafficCost; for Amulet protocol fees use the x402 fee-preview endpoint.
Canton operations
The escape hatch: drive any Daml template — a third-party venue’s AMM, an allocation, a DvP leg — that the typed surface has no model for. None of these accept node.
{
"partyId": "alice::1220abc…",
"command": { "ExerciseCommand": { "templateId": "#pkg:Module:Entity",
"contractId": "00abc…", "choice": "Swap",
"choiceArgument": {} } },
"disclosedContracts": [],
"instrument": { "id": "Amulet", "admin": "dso::1220…" },
"synchronizerId": "global-domain::1220…"
}Pass exactly one of command (a single command object) or commands (a non-empty array, for several commands in one atomic transaction) — both or neither is 400 VALIDATION_ERROR.
Body identical to /v1/transfers/broadcast.
Returns { status, transactionId?, cantonUpdateId? }.
Prepare the holding-merge delegation — Amulet housekeeping that consolidates fragmented holdings.
{ "partyId": "alice::1220abc…" } -> PreparedSubmissionWhether a traffic top-up is outstanding for the party.
{ "partyId": "alice::1220abc…" }
->
{ "pending": false, "trackingId": "…", "gasAmount": "…" }Ledger reads
Raw ACS and update-log reads that complete the escape hatch: read contracts Cove has no typed model for, recover a contract id a choice created but did not return, and tell a fill from a cancellation. All four are reads, so none is party-scoped; all four accept node.
{
"partyId": "alice::1220abc…",
"templateId": "#splice-amulet:Splice.Amulet:Amulet",
"includeBlob": false,
"includeInterfaceView": true
}| Field | Notes |
|---|---|
| templateId | fully qualified, e.g. #pkg-name:Module:Entity |
| interfaceId | returns the interface view alongside each contract |
| entityName | last template segment, matched client-side over a wildcard sweep — use when you do not know the package |
| includeBlob | include createdEventBlob so results can be forwarded as disclosures (default false) |
| includeInterfaceView | default true |
templateId and interfaceId are mutually exclusive (400 otherwise).
{
"partyId": "alice::1220abc…",
"count": 2,
"contracts": [
{ "templateId": "#pkg:Module:Entity", "contractId": "00abc…",
"synchronizerId": "global-domain::1220…", "createArgument": { },
"interfaceViews": [ ], "createdEventBlob": "<base64, only if includeBlob>" }
]
}One transaction's events, by update id.
{ "partyId": "alice::1220abc…", "updateId": "1220upd…", "shape": "LEDGER_EFFECTS" }shape is ACS_DELTA (default — “what did my submission create”) or LEDGER_EFFECTS, which also reports which choice ran — the only shape that distinguishes a fill from a cancellation, since both archive the contract.
{ "updateId": "1220upd…", "created": [ … ],
"exercised": [ { "contractId": "00abc…", "templateId": "#pkg:Module:Entity",
"choice": "Swap", "choiceArgument": { }, "exerciseResult": { },
"consuming": true, "actingParties": ["alice::1220abc…"] } ] }exercised is empty unless you asked for LEDGER_EFFECTS.
Which choice consumed a contract, searched backwards from the ledger end.
{ "partyId": "alice::1220abc…", "contractId": "00abc…", "offsetsBack": 4000 }offsetsBack is 1–100 000, default 4 000.
{ "contractId": "00abc…", "found": true,
"exercise": { "choice": "Swap", "consuming": true, "updateId": "1220upd…" } }Package ids vetted on the connected participant.
{ "count": 214, "packageIds": ["1220pkg…", "…"] }A preflight for third-party workflows: exercising a venue’s template by package name only resolves if some upgrade-compatible version is vetted here, and the failure when it is not (TEMPLATES_OR_INTERFACES_NOT_FOUND) reads like a bad template id rather than a missing DAR.
Utility Registry
Utility Registry instruments (USDCx, CBTC, …). These are not Amulet: the instrument admin — the registrar / issuer — is a validator-hosted party, so issuance runs as the issuer and no client key is involved in the mint. A holding is self-minted to the issuer, then delivered to a receiver holding a matching TransferPreapproval, atomically.
Issuer, operator, validator, package ids and synchronizer come from deployment configuration. Any of them can be overridden per request with an optional reg object, on every endpoint in this section:
{
"reg": {
"issuerPartyId": "issuer::1220…",
"operatorPartyId": "operator::1220…",
"validatorPartyId": "validator::1220…",
"regAppPackageId": "1220pkg…",
"synchronizerId": "global-domain::1220…",
"regAppPackageName": "utility-registry-app",
"registryPackageName": "utility-registry",
"allocationFactoryId": "00fac…"
}
}The preapproval-then-fund flow
1. POST /v1/utility-registry/preapproval/prepare { receiverPartyId }
2. receiver signs preparedTransactionHash (client-side, Ed25519)
3. POST /v1/interactive/execute { partyId: receiver, signature, publicKey, prepared… }Once the pre-approval is live the registrar can settle into that account directly, with no further signature from the receiver. Issuance itself runs as the issuer and is not part of the client surface.
Discover the AllocationFactory, InstrumentConfiguration and TransferRule for an instrument.
{ "instrumentId": "USDCx" }
->
{ "allocationFactoryId": "00fac…",
"instrumentConfig": { "contractId": "00cfg…" },
"transferRule": { "contractId": "00rul…" } }Registry factory endpoints are disclosure-gated.
They only answer for a party with real owned holdings of the instrument — keep a permanent dust holding, or these calls start failing once the balance hits zero. Two distinct rejections: no contract ids at all gives 400 “No holdings provided”, while a fabricated or already-spent id gives “Given holdings are invalid”. Holding contract ids are single-use — a transfer archives its input and creates a fresh change holding, so re-read the sender’s holdings before every send.Assemble a direct transfer context — for a DvP leg, or to hand to /v1/canton/prepare.
{ "receiverPartyId": "bob::1220def…", "instrumentId": "USDCx" }
->
{ "factoryId": "00fac…", "transferKind": "direct",
"choiceContext": { "choiceContextData": { "values": { } } } }Deliver an existing issuer holding to a receiver, direct settlement against their preapproval.
{ "holdingCid": "00hold…", "receiverPartyId": "bob::1220def…",
"instrumentId": "USDCx", "amount": "1000" }
->
{ "updateId": "1220upd…", "executedTransferCid": "00xfer…" }executedTransferCid may be null when the transfer completes without leaving a contract.
Build the receiver's TransferPreapproval create command for signing.
{
"receiverPartyId": "bob::1220def…",
"operatorPartyId": "operator::1220…",
"registrarPartyId": "issuer::1220…",
"instrumentAllowances": [ { "id": "USDCx" } ],
"templateId": "#pkg-name:Module:TransferPreapproval"
}operatorPartyId and registrarPartyId resolve body → reg override → configuration, so { "receiverPartyId": "…" } alone is usually enough, and a body carrying both parties works on a deployment that is not configured at all. Empty instrumentAllowances means all instruments from that registrar. Note the shape difference from the wallet variant: allowances here are { id } only, not { admin, id }.
Commit any prepared submission with one external signature, passing the real hash and hashing scheme.
{
"partyId": "bob::1220def…",
"signature": "<base64>",
"publicKey": "<base64>",
"preparedTransaction": "<base64 from prepare>",
"preparedTransactionHash": "<base64 from prepare>",
"hashingSchemeVersion": "HASHING_SCHEME_VERSION_V2"
}
->
{ "status": "confirmed", "cantonUpdateId": "1220upd…" }x402 paid surface
Pay-per-call in Canton Coin. No API key — in x402 the payment is the credential. Cove is the merchant and resource server; the agent’s facilitator prepares, submits and pays the Canton traffic, so this path needs no ledger write access on our side.
Wire format
x402 v2, headers in both directions, base64-encoded JSON.
| Header | Direction | Meaning |
|---|---|---|
| X-PAYMENT-REQUIRED | response (402) | the requirements, including price and payTo |
| X-PAYMENT-SIGNATURE | request | the payment envelope (legacy X-PAYMENT also accepted) |
| X-PAYMENT-RESPONSE | response (200) | the settle receipt |
A 402 also repeats the requirements in the body — clients ignore it; it is there so a human with curl can read the price without decoding a header. Guard order:
1. no X-PAYMENT-SIGNATURE -> 402 + requirements header
2. undecodable header -> 400 MALFORMED_PAYMENT_HEADER
3. claim does not match our price -> 402, facilitator NOT called
4. facilitator /verify says invalid-> 402 with their reason
5. /settle not success -> 402 with their reason
6. success -> handler runs, X-PAYMENT-RESPONSE setTwo non-402 failure modes matter to an integrator:
- 400 PAYMENT_ENVELOPE_REJECTED — the facilitator understood us and refused the envelope. Retrying is futile; fix the envelope, their message names the field.
- 502 FACILITATOR_UNAVAILABLE — our dependency is down, not your payment. Retry with the same payment.
One payment, one delivery.
After a successful settle the guard claims the facilitator’s update id as a redemption ticket and refuses a second delivery with 402 payment_already_redeemed. Nothing in x402 binds a settle to a delivery, so a replayed payment header would otherwise re-verify and settle straight back to the recorded success. Pay again to buy another unit. A retry after a response you lost costs a verify + settle round trip and then answers 402 — double charging remains impossible two layers down, so a lost response costs you nothing but the call.Free, unpriced on purpose — an agent must be able to discover the price and the payTo party without paying to find out what they cost.
{
"x402Version": 2,
"network": "canton:mainnet",
"payTo": "cove::1220…",
"facilitator": "https://facilitator.example",
"instrument": { "admin": "dso::1220…", "id": "Amulet" },
"feePayer": "payer::1220…",
"synchronizerId": "global-domain::1220…",
"routes": [
{ "method": "GET", "path": "/v1/x402/ping",
"priceCc": "0.01", "priceAtomic": "100000000", "description": "…" },
{ "method": "POST", "path": "/v1/x402/transfers/fee-preview",
"priceCc": "0.02", "priceAtomic": "200000000", "description": "…" }
],
"notes": [ "…" ]
}priceAtomic is CC × 10^10.
Paying in something other than Canton Coin. The surface can quote several instruments for the same route. instruments[] lists every instrument accepted, primary first; routes[].accepts[] lists every way to pay that route, each with its own price. Prices are set per instrument, not converted between them — pay whichever you hold, echo that entry as accepted in your envelope and the guard pins to it. Pinning matches on extra.instrumentId, so a claim that pays the cheap instrument’s amount under the dear instrument’s entry is refused before the facilitator is called.
Liveness echo — proves the payment loop with no data involved.
{ "ok": true, "at": "2026-08-20T09:41:02.114Z", "paidBy": "payer::1220…",
"amount": "0.01", "asset": "CC",
"instrument": { "admin": "dso::1220…", "id": "Amulet" },
"transaction": "1220upd…" }amount is in the units of whichever instrument paid; asset and instrument say which one settled, taken from the pinned entry rather than from the client’s envelope. Keep transaction — the Canton update id of the payment is the only receipt on our side tying a payer to a ledger update, and it is the ticket this payment was redeemed with.
The live Amulet fee schedule applied to your amount, plus current network pricing.
{ "amountCc": "100", "outputCount": 2 }All fields optional. outputCount is 1–100 (default 1).
{
"basis": "fee-schedule",
"amuletFees": {
"createFee": "0.03", "transferFeeInitialRate": "0.01", "transferFeeSteps": [],
"holdingFeeRatePerRound": "0.0000048", "lockHolderFee": "0.005",
"maxNumInputs": "100", "maxNumOutputs": "100"
},
"pricing": {
"extraTrafficPriceUsdPerMb": "1.0", "readVsWriteScalingFactor": "4",
"amuletPriceUsdPerCc": "0.005", "roundNumber": "18422"
},
"amuletFeeEstimate": {
"transferFeeCc": "1", "createFeeCc": "0.06", "subtotalCc": "1.06",
"transferAmountCc": "100"
},
"trafficCost": { "included": false, "code": "TRAFFIC_COST_NOT_MEASURED", "message": "…" },
"flags": [ { "code": "traffic_cost_not_measured", "severity": "warn", "message": "…" } ]
}This deliberately does not return a total cost.
The Amulet terms are the live schedule applied to your amount — exact given the inputs. The Canton traffic term is absent, not estimated: traffic is priced above a free base-rate allowance and the billed quantity is the sequenced submission, not a payload size, so a byte-derived figure measured wildly high. trafficCost.included is present and false so the omission cannot be missed. For the per-submission byte estimate, read trafficCost off the prepare response.flags is machine-readable — branch on code, do not parse the prose. Scan problems surface as 503 FEE_PREVIEW_UNCONFIGURED or 502 FEE_PREVIEW_SOURCE_ERROR, deliberately an error rather than a fee defaulted to zero: a confidently wrong price is worse than none for something a customer paid to learn.
Events
An ingester tails the ledger’s update stream and publishes per-party events, with a five-minute replay buffer. Both transports authenticate with a short-lived stream token.
Step 1 — get a stream token.
curl -X POST "$BASE/v1/auth/stream-token" -H "Authorization: Bearer $KEY"
# { "token": "e7c9…", "expiresIn": 60 }WebSocket. Connect, then subscribe.
{ "action": "subscribe", "partyIds": ["alice::1220abc…", "bob::1220def…"] }Acknowledged with { "status": "subscribed", "partyIds": […] }. A second subscribe replaces the first. The server pings every 30 s. Auth failures arrive as a JSON error frame followed by a close:
{ "error": { "code": "INVALID_TOKEN", "message": "Stream token is invalid or expired" } }Server-sent events. One party per connection.
curl -N "$BASE/v1/events/stream?token=$TOKEN&partyId=alice::1220abc…"partyId is required (400 MISSING_PARTY_ID). Each event is emitted with its eventId as the SSE id and its type as the SSE event name. Headers arrive immediately, followed by retry: 5000 and a : connected comment, then a : ping comment every 15 s while idle — so a client can tell a live stream from a hung one without waiting for ledger activity. If the subscription cannot be established the stream reports it in-band as an error event and closes.
Event envelope
{
"eventId": "1220upd…-<uuid>",
"type": "transfer.executed",
"partyId": "alice::1220abc…",
"data": {
"contractId": "00abc…",
"templateId": "#pkg:Module:Entity",
"choice": "Transfer",
"consuming": true,
"updateId": "1220upd…",
"effectiveAt": "2026-08-20T09:41:04Z"
},
"timestamp": "2026-08-20T09:41:04.902Z"
}| Type | Emitted when |
|---|---|
| transfer.pending | a contract is created whose template id contains transfer or instruction |
| contract.created | any other created event |
| transfer.executed | the Transfer or Execute choice is exercised |
| transfer.accepted | the Accept choice is exercised |
| transfer.rejected | the Reject choice is exercised |
| transfer.withdrawn | the Withdraw choice is exercised |
| contract.archived | an archived event |
| choice.<lowercased-name> | any other exercised choice |
Events are at-most-once: if no subscriber is listening, the five-minute buffer is the only recovery, and there is no replay-from-offset API. For anything that must not be missed, poll GET /v1/transfers/:commandId/status or read the ledger.
Errors
Every error has the same envelope:
{ "error": { "code": "VALIDATION_ERROR", "message": "Validation failed",
"details": { "issues": [ ] } } }details is present only when the error carries it — notably schema issues on a 400. An unrecognised internal error is deliberately flattened to INTERNAL_ERROR with no details.
| Code | Status | Meaning |
|---|---|---|
| VALIDATION_ERROR | 400 | schema failure; see details.issues |
| MISSING_PUBLIC_KEY | 400 | party register without a resolvable public key |
| MISSING_PARTY_ID | 400 | SSE without partyId |
| MALFORMED_PAYMENT_HEADER | 400 | x402 payment header could not be decoded |
| PAYMENT_ENVELOPE_REJECTED | 400 | facilitator refused the envelope — do not retry as-is |
| UNAUTHORIZED | 401 | missing or malformed Authorization header |
| INVALID_API_KEY | 401 | key not found |
| API_KEY_REVOKED | 401 | key revoked |
| API_KEY_EXPIRED | 401 | past expiresAt |
| INVALID_TOKEN | 401 | stream token invalid, expired, or already used |
| FORBIDDEN | 403 | party not managed by this account, or admin gate refused |
| IP_NOT_WHITELISTED | 403 | source IP not in the key's whitelist |
| ACCOUNT_SUSPENDED | 403 | account status is not active |
| NOT_FOUND | 404 | unknown party / node / command id / key id, or an unrouted wallet action |
| RATE_LIMIT_EXCEEDED | 429 | global IP limit, or the stream-token ceiling |
| INTERNAL_ERROR | 500 | unhandled |
| NOT_IMPLEMENTED | 501 | POST /v1/transfers/estimate-gas |
| PARTY_RIGHTS_GRANT_FAILED | 502 | party allocated but actAs/readAs grant failed |
| TRANSACTION_BROADCAST_ERROR | 502 | Canton rejected the submission; the message wraps its error |
| FEE_PREVIEW_SOURCE_ERROR | 502 | scan error, or an unrecognised payload shape |
| FACILITATOR_UNAVAILABLE | 502 | x402 facilitator unreachable — retry with the same payment |
| FEE_PREVIEW_UNCONFIGURED | 503 | fee preview not configured on this deployment |
Any error at status ≥ 500 is logged with the Canton reason attached — quote the X-Request-Id when you report one. A few Canton-level failures are worth recognising by their message rather than their code:
| Message fragment | Usual cause |
|---|---|
| TEMPLATES_OR_INTERFACES_NOT_FOUND | the DAR is not vetted on this participant — check GET /v1/ledger/packages |
| contract could not be found | on a third-party exercise: wrong synchronizer — pin synchronizerId on /v1/canton/prepare |
| security-sensitive error | intermittent: client clock ahead of the participant |
| DUPLICATE_CONFIRMATION_REQUEST_UUID | the same prepared transaction was already submitted — re-prepare, do not retry the body |
Rate limits and tiers
A global, IP-keyed limit of 1000 requests per minute applies, returning 429 RATE_LIMIT_EXCEEDED. It is anti-abuse in intent. Cove sits behind a proxy that preserves the real client IP, so one noisy tenant does not exhaust the bucket for everyone.
| Tier | Requests / min | WebSocket conns | Bulk recipients | Job priority |
|---|---|---|---|---|
| free | 30 | 2 | 10 | 30 |
| growth | 300 | 10 | 50 | 20 |
| enterprise | 3000 | 50 | 50 | 10 |
End-to-end recipes
Copy-pasteable curl for one wallet’s whole life: create a self-custodied external party, read its balance, then pre-approve and transfer Canton Coin and utility-registry tokens. Every command below was run against mainnet.
Setup
export COVE=https://walletapi.cove.qasara.ai
export KEY=canton_sk_... # Cove API key; must own the party it acts for
export PARTY='<party id>' # receiver / subject party
export PUB='<public key, base64>' # that party's Ed25519 public key
export PRIV='<private key, base64>' # that party's private key — never sent to Cove
export DSO='DSO::1220b1431ef217342db44d516bb9befde802be7d8899637d290895fa58880f19accc'Where $KEY comes from.
Keys are issued by us, not self-served — tell us what you are building and we provision one against your account, with an optional IP allowlist and expiry. The key is shown once, so store it before you close the reply. Every party you go on to create belongs to the account that key was minted for, which is what makes it actable later — so use one account per environment rather than mixing test and production parties under one key.Health and auth smoke test
curl -s $COVE/v1/health # {"status":"healthy","timestamp":"...","version":"..."} - no auth needed
curl -s $COVE/v1/ready # {"status":"ready","db":"connected","redis":"connected","canton":"connected"}
curl -s $COVE/v1/parties -H "authorization: Bearer $KEY"/v1/health is liveness only — it returns healthy from a live process even when Canton is down. /v1/ready is the real readiness probe, reporting database, Redis and Canton separately, and it returns 200 whether or not it is ready — status flips to "degraded" and the individual fields name the culprit. /v1/parties lists the calling account’s parties and is the cheapest confirmation that the key works and is scoped where you expect.
The signing step, used by every mutating call
Cove is non-custodial: it returns material to sign, you sign locally, it broadcasts. Every /prepare response carries a base64 hash, and the signature must be a raw Ed25519 detached signature over the decoded hash bytes, base64-encoded.
Check the prepare succeeded before you sign. One line, and it saves a confusing detour:
node -e 'console.log(Object.keys(require("./prep.json")).join(", "))'You want commandId, preparedTransaction, preparedTransactionHash, hashingSchemeVersion, trafficCost. If it prints error, the prepare failed — read the body and fix that. Do not sign.
export SIG=$(node -e 'const c=require("crypto"),p=require("./prep.json"),s=Buffer.from(process.env.PRIV,"base64").subarray(0,32),k=c.createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),s]),format:"der",type:"pkcs8"});console.log(c.sign(null,Buffer.from(p.preparedTransactionHash,"base64"),k).toString("base64"))')
echo -n "$SIG" | wc -c # expect 88 — 64 signature bytes in base64Every flow below repeats this command verbatim. Only party registration differs: swap p.preparedTransactionHash for p.multiHash.
Buffer.from(PRIV,'base64') never errors. A hex-encoded key silently decodes to garbage and produces a valid-looking signature that the participant rejects.
A TypeError … Received undefined from Buffer.from means prep.json is an error body, so p.preparedTransactionHash is undefined. The signing command is not at fault — the prepare before it failed.
Why those three details, if you are reimplementing this
They look arbitrary and are not. If you port the signing step into another language or runtime, these are the parts to get right.
The 302e020100300506032b657004220420 constant is not a magic number — it is the exact 16 bytes Node prepends when exporting an Ed25519 private key as PKCS#8 DER (48 bytes = 16-byte header followed by the 32-byte seed). crypto.createPrivateKey accepts only DER/PEM/JWK, never raw key bytes, so the smallest valid DER blob is assembled by hand. It is fixed for every Ed25519 key:
30 2e SEQUENCE, 46 bytes follow
02 01 00 INTEGER 0 — PKCS#8 version
30 05 SEQUENCE, 5 bytes — AlgorithmIdentifier
06 03 2b 65 70 OID 1.3.101.112 — "this is Ed25519"
04 22 OCTET STRING, 34 bytes
04 20 OCTET STRING, 32 bytes — the seed goes herecrypto.sign(null, …) passes null for the digest because Ed25519 is PureEdDSA and hashes internally. Passing 'sha512' is wrong.
.subarray(0, 32) exists because a 64-byte nacl secret key is the seed followed by the public key, and Node wants only the seed. Two key formats are in circulation and they are not interchangeable: signTransactionHash from @canton-network/core-signing-lib requires the full 64-byte nacl key and throws bad secret key size on a bare seed, whereas the node:crypto path above takes either.
The commit body
Two shapes, and they are not interchangeable.
Nested — for POST /v1/transfers/broadcast, which zeroes the hash server-side. This is the one almost every flow uses:
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:{preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}}))' > body.jsonFlat — for POST /v1/interactive/execute, which passes the real hash that some templates validate. Same fields, one level up:
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}))' > body-exec.jsonBoth read the current prep.json and $SIG, so always run the body command after the signing command, in that order. Building body.json from a stale prep.json is what produces DUPLICATE_CONFIRMATION_REQUEST_UUID. hashingSchemeVersion is a string; coercing it to a number gets a 400.
A broadcast is not retryable.
The confirmation-request UUID is minted at /prepare time and travels inside preparedTransaction, so re-posting the same body.json replays the same UUID and Canton rejects it for ~48 h. To retry, go back to /prepare for a fresh UUID and re-sign — the hash changes, so the old $SIG is useless. Before retrying, check whether the first attempt actually committed: the error says the UUID was consumed, not that the transaction failed.Create an external party
Three calls: prepare (you supply a public key), sign, register. The private key never leaves the machine.
# 1. keypair, locally. Emits the 64-byte nacl key the SDK expects.
node -e 'const c=require("crypto");const {publicKey,privateKey}=c.generateKeyPairSync("ed25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(-32);const seed=privateKey.export({type:"pkcs8",format:"der"}).subarray(-32);console.log(JSON.stringify({publicKey:pub.toString("base64"),privateKey:Buffer.concat([seed,pub]).toString("base64")},null,2))' > key.json
export PUB=$(node -e 'console.log(require("./key.json").publicKey)')
export PRIV=$(node -e 'console.log(require("./key.json").privateKey)')
# 2. prepare
curl -sX POST "$COVE/v1/parties/prepare" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d "{\"publicKey\":\"$PUB\"}" > prep.json
# 3. sign — note p.multiHash here, not p.preparedTransactionHash
export SIG=$(node -e 'const c=require("crypto"),p=require("./prep.json"),s=Buffer.from(process.env.PRIV,"base64").subarray(0,32),k=c.createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),s]),format:"der",type:"pkcs8"});console.log(c.sign(null,Buffer.from(p.multiHash,"base64"),k).toString("base64"))')
# 4. register — allocates and grants Cove actAs/readAs as a side effect
curl -sX POST "$COVE/v1/parties/register" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"signature\":\"$SIG\",\"preparedParty\":$(cat prep.json)}"
# 5. capture the party id and verify
export PARTY=$(node -e 'console.log(require("./prep.json").partyId)')
curl -s "$COVE/v1/parties" -H "authorization: Bearer $KEY"- Prepare and register must use keys on the same Cove account. The preparing party row written at prepare time is what the party-scoping check reads at register.
- 502 PARTY_RIGHTS_GRANT_FAILED means the party is allocated and only the actAs/readAs grant failed. Grant the rights; do not re-register.
- There is no party-hint parameter — the hint is whatever the validator assigns.
Balances and holdings
curl -s "$COVE/v1/wallets/$PARTY/balance" -H "authorization: Bearer $KEY"
{ "partyId": "…", "balance": "1234.5",
"instruments": [ { "id": "Amulet", "amount": "1200" }, { "id": "USDCx", "amount": "34.5" } ] }Read instruments[], not balance. The top-level figure sums different instruments together and denominates nothing.
The contracts behind the balance — you need a contractId from here as the input holding for a utility transfer:
curl -s "$COVE/v1/wallets/$PARTY/contracts" -H "authorization: Bearer $KEY"
# [ { "contractId": "00abc…", "asset": "Amulet", "amount": "1200" } ]The package-independent read — every holding with its instrument id, whatever package it came from, which is the robust option for utility tokens. Add "includeBlob": true when the results will be forwarded as disclosedContracts:
curl -sX POST "$COVE/v1/ledger/active-contracts" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"partyId\":\"$PARTY\",\"interfaceId\":\"#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding\"}"The operator view — who holds an instrument, across every party the gateway knows. instrument is required:
curl -s "$COVE/v1/wallets/holdings?instrument=USDCx" -H "authorization: Bearer $KEY"This fans a balance query out to every allocated party in the gateway’s database, so cost scales with the party count, and a non-zero queryErrors makes totalBalance a lower bound.
Canton Coin — pre-approve, then transfer
Check first. Unscoped, so any valid key can check any party — and Amulet only:
curl -s "$COVE/v1/wallets/$PARTY/preapproval" -H "authorization: Bearer $KEY"
# {"partyId":"…","isPreApproved":true,"status":"active"}Create it. PARTY / PUB / PRIV must all be the receiving party:
# 1. prepare
curl -sX POST "$COVE/v1/wallets/$PARTY/preapproval/prepare" \
-H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d "{\"registry\":\"amulet\",\"instrument\":{\"id\":\"Amulet\",\"admin\":\"$DSO\"}}" > prep.json
# 2. sign
export SIG=$(node -e 'const c=require("crypto"),p=require("./prep.json"),s=Buffer.from(process.env.PRIV,"base64").subarray(0,32),k=c.createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),s]),format:"der",type:"pkcs8"});console.log(c.sign(null,Buffer.from(p.preparedTransactionHash,"base64"),k).toString("base64"))')
# 3. build the nested body
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:{preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}}))' > body.json
# 4. commit
curl -sX POST "$COVE/v1/transfers/broadcast" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d @body.jsonThen poll — it is not instant.
The commit creates only a TransferPreapprovalProposal; the validator’s wallet automation accepts it and mints the real pre-approval, typically within 30–60 s. "status":"none" immediately after committing is not a failure. The resulting pre-approval carries validFrom, lastRenewedAt and expiresAt — 90 days. Confirm renewal before expiry rather than assuming automation covers it.for i in $(seq 1 8); do curl -s "$COVE/v1/wallets/$PARTY/preapproval" -H "authorization: Bearer $KEY"; echo; sleep 10; doneNow the transfer. The sender signs this one:
export SENDER='<sender party id>'
export RECEIVER='<receiver party id>'
export PARTY="$SENDER" # broadcast's partyId is the signer
export PUB='<SENDER public key>'
export PRIV='<SENDER private key>'
# 1. prepare
curl -sX POST "$COVE/v1/transfers/prepare" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"senderPartyId\":\"$SENDER\",\"receiverPartyId\":\"$RECEIVER\",\"amount\":\"0.01\",\"instrument\":{\"id\":\"Amulet\",\"admin\":\"$DSO\"},\"memo\":\"invoice 4471\"}" > prep.json
# 2. sign as the SENDER
export SIG=$(node -e 'const c=require("crypto"),p=require("./prep.json"),s=Buffer.from(process.env.PRIV,"base64").subarray(0,32),k=c.createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),s]),format:"der",type:"pkcs8"});console.log(c.sign(null,Buffer.from(p.preparedTransactionHash,"base64"),k).toString("base64"))')
# 3. build the nested body
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:{preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}}))' > body.json
# 4. commit
curl -sX POST "$COVE/v1/transfers/broadcast" -H "authorization: Bearer $KEY" \n -H "content-type: application/json" -d @body.json
{ "status": "confirmed", "transactionId": "1220upd…", "cantonUpdateId": "1220upd…", "commandId": "…" }cantonUpdateId is the commit proof and is lookup-able on Scan. Add -H "X-Async: true" to get a 202 with a jobId and statusUrl instead of waiting. If the receiver has no pre-approval, the transfer lands as a pending TransferInstruction instead of settling — the accept flow below is the same for Amulet and utility tokens.
export COMMAND_ID='<commandId from the broadcast response>'
curl -s "$COVE/v1/transfers/$COMMAND_ID/status" -H "authorization: Bearer $KEY"status is one of prepared, submitted, queued, processing, confirmed, failed, dead_letter. A sync broadcast is already terminal when it returns, so this reports confirmed or failed on the first poll and never sits at submitted. Status lookups are scoped to the calling key — another key’s commandId is a 404.
Bulk: POST /v1/transfers/prepare/bulk with {partyId, receivers:[{recipient, amount, memo}], instrument} pays up to 50 recipients from one prepared transaction. Sign and broadcast it exactly as above — it is one prepared transaction, so one signature.
Utility-registry tokens
A utility-registry TransferPreapproval is the receiver saying “settle this instrument into my account without asking me each time”. It is scoped to an (operator, registrar) pair and, within that, to a list of instrument ids. The receiver is the sole signatory, so it is live the moment it commits — no proposal, no provider accept, nothing to poll, and no cooperation needed from the registrar or the operator. That is the opposite of the Canton Coin flow above.
What you need, for any utility token
Ten values. Two are constants, three are yours, and five describe the instrument.
| Field | What it is | How to obtain it |
|---|---|---|
| receiverPartyId | party being pre-approved | yours |
| reg.validatorPartyId | the validator party submitting the transaction — yours, not the token’s home validator | your own participant's operator party |
| reg.synchronizerId | the synchronizer your participant and the registrar's share | synchronizerId on any contract from an ACS read |
| registrarPartyId reg.issuerPartyId | the instrument admin / registrar (same value in both fields) | registry /registry/metadata/v1/info → adminId, or the registrar field on the instrument’s InstrumentConfiguration |
| operatorPartyId reg.operatorPartyId | the utility operator the registry runs under (same value in both fields) | the operator field on the instrument’s InstrumentConfiguration and TransferRule — both must agree |
| instrumentAllowances | [{"id":"…"}], or [] for every instrument from that registrar | registry /registry/metadata/v1/instruments → id |
| reg.regAppPackageId | package id of the utility-registry-app-v0 release that registrar runs | the 64-hex prefix of the templateId on one of that registrar’s live app contracts |
| templateId | the TransferPreapproval template | #utility-registry-app-v0:Utility.Registry.App.V0.Model.TransferPreapproval:TransferPreapproval |
| reg.regAppPackageName | constant | utility-registry-app-v0 |
| reg.registryPackageName | constant | utility-registry-v0 |
Discovering the instrument, registrar and operator
The registry HTTP API answers unauthenticated. The /registrars/{party} prefix is mandatory and the party’s :: must be percent-encoded as %3A%3A; the bare host 404s.
export REG_HOST='https://api.utilities.digitalasset.com' # DA-hosted registrars
export REG_PARTY='<registrar party, %3A%3A-encoded>'
export REG_BASE="$REG_HOST/api/token-standard/v0/registrars/$REG_PARTY"
curl -s "$REG_BASE/registry/metadata/v1/instruments" # instrument ids, decimals, supported APIs
curl -s "$REG_BASE/registry/metadata/v1/info" # adminId, supported APIsThe operator and the app package id are not served by the registry API. Get them from the instrument’s InstrumentConfiguration contract — via an ACS read if your party is a stakeholder, otherwise from a block explorer’s token-info view:
curl -sX POST "$COVE/v1/ledger/active-contracts" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"partyId\":\"$PARTY\",\"entityName\":\"InstrumentConfiguration\"}"Take createArgument.operator and createArgument.registrar; the templateId’s 64-hex prefix is the utility-registry-v0 package, which is not the app package id. Repeat with "entityName":"TransferRule" and confirm the operator matches — a registrar can host several rule and configuration contracts under different operators, and a mismatch fails at transfer time, not at pre-approval time.
Two more pre-flight checks. First, that the package is vetted on your participant: a bogus entity inside a present package returns NO_TEMPLATES_FOR_PACKAGE_NAME_AND_QUALIFIED_NAME, while an absent package returns PACKAGE_NAMES_NOT_FOUND — that difference tests vetting without knowing any real module path. Second, that you and the registrar are on a shared synchronizer; if not, the party cannot hold the instrument at all and no pre-approval will help.
curl -sX POST "$COVE/v1/ledger/active-contracts" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"partyId\":\"$PARTY\",\"templateId\":\"#utility-registry-app-v0:Nope.Nope:Nope\"}"Known mainnet instruments
| Instrument | Registrar / instrumentAdmin | Decimals |
|---|---|---|
| USDCx | decentralized-usdc-interchain-rep::12208115f1e168dd7e792320be9c4ca720c751a02a3053c7606e1c1cd3dad9bf60ef | 10 |
| CBTC | cbtc-network::12205af3b949a04776fc48cdcc05a060f6bda2e470632935f375d1049a8546a3b262 | 10 |
Both are served by DA’s hosted registry (https://api.utilities.digitalasset.com) and share one operator:
operator auth0_007c6643538f2eadd3e573dd05b9::12205bcc106efa0eaa7f18dc491e5c6f5fb9b0cc68dc110ae66f4ed6467475d7c78e
validatorPartyId qasara-validator-1::1220632cdae7977b01e0024d6310a32003bb67a6215cbdfffc6bbc8791be2382f0e0
regAppPackageId 2293eb12e82ceaeb3ff7f8fe3346dece8578e1e9fd7d46624aeb7bb07fa31eda (utility-registry-app-v0 0.8.2)
synchronizerId global-domain::1220b1431ef217342db44d516bb9befde802be7d8899637d290895fa58880f19acccCheck the pre-approval
GET /v1/wallets/{party}/preapproval does not work here — it reports the Amulet pre-approval and ignores the instrument, returning true for a utility token that has none. Read the ACS instead; entityName sweeps across packages, so one command covers every instrument regardless of package id:
curl -sX POST "$COVE/v1/ledger/active-contracts" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"partyId\":\"$PARTY\",\"entityName\":\"TransferPreapproval\"}"Match on createArgument.instrumentAdmin and instrumentAllowances. Utility pre-approvals carry no expiry — indefinite until archived, unlike Canton Coin’s 90 days. The functional check is stronger: the transfer-factory lookup below returns transferKind: "direct" instead of "offer".
Create the pre-approval
Fill the four instrument-specific exports; the rest is constant per network.
export UR_REGISTRAR='decentralized-usdc-interchain-rep::12208115f1e168dd7e792320be9c4ca720c751a02a3053c7606e1c1cd3dad9bf60ef'
export UR_INSTRUMENT='USDCx'
export UR_OPERATOR='auth0_007c6643538f2eadd3e573dd05b9::12205bcc106efa0eaa7f18dc491e5c6f5fb9b0cc68dc110ae66f4ed6467475d7c78e'
export UR_APP_PKG='2293eb12e82ceaeb3ff7f8fe3346dece8578e1e9fd7d46624aeb7bb07fa31eda'
cat > ur-body.json <<EOF
{ "receiverPartyId": "$PARTY",
"operatorPartyId": "$UR_OPERATOR",
"registrarPartyId": "$UR_REGISTRAR",
"instrumentAllowances": [ { "id": "$UR_INSTRUMENT" } ],
"templateId": "#utility-registry-app-v0:Utility.Registry.App.V0.Model.TransferPreapproval:TransferPreapproval",
"reg": {
"issuerPartyId": "$UR_REGISTRAR",
"operatorPartyId": "$UR_OPERATOR",
"validatorPartyId": "qasara-validator-1::1220632cdae7977b01e0024d6310a32003bb67a6215cbdfffc6bbc8791be2382f0e0",
"regAppPackageId": "$UR_APP_PKG",
"regAppPackageName": "utility-registry-app-v0",
"registryPackageName": "utility-registry-v0",
"synchronizerId": "global-domain::1220b1431ef217342db44d516bb9befde802be7d8899637d290895fa58880f19accc"
} }
EOFThen prepare, sign, build the body and commit — in this order, every time. PARTY / PUB / PRIV must all be the receiver: it signs its own pre-approval, and it must match receiverPartyId inside ur-body.json.
# 1. prepare
curl -sX POST "$COVE/v1/utility-registry/preapproval/prepare" \
-H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d @ur-body.json > prep.json
# 2. sign
export SIG=$(node -e 'const c=require("crypto"),p=require("./prep.json"),s=Buffer.from(process.env.PRIV,"base64").subarray(0,32),k=c.createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),s]),format:"der",type:"pkcs8"});console.log(c.sign(null,Buffer.from(p.preparedTransactionHash,"base64"),k).toString("base64"))')
# 3. build the nested body
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:{preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}}))' > body.json
# 4. commit
curl -sX POST "$COVE/v1/transfers/broadcast" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d @body.jsonExpect {"status":"confirmed","cantonUpdateId":"1220…"}. Verify with the ACS read above — the new contract appears immediately, with no polling. If step 4 is rejected, rebuild with the flat body and commit through /v1/interactive/execute instead.
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}))' > body-exec.json
curl -sX POST "$COVE/v1/interactive/execute" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d @body-exec.jsonDebugging a duplicate-UUID error: print the UUID your body actually carries and compare it with the one in the error message. A match means the transaction genuinely reached the ledger before; a mismatch means body.json is older than prep.json and the build step was skipped.
node -e 'console.log(require("./prep.json").commandId)'Send a utility token
The send path does not resolve a foreign registry for you.
POST /v1/transfers/prepare uses a caller-supplied context, then a cache, then the gateway’s default (Amulet) registry — so for any instrument Cove is not itself the registrar for, you must fetch the transfer factory yourself and pass it as registryChoiceContext. POST /v1/transfers/context does not help here: it reads the same default registry. The accept path does resolve — the asymmetry is real.Step 0 — parameters. Note the registry base URL wants the party %3A%3A-encoded, unlike UR_REGISTRAR.
export UR_SENDER='<sender party>'
export UR_RECEIVER='<receiver party>'
export UR_INSTRUMENT='USDCx'
export UR_REGISTRAR='<registrar party, raw ::>'
export UR_AMOUNT='0.1'
export REG_BASE="https://api.utilities.digitalasset.com/api/token-standard/v0/registrars/<registrar %3A%3A-encoded>"Step 1 — an input holding cid. Re-run this before every transfer. A transfer archives the input holding and creates a fresh change holding with a new contract id, so a cid from a previous transfer is always dead. Reusing one gives 400 "Given holdings are invalid".
curl -s "$COVE/v1/wallets/$UR_SENDER/contracts?limit=100" -H "authorization: Bearer $KEY" > hold.json
node -e 'const a=require("./hold.json");a.filter(h=>h.asset===process.env.UR_INSTRUMENT).forEach(h=>console.log(h.contractId,h.amount))'
export UR_HOLDING='<contract id from the line above>'Step 2 — the transfer factory, straight from the owning registry.
node -e 'const now=new Date(),then=new Date(now.getTime()+3600e3);console.log(JSON.stringify({choiceArguments:{expectedAdmin:process.env.UR_REGISTRAR,transfer:{sender:process.env.UR_SENDER,receiver:process.env.UR_RECEIVER,amount:process.env.UR_AMOUNT,instrumentId:{admin:process.env.UR_REGISTRAR,id:process.env.UR_INSTRUMENT},requestedAt:now.toISOString(),executeBefore:then.toISOString(),inputHoldingCids:[process.env.UR_HOLDING],meta:{values:{}}},extraArgs:{context:{values:{}},meta:{values:{}}}},excludeDebugFields:true}))' > factory-req.json
curl -sX POST "$REG_BASE/registry/transfer-instruction/v1/transfer-factory" \
-H "content-type: application/json" -d @factory-req.json > factory.json
node -e 'const f=require("./factory.json");console.log("transferKind:",f.transferKind,"factoryId:",f.factoryId)'transferKind tells you which settlement mode you are about to get: direct (one step, the receiver holds a pre-approval) or offer (two steps — see the accept flow). This call requires a real, unlocked, sender-owned holding: an empty list gives 400 "No holdings provided" and a fabricated or spent cid gives "Given holdings are invalid".
Retry this call on failure.
DA’s hosted factory endpoint flaps: it can hang for ~45 s and then return a 502 from its own proxy — including for an empty body — while metadata GETs on the same host keep returning 200. A 502 or timeout here is their backend, not your request; it has cleared on its own within a couple of minutes. Loop until you get a 200 before reading transferKind.Step 3 — prepare, passing the registry’s context through. choiceContext is a free-form record, so the registry’s disclosedContracts ride through untouched.
node -e 'const f=require("./factory.json");console.log(JSON.stringify({senderPartyId:process.env.UR_SENDER,receiverPartyId:process.env.UR_RECEIVER,amount:process.env.UR_AMOUNT,instrument:{id:process.env.UR_INSTRUMENT,admin:process.env.UR_REGISTRAR},registryChoiceContext:{factoryId:f.factoryId,choiceContext:f.choiceContext}}))' > xfer-req.json
curl -sX POST "$COVE/v1/transfers/prepare" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d @xfer-req.json > prep.jsonStep 4 — sign as the sender and broadcast. PARTY / PUB / PRIV must be the sender here. Broadcast works for this leg; /v1/interactive/execute is not needed.
export PARTY="$UR_SENDER" # broadcast's partyId is the signer
export PUB='<sender public key>'
export PRIV='<sender private key>'
# sign
export SIG=$(node -e 'const c=require("crypto"),p=require("./prep.json"),s=Buffer.from(process.env.PRIV,"base64").subarray(0,32),k=c.createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),s]),format:"der",type:"pkcs8"});console.log(c.sign(null,Buffer.from(p.preparedTransactionHash,"base64"),k).toString("base64"))')
# build the nested body
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:{preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}}))' > body.json
# commit
curl -sX POST "$COVE/v1/transfers/broadcast" -H "authorization: Bearer $KEY" \n -H "content-type: application/json" -d @body.jsonA direct transfer creates two holdings — the payee’s and the sender’s change — and no offer. A party-scoped read shows only the one the sender is a stakeholder on, so seeing a single created Holding there is correct, not a partial settlement. Confirm a direct settle two ways: GET /v1/transfers/pending?partyId=<receiver> returns [], and the ledger effects contain no TransferOffer.
Accept an incoming transfer
curl -s "$COVE/v1/transfers/pending?partyId=$PARTY" -H "authorization: Bearer $KEY"
# [ { "transferContractId": "00inst…", "sender": "…", "receiver": "…", "amount": "10.5", "status": "pending" } ]
# PARTY / PUB / PRIV must all be the RECEIVER
export CID='00inst…'
curl -sX POST "$COVE/v1/transfers/accept" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"partyId\":\"$PARTY\",\"transferContractId\":\"$CID\"}" > prep.json
# sign
export SIG=$(node -e 'const c=require("crypto"),p=require("./prep.json"),s=Buffer.from(process.env.PRIV,"base64").subarray(0,32),k=c.createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),s]),format:"der",type:"pkcs8"});console.log(c.sign(null,Buffer.from(p.preparedTransactionHash,"base64"),k).toString("base64"))')
# build the nested body
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:{preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}}))' > body.json
# commit
curl -sX POST "$COVE/v1/transfers/broadcast" -H "authorization: Bearer $KEY" \n -H "content-type: application/json" -d @body.json{partyId, transferContractId} alone is enough, including for a foreign instrument. Unlike the send path, this route resolves the instrument’s own registry: it reads the admin off the TransferInstruction and tries each configured registry host until one answers, remembering the winner. Passing instrument just saves one ACS read. reject (receiver) and withdraw (sender) take the same body and follow the same flow.
Drive a third-party Daml workflow
# 1. find the venue's contracts, WITH disclosure blobs
curl -sX POST "$COVE/v1/ledger/active-contracts" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"partyId\":\"$PARTY\",\"entityName\":\"SwapOrder\",\"includeBlob\":true}" > acs.json
# 2. build the exercise, pinning THEIR synchronizer, blobs as disclosures
curl -sX POST "$COVE/v1/canton/prepare" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d @command.json > prep.json
# 3. sign
export SIG=$(node -e 'const c=require("crypto"),p=require("./prep.json"),s=Buffer.from(process.env.PRIV,"base64").subarray(0,32),k=c.createPrivateKey({key:Buffer.concat([Buffer.from("302e020100300506032b657004220420","hex"),s]),format:"der",type:"pkcs8"});console.log(c.sign(null,Buffer.from(p.preparedTransactionHash,"base64"),k).toString("base64"))')
# 4. build the FLAT body - interactive/execute passes the real hash
node -e 'const p=require("./prep.json");console.log(JSON.stringify({partyId:process.env.PARTY,signature:process.env.SIG,publicKey:process.env.PUB,preparedTransaction:p.preparedTransaction,preparedTransactionHash:p.preparedTransactionHash,hashingSchemeVersion:p.hashingSchemeVersion}))' > body-exec.json
# 5. commit
curl -sX POST "$COVE/v1/interactive/execute" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" -d @body-exec.json > done.json
# 6. a fill and a cancellation both archive the order —
# only LEDGER_EFFECTS names the choice
curl -sX POST "$COVE/v1/ledger/update" -H "authorization: Bearer $KEY" \
-H "content-type: application/json" \
-d "{\"partyId\":\"$PARTY\",\"updateId\":\"$(node -e 'console.log(require("./done.json").cantonUpdateId)')\",\"shape\":\"LEDGER_EFFECTS\"}"Preflight with GET /v1/ledger/packages — if the venue’s package is not vetted on this participant, step 2 fails with TEMPLATES_OR_INTERFACES_NOT_FOUND, which reads like a typo in the template id.
Pay for a call with x402
# 1. discover the price — free
curl -s "$COVE/v1/x402/info" | jq '.routes, .payTo'
# 2. call unpaid to get the requirements header
curl -si "$COVE/v1/x402/ping" | grep -i x-payment-required
# 3. hand those requirements to your facilitator, then replay with the envelope
curl -s "$COVE/v1/x402/ping" -H "X-PAYMENT-SIGNATURE: $ENVELOPE_B64"A 502 FACILITATOR_UNAVAILABLE means retry with the same envelope; a 400 PAYMENT_ENVELOPE_REJECTED means fix it first.
Clean up.
key.json holds a private key. Delete the scratch files — key.json, prep*.json, body*.json, factory*.json, *-req.json — when you are done.Traps
Every one of these cost someone an afternoon. They are collected here because each is a case where the API does something defensible that does not look like what you expected.
| # | Trap |
|---|---|
| 1 | POST /v1/wallets/{party}/preapproval/prepare with registry:"utility" cannot succeed with a non-empty allowance list — its schema requires admin inside instrumentAllowances, which the Daml template rejects. Use /v1/utility-registry/preapproval/prepare. |
| 2 | reg is mandatory for a registrar the gateway is not configured for, otherwise 400 UTILITY_REGISTRY_NOT_CONFIGURED names fields already present in the body. issuerPartyId is the registrar; validatorPartyId is yours. |
| 3 | /v1/transfers/broadcast works for utility pre-approvals. Keep /v1/interactive/execute as the fallback, not the default. |
| 4 | hashingSchemeVersion is a string. Coercing it to a number gets a 400. |
| 5 | GET /v1/wallets/{party}/preapproval is Amulet-only and ignores the instrument — it returns true for utility tokens that have no pre-approval. |
| 6 | A registry 200 proves nothing. /registry/metadata/v1/info echoes back the registrar from the request path, and an unknown registrar returns 200 {"instruments":[]} rather than 404. Check that /instruments is non-empty. |
| 7 | Factory lookups are disclosure-gated on real owned holdings — 400 "No holdings provided", and a fabricated cid gives "Given holdings are invalid". Keep a dust holding so these calls keep answering. |
| 8 | Operator pinning. A pre-approval’s operator must match the operator on the instrument’s InstrumentConfiguration and TransferRule, or the first transfer fails AssertionFailed: "Operator must match expected". The pre-approval commits regardless, so this surfaces late. |
| 9 | Send resolves no foreign registry; accept does. /v1/transfers/prepare needs registryChoiceContext for a foreign instrument, while /v1/transfers/accept resolves it from the instruction. |
| 10 | /v1/wallets/{party}/balance over-counts for an issuer or registrar (stakeholder ≠ owner) and its top-level balance sums unlike instruments. Read instruments[], and use an owner-filtered ACS read for registrar parties. |
| 11 | /v1/ready returns 200 even when degraded. Read status and the per-dependency fields. |
| 12 | /v1/transfers/history is the gateway’s own log, not a ledger read — it contains only transfers this API key broadcast through this gateway. |
| 13 | DUPLICATE_CONFIRMATION_REQUEST_UUID means you re-broadcast the same prepared transaction. The UUID is fixed at prepare time, so retrying a broadcast can never work — it is blocked for ~48 h. Re-prepare, re-sign, re-broadcast, and check first whether the original actually committed. |
| 14 | Holding contract ids are single-use. A transfer archives its input holding, so the cid must be re-read before every send; a spent cid gives 400 "Given holdings are invalid". |
| 15 | Small amounts come back in exponential notation. 0.0000001 reads as 1e-7 in a balance response. It is the right value — do not parse instruments[].amount assuming plain decimal. |
| 16 | An unset shell variable becomes an empty string, not an error. "admin":"$DSO" with DSO unset sends ""; the prepare returns 400 VALIDATION_ERROR naming the field, and the signing step then dies with a cryptic Buffer.from … Received undefined. A fresh shell loses every export — check prep.json after every prepare. |
| 17 | key.json holds a private key. Delete the scratch files when done — they are not gitignored. |
Route index
| Method | Path | Auth |
|---|---|---|
| GET | /v1/health | public |
| GET | /v1/ready | public |
| POST | /v1/auth/stream-token | key |
| POST | /v1/parties/prepare | key |
| POST | /v1/parties/register | key · party-scoped |
| GET | /v1/parties | key · account-scoped |
| GET | /v1/parties/{partyId} | key |
| GET | /v1/wallets/holdings | key |
| GET | /v1/wallets/{partyId}/balance | key |
| GET | /v1/wallets/{partyId}/contracts | key |
| GET | /v1/wallets/{partyId}/preapproval | key |
| POST | /v1/wallets/{partyId}/preapproval/prepare | key · party-scoped |
| POST | /v1/transfers/prepare | key · party-scoped |
| POST | /v1/transfers/prepare/bulk | key · party-scoped |
| POST | /v1/transfers/accept | key · party-scoped |
| POST | /v1/transfers/reject | key · party-scoped |
| POST | /v1/transfers/withdraw | key · party-scoped |
| POST | /v1/transfers/broadcast | key · party-scoped |
| GET | /v1/transfers/:commandId/status | key-scoped |
| GET | /v1/transfers/pending | key |
| GET | /v1/transfers/history | key-scoped |
| POST | /v1/transfers/context | key · party-scoped |
| POST | /v1/transfers/estimate-gas | key |
| POST | /v1/canton/prepare | key · party-scoped |
| POST | /v1/canton/broadcast | key · party-scoped |
| POST | /v1/canton/merge-delegation/prepare | key · party-scoped |
| POST | /v1/canton/gas/check | key |
| POST | /v1/ledger/active-contracts | key |
| POST | /v1/ledger/update | key |
| POST | /v1/ledger/consuming-exercise | key |
| GET | /v1/ledger/packages | key |
| POST | /v1/utility-registry/factory | key |
| POST | /v1/utility-registry/transfer-context | key |
| POST | /v1/utility-registry/transfer | key + admin |
| POST | /v1/utility-registry/preapproval/prepare | key · party-scoped |
| POST | /v1/interactive/execute | key · party-scoped |
| GET | /v1/x402/info | public |
| GET | /v1/x402/ping | x402 payment |
| POST | /v1/x402/transfers/fee-preview | x402 payment |
| WS | /v1/events | stream token |
| GET | /v1/events/stream | stream token |
Keys are issued to design partners.
Tell us what you are building and which instruments you need. We issue a key, a test party, and a route into the network — usually the same week.