> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dakota.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Signing & Endorsed Requests

> How to sign intents, build endorsed requests, and manage signer group and policy lifecycles

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.

<Info>
  If you haven't set up a wallet yet, start with [Non-Custodial Wallets](/documentation/common-flows#create-a-wallet-non-custodial) first, then return here for the complete signing reference.
</Info>

***

## 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 (or [WebAuthn assertions](/documentation/webauthn-signing)) proving that authorized signers approved the intent

```json theme={null}
{
  "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](https://www.rfc-editor.org/rfc/rfc8785) 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
```

<Note>
  This is the flow for **`ES256`** signers. If your signer is a **WebAuthn credential** (`key_type: WEBAUTHN`) — a passkey or hardware authenticator — the signature is a WebAuthn assertion bundle rather than a DER signature over the hash, and the intent is carried as the assertion's challenge. See [WebAuthn & Passkey Signing](/documentation/webauthn-signing).
</Note>

### Code Examples

<CodeGroup>
  ```javascript Node.js theme={null}
  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');
  }
  ```

  ```python Python theme={null}
  import base64
  import jcs  # pip install jcs
  from cryptography.hazmat.primitives import hashes
  from cryptography.hazmat.primitives.asymmetric import ec

  def sign_intent(intent, private_key):
      canonical = jcs.canonicalize(intent)
      signature = private_key.sign(canonical, ec.ECDSA(hashes.SHA256()))
      return base64.b64encode(signature).decode('ascii')
  ```

  ```go Go theme={null}
  import (
      "crypto/ecdsa"
      "crypto/rand"
      "crypto/sha256"
      "encoding/base64"
      "encoding/json"

      "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer"
  )

  func signIntent(intent any, priv *ecdsa.PrivateKey) (string, error) {
      raw, err := json.Marshal(intent)
      if err != nil {
          return "", err
      }
      canonical, err := jsoncanonicalizer.Transform(raw)
      if err != nil {
          return "", err
      }
      hash := sha256.Sum256(canonical)
      sig, err := ecdsa.SignASN1(rand.Reader, priv, hash[:])
      if err != nil {
          return "", err
      }
      return base64.StdEncoding.EncodeToString(sig), nil
  }
  ```

  ```javascript JavaScript (Browser) theme={null}
  import canonicalize from 'canonicalize';

  async function signIntent(intent, privateKey) {
    const canonical = canonicalize(intent);
    const data = new TextEncoder().encode(canonical);
    // WebCrypto returns raw r||s (IEEE P1363); Dakota expects ASN.1 DER.
    const rawSig = new Uint8Array(
      await crypto.subtle.sign(
        { name: 'ECDSA', hash: 'SHA-256' },
        privateKey,
        data,
      ),
    );
    const der = rawEcdsaSignatureToDer(rawSig);
    let binary = '';
    for (const b of der) binary += String.fromCharCode(b);
    return btoa(binary);
  }

  // Convert a WebCrypto ECDSA P-256 signature (64 bytes, r || s) to
  // ASN.1 DER: SEQUENCE { INTEGER r, INTEGER s }.
  function rawEcdsaSignatureToDer(raw) {
    const r = trimLeadingZeros(raw.slice(0, 32));
    const s = trimLeadingZeros(raw.slice(32, 64));
    const rDer = encodeInteger(r);
    const sDer = encodeInteger(s);
    const seqLen = rDer.length + sDer.length;
    const out = new Uint8Array(2 + seqLen);
    out[0] = 0x30; // SEQUENCE
    out[1] = seqLen;
    out.set(rDer, 2);
    out.set(sDer, 2 + rDer.length);
    return out;
  }

  function trimLeadingZeros(bytes) {
    let i = 0;
    while (i < bytes.length - 1 && bytes[i] === 0) i++;
    return bytes.slice(i);
  }

  function encodeInteger(bytes) {
    const needsPad = (bytes[0] & 0x80) !== 0;
    const body = needsPad ? new Uint8Array([0, ...bytes]) : bytes;
    return new Uint8Array([0x02, body.length, ...body]);
  }
  ```
