Skip to main content
Every action that changes wallet state in Dakota — sending funds, attaching a signer group, modifying a policy — requires an endorsed request: a cryptographically signed declaration of intent. This guide explains the signing model end-to-end, documents every intent type, and walks through the full signer group and policy lifecycles.
If you haven’t set up a wallet yet, start with Non-Custodial Wallets first, then return here for the complete signing reference.

The Endorsed Request Model

An endorsed request wraps two things:
  1. An intent — a JSON object describing exactly what you want to do
  2. One or more signatures — ECDSA P-256 signatures proving that authorized signers approved the intent
{
  "signatures": [
    "<base64-encoded ECDSA signature>"
  ],
  "intent": {
    "type": "attach_group_to_wallet",
    "wallet_id": "wal_...",
    "group_id": "grp_...",
    "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
  }
}
Dakota’s policy engine re-canonicalizes the intent server-side, verifies each signature against the stored public keys, checks that the signer group’s approval threshold is met, and only then executes the action. This means:
  • You never send private keys — only signatures
  • The server cannot fabricate intents — every action has cryptographic proof of authorization
  • Multiple signers can co-approve — the signatures array accepts as many entries as your threshold requires

How Signing Works

Every signature follows the same three-step process, regardless of intent type.

Step 1: Build the Intent JSON

Create the intent object with the exact fields required for the operation. Field names are snake_case. Amounts are strings (e.g., "10.5", not 10.5). Omit any optional fields that don’t apply.

Step 2: Canonicalize with RFC 8785 (JCS)

RFC 8785 JSON Canonicalization Scheme produces a deterministic byte sequence from any JSON object by sorting keys alphabetically and removing insignificant whitespace. This ensures that your client and Dakota’s policy engine hash identical bytes, regardless of how your language orders JSON keys.

Step 3: Hash with SHA-256, Sign with ECDSA P-256

Hash the canonical bytes with SHA-256, then sign the hash using your ECDSA P-256 private key. The signature must be in ASN.1 DER encoding (not raw r || s), then base64-encoded.
Intent JSON → RFC 8785 canonicalize → SHA-256 hash → ECDSA P-256 sign (DER) → Base64 encode

Code Examples

import { createSign } from 'node:crypto';
import canonicalize from 'canonicalize'; // npm install canonicalize

function signIntent(intent, privateKey) {
  const canonical = canonicalize(intent);
  const signer = createSign('SHA256');
  signer.update(canonical);
  const signature = signer.sign({ key: privateKey, dsaEncoding: 'der' });
  return signature.toString('base64');
}
Browser only: crypto.subtle.sign with ECDSA returns an IEEE P1363 raw r || s encoding, not ASN.1 DER. You must convert it before submitting — the rawEcdsaSignatureToDer helper above does this. Submitting the raw form will fail signature verification.

Intent Types Reference

Every endorsed request carries one of nine intent types. The table below lists all of them with their discriminator value, endpoint, and required fields.
Intenttype valueEndpointRequired Fields
Send Transaction(none)POST /wallets/{wallet_id}/transactionswallet_id, caip2, operation, idempotency_key
Attach Signer Groupattach_group_to_walletPUT /wallets/{wallet_id}/signer-groups/{group_id}type, wallet_id, group_id, idempotency_key
Detach Signer Groupdetach_group_from_walletDELETE /wallets/{wallet_id}/signer-groups/{group_id}type, wallet_id, group_id, idempotency_key
Attach Policyattach_policy_to_walletPUT /policies/{policy_id}/wallets/{wallet_id}type, wallet_id, policy_id, idempotency_key
Detach Policydetach_policy_from_walletDELETE /policies/{policy_id}/wallets/{wallet_id}type, wallet_id, policy_id, idempotency_key
Add Policy Ruleadd_policy_rulePOST /policies/{policy_id}/rulestype, policy_id, rule_type, action, definition, idempotency_key
Remove Policy Ruleremove_policy_ruleDELETE /policies/{policy_id}/rules/{rule_id}type, policy_id, rule_id, idempotency_key
Update Policy Ruleupdate_policy_rulePATCH /policies/{policy_id}/rules/{rule_id}type, policy_id, rule_id, updated_definition, idempotency_key
Delete Policydelete_policyDELETE /policies/{policy_id}type, policy_id, idempotency_key

Send Transaction

Send crypto from a wallet. This is the only intent type without a type discriminator field.
{
  "wallet_id": "wal_2LfZm5KMnRvLFtRP7nJJug4zJEP",
  "caip2": "eip155:1",
  "operation": {
    "kind": "transfer",
    "from": "0xYourWalletAddress...",
    "to": "0xDestinationAddress...",
    "amount": "10.5",
    "asset_id": "USDC"
  },
  "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0"
}
The operation.kind field determines which sub-fields are required:
KindRequired FieldsOptional Fields
transferfrom, to, amount, asset_id
contract_callfrom, to, asset_idmethod, args, data
Amounts are strings ("10.5", not 10.5). The caip2 field uses CAIP-2 chain identifiers (e.g., eip155:1 for Ethereum mainnet, eip155:11155111 for Sepolia).

Attach Signer Group to Wallet

{
  "type": "attach_group_to_wallet",
  "wallet_id": "wal_2LfZm5KMnRvLFtRP7nJJug4zJEP",
  "group_id": "grp_2LfPqT9VmQzKDvQP9rGHth3yHCN",
  "idempotency_key": "b7e9d1a2-3c4f-5e6d-7a8b-9c0d1e2f3a4b"
}

Detach Signer Group from Wallet

{
  "type": "detach_group_from_wallet",
  "wallet_id": "wal_2LfZm5KMnRvLFtRP7nJJug4zJEP",
  "group_id": "grp_2LfPqT9VmQzKDvQP9rGHth3yHCN",
  "idempotency_key": "c8f0e2b3-4d5e-6f7a-8b9c-0d1e2f3a4b5c"
}

Attach Policy to Wallet

{
  "type": "attach_policy_to_wallet",
  "wallet_id": "wal_2LfZm5KMnRvLFtRP7nJJug4zJEP",
  "policy_id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "idempotency_key": "d9a1f3c4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}

Detach Policy from Wallet

{
  "type": "detach_policy_from_wallet",
  "wallet_id": "wal_2LfZm5KMnRvLFtRP7nJJug4zJEP",
  "policy_id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "idempotency_key": "e0b2a4d5-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
}

Add Policy Rule

{
  "type": "add_policy_rule",
  "policy_id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "rule_type": "approval_threshold",
  "action": "allow",
  "definition": {
    "threshold": 2,
    "description": "Require 2 approvals"
  },
  "idempotency_key": "f1c3b5e6-7a8b-9c0d-1e2f-3a4b5c6d7e8f"
}
Available rule types:
rule_typeactiondefinition example
approval_thresholdallow{"threshold": 2, "description": "Require 2 approvals"}
amount_thresholddeny{"amount": "10000", "currency": "USD"}
address_listallow or denyAddress allowlist/denylist configuration

Remove Policy Rule

{
  "type": "remove_policy_rule",
  "policy_id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "rule_id": "rule_2N4YkKpKu7M3mKpGYmF8kcJ8oZT",
  "idempotency_key": "a2d4c6e7-8b9c-0d1e-2f3a-4b5c6d7e8f9a"
}

Update Policy Rule

{
  "type": "update_policy_rule",
  "policy_id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "rule_id": "rule_2N4YkKpKu7M3mKpGYmF8kcJ8oZT",
  "updated_definition": "{\"amount\": \"15000\", \"currency\": \"USD\"}",
  "idempotency_key": "b3e5d7f8-9c0d-1e2f-3a4b-5c6d7e8f9a0b"
}
updated_definition is a JSON string, not a nested object. Serialize the definition object to a string before including it in the intent.

Delete Policy

{
  "type": "delete_policy",
  "policy_id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "idempotency_key": "c4f6e8a9-0d1e-2f3a-4b5c-6d7e8f9a0b1c"
}
Deleting a policy is irreversible. Detach the policy from all wallets first — attempting to delete a policy that is still attached to a wallet will fail.

Signer Group Lifecycle

A signer group controls who can authorize wallet actions. Here is the complete lifecycle from creation through teardown.

Step-by-step

StepActionEndpointEndorsed?Prerequisites
1Register signersPOST /signersNoGenerate ES256 keypairs client-side
2Create signer groupPOST /signer-groupsNoAt least one registered signer public key
3Add more signersPOST /signer-groups/{id}/signersNoSigner public key already registered
4Attach group to walletPUT /wallets/{id}/signer-groups/{group_id}YesGroup exists, wallet exists
5Send transactionsPOST /wallets/{id}/transactionsYesGroup attached, threshold met
6Remove a signerDELETE /signer-groups/{id}/signers/{signer_id}NoSigner is a member of the group
7Detach group from walletDELETE /wallets/{id}/signer-groups/{group_id}YesGroup is attached to wallet
Before detaching a signer group, ensure the wallet has at least one other signer group attached, or the wallet will become unusable — no one will be able to sign transactions for it.
Adding and removing signers (steps 3 and 6) do not require endorsed requests — they are standard API calls authenticated with your API key. Only wallet-level operations (attach, detach, transact) require cryptographic signatures.

Policy Lifecycle

Policies define automated governance rules for wallets — approval thresholds, amount limits, address allowlists, and more. Here is the complete lifecycle.

Step-by-step

StepActionEndpointEndorsed?Prerequisites
1Create policyPOST /policiesNoSigner group exists
2Add rulesPOST /policies/{id}/rulesYesPolicy exists
3Attach to walletPUT /policies/{id}/wallets/{wallet_id}YesPolicy has at least one rule
4Update a rulePATCH /policies/{id}/rules/{rule_id}YesRule exists on policy
5Detach from walletDELETE /policies/{id}/wallets/{wallet_id}YesPolicy is attached to wallet
6Remove rulesDELETE /policies/{id}/rules/{rule_id}YesRule exists, policy detached from wallets
7Delete policyDELETE /policies/{id}YesPolicy is detached from all wallets
Deletion order matters. You must detach a policy from all wallets before you can delete it. Attempting to delete an attached policy will return an error.

Troubleshooting

Signature Verification Failed

SymptomCauseFix
invalid_signatureSignature is in IEEE P1363 format (r || s) instead of ASN.1 DERUse dsaEncoding: 'der' in Node.js, or convert with rawEcdsaSignatureToDer in browsers. See code examples.
invalid_signatureCanonicalization mismatch — client and server produced different bytesEnsure you are using an RFC 8785 JCS library (canonicalize for JS, jcs for Python, json-canonicalization for Go). Standard JSON.stringify is not deterministic.
invalid_signatureAmount passed as number instead of stringUse "10.5" not 10.5. Numbers and strings canonicalize differently.
invalid_signatureExtra or missing fields in the intentThe intent you sign must have exactly the fields the server expects. Omit optional fields you are not using — do not set them to null.

Threshold Not Met

SymptomCauseFix
threshold_not_metFewer signatures than the policy’s approval thresholdIf the policy requires 2-of-3, the signatures array must contain at least 2 valid signatures from distinct signers in the group.

Signer Not Authorized

SymptomCauseFix
signer_not_foundThe signing key is not a member of any signer group attached to the walletVerify the public key is registered as a signer, added to a signer group, and that group is attached to the wallet.
signer_not_foundWrong private key used for signingEnsure you are signing with the private key that corresponds to the public key registered with Dakota.

Key Format Errors

SymptomCauseFix
400 on POST /signersPublic key is not a valid P-256 SPKIExport the public key as X.509 SubjectPublicKeyInfo in DER format, then base64-encode it. Keys on other curves (P-384, P-521, secp256k1) are rejected.
400 on POST /signersKey type mismatchEnsure key_type is "ES256" for ECDSA P-256 keys or "WEBAUTHN" for WebAuthn keys.