Qasara
Cove
APIVersion 1 · REST

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_…

00Reference

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

EndpointReal hash?Use for
POST /v1/transfers/broadcastNo (legacy path)CC / CIP-56 transfers, accept · reject · withdraw, Amulet preapproval proposals
POST /v1/canton/broadcastNosubmissions built by /v1/canton/prepare
POST /v1/interactive/executeYesanything 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 only

Keys 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…/balance

Under /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.
01Reference

System

Both routes are unauthenticated.

GET/v1/healthno auth

Liveness. Never touches the ledger, the database, or Redis.

{ "status": "healthy", "timestamp": "2026-08-20T09:41:02.114Z", "version": "1.0.0" }
GET/v1/readyno auth

Readiness. Probes Canton, Postgres and Redis independently.

{ "status": "ready", "db": "connected", "redis": "connected", "canton": "connected" }
status is ready only when all three are connected, otherwise degraded — and the response is 200 either way, so a load balancer must inspect the body, not the status code. canton is one of connected / unhealthy / not_initialized.
02Reference

Parties

External, self-custodied party onboarding: Cove derives the topology from a public key you supply, you sign the resulting hash, Cove allocates.

POST/v1/parties/prepareapi keynode
{ "publicKey": "<base64 raw Ed25519 public key>" }
{
  "partyId": "alice::1220abc…",
  "publicKey": "<echoed base64>",
  "topologyTransactions": ["<base64>", "…"],
  "multiHash": "<base64 — sign THIS>",
  "publicKeyFingerprint": "1220abc…"
}
A party row is written with status preparing, owned by the calling account — that row is what later makes the party actable, so prepare and register must be done by keys on the same account.
POST/v1/parties/registerapi keyparty-scoped
{
  "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.

GET/v1/partiesapi key

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
}
GET/v1/parties/{partyId}api key
{
  "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.

03Reference

Wallets

All reads here are unscoped: pass any party id.

GET/v1/wallets/holdingsapi keynode

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.

GET/v1/wallets/{partyId}/balanceapi keynode
{
  "partyId": "alice::1220abc…",
  "balance": "1234.5",
  "instruments": [ { "id": "Amulet", "amount": "1200" }, { "id": "USDCx", "amount": "34.5" } ]
}
Small amounts come back in exponential notation — a balance of 0.0000001 reads as 1e-7. It is the correct value; do not parse instruments[].amount assuming plain decimal.

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.
GET/v1/wallets/{partyId}/contractsapi keynode

?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" } ]
The instrument query param is accepted and validated but not applied. Filter client-side, or use POST /v1/ledger/active-contracts with a templateId.
GET/v1/wallets/{partyId}/preapprovalapi key
{ "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.

POST/v1/wallets/{partyId}/preapproval/prepareapi keyparty-scoped

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.

POST /v1/utility-registry/preapproval/prepare is the same operation with the operator and registrar defaulted from configuration — usually the one you want. Either submit path commits it: /v1/transfers/broadcast is verified on USDCx and CBTC, and /v1/interactive/execute is the safe fallback.
04Reference

Transfers

The typed CC / CIP-56 money path. Every mutating call here is party-scoped.

POST/v1/transfers/prepareapi keyparty-scopednode
{
  "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.
POST/v1/transfers/prepare/bulkapi keyparty-scopednode

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.

POST/v1/transfers/accept · reject · withdrawapi keyparty-scoped

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:

  1. registryChoiceContext — used as-is, no registry call.
  2. registryApiUrl — one call, fetched from there.
  3. an exact configured mapping for the instrument admin.
  4. 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.
  5. 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.

Reaching step 5 with a foreign instrument fails with TRANSFER_CHOICE_ERROR and AmuletTransferInstruction ‘<cid>’ not found — the Amulet registry has never seen another registrar’s instruction. If registries were resolved and none answered, it is REGISTRY_CHOICE_CONTEXT_ERROR (502) listing every host tried.

synchronizerId matters when the registry’s disclosed contracts live on a domain other than the configured default — a mismatch surfaces as contract-not-found.
POST/v1/transfers/broadcastapi keyparty-scoped
{
  "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.
GET/v1/transfers/:commandId/statusapi key

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.

GET/v1/transfers/pendingapi keynode

Live read of open TransferInstruction contracts.

?partyId=… — returns a bare array.

[ { "transferContractId": "00inst…", "sender": "alice::1220abc…",
    "receiver": "bob::1220def…", "amount": "10.5", "status": "pending" } ]
GET/v1/transfers/historyapi key

Cove's own transaction log — not a ledger read.

It contains only transfers this API key broadcast through Cove.

QueryNotes
partyIdrequired
statuspending · confirmed · failed
asset, counterpartyexact match
from, toISO 8601 datetimes, filter on createdAt
sortasc · desc (default desc)
limit, cursor1–100, default 25; id cursor

Returns { items, hasMore, nextCursor }.

POST/v1/transfers/contextapi keyparty-scoped

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.

POST/v1/transfers/estimate-gasapi key

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.

05Reference

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.

POST/v1/canton/prepareapi keyparty-scoped
{
  "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.

synchronizerId pins the submission to a synchronizer, overriding the node default. You need it when exercising a third-party venue’s contracts: their disclosures name their own domain, and a mismatch surfaces as the misleading “contract could not be found”.
POST/v1/canton/broadcastapi keyparty-scoped

Body identical to /v1/transfers/broadcast.

Returns { status, transactionId?, cantonUpdateId? }.

It accepts preparedTransactionHash and hashingSchemeVersion but forwards only preparedTransaction — the legacy path zeroes the hash. If the participant rejects the submission on hash grounds, resubmit through POST /v1/interactive/execute, which sends the real values.
POST/v1/canton/merge-delegation/prepareapi keyparty-scoped

Prepare the holding-merge delegation — Amulet housekeeping that consolidates fragmented holdings.

{ "partyId": "alice::1220abc…" }   ->   PreparedSubmission
POST/v1/canton/gas/checkapi key

Whether a traffic top-up is outstanding for the party.

{ "partyId": "alice::1220abc…" }
->
{ "pending": false, "trackingId": "…", "gasAmount": "…" }
06Reference

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.

POST/v1/ledger/active-contractsapi keynode
{
  "partyId": "alice::1220abc…",
  "templateId": "#splice-amulet:Splice.Amulet:Amulet",
  "includeBlob": false,
  "includeInterfaceView": true
}
FieldNotes
templateIdfully qualified, e.g. #pkg-name:Module:Entity
interfaceIdreturns the interface view alongside each contract
entityNamelast template segment, matched client-side over a wildcard sweep — use when you do not know the package
includeBlobinclude createdEventBlob so results can be forwarded as disclosures (default false)
includeInterfaceViewdefault 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>" }
  ]
}
POST/v1/ledger/updateapi keynode

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.

POST/v1/ledger/consuming-exerciseapi keynode

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…" } }
found: false means not found in this window — which is not the same as “still active”. Widen offsetsBack before concluding anything.
GET/v1/ledger/packagesapi keynode

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.

07Reference

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.

POST/v1/utility-registry/factoryapi keynode

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.
POST/v1/utility-registry/transfer-contextapi keynode

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": { } } } }
POST/v1/utility-registry/transferapi keyadmin secretnode

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.

POST/v1/utility-registry/preapproval/prepareapi keyparty-scopednode

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 }.

POST/v1/interactive/executeapi keyparty-scoped

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…" }
Note the flat body: preparedTransaction is a string here, whereas /v1/transfers/broadcast and /v1/canton/broadcast nest it in an object. Despite living under the utility-registry routes, this endpoint is general — use it for any prepared submission whose hash the participant validates.
08Reference

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.

One hard constraint: the payer party must be hosted by the facilitator, because its relay reads payer holdings from its own participant. The merchant party need not be — preapproval lookup is Scan-backed.

Wire format

x402 v2, headers in both directions, base64-encoded JSON.

HeaderDirectionMeaning
X-PAYMENT-REQUIREDresponse (402)the requirements, including price and payTo
X-PAYMENT-SIGNATURErequestthe payment envelope (legacy X-PAYMENT also accepted)
X-PAYMENT-RESPONSEresponse (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 set

Two 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.
GET/v1/x402/infono auth

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.

GET/v1/x402/pingx402 payment

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.

POST/v1/x402/transfers/fee-previewx402 payment

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.

09Reference

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.

Canton filters an update stream by party as a stakeholder, so a stream only carries updates its own party is party to — a transfer between two parties that are neither of them produces no event. Every published event is fanned out to every party the event names (signatories, observers, witnesses, acting parties), so both sides of a transfer see it on their own channel.
POST/v1/auth/stream-tokenapi key

Step 1 — get a stream token.

curl -X POST "$BASE/v1/auth/stream-token" -H "Authorization: Bearer $KEY"
# { "token": "e7c9…", "expiresIn": 60 }
WS/v1/events?token=…stream token

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" } }
GET/v1/events/stream?token=…&partyId=…stream token

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"
}
TypeEmitted when
transfer.pendinga contract is created whose template id contains transfer or instruction
contract.createdany other created event
transfer.executedthe Transfer or Execute choice is exercised
transfer.acceptedthe Accept choice is exercised
transfer.rejectedthe Reject choice is exercised
transfer.withdrawnthe Withdraw choice is exercised
contract.archivedan archived event
choice.<lowercased-name>any other exercised choice
The stream requests Canton’s ACS_DELTA shape, which carries created and archived events only — so in practice you receive contract.created / transfer.pending / contract.archived. An accept, for instance, arrives as the archive of the offer plus the created holdings. Classification is by string matching on template ids and choice names, so treat type as a hint and data.templateId as the truth.

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.
10Reference

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.

CodeStatusMeaning
VALIDATION_ERROR400schema failure; see details.issues
MISSING_PUBLIC_KEY400party register without a resolvable public key
MISSING_PARTY_ID400SSE without partyId
MALFORMED_PAYMENT_HEADER400x402 payment header could not be decoded
PAYMENT_ENVELOPE_REJECTED400facilitator refused the envelope — do not retry as-is
UNAUTHORIZED401missing or malformed Authorization header
INVALID_API_KEY401key not found
API_KEY_REVOKED401key revoked
API_KEY_EXPIRED401past expiresAt
INVALID_TOKEN401stream token invalid, expired, or already used
FORBIDDEN403party not managed by this account, or admin gate refused
IP_NOT_WHITELISTED403source IP not in the key's whitelist
ACCOUNT_SUSPENDED403account status is not active
NOT_FOUND404unknown party / node / command id / key id, or an unrouted wallet action
RATE_LIMIT_EXCEEDED429global IP limit, or the stream-token ceiling
INTERNAL_ERROR500unhandled
NOT_IMPLEMENTED501POST /v1/transfers/estimate-gas
PARTY_RIGHTS_GRANT_FAILED502party allocated but actAs/readAs grant failed
TRANSACTION_BROADCAST_ERROR502Canton rejected the submission; the message wraps its error
FEE_PREVIEW_SOURCE_ERROR502scan error, or an unrecognised payload shape
FACILITATOR_UNAVAILABLE502x402 facilitator unreachable — retry with the same payment
FEE_PREVIEW_UNCONFIGURED503fee 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 fragmentUsual cause
TEMPLATES_OR_INTERFACES_NOT_FOUNDthe DAR is not vetted on this participant — check GET /v1/ledger/packages
contract could not be foundon a third-party exercise: wrong synchronizer — pin synchronizerId on /v1/canton/prepare
security-sensitive errorintermittent: client clock ahead of the participant
DUPLICATE_CONFIRMATION_REQUEST_UUIDthe same prepared transaction was already submitted — re-prepare, do not retry the body
11Reference

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.

TierRequests / minWebSocket connsBulk recipientsJob priority
free3021030
growth300105020
enterprise3000505010
Today only jobPriority is enforced (async-broadcast ordering, lower runs first). Per-key request limits, WebSocket connection caps and monthly request limits are declared but not yet enforced, and no X-RateLimit-* headers are emitted — do not build a client backoff around them. Bulk transfers are capped at a flat 50 recipients on every tier.
12Reference

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.

Shell: these are written for a POSIX shell (Git Bash, zsh, WSL). In PowerShell use curl.exe — bare curl is an alias for Invoke-WebRequest and will not accept these flags.

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 base64

Every flow below repeats this command verbatim. Only party registration differs: swap p.preparedTransactionHash for p.multiHash.

Sign with the key of the party whose authority the command needs — the sender for a transfer, the receiver for a pre-approval or an accept. PRIV must be that party’s key when this runs, and PARTY / PUB must be the same party when the body is built.

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 here

crypto.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.json

Flat — 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.json

Both 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.json

Then 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; done

Now 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.

Use POST /v1/utility-registry/preapproval/prepare. Do not use POST /v1/wallets/{party}/preapproval/prepare with registry:"utility": its schema requires admin inside instrumentAllowances, but the Daml template rejects that key with "Unexpected fields: admin", so that branch cannot succeed with a non-empty allowance list.

What you need, for any utility token

Ten values. Two are constants, three are yours, and five describe the instrument.

FieldWhat it isHow to obtain it
receiverPartyIdparty being pre-approvedyours
reg.validatorPartyIdthe validator party submitting the transaction — yours, not the token’s home validatoryour own participant's operator party
reg.synchronizerIdthe synchronizer your participant and the registrar's sharesynchronizerId 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 registrarregistry /registry/metadata/v1/instruments → id
reg.regAppPackageIdpackage id of the utility-registry-app-v0 release that registrar runsthe 64-hex prefix of the templateId on one of that registrar’s live app contracts
templateIdthe TransferPreapproval template#utility-registry-app-v0:Utility.Registry.App.V0.Model.TransferPreapproval:TransferPreapproval
reg.regAppPackageNameconstantutility-registry-app-v0
reg.registryPackageNameconstantutility-registry-v0
reg is mandatory for any registrar the gateway is not itself configured for. The route resolves its own configuration before reading the top-level operatorPartyId / registrarPartyId, so omitting it returns 400 UTILITY_REGISTRY_NOT_CONFIGURED listing fields you already supplied in the body.

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 APIs
Read /instruments and check the array is non-empty. Do not treat a 200 as proof of anything — /info echoes back whatever registrar is in the request path, and an unknown registrar returns 200 {"instruments":[]} rather than a 404.

The 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

InstrumentRegistrar / instrumentAdminDecimals
USDCxdecentralized-usdc-interchain-rep::12208115f1e168dd7e792320be9c4ca720c751a02a3053c7606e1c1cd3dad9bf60ef10
CBTCcbtc-network::12205af3b949a04776fc48cdcc05a060f6bda2e470632935f375d1049a8546a3b26210

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::1220b1431ef217342db44d516bb9befde802be7d8899637d290895fa58880f19accc
regAppPackageId is confirmed for USDCx. It is unconfirmed for CBTC, whose InstrumentConfiguration sits under an older release — DA runs different versions per registrar, so treat a shared app package id as an assumption to verify, not a given. Devnet CBTC is a different registrar party, with a different operator, on a different host and synchronizer; do not mix the two sets of coordinates.

Check 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"
  } }