</CodeGroup>

<Warning>
  **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.
</Warning>

***

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

| Intent                                                  | `type` value                | Endpoint                                               | Required Fields                                                             |
| ------------------------------------------------------- | --------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------- |
| [Send Transaction](#send-transaction)                   | *(none)*                    | `POST /wallets/{wallet_id}/transactions`               | `wallet_id`, `caip2`, `operation`, `idempotency_key`                        |
| [Attach Signer Group](#attach-signer-group-to-wallet)   | `attach_group_to_wallet`    | `PUT /wallets/{wallet_id}/signer-groups/{group_id}`    | `type`, `wallet_id`, `group_id`, `idempotency_key`                          |
| [Detach Signer Group](#detach-signer-group-from-wallet) | `detach_group_from_wallet`  | `DELETE /wallets/{wallet_id}/signer-groups/{group_id}` | `type`, `wallet_id`, `group_id`, `idempotency_key`                          |
| [Attach Policy](#attach-policy-to-wallet)               | `attach_policy_to_wallet`   | `PUT /policies/{policy_id}/wallets/{wallet_id}`        | `type`, `wallet_id`, `policy_id`, `idempotency_key`                         |
| [Detach Policy](#detach-policy-from-wallet)             | `detach_policy_from_wallet` | `DELETE /policies/{policy_id}/wallets/{wallet_id}`     | `type`, `wallet_id`, `policy_id`, `idempotency_key`                         |
| [Add Policy Rule](#add-policy-rule)                     | `add_policy_rule`           | `POST /policies/{policy_id}/rules`                     | `type`, `policy_id`, `rule_type`, `action`, `definition`, `idempotency_key` |
| [Remove Policy Rule](#remove-policy-rule)               | `remove_policy_rule`        | `DELETE /policies/{policy_id}/rules/{rule_id}`         | `type`, `policy_id`, `rule_id`, `idempotency_key`                           |
| [Update Policy Rule](#update-policy-rule)               | `update_policy_rule`        | `PATCH /policies/{policy_id}/rules/{rule_id}`          | `type`, `policy_id`, `rule_id`, `updated_definition`, `idempotency_key`     |
| [Delete Policy](#delete-policy)                         | `delete_policy`             | `DELETE /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.

```json theme={null}
{
  "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:

| Kind                | Required Fields                    | Optional Fields          |
| ------------------- | ---------------------------------- | ------------------------ |
| `transfer`          | `from`, `to`, `amount`, `asset_id` | —                        |
| `evm_contract_call` | `from`, `to`, `asset_id`           | `method`, `args`, `data` |

<Note>
  Amounts are **strings** (`"10.5"`, not `10.5`). The `caip2` field uses [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) chain identifiers (e.g., `eip155:1` for Ethereum mainnet, `eip155:11155111` for Sepolia).
</Note>

### Attach Signer Group to Wallet

```json theme={null}
{
  "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

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

### Attach Policy to Wallet

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

### Detach Policy from Wallet

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

### Add Policy Rule

```json theme={null}
{
  "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_type`          | `action`          | `definition` example                                                                   |
| -------------------- | ----------------- | -------------------------------------------------------------------------------------- |
| `approval_threshold` | `allow`           | `{"threshold": 2, "description": "Require 2 approvals"}`                               |
| `amount_threshold`   | `deny`            | `{"min_amount": 1000000, "threshold": 0, "asset": {"id": "USD", "name": "US Dollar"}}` |
| `address_list`       | `allow` or `deny` | Address allowlist/denylist configuration                                               |

### Remove Policy Rule

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

### Update Policy Rule

```json theme={null}
{
  "type": "update_policy_rule",
  "policy_id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "rule_id": "rule_2N4YkKpKu7M3mKpGYmF8kcJ8oZT",
  "updated_definition": "{\"threshold\": 2, \"description\": \"Require 2 approvals\"}",
  "idempotency_key": "b3e5d7f8-9c0d-1e2f-3a4b-5c6d7e8f9a0b"
}
```

<Note>
  `updated_definition` is a **JSON string**, not a nested object. Serialize the definition object to a string before including it in the intent.

  The update path is stricter than create — `threshold` must be `> 0`, and `amount_threshold` updates require the full proto `Asset` shape with `contract_address` (not just `{id, name}`). The accepted shapes:

  * `approval_threshold`: `{"threshold": int32 (> 0), "description"?: string}`
  * `amount_threshold`: `{"min_amount": int64 (>= 0), "threshold": int32 (> 0), "asset": {"id", "name", "network_id", "contract_address", "token_standard", "decimals"}}`
  * `address_list`: `{"addresses": [string] (non-empty)}`
</Note>

### Delete Policy

```json theme={null}
{
  "type": "delete_policy",
  "policy_id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP",
  "idempotency_key": "c4f6e8a9-0d1e-2f3a-4b5c-6d7e8f9a0b1c"
}
```

<Warning>
  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.
</Warning>

***

## Signer Group Lifecycle

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

```mermaid theme={null}
sequenceDiagram
    participant You
    participant Dakota

    Note over You,Dakota: Setup
    You->>Dakota: POST /signers (register each public key)
    Dakota-->>You: signer_id per key
    You->>Dakota: POST /signer-groups
    Dakota-->>You: group_id

    Note over You,Dakota: Manage Members
    You->>Dakota: POST /signer-groups/{id}/signers (add member)
    Dakota-->>You: Updated group
    You->>Dakota: DELETE /signer-groups/{id}/signers/{signer_id} (remove member)
    Dakota-->>You: Updated group

    Note over You,Dakota: Attach to Wallet (endorsed)
    You->>You: Sign AttachGroupToWalletIntent
    You->>Dakota: PUT /wallets/{id}/signer-groups/{group_id}
    Dakota-->>You: Attached

    Note over You,Dakota: Use Wallet (endorsed)
    You->>You: Sign SendTransactionIntent
    You->>Dakota: POST /wallets/{id}/transactions
    Dakota-->>You: transaction_id

    Note over You,Dakota: Detach from Wallet (endorsed)
    You->>You: Sign DetachGroupFromWalletIntent
    You->>Dakota: DELETE /wallets/{id}/signer-groups/{group_id}
    Dakota-->>You: Detached
```

### Step-by-step

| Step | Action                   | Endpoint                                         | Endorsed? | Prerequisites                             |
| ---- | ------------------------ | ------------------------------------------------ | --------- | ----------------------------------------- |
| 1    | Register signers         | `POST /signers`                                  | No        | Generate ES256 keypairs client-side       |
| 2    | Create signer group      | `POST /signer-groups`                            | No        | At least one registered signer public key |
| 3    | Add more signers         | `POST /signer-groups/{id}/signers`               | No        | Signer public key already registered      |
| 4    | Attach group to wallet   | `PUT /wallets/{id}/signer-groups/{group_id}`     | **Yes**   | Group exists, wallet exists               |
| 5    | Send transactions        | `POST /wallets/{id}/transactions`                | **Yes**   | Group attached, threshold met             |
| 6    | Remove a signer          | `DELETE /signer-groups/{id}/signers/{signer_id}` | No        | Signer is a member of the group           |
| 7    | Detach group from wallet | `DELETE /wallets/{id}/signer-groups/{group_id}`  | **Yes**   | Group is attached to wallet               |

<Warning>
  **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.
</Warning>

<Note>
  **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.
</Note>

***

## Policy Lifecycle

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

```mermaid theme={null}
sequenceDiagram
    participant You
    participant Dakota

    Note over You,Dakota: Create & Configure
    You->>Dakota: POST /policies
    Dakota-->>You: policy_id

    Note over You,Dakota: Add Rules (endorsed)
    You->>You: Sign AddPolicyRuleIntent
    You->>Dakota: POST /policies/{id}/rules
    Dakota-->>You: rule_id

    Note over You,Dakota: Attach to Wallet (endorsed)
    You->>You: Sign AttachPolicyToWalletIntent
    You->>Dakota: PUT /policies/{id}/wallets/{wallet_id}
    Dakota-->>You: Attached

    Note over You,Dakota: Modify Rules (endorsed)
    You->>You: Sign UpdatePolicyRuleIntent
    You->>Dakota: PATCH /policies/{id}/rules/{rule_id}
    Dakota-->>You: Updated

    Note over You,Dakota: Teardown (endorsed)
    You->>You: Sign DetachPolicyFromWalletIntent
    You->>Dakota: DELETE /policies/{id}/wallets/{wallet_id}
    Dakota-->>You: Detached

    You->>You: Sign RemovePolicyRuleIntent
    You->>Dakota: DELETE /policies/{id}/rules/{rule_id}
    Dakota-->>You: Rule removed

    You->>You: Sign DeletePolicyIntent
    You->>Dakota: DELETE /policies/{id}
    Dakota-->>You: Policy deleted
```

### Step-by-step

| Step | Action             | Endpoint                                    | Endorsed? | Prerequisites                             |
| ---- | ------------------ | ------------------------------------------- | --------- | ----------------------------------------- |
| 1    | Create policy      | `POST /policies`                            | No        | Signer group exists                       |
| 2    | Add rules          | `POST /policies/{id}/rules`                 | **Yes**   | Policy exists                             |
| 3    | Attach to wallet   | `PUT /policies/{id}/wallets/{wallet_id}`    | **Yes**   | Policy has at least one rule              |
| 4    | Update a rule      | `PATCH /policies/{id}/rules/{rule_id}`      | **Yes**   | Rule exists on policy                     |
| 5    | Detach from wallet | `DELETE /policies/{id}/wallets/{wallet_id}` | **Yes**   | Policy is attached to wallet              |
| 6    | Remove rules       | `DELETE /policies/{id}/rules/{rule_id}`     | **Yes**   | Rule exists, policy detached from wallets |
| 7    | Delete policy      | `DELETE /policies/{id}`                     | **Yes**   | Policy is detached from all wallets       |

<Warning>
  **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.
</Warning>

***

## Troubleshooting

### Signature Verification Failed

| Symptom             | Cause                                                                  | Fix                                                                                                                                                                         |
| ------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_signature` | Signature is in IEEE P1363 format (`r \|\| s`) instead of ASN.1 DER    | Use `dsaEncoding: 'der'` in Node.js, or convert with `rawEcdsaSignatureToDer` in browsers. See [code examples](#code-examples).                                             |
| `invalid_signature` | Canonicalization mismatch — client and server produced different bytes | Ensure 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_signature` | Amount passed as number instead of string                              | Use `"10.5"` not `10.5`. Numbers and strings canonicalize differently.                                                                                                      |
| `invalid_signature` | Extra or missing fields in the intent                                  | The 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

| Symptom             | Cause                                                 | Fix                                                                                                                                |
| ------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `threshold_not_met` | Fewer signatures than the policy's approval threshold | If 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

| Symptom            | Cause                                                                      | Fix                                                                                                                 |
| ------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `signer_not_found` | The signing key is not a member of any signer group attached to the wallet | Verify the public key is registered as a signer, added to a signer group, and that group is attached to the wallet. |
| `signer_not_found` | Wrong private key used for signing                                         | Ensure you are signing with the private key that corresponds to the public key registered with Dakota.              |

### Key Format Errors

| Symptom                  | Cause                                | Fix                                                                                                                                                                                                          |
| ------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400` on `POST /signers` | Public key is not a valid P-256 SPKI | Export 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 /signers` | Key type mismatch                    | Ensure `key_type` is `"ES256"` for ECDSA P-256 keys or `"WEBAUTHN"` for WebAuthn keys. For the WebAuthn COSE key format and signing flow, see [WebAuthn & Passkey Signing](/documentation/webauthn-signing). |