EOF

Then 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.json

Expect {"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.json

Debugging 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.json

Step 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.json

A 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.
13Reference

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
1POST /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.
2reg 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.
4hashingSchemeVersion is a string. Coercing it to a number gets a 400.
5GET /v1/wallets/{party}/preapproval is Amulet-only and ignores the instrument — it returns true for utility tokens that have no pre-approval.
6A 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.
7Factory 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.
8Operator 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.
9Send 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.
13DUPLICATE_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.
14Holding 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".
15Small 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.
16An 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.
17key.json holds a private key. Delete the scratch files when done — they are not gitignored.
14Reference

Route index

MethodPathAuth
GET/v1/healthpublic
GET/v1/readypublic
POST/v1/auth/stream-tokenkey
POST/v1/parties/preparekey
POST/v1/parties/registerkey · party-scoped
GET/v1/partieskey · account-scoped
GET/v1/parties/{partyId}key
GET/v1/wallets/holdingskey
GET/v1/wallets/{partyId}/balancekey
GET/v1/wallets/{partyId}/contractskey
GET/v1/wallets/{partyId}/preapprovalkey
POST/v1/wallets/{partyId}/preapproval/preparekey · party-scoped
POST/v1/transfers/preparekey · party-scoped
POST/v1/transfers/prepare/bulkkey · party-scoped
POST/v1/transfers/acceptkey · party-scoped
POST/v1/transfers/rejectkey · party-scoped
POST/v1/transfers/withdrawkey · party-scoped
POST/v1/transfers/broadcastkey · party-scoped
GET/v1/transfers/:commandId/statuskey-scoped
GET/v1/transfers/pendingkey
GET/v1/transfers/historykey-scoped
POST/v1/transfers/contextkey · party-scoped
POST/v1/transfers/estimate-gaskey
POST/v1/canton/preparekey · party-scoped
POST/v1/canton/broadcastkey · party-scoped
POST/v1/canton/merge-delegation/preparekey · party-scoped
POST/v1/canton/gas/checkkey
POST/v1/ledger/active-contractskey
POST/v1/ledger/updatekey
POST/v1/ledger/consuming-exercisekey
GET/v1/ledger/packageskey
POST/v1/utility-registry/factorykey
POST/v1/utility-registry/transfer-contextkey
POST/v1/utility-registry/transferkey + admin
POST/v1/utility-registry/preapproval/preparekey · party-scoped
POST/v1/interactive/executekey · party-scoped
GET/v1/x402/infopublic
GET/v1/x402/pingx402 payment
POST/v1/x402/transfers/fee-previewx402 payment
WS/v1/eventsstream token
GET/v1/events/streamstream token
Get started

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.