# Create an account Source: https://docs.dakota.xyz/api-reference/accounts/create-an-account /openapi.yaml post /accounts Create a new onramp, offramp, or swap account. # Delete an account Source: https://docs.dakota.xyz/api-reference/accounts/delete-an-account /openapi.yaml delete /accounts/{account_id} Soft-deletes an auto account. The account must be owned by the requesting client. # Get an account Source: https://docs.dakota.xyz/api-reference/accounts/get-an-account /openapi.yaml get /accounts/{account_id} Get details for an account by ID. # List accounts Source: https://docs.dakota.xyz/api-reference/accounts/list-accounts /openapi.yaml get /accounts List onramp, offramp, or swap accounts filtered by account type. # Update an account Source: https://docs.dakota.xyz/api-reference/accounts/update-an-account /openapi.yaml patch /accounts/{account_id} Partially update an onramp, offramp, or swap account. Only the account's destination routing may be changed. For onramp accounts these are `crypto_destination_id`, `destination_network_id`, and `destination_asset`; for offramp accounts it is `fiat_destination_id`. An account's `capabilities` and `rail` are fixed when the account is created and cannot be changed through this endpoint. To use a different capability or rail, create a new account. # Accept instructions — actuate proposals (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/accept-instructions-—-actuate-proposals-alpha /openapi.yaml post /instructions > **Alpha** — early access. Each accepted proposal becomes one persisted instruction whose action series is actuated deterministically. Every proposal must instruct a payment. Returns only the instruction ids. # Cancel one scheduled payment (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/cancel-one-scheduled-payment-alpha /openapi.yaml post /scheduled-payments/{scheduled_payment_id}/cancel > **Alpha** — early access. Finalizes a single still-scheduled payment as cancelled. No signature is required - schedule rows are bookkeeping, not authorization (they are created by a plain API-key call); the signed grant is the MANDATE, which this does not touch. New payments can be instructed under the same mandate at any time. Executed, failed, and already-cancelled rows are not cancellable; a cancel racing the cron at the exact fire moment loses cleanly (the status guard never overwrites an executed payment). # Create a hosted payment agent (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/create-a-hosted-payment-agent-alpha /openapi.yaml post /payment-agents > **Alpha** — early access. Creates a payment agent with a real signer row for its derived key (an agent is an ordinary signer). Wallet access is granted separately through the endorsed signer-group / policy attach flow. # Draft payment proposals from a conversation (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/draft-payment-proposals-from-a-conversation-alpha /openapi.yaml post /payment-agents/{payment_agent_id}/proposals > **Alpha** — early access. Pure cognition, no side effects: turn a customer's natural-language request into reviewable PROPOSALS — the same action-series shape POST /instructions accepts. Stateless: send the conversation so far in `messages` (and/or a `prompt` appended as the latest user turn) on each call, and the response carries either a clarifying/confirming reply, or — only at high confidence — validated proposals (sometimes both). The agent may consult the customer's existing payees and this agent's payment history; it never guesses amounts, assets, networks, or addresses, and proposals still only take effect via the instructions + mandate-signature flow. Requires the freeform LLM layer to be configured server-side. # Get a hosted payment agent (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/get-a-hosted-payment-agent-alpha /openapi.yaml get /payment-agents/{payment_agent_id} > **Alpha** — early access. Returns the agent together with the wallets it can currently spend from. The wallet_ids are DERIVED at query time from the agent signer's live signer-group membership (recognition over the policy-engine truth mirror), so they reflect the agent's access right now — not just at creation time. Use this to read an agent back and reconcile which wallets it is authorized on. The derived state folds the same recognition in (a non-revoked agent recognized on no wallet is pending). # Get an instruction (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/get-an-instruction-alpha /openapi.yaml get /instructions/{instruction_id} > **Alpha** — early access. Returns the accepted proposal - its action series and the per-action downstream artifacts actuation produced. # List the calling client's scheduled payments (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/list-the-calling-clients-scheduled-payments-alpha /openapi.yaml get /scheduled-payments > **Alpha** — early access. Returns the calling client's scheduled payments (the ScheduledPayment primitive — all statuses, not just future ones), oldest due first. Each shows its funding wallet (the customer's choice at acceptance); once executed, a row also carries the covering mandate and money-path transaction as audit. Narrow the collection with the optional customer_id, signer_id, wallet_id, mandate_id, and status filters; omit them all for the full client collection. The mandate_id filter naturally matches executed rows only (a row carries no mandate until it fires). # Revoke a hosted agent (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/revoke-a-hosted-agent-alpha /openapi.yaml post /payment-agents/{payment_agent_id}/revoke > **Alpha** — early access. Revokes an agent: it can no longer be used, and its signing key is destroyed in the isolated signer service (best-effort — the revoked state is authoritative). Idempotent. # Schedule a payment directly (ALPHA) Source: https://docs.dakota.xyz/api-reference/agentic-payments/schedule-a-payment-directly-alpha /openapi.yaml post /scheduled-payments > **Alpha** — early access. Creates one or more scheduled payments for a signer WITHOUT the proposal flow — schedule directly under an existing active mandate (coverage is matched at fire time, so no new signature is needed here). The payments bind the given signer and funding wallet. The signer must be permitted to spend on that wallet, the wallet must belong to the calling client, and the destination must be a crypto destination of the wallet's customer. The schedule is explicit `dates` (one payment per timestamp) OR `count` × `interval_seconds` from `start_at` (0 ⇒ now); dates are unix SECONDS and are rejected if in the past or implausibly far ahead. Whether a mandate covers each payment is decided at fire time by the money-path gate — a scheduled payment with no covering active mandate fails at fire, it is not rejected here. # Create a new API key Source: https://docs.dakota.xyz/api-reference/authentication/create-a-new-api-key /openapi.yaml post /api-keys Generate a new API key # Create an API key for a client (Admin only) Source: https://docs.dakota.xyz/api-reference/authentication/create-an-api-key-for-a-client-admin-only /openapi.yaml post /api-keys/admin Create an API key for a specified client. Requires admin access token. # Delete all API keys Source: https://docs.dakota.xyz/api-reference/authentication/delete-all-api-keys /openapi.yaml delete /api-keys Delete all API keys (intended for incident response only) # Delete an API key Source: https://docs.dakota.xyz/api-reference/authentication/delete-an-api-key /openapi.yaml delete /api-keys/{api_key_id} Delete an existing API key by ID # List API keys Source: https://docs.dakota.xyz/api-reference/authentication/list-api-keys /openapi.yaml get /api-keys Get a list of active API keys # Get details of a specific auto account transaction Source: https://docs.dakota.xyz/api-reference/auto-transactions/get-details-of-a-specific-auto-account-transaction /openapi.yaml get /auto-transactions/{auto_transaction_id} Retrieves a single auto-transaction record by `auto_transaction_id` with execution details and status. # List transactions that were created automatically by auto accounts Source: https://docs.dakota.xyz/api-reference/auto-transactions/list-transactions-that-were-created-automatically-by-auto-accounts /openapi.yaml get /auto-transactions Returns a paginated list of transactions generated by auto-account rules with support for account, destination, status, provider, and network filters. # Create a customer record Source: https://docs.dakota.xyz/api-reference/customers/create-a-customer-record /openapi.yaml post /customers Creates a new customer for the authenticated client and returns the created customer record. # Delete a customer record Source: https://docs.dakota.xyz/api-reference/customers/delete-a-customer-record /openapi.yaml delete /customers/{customer_id} Soft-deletes a customer record. Blocked if the customer has any associated accounts or is referenced as a sub-client. # Get sub-client summary Source: https://docs.dakota.xyz/api-reference/customers/get-sub-client-summary /openapi.yaml get /customers/sub-client-summary Returns a list of all sub-clients for the authenticated client, along with the count of customers associated with each. # List a customer's capabilities and what's needed to unlock them Source: https://docs.dakota.xyz/api-reference/customers/list-a-customers-capabilities-and-whats-needed-to-unlock-them /openapi.yaml get /customers/{customer_id}/capabilities Returns the customer's rails/capabilities (e.g. `international_wire`) and, per capability, the current status plus any OUTSTANDING requirements (terms to accept, documents to upload) needed to unlock it. This resource is intentionally partner-agnostic: it never exposes provider or partner identities — only capabilities and the opaque join keys (terms id / document type) required to satisfy each requirement. # List all customer records Source: https://docs.dakota.xyz/api-reference/customers/list-all-customer-records /openapi.yaml get /customers Returns a paginated list of customers for the authenticated client. Supports cursor pagination and optional `external_id` or `search` filters. # Mint a fresh application link for re-engaging an approved customer Source: https://docs.dakota.xyz/api-reference/customers/mint-a-fresh-application-link-for-re-engaging-an-approved-customer /openapi.yaml post /customers/{customer_id}/re-engagement Issues a fresh onboarding token for an existing **approved** customer's application and returns the rebuilt `application_url`. Use this to re-engage a dormant approved customer through the customer-online terms-acceptance flow (`POST /applications/{application_id}/attestations`) when the original onboarding token has expired. The new link carries the customer's existing approved application — it does **not** create a new application, re-run compliance review, or change the application's status or decision. It only rotates the onboarding token. Authorization is the standard client scope: the customer must belong to the calling client (an api key for that client, or a logged-in user of that client). Because the caller is already authenticated as the owner, re-minting a scoped token for their own application is seamless. # Retrieve a customer record by ID Source: https://docs.dakota.xyz/api-reference/customers/retrieve-a-customer-record-by-id /openapi.yaml get /customers/{customer_id} Retrieves a single customer by `customer_id` for the authenticated client. # Update sub-client association for a customer Source: https://docs.dakota.xyz/api-reference/customers/update-sub-client-association-for-a-customer /openapi.yaml patch /customers/{customer_id}/sub-client Deprecated and non-functional. The sub-client association is set only at customer creation and is immutable afterwards; this endpoint now rejects all requests with 400. Set `sub_client_id` on `POST /customers` instead. # Errors Source: https://docs.dakota.xyz/api-reference/errors HTTP status codes, error types, and RFC 9457 Problem Details responses The Dakota API uses [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) for all error responses. This standardized format provides machine-readable error types, human-readable descriptions, and actionable details to help you handle errors programmatically. ## Error Response Format All errors return a JSON response with the `application/problem+json` content type: ```json theme={null} { "type": "not-found", "title": "Customer Not Found", "status": 404, "detail": "Customer cst_2abc123 was not found in your organization.", "instance": "/customers/cst_2abc123", "request_id": "req_7f3a8b2c" } ``` ### Response Fields | Field | Type | Required | Description | | ------------ | ------------ | -------- | -------------------------------------------------------------------------------- | | `type` | string | Yes | Machine-readable error type identifier. | | `title` | string | Yes | Short, human-readable summary. Stable across occurrences of the same error type. | | `status` | integer | Yes | HTTP status code for this occurrence. | | `detail` | string | No | Human-readable explanation specific to this occurrence. | | `instance` | string (URI) | No | The request path that triggered this error. | | `request_id` | string | No | Unique request identifier. **Include this when contacting support.** | | `errors` | array | No | Field-level validation errors (present only for validation failures). | ### Validation Errors When a request fails validation, the response includes an `errors` array with field-level details: ```json theme={null} { "type": "validation-error", "title": "Validation Error", "status": 400, "detail": "One or more fields failed validation.", "instance": "/customers", "request_id": "req_8d4b2e1f", "errors": [ { "field": "bank_account.routing_number", "message": "Routing number must be exactly 9 digits", "code": "invalid_format" }, { "field": "email", "message": "Invalid email address format", "code": "invalid_format" } ] } ``` **Validation Error Fields:** | Field | Type | Required | Description | | --------- | ------ | -------- | -------------------------------------------------------------------------------------- | | `field` | string | Yes | Field path using dot notation for nested fields (e.g., `bank_account.routing_number`). | | `message` | string | Yes | Human-readable description of the field error. | | `code` | string | No | Machine-readable error code for this specific field. | *** ## HTTP Status Codes | Status Code | Category | Description | | ----------- | ------------ | ------------------------------------------------ | | `200` | Success | Request completed successfully | | `201` | Success | Resource created successfully | | `204` | Success | Request succeeded with no response body | | `400` | Client Error | Invalid request parameters, body, or identifiers | | `401` | Client Error | Missing or invalid authentication credentials | | `403` | Client Error | Valid credentials but insufficient permissions | | `404` | Client Error | Resource does not exist | | `409` | Client Error | Resource conflict or state conflict | | `413` | Client Error | Request payload exceeds maximum allowed size | | `422` | Client Error | Request blocked due to compliance restrictions | | `429` | Client Error | Rate limit exceeded | | `500` | Server Error | Internal server error | | `501` | Server Error | Endpoint not implemented | | `502` | Server Error | Upstream provider error | | `503` | Server Error | Service temporarily unavailable | *** ## Error Types Reference Each error type has a unique URI that identifies the problem category. The `type` field in every error response links directly to the corresponding section below. ### Client Errors (4xx) **HTTP Status:** `400 Bad Request` **Description:** One or more request fields failed validation. Check the `errors` array for specific field-level details. **Example Response:** ```json theme={null} { "type": "validation-error", "title": "Validation Error", "status": 400, "detail": "One or more fields failed validation.", "instance": "/accounts/onramp", "request_id": "req_abc123", "errors": [ { "field": "capabilities", "message": "capabilities is required", "code": "required" } ] } ``` **Common Causes:** * Missing required fields in the request body * Invalid field format (e.g., malformed email, invalid phone number) * Invalid enum values * Field value out of allowed range **Resolution:** Review the `errors` array and correct each field according to the API specification. **HTTP Status:** `400 Bad Request` **Description:** The request could not be processed due to malformed syntax or invalid content. **Example Response:** ```json theme={null} { "type": "invalid-request", "title": "Invalid Request", "status": 400, "detail": "Request body must be valid JSON.", "instance": "/customers", "request_id": "req_def456" } ``` **Common Causes:** * Malformed JSON in request body * Invalid Content-Type header * Missing required headers * Request body too large **Resolution:** Verify that your request body is valid JSON and includes all required headers. **HTTP Status:** `400 Bad Request` **Description:** One or more resource identifiers in the request are malformed or invalid. **Example Response:** ```json theme={null} { "type": "invalid-identifier", "title": "Invalid Identifier", "status": 400, "detail": "The identifier 'invalid-id' is not a valid KSUID.", "instance": "/customers/invalid-id", "request_id": "req_ghi789" } ``` **Common Causes:** * Invalid KSUID format in path or query parameters * Using an identifier from a different resource type * Malformed UUID or prefixed ID **Resolution:** Ensure all identifiers are valid KSUIDs (27-character base62 strings) obtained from previous API responses. **HTTP Status:** `400 Bad Request` **Description:** The operation cannot be completed due to insufficient balance in the source account or wallet. **Example Response:** ```json theme={null} { "type": "insufficient-balance", "title": "Insufficient Balance", "status": 400, "detail": "Wallet has insufficient USDC balance for this transaction.", "instance": "/transactions", "request_id": "req_jkl012" } ``` **Common Causes:** * Attempting to transfer more than available balance * Balance reserved for pending transactions * Network fees not accounted for in available balance **Resolution:** Check the account or wallet balance before initiating the transaction. **HTTP Status:** `400 Bad Request` **Description:** A configured resource limit has been reached for your organization. **Example Response:** ```json theme={null} { "type": "limit-reached", "title": "Limit Reached", "status": 400, "detail": "Maximum number of API keys (10) has been reached.", "instance": "/api-keys", "request_id": "req_mno345" } ``` **Common Causes:** * Maximum API keys created * Maximum webhook targets configured * Resource quota exceeded **Resolution:** Delete unused resources or contact support to increase limits. **HTTP Status:** `400 Bad Request` **Description:** The authenticated user has access to multiple clients. You must specify which client to use. **Example Response:** ```json theme={null} { "type": "multiple-clients", "title": "Multiple Clients", "status": 400, "detail": "Multiple clients are available; select an active client.", "instance": "/customers", "request_id": "req_pqr678" } ``` **Resolution:** Include the appropriate client context in your request or select a default client in the dashboard. **HTTP Status:** `401 Unauthorized` **Description:** Authentication credentials are missing, invalid, or expired. **Example Response:** ```json theme={null} { "type": "authentication-error", "title": "Authentication Required", "status": 401, "detail": "Missing or invalid authentication credentials.", "instance": "/customers", "request_id": "req_stu901" } ``` **Common Causes:** * Missing `x-api-key` header * Invalid or revoked API key * Expired application token * Malformed authentication header **Resolution:** Verify your API key is correct and active. Check that the `x-api-key` header is properly formatted. **HTTP Status:** `403 Forbidden` **Description:** Your credentials are valid but you do not have permission to perform this action. **Example Response:** ```json theme={null} { "type": "forbidden", "title": "Forbidden", "status": 403, "detail": "You do not have permission to access this customer.", "instance": "/customers/cst_xyz789", "request_id": "req_vwx234" } ``` **Common Causes:** * Accessing a resource belonging to another organization * API key lacks required scopes * User role insufficient for the operation * Resource access restricted by policy **Resolution:** Verify you have the necessary permissions. Contact your administrator to request access. **HTTP Status:** `403 Forbidden` **Description:** The requested onramp or swap account would deal in **RD**, but RD is not available in the customer's US state. **Example Response:** ```json theme={null} { "type": "state-restricted-rd", "title": "State Restricted", "status": 403, "detail": "RD is not available in your state.", "instance": "/accounts", "request_id": "req_abc123" } ``` **When it happens:** RD availability is geofenced by US state. A customer is blocked when any of their governing US states — a business's registered or operating address, or an individual's residential address — is on Dakota's restricted list. Restricted states are currently **FL, GA, NY, TX, WA, and LA** (subject to change). Non-US customers, and customers whose US jurisdiction is not yet resolved, are unaffected. **Resolution:** Use a non-RD destination stablecoin (e.g. `USDC`) for affected customers, or contact Dakota if you believe the customer's state has been resolved incorrectly. **HTTP Status:** `404 Not Found` **Description:** The requested resource does not exist or has been deleted. **Example Response:** ```json theme={null} { "type": "not-found", "title": "Customer Not Found", "status": 404, "detail": "Customer cst_2abc123 was not found.", "instance": "/customers/cst_2abc123", "request_id": "req_yza567" } ``` **Common Causes:** * Resource was deleted * Incorrect resource ID * Resource belongs to a different organization * Typo in the resource path **Resolution:** Verify the resource ID is correct. Use list endpoints to confirm the resource exists. **HTTP Status:** `409 Conflict` **Description:** The request conflicts with the current state of the resource. **Example Response:** ```json theme={null} { "type": "conflict", "title": "Customer Conflict", "status": 409, "detail": "A customer with external_id 'ext-123' already exists.", "instance": "/customers", "request_id": "req_bcd890" } ``` **Common Causes:** * Duplicate `external_id` when creating a customer * Duplicate name for a unique resource * Attempting to transition to an invalid state * Concurrent modification conflict **Resolution:** Use unique identifiers or fetch the existing resource to update it instead. **HTTP Status:** `413 Payload Too Large` **Description:** The request payload exceeds the maximum allowed size. **Example Response:** ```json theme={null} { "type": "payload-too-large", "title": "Payload Too Large", "status": 413, "detail": "Request payload exceeds the maximum allowed size of 500 MB.", "instance": "/applications/app_123/documents", "request_id": "req_efg123" } ``` **Common Causes:** * File upload exceeds 500 MB limit * Request body too large * Bulk operation with too many items **Resolution:** Reduce the payload size or split into multiple requests. **HTTP Status:** `422 Unprocessable Entity` **Description:** The request cannot be completed due to compliance restrictions. **Example Response:** ```json theme={null} { "type": "compliance-blocked", "title": "Compliance Blocked", "status": 422, "detail": "This wallet address cannot be processed due to compliance restrictions.", "instance": "/transactions", "request_id": "req_hij456" } ``` **Common Causes:** * Destination wallet address flagged by compliance screening * Transaction blocked by sanctions screening * Customer flagged for compliance review * Geographic restrictions **Resolution:** Contact support for details on the compliance block. Do not attempt to circumvent compliance controls. **HTTP Status:** `429 Too Many Requests` **Description:** You have exceeded the rate limit for API requests. **Example Response:** ```json theme={null} { "type": "rate-limited", "title": "Rate Limited", "status": 429, "detail": "Too many requests. Please retry later.", "instance": "/customers", "request_id": "req_klm789" } ``` **Rate Limit Headers:** Every response includes rate limit information: | Header | Description | | ----------------------- | --------------------------------------------------------- | | `X-RateLimit-Limit` | Maximum requests allowed in the current one-minute window | | `X-RateLimit-Remaining` | Requests remaining in the current window | | `X-RateLimit-Reset` | Unix timestamp when the current window resets | | `Retry-After` | Seconds to wait before retrying (only on 429 responses) | **Rate Limits by Authentication Type:** | Authentication | Limit | | ----------------- | ----------------------- | | API Key | 60 requests per minute | | JWT (Dashboard) | 600 requests per minute | | Application Token | 100 requests per hour | | Unauthenticated | 10 requests per minute | **Resolution:** Implement exponential backoff and respect the `Retry-After` header. ### Server Errors (5xx) **HTTP Status:** `500 Internal Server Error` **Description:** An unexpected error occurred on the server. The error details are sanitized for security. **Example Response:** ```json theme={null} { "type": "internal-error", "title": "Internal Server Error", "status": 500, "detail": "An unexpected error occurred.", "instance": "/customers", "request_id": "req_nop012" } ``` **Resolution:** Retry with exponential backoff. If the error persists, contact support with the `request_id`. **HTTP Status:** `501 Not Implemented` **Description:** The requested endpoint or feature is not yet implemented. **Example Response:** ```json theme={null} { "type": "not-implemented", "title": "Not Implemented", "status": 501, "detail": "This endpoint is not implemented.", "instance": "/some-future-endpoint", "request_id": "req_qrs345" } ``` **Resolution:** Check the API documentation for available endpoints. Contact support if you believe this is an error. **HTTP Status:** `502 Bad Gateway` **Description:** An upstream provider returned an error. **Example Response:** ```json theme={null} { "type": "provider-error", "title": "Provider Error", "status": 502, "detail": "An upstream provider returned an error.", "instance": "/transactions", "request_id": "req_tuv678" } ``` **Resolution:** Retry with exponential backoff. These errors are typically transient. **HTTP Status:** `503 Service Unavailable` **Description:** The service is temporarily unavailable, typically during maintenance. **Example Response:** ```json theme={null} { "type": "service-unavailable", "title": "Service Unavailable", "status": 503, "detail": "Service is temporarily unavailable.", "instance": "/customers", "request_id": "req_wxy901" } ``` **Resolution:** Retry with exponential backoff. Check our status page for maintenance announcements. *** ## Error Handling Best Practices ### Basic Error Handler ```javascript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers', { headers: { 'x-api-key': apiKey } }); if (!response.ok) { const problem = await response.json(); // Log the request_id for support tickets console.error(`Error [${problem.request_id}]: ${problem.title}`); switch (problem.status) { case 400: // Handle validation errors if (problem.errors) { problem.errors.forEach(err => { console.log(`Field '${err.field}': ${err.message}`); }); } break; case 401: console.log('Check your API key'); break; case 404: console.log('Resource not found'); break; case 429: const retryAfter = response.headers.get('Retry-After') || 60; console.log(`Rate limited. Retry after ${retryAfter} seconds`); break; default: console.error(`Error: ${problem.detail}`); } } ``` ### Retry Logic with Exponential Backoff For transient errors (5xx, rate limits), implement exponential backoff: ```javascript JavaScript theme={null} async function fetchWithRetry(url, options, maxRetries = 3) { const retryableStatuses = [429, 500, 502, 503]; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const response = await fetch(url, options); if (response.ok) { return response.json(); } const problem = await response.json(); // Check if error is retryable if (retryableStatuses.includes(problem.status) && attempt < maxRetries) { let delay; if (problem.status === 429) { // Use Retry-After header if available delay = (parseInt(response.headers.get('Retry-After')) || 60) * 1000; } else { // Exponential backoff: 1s, 2s, 4s delay = Math.pow(2, attempt) * 1000; } console.log(`Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`); await new Promise(r => setTimeout(r, delay)); continue; } // Non-retryable error throw new Error(`API Error [${problem.request_id}]: ${problem.title} - ${problem.detail}`); } catch (networkError) { // Network errors are retryable if (attempt < maxRetries) { const delay = Math.pow(2, attempt) * 1000; console.log(`Network error, retrying in ${delay}ms`); await new Promise(r => setTimeout(r, delay)); continue; } throw networkError; } } } // Usage try { const customer = await fetchWithRetry( 'https://api.platform.dakota.xyz/customers/cst_123', { headers: { 'x-api-key': apiKey } } ); console.log('Customer:', customer); } catch (error) { console.error('Request failed after retries:', error.message); } ``` ```python Python theme={null} import time import requests from typing import Dict, Any def fetch_with_retry( url: str, headers: Dict[str, str], max_retries: int = 3 ) -> Dict[str, Any]: """Make API request with exponential backoff retry logic.""" retryable_statuses = [429, 500, 502, 503] for attempt in range(max_retries + 1): try: response = requests.get(url, headers=headers) if response.ok: return response.json() problem = response.json() status = problem.get('status', response.status_code) # Check if error is retryable if status in retryable_statuses and attempt < max_retries: if status == 429: # Use Retry-After header if available delay = int(response.headers.get('Retry-After', 60)) else: # Exponential backoff: 1s, 2s, 4s delay = 2 ** attempt print(f"Retrying in {delay}s (attempt {attempt + 1}/{max_retries})") time.sleep(delay) continue # Non-retryable error request_id = problem.get('request_id', 'unknown') raise Exception( f"API Error [{request_id}]: {problem.get('title')} - {problem.get('detail')}" ) except requests.exceptions.RequestException as e: # Network errors are retryable if attempt < max_retries: delay = 2 ** attempt print(f"Network error, retrying in {delay}s") time.sleep(delay) continue raise # Usage try: customer = fetch_with_retry( 'https://api.platform.dakota.xyz/customers/cst_123', headers={'x-api-key': api_key} ) print('Customer:', customer) except Exception as e: print(f'Request failed after retries: {e}') ``` ```go Go theme={null} package main import ( "encoding/json" "fmt" "net/http" "strconv" "time" ) type ProblemDetails struct { Type string `json:"type"` Title string `json:"title"` Status int `json:"status"` Detail string `json:"detail"` RequestID string `json:"request_id"` } func fetchWithRetry(url, apiKey string, maxRetries int) ([]byte, error) { retryableStatuses := map[int]bool{429: true, 500: true, 502: true, 503: true} for attempt := 0; attempt <= maxRetries; attempt++ { req, _ := http.NewRequest("GET", url, nil) req.Header.Set("x-api-key", apiKey) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { // Network error - retry if attempt < maxRetries { delay := time.Duration(1<= 200 && resp.StatusCode < 300 { var result []byte json.NewDecoder(resp.Body).Decode(&result) return result, nil } var problem ProblemDetails json.NewDecoder(resp.Body).Decode(&problem) if retryableStatuses[problem.Status] && attempt < maxRetries { var delay time.Duration if problem.Status == 429 { retryAfter := resp.Header.Get("Retry-After") seconds, _ := strconv.Atoi(retryAfter) if seconds == 0 { seconds = 60 } delay = time.Duration(seconds) * time.Second } else { delay = time.Duration(1< ### Idempotency for Safe Retries For POST requests, always include the `x-idempotency-key` header to safely retry failed requests: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" \ -H "x-idempotency-key: unique-request-id-123" \ -H "Content-Type: application/json" \ -d '{"customer_type": "business", "name": "Acme Corp"}' ``` **Important:** The idempotency key must be unique per logical operation. Reusing a key with different request parameters may return the cached response from the original request. *** ## Troubleshooting ### Getting Help When contacting support about an error: 1. **Always include the `request_id`** from the error response 2. Provide the full error response body 3. Include the request method, path, and relevant headers (redact the API key) 4. Describe what you expected vs. what happened ### Common Issues **Symptoms:** All requests return authentication errors. **Checklist:** 1. Verify the `x-api-key` header is included (not `X-Api-Key` or `api-key`) 2. Check that the API key is not expired or revoked 3. Ensure there are no extra spaces or newlines in the key 4. Verify you're using the correct environment (sandbox vs production) **Symptoms:** Getting validation errors without clear field information. **Checklist:** 1. Check the `errors` array in the response for field-level details 2. Verify your `Content-Type` header is `application/json` 3. Ensure the request body is valid JSON 4. Compare your request against the OpenAPI specification **Symptoms:** Hitting rate limits during normal operation. **Checklist:** 1. Check `X-RateLimit-Remaining` header to monitor usage 2. Implement request batching where possible 3. Add caching for frequently accessed resources 4. Consider using webhooks instead of polling For detailed troubleshooting, see our [Troubleshooting Guide](/documentation/authentication/troubleshooting). # List events Source: https://docs.dakota.xyz/api-reference/events/list-events /openapi.yaml get /events Returns a paginated event stream for the authenticated client for audit and operational troubleshooting. `customer.kyb_status.updated` events may include a `reason_code` field in `data.object` when the status change is driven by the Proof-of-Address (PoA) flow: - `pending_proof_of_address` — emitted on freeze when the customer's rolling 7-day inbound volume exceeds the PoA-required threshold and no PoA is on file. - `proof_of_address_rejected` — emitted when compliance rejects a submitted PoA document. - `proof_of_address_approved` — emitted when compliance approves a submitted PoA, unfreezing the customer. Other `customer.kyb_status.*` events do not include `reason_code`. # Get countries Source: https://docs.dakota.xyz/api-reference/info/get-countries /openapi.yaml get /capabilities/countries Returns an array of all recognized country codes (in ISO-3166-1 alpha-2 format). Inclusion in this list does NOT imply that the system is allowed by law or regulation to interact with a given country; only that the country code is recognized. # Get supported blockchain networks Source: https://docs.dakota.xyz/api-reference/info/get-supported-blockchain-networks /openapi.yaml get /capabilities/networks Returns an array of all supported blockchain networks # Ask about the customer's account (ALPHA) Source: https://docs.dakota.xyz/api-reference/insights/ask-about-the-customers-account-alpha /openapi.yaml post /customers/{customer_id}/insights/chat > **Alpha** — early access. A read-only, advisory conversation over the customer's account. Stateless: send the conversation so far in `messages` on each call; the response is the assistant's next plain-text `reply`. The assistant narrates the same deterministic report `GET /customers/{customer_id}/insights` returns (plus the customer's payments and mandates) — it never originates a number, never proposes, and never moves money. Requires the LLM layer to be configured server-side. # Get the customer's account insight report (ALPHA) Source: https://docs.dakota.xyz/api-reference/insights/get-the-customers-account-insight-report-alpha /openapi.yaml get /customers/{customer_id}/insights > **Alpha** — early access. A deterministic, read-only report over the customer's agentic activity: a snapshot of typed facts (funding-wallet balances, upcoming totals, open payments, active mandates), observations (`insights`), and advisory recommendations (`suggestions`). Observations and suggestions share one item schema — `{kind, severity, message, detail, evidence}` — and every item carries `evidence`: typed references to the platform objects it was computed from. `kind` is an OPEN set; clients must ignore kinds they do not recognize. Every number is computed server-side; nothing here moves money or changes state. # API Reference Source: https://docs.dakota.xyz/api-reference/introduction Complete API reference for the Dakota Platform The Dakota Platform API is a RESTful API that enables you to programmatically manage customers, wallets, transactions, and money movement. ## Quick Start To start making API requests, you need: 1. **Get Dashboard Access** — [Contact sales](https://dakota.xyz/talk-to-sales) if you don't have an account yet 2. **Create an API Key** — Log into the [Dakota Dashboard](https://platform.sandbox.dakota.xyz) and navigate to **API Keys** to create a new key 3. **Make Your First Request** — Use your API key with the sandbox base URL ```bash theme={null} curl -X GET https://api.platform.sandbox.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" ``` See [API Keys & Headers](/documentation/authentication/api-keys-headers) for detailed setup instructions. ## API Versioning The Dakota API uses a stable versioning approach: | Aspect | Details | | ------------------ | --------------------------------------------------------------------- | | Current Version | `1.0.0` | | Version Location | No URL prefix required - all endpoints use the current stable version | | Breaking Changes | Announced via email and changelog with migration guides | | Deprecation Policy | Deprecated endpoints remain available for 6 months minimum | The API follows semantic versioning principles. Non-breaking changes (new endpoints, optional fields) are added without version changes. Breaking changes will be announced in advance with migration documentation. ## OpenAPI Specification **OpenAPI 3.0.3 spec available** - The complete machine-readable API specification can be downloaded for SDK generation, Postman import, or AI agent integration. | Format | URL | | --------------------- | ------------------------------ | | OpenAPI 3.0 (YAML) | [/openapi.yaml](/openapi.yaml) | | OpenAPI 3.0 (JSON) | [/openapi.json](/openapi.json) | | Specification Version | `3.0.3` | **Use cases:** * Generate client SDKs in any language (openapi-generator) * Import into Postman, Insomnia, or Swagger UI * Power AI agents and code generation tools * Automated API testing and validation ## Base URLs | Environment | API Base URL | Dashboard URL | | ----------- | ----------------------------------------- | ------------------------------------------------------------------ | | Sandbox | `https://api.platform.sandbox.dakota.xyz` | [platform.sandbox.dakota.xyz](https://platform.sandbox.dakota.xyz) | | Production | `https://api.platform.dakota.xyz` | [platform.dakota.xyz](https://platform.dakota.xyz) | **Start with Sandbox** — We recommend building and testing your integration in the sandbox environment first. Sandbox uses simulated data and won't process real transactions. ## Authentication All API requests require authentication via the `x-api-key` header: ```bash theme={null} curl -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" ``` For POST requests, include an idempotency key: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" \ -H "x-idempotency-key: unique-request-id" \ -H "Content-Type: application/json" \ -d '{"customer_type": "business", "name": "Acme Corp"}' ``` See [API Keys & Headers](/documentation/authentication/api-keys-headers) for details. ## Code Examples The following examples demonstrate common API operations in JavaScript, Python, and Go. ### Create a Customer ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers', { method: 'POST', headers: { 'x-api-key': 'YOUR_API_KEY', 'x-idempotency-key': 'unique-request-id', 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_type: 'business', name: 'Acme Corp', external_id: 'your-internal-id' }) }); const customer = await response.json(); console.log('Customer ID:', customer.id); console.log('Application URL:', customer.application_url); ``` ```python Python theme={null} import requests response = requests.post( 'https://api.platform.dakota.xyz/customers', headers={ 'x-api-key': 'YOUR_API_KEY', 'x-idempotency-key': 'unique-request-id', 'Content-Type': 'application/json' }, json={ 'customer_type': 'business', 'name': 'Acme Corp', 'external_id': 'your-internal-id' } ) customer = response.json() print('Customer ID:', customer['id']) print('Application URL:', customer['application_url']) ``` ```go Go theme={null} package main import ( "bytes" "encoding/json" "fmt" "net/http" ) type CustomerRequest struct { CustomerType string `json:"customer_type"` Name string `json:"name"` ExternalID *string `json:"external_id,omitempty"` } type CustomerResponse struct { ID string `json:"id"` ApplicationID string `json:"application_id"` ApplicationURL string `json:"application_url"` } func main() { externalID := "your-internal-id" reqBody := CustomerRequest{ CustomerType: "business", Name: "Acme Corp", ExternalID: &externalID, } jsonBody, _ := json.Marshal(reqBody) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/customers", bytes.NewBuffer(jsonBody)) req.Header.Set("x-api-key", "YOUR_API_KEY") req.Header.Set("x-idempotency-key", "unique-request-id") req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, _ := client.Do(req) defer resp.Body.Close() var customer CustomerResponse json.NewDecoder(resp.Body).Decode(&customer) fmt.Println("Customer ID:", customer.ID) fmt.Println("Application URL:", customer.ApplicationURL) } ``` ### List Customers with Pagination ```javascript JavaScript theme={null} async function listAllCustomers(apiKey) { const customers = []; let startingAfter = null; while (true) { const url = new URL('https://api.platform.dakota.xyz/customers'); url.searchParams.set('limit', '100'); if (startingAfter) { url.searchParams.set('starting_after', startingAfter); } const response = await fetch(url, { headers: { 'x-api-key': apiKey } }); const result = await response.json(); customers.push(...result.data); if (!result.meta.has_more_after) break; startingAfter = result.data[result.data.length - 1].id; } return customers; } ``` ```python Python theme={null} import requests def list_all_customers(api_key: str) -> list: customers = [] starting_after = None while True: params = {'limit': 100} if starting_after: params['starting_after'] = starting_after response = requests.get( 'https://api.platform.dakota.xyz/customers', headers={'x-api-key': api_key}, params=params ) result = response.json() customers.extend(result['data']) if not result['meta']['has_more_after']: break starting_after = result['data'][-1]['id'] return customers ``` ```go Go theme={null} package main import ( "encoding/json" "fmt" "net/http" "net/url" ) type Customer struct { ID string `json:"id"` Name string `json:"name"` CustomerType string `json:"customer_type"` } type ListResponse struct { Data []Customer `json:"data"` Meta struct { TotalCount int `json:"total_count"` HasMoreAfter bool `json:"has_more_after"` } `json:"meta"` } func listAllCustomers(apiKey string) ([]Customer, error) { var customers []Customer var startingAfter string for { u, _ := url.Parse("https://api.platform.dakota.xyz/customers") q := u.Query() q.Set("limit", "100") if startingAfter != "" { q.Set("starting_after", startingAfter) } u.RawQuery = q.Encode() req, _ := http.NewRequest("GET", u.String(), nil) req.Header.Set("x-api-key", apiKey) client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var result ListResponse json.NewDecoder(resp.Body).Decode(&result) customers = append(customers, result.Data...) if !result.Meta.HasMoreAfter { break } startingAfter = result.Data[len(result.Data)-1].ID } return customers, nil } ``` ### Create an Onramp Account ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/accounts/onramp', { method: 'POST', headers: { 'x-api-key': 'YOUR_API_KEY', 'x-idempotency-key': 'unique-request-id', 'Content-Type': 'application/json' }, body: JSON.stringify({ capabilities: ['ach'], source_asset: 'USD', destination_asset: 'USDC', destination_id: '2hCjxJzUAW6JVRkZqaF9E0KpM3a' }) }); const account = await response.json(); console.log('Onramp Account ID:', account.id); console.log('Bank Account:', account.bank_account); ``` ```python Python theme={null} import requests response = requests.post( 'https://api.platform.dakota.xyz/accounts/onramp', headers={ 'x-api-key': 'YOUR_API_KEY', 'x-idempotency-key': 'unique-request-id', 'Content-Type': 'application/json' }, json={ 'capabilities': ['ach'], 'source_asset': 'USD', 'destination_asset': 'USDC', 'destination_id': '2hCjxJzUAW6JVRkZqaF9E0KpM3a' } ) account = response.json() print('Onramp Account ID:', account['id']) print('Bank Account:', account['bank_account']) ``` ```go Go theme={null} package main import ( "bytes" "encoding/json" "fmt" "net/http" ) type OnrampRequest struct { Capabilities []string `json:"capabilities"` SourceAsset string `json:"source_asset"` DestinationAsset string `json:"destination_asset"` DestinationID string `json:"destination_id"` } type OnrampResponse struct { ID string `json:"id"` BankAccount interface{} `json:"bank_account"` } func main() { reqBody := OnrampRequest{ Capabilities: []string{"ach"}, SourceAsset: "USD", DestinationAsset: "USDC", DestinationID: "2hCjxJzUAW6JVRkZqaF9E0KpM3a", } jsonBody, _ := json.Marshal(reqBody) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/accounts/onramp", bytes.NewBuffer(jsonBody)) req.Header.Set("x-api-key", "YOUR_API_KEY") req.Header.Set("x-idempotency-key", "unique-request-id") req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, _ := client.Do(req) defer resp.Body.Close() var account OnrampResponse json.NewDecoder(resp.Body).Decode(&account) fmt.Println("Onramp Account ID:", account.ID) } ``` ### Create a Webhook Target ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/webhook-targets', { method: 'POST', headers: { 'x-api-key': 'YOUR_API_KEY', 'x-idempotency-key': 'unique-request-id', 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://your-server.com/webhooks/dakota', global: false, event_types: ['customer.created', 'customer.updated', 'auto_account.created'] }) }); const target = await response.json(); console.log('Webhook Target ID:', target.id); ``` ```python Python theme={null} import requests response = requests.post( 'https://api.platform.dakota.xyz/webhook-targets', headers={ 'x-api-key': 'YOUR_API_KEY', 'x-idempotency-key': 'unique-request-id', 'Content-Type': 'application/json' }, json={ 'url': 'https://your-server.com/webhooks/dakota', 'global': False, 'event_types': ['customer.created', 'customer.updated', 'auto_account.created'] } ) target = response.json() print('Webhook Target ID:', target['id']) ``` ```go Go theme={null} package main import ( "bytes" "encoding/json" "fmt" "net/http" ) type WebhookTargetRequest struct { URL string `json:"url"` Global bool `json:"global"` EventTypes []string `json:"event_types,omitempty"` } type WebhookTargetResponse struct { ID string `json:"id"` URL string `json:"url"` } func main() { reqBody := WebhookTargetRequest{ URL: "https://your-server.com/webhooks/dakota", Global: false, EventTypes: []string{"customer.created", "customer.updated", "auto_account.created"}, } jsonBody, _ := json.Marshal(reqBody) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/webhook-targets", bytes.NewBuffer(jsonBody)) req.Header.Set("x-api-key", "YOUR_API_KEY") req.Header.Set("x-idempotency-key", "unique-request-id") req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, _ := client.Do(req) defer resp.Body.Close() var target WebhookTargetResponse json.NewDecoder(resp.Body).Decode(&target) fmt.Println("Webhook Target ID:", target.ID) } ``` ## Rate Limits | Authentication Type | Rate Limit | | ------------------- | ----------------------- | | API Key | 60 requests per minute | | JWT (Dashboard) | 600 requests per minute | | Unauthenticated | 10 requests per minute | | Application Token | 250 requests per hour | Rate limit headers included in every response: * `X-RateLimit-Limit` - Maximum requests allowed in the current one-minute window * `X-RateLimit-Remaining` - Requests remaining in the current window * `X-RateLimit-Reset` - Unix timestamp when the current rate-limit window resets When a request is throttled (`429`), responses also include `Retry-After` with seconds to wait before retrying. See [Rate Limiting](/documentation/authentication/rate-limiting) for handling strategies. ## Response Format All responses are JSON. List responses include pagination: ```json theme={null} { "data": [ ... ], "meta": { "total_count": 100, "has_more_after": true, "has_more_before": false } } ``` **Pagination parameters:** * `limit` - Items per page (default: 20, max: 100) * `starting_after` - Cursor for next page * `ending_before` - Cursor for previous page Error responses follow [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) format with `application/problem+json` content type: ```json theme={null} { "type": "validation-error", "title": "Validation Error", "status": 400, "detail": "One or more fields failed validation.", "instance": "/customers", "request_id": "req_abc123", "errors": [ { "field": "amount", "message": "must be greater than 0", "code": "invalid_value" } ] } ``` See [Errors](/api-reference/errors) for all error types and handling best practices. ## Data Model The Dakota API is organized around the following core resources and their relationships: ```mermaid theme={null} flowchart TD Client[Client - Your Organization] Client --> Customers Client --> AutoAccounts[Auto Accounts] Client --> Wallets Client --> OneOff[One-off Transactions] Customers --> Applications[Applications - KYB/KYC] Customers --> Recipients[Recipients] Customers --> Transactions Applications --> Business[Business Entity] Applications --> Individuals[Individual Entities] Recipients --> Destinations[Destinations] AutoAccounts --> AutoTx[Auto Transactions] ``` ### Resource Relationships | Parent Resource | Child Resource | Relationship | Access Pattern | | --------------- | ---------------- | ------------ | ----------------------------------------------- | | Customer | Application | One-to-one | `GET /customers/{id}` includes `application_id` | | Customer | Recipient | One-to-many | `GET /customers/{id}/recipients` | | Customer | Transaction | One-to-many | `GET /customers/{id}/transactions` | | Recipient | Destination | One-to-many | `GET /recipients/{id}/destinations` | | Auto Account | Auto Transaction | One-to-many | `GET /auto-transactions?auto_account_id={id}` | | Application | Business | One-to-one | Nested in application response | | Application | Individual | One-to-many | Nested in application `entities.individuals` | ### Resource Identifiers All resources use [KSUID](https://github.com/segmentio/ksuid) (K-Sortable Unique Identifier) for IDs: * **Format:** 27-character base62 string (e.g., `2hCjxJzUAW6JVRkZqaF9E0KpM3a`) * **Properties:** Lexicographically sortable by creation time, globally unique * **Usage:** Used for pagination cursors (`starting_after`, `ending_before`) ## Filtering and Sorting List endpoints support filtering via query parameters. Common patterns: ### Customers ```bash theme={null} # Filter by external ID GET /customers?external_id=your-external-id # Search by name, email, or customer ID (case-insensitive) GET /customers?search=acme ``` ### Auto Transactions ```bash theme={null} # Filter by auto account GET /auto-transactions?auto_account_id=2hCjxJzUAW6JVRkZqaF9E0KpM3a # Filter by status GET /auto-transactions?status=completed # Filter by date range (Unix timestamps in seconds) GET /auto-transactions?start_date=1704067200&end_date=1706745600 # Filter by transaction type GET /auto-transactions?type=onramp # Filter by asset GET /auto-transactions?input_asset=USD&destination_asset=ETH # Filter by blockchain details GET /auto-transactions?source_network_id=ethereum&destination_crypto_address=0x... ``` ### Applications ```bash theme={null} # Filter by type GET /applications?type=business # Filter by status GET /applications?status=submitted ``` ### Available Filter Parameters | Endpoint | Parameter | Type | Description | | -------------------- | ------------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/customers` | `external_id` | string | Exact match on external ID | | `/customers` | `search` | string | Search name, email, or ID | | `/auto-transactions` | `auto_account_id` | KSUID | Filter by auto account | | `/auto-transactions` | `status` | enum | `pending`, `processing`, `completed`, `failed`, `canceled`, `reversed`, `in_progress`, `awaiting_confirmation`, `broadcasted`, `rejected`, `invalid`, `timed_out`, `not_started` | | `/auto-transactions` | `type` | enum | `onramp`, `offramp`, `swap` | | `/auto-transactions` | `start_date` | integer | Unix timestamp (seconds) | | `/auto-transactions` | `end_date` | integer | Unix timestamp (seconds) | | `/auto-transactions` | `input_asset` | string | Asset symbol (e.g., `USD`) | | `/auto-transactions` | `destination_asset` | string | Asset symbol (e.g., `ETH`) | | `/auto-transactions` | `source_network_id` | string | Blockchain network ID | | `/auto-transactions` | `destination_network_id` | string | Blockchain network ID | | `/auto-transactions` | `transaction_hash` | string | Blockchain transaction hash | | `/applications` | `type` | enum | `individual`, `business` | | `/applications` | `status` | enum | `pending`, `submitted`, `completed` | ## Bulk Operations The API provides bulk endpoints for specific use cases: ### Bulk Risk Rating Calculation Calculate risk ratings for multiple entities in a single request: ```bash theme={null} POST /onboarding/risk-ratings/bulk ``` **Behavior:** * Does NOT persist data - calculation only * All-or-nothing validation: if any item fails, returns detailed errors for all invalid items * Returns `risk_rating` object with `score`, `level`, `factors`, and more See the OpenAPI specification for complete request/response schemas. ### Pagination Limits | Parameter | Default | Maximum | Description | | ----------- | ------- | ------- | ----------------- | | `limit` | 20 | 100 | Items per page | | File upload | - | 500 MB | Maximum file size | ## Response Format Details ### List Response Structure All list endpoints return a paginated response: ```json theme={null} { "data": [...], "meta": { "total_count": 100, "has_more_after": true, "has_more_before": false } } ``` **Fetching the next page:** ```bash theme={null} GET /customers?starting_after={last_item_id}&limit=20 ``` ### Error Response Structure All errors return RFC 9457 Problem Details: ```json theme={null} { "type": "not-found", "title": "Customer Not Found", "status": 404, "detail": "Customer cst_abc123 was not found.", "instance": "/customers/cst_abc123", "request_id": "req_xyz789" } ``` The `request_id` should be included when contacting support. See [Errors](/api-reference/errors) for all error types. For complete response schemas and examples, see the [OpenAPI specification](/openapi.yaml). # Approve a mandate (ALPHA) Source: https://docs.dakota.xyz/api-reference/mandates/approve-a-mandate-alpha /openapi.yaml post /mandates/{mandate_id}/approve > **Alpha** — early access. A recognized signer OTHER than the bound one signs the mandate payload to activate it; arms its scheduled payments. # Cancel a mandate (ALPHA) Source: https://docs.dakota.xyz/api-reference/mandates/cancel-a-mandate-alpha /openapi.yaml post /mandates/{mandate_id}/cancel > **Alpha** — early access. Revokes a pending or active mandate. The canceller must be a recognized signer OTHER than the bound one, with a valid signature over the mandate payload — the bound signer can never mutate its own mandate (§8 applies to every mutation, including authority-reducing ones). # Create a mandate directly (ALPHA) Source: https://docs.dakota.xyz/api-reference/mandates/create-a-mandate-directly-alpha /openapi.yaml post /mandates > **Alpha** — early access. Drafts a PENDING mandate from a direct user interaction - no instruction back-link. Exactly one binding form names the signer the mandate binds: payment_agent_id (the hosted convenience - binds that agent's signer and anchors the mandate to the agent's customer), or signer_id together with customer_id (any of the client's signers, BYO keys included; the customer anchors the recipient-target scope and the §8 approver set). The rule is the one the customer will approve (POST /mandates/{mandate_id}/approve, a recognized signer other than the bound one, §8). Recipient targets may be recipient ids or payee names - names resolve to the mandate's customer's existing recipients before anything is stored. Schedule payments under it by mandate_id; they arm the moment it activates. # Get a mandate (ALPHA) Source: https://docs.dakota.xyz/api-reference/mandates/get-a-mandate-alpha /openapi.yaml get /mandates/{mandate_id} > **Alpha** — early access. Returns the mandate - the signer it binds, its rule exactly as the customer approves it, validity, and status. # List the calling client's mandates (ALPHA) Source: https://docs.dakota.xyz/api-reference/mandates/list-the-calling-clients-mandates-alpha /openapi.yaml get /mandates > **Alpha** — early access. Returns the calling client's mandates, newest first, with the EFFECTIVE status - a pending or active mandate past its valid_until reads "expired" (derived on read; the stored status and audit columns are unchanged). Narrow the collection with the optional customer_id, signer_id, and status filters; omit them all for the full client collection. # Add associated individual to business application Source: https://docs.dakota.xyz/api-reference/onboarding/add-associated-individual-to-business-application /openapi.yaml post /applications/{application_id}/associated-individuals Adds a new associated individual (UBO, control person, or applicant) to a business application. Can only be called when the application status is 'pending'. Each individual must have a unique first and last name combination within the application. # Bulk import customers from Sumsub share tokens Source: https://docs.dakota.xyz/api-reference/onboarding/bulk-import-customers-from-sumsub-share-tokens /openapi.yaml post /customers/bulk-import-sumsub-tokens Processes multiple Sumsub share tokens in a single request. For each token, the system redeems it with Sumsub, extracts the applicant's name and identity data, creates a customer and individual application, and imports documents. Customer names are derived automatically from Sumsub data. # Create an application document upload session Source: https://docs.dakota.xyz/api-reference/onboarding/create-an-application-document-upload-session /openapi.yaml post /applications/{application_id}/document-uploads Creates a presigned upload session for application documents. Supported categories are business and EDD documents. Use the returned upload URL to upload file content directly to cloud storage, then create a verification resource for the upload. **Authentication:** Accepts Application Token (X-Application-Token header). **Individual PoA & transaction limits** Individual customers may onboard without a Proof of Address. Dakota enforces a **$3,000 USD-equivalent rolling 7-day transaction limit** on individuals without PoA on file. If a customer's volume crosses this threshold, the customer is frozen and inbound transactions paused until a Proof of Address is submitted and approved. **Clients are expected to implement their own volume tracking and limits on their customers** — Dakota's enforcement is a backstop, not the only line of defense. **Post-decision PoA upload** Uploading a PoA-equivalent document (`proof_of_address`, `bank_statement`, or `utility_bill`) on an already-decided individual application (status `approved` or `completed`) automatically transitions the application to `compliance_review` and sets `poa_status` to `submitted_pending_review`. The applicant can use the same onboarding link to upload their document after the initial decision. # Create or update EDD record Source: https://docs.dakota.xyz/api-reference/onboarding/create-or-update-edd-record /openapi.yaml put /applications/{application_id}/edd Creates or updates the Enhanced Due Diligence (EDD) record for an application. This endpoint is idempotent and should be used when EDD is required for the application. **Authentication:** Accepts Application Token (X-Application-Token header). # Delete an onboarding document Source: https://docs.dakota.xyz/api-reference/onboarding/delete-an-onboarding-document /openapi.yaml delete /applications/{application_id}/documents/{document_id} Deletes a document from a pending onboarding application. Only documents from applications in 'pending' status can be deleted. Deletes both the database record and the file from cloud storage. **Authentication:** Accepts API Key (x-api-key header) or Application Token (X-Application-Token header). # Download a document Source: https://docs.dakota.xyz/api-reference/onboarding/download-a-document /openapi.yaml get /applications/{application_id}/documents/{document_id} Downloads a document by streaming it through the API server. Returns the file with appropriate content type and disposition headers. **Authentication:** Accepts API Key (x-api-key header) or Application Token (X-Application-Token header). # Get an onboarding application Source: https://docs.dakota.xyz/api-reference/onboarding/get-an-onboarding-application /openapi.yaml get /applications/{application_id} Retrieves a specific application. By default returns lightweight status metadata only. Use the `include` query parameter to request additional sections. **Include values:** - `entities` — business/individual applicant data - `validation` — computed validation state (shows what's missing before submit) - `edd` — enhanced due diligence data (if applicable) - `attestations` — attestation records - `all` — all of the above Multiple values can be combined as a comma-separated list (e.g. `?include=entities,validation`). **Authentication:** Accepts Application Token (X-Application-Token header). Application tokens are scoped to a single application for use in public-facing flows. # Get EDD record for application Source: https://docs.dakota.xyz/api-reference/onboarding/get-edd-record-for-application /openapi.yaml get /applications/{application_id}/edd Retrieves the Enhanced Due Diligence (EDD) record for an application, including which documents are required and which are still missing. **Authentication:** Accepts Application Token (X-Application-Token header). # Get presigned URL for individual document upload Source: https://docs.dakota.xyz/api-reference/onboarding/get-presigned-url-for-individual-document-upload /openapi.yaml post /applications/{application_id}/associated-individuals/{individual_id}/document-uploads Generates a presigned URL for uploading large individual documents directly to cloud storage. Supports identity documents and EDD documents. Use this endpoint for files larger than 10MB. After uploading to the presigned URL, call the verify endpoint to complete the upload process. **Authentication:** Accepts Application Token (X-Application-Token header). **Post-decision PoA upload** Uploading a PoA-equivalent document (`proof_of_address`, `bank_statement`, or `utility_bill`) on an already-decided individual application (status `approved` or `completed`) automatically transitions the application to `compliance_review` and sets `poa_status` to `submitted_pending_review`. # List all documents for an application Source: https://docs.dakota.xyz/api-reference/onboarding/list-all-documents-for-an-application /openapi.yaml get /applications/{application_id}/documents Returns paginated metadata for documents associated with an application. Supports optional filtering by category and document type. # List all onboarding applications Source: https://docs.dakota.xyz/api-reference/onboarding/list-all-onboarding-applications /openapi.yaml get /applications Returns a paginated list of your onboarding applications for both businesses and individuals. Use the `type` parameter to filter by application type, and `status` to filter by application status. Supports cursor-based pagination using `starting_after`, `ending_before`, and `limit` parameters. **Authentication:** Accepts API Key (X-API-Key header). # Remove associated individual from business application Source: https://docs.dakota.xyz/api-reference/onboarding/remove-associated-individual-from-business-application /openapi.yaml delete /applications/{application_id}/associated-individuals/{individual_id} Removes an associated individual from a business application. Can only be called when the application status is 'pending'. At least one individual must remain on the application. # Submit an application for verification Source: https://docs.dakota.xyz/api-reference/onboarding/submit-an-application-for-verification /openapi.yaml post /applications/{application_id}/submissions Submits an application for verification. The application must be complete with all required information and documents before it can be submitted. If the application is not ready, returns detailed error information about what is missing. **Authentication:** Accepts Application Token (X-Application-Token header). # Submit an attestation for an application Source: https://docs.dakota.xyz/api-reference/onboarding/submit-an-attestation-for-an-application /openapi.yaml post /applications/{application_id}/attestations Submits an attestation for an application. For business applications, the attestor must be a control person. For individual applications, the attestor must be the individual. The attestor name must exactly match the name on file. **Authentication:** Accepts Application Token (X-Application-Token header). # Update associated individual Source: https://docs.dakota.xyz/api-reference/onboarding/update-associated-individual /openapi.yaml put /applications/{application_id}/associated-individuals/{individual_id} Updates an associated individual's information for a business application (UBO, control person, or applicant). Can only be called when the application status is 'pending'. # Update business application details Source: https://docs.dakota.xyz/api-reference/onboarding/update-business-application-details /openapi.yaml put /applications/{application_id}/business-details Updates a business application with company information including legal name, registration details, address, industry, and financial information. This endpoint is idempotent and can only be called when the application status is 'pending'. # Update individual application details Source: https://docs.dakota.xyz/api-reference/onboarding/update-individual-application-details /openapi.yaml put /applications/{application_id}/individual-details Updates an individual application with personal information including name, date of birth, address, nationality, and identification details. This endpoint is idempotent and can only be called when the application status is 'pending'. # Upload an application document Source: https://docs.dakota.xyz/api-reference/onboarding/upload-an-application-document /openapi.yaml post /applications/{application_id}/documents Uploads an application document using base64-encoded content. Supported categories are business and EDD documents. For files larger than 20MB, create a document upload session and upload directly to cloud storage. **Authentication:** Accepts Application Token (X-Application-Token header). # Upload an individual document Source: https://docs.dakota.xyz/api-reference/onboarding/upload-an-individual-document /openapi.yaml post /applications/{application_id}/associated-individuals/{individual_id}/documents Uploads a document for an individual using base64-encoded content. Supports identity documents (passport, driver's license, etc.) and EDD documents. For files larger than 10MB, use the presigned URL endpoint instead. **Authentication:** Accepts Application Token (X-Application-Token header). # Verify a document upload Source: https://docs.dakota.xyz/api-reference/onboarding/verify-a-document-upload /openapi.yaml post /applications/{application_id}/documents/{document_id}/verifications Verifies that a document was successfully uploaded to cloud storage using a presigned URL. Call this endpoint after uploading to the presigned URL to confirm the file exists and register it in the system. Use the upload_id returned from the presigned URL endpoint as the document_id. **Authentication:** Accepts Application Token (X-Application-Token header). # Get your fee payout destination Source: https://docs.dakota.xyz/api-reference/payouts/get-your-fee-payout-destination /openapi.yaml get /fee-payout-destination Returns the destination registered to receive your accrued developer-fee payouts. # Register or replace your fee payout destination Source: https://docs.dakota.xyz/api-reference/payouts/register-or-replace-your-fee-payout-destination /openapi.yaml put /fee-payout-destination Registers where Dakota pays your accrued developer fees — a USDC wallet on any supported chain. Exactly one destination exists per organization; PUT replaces it. Emits `fee_payout_destination.updated`. # Remove your fee payout destination Source: https://docs.dakota.xyz/api-reference/payouts/remove-your-fee-payout-destination /openapi.yaml delete /fee-payout-destination Removes the registered fee payout destination, if one exists. Emits `fee_payout_destination.deleted`. # Add a new rule to a policy Source: https://docs.dakota.xyz/api-reference/policies/add-a-new-rule-to-a-policy /openapi.yaml post /policies/{policy_id}/rules Creates a new rule under the specified policy. # Attach policy to a wallet Source: https://docs.dakota.xyz/api-reference/policies/attach-policy-to-a-wallet /openapi.yaml put /policies/{policy_id}/wallets/{wallet_id} Attaches a policy to a wallet, creating or updating the relationship as needed. # Create a new policy Source: https://docs.dakota.xyz/api-reference/policies/create-a-new-policy /openapi.yaml post /policies Creates a new policy that can be attached to wallets for transaction governance. # Delete a policy Source: https://docs.dakota.xyz/api-reference/policies/delete-a-policy /openapi.yaml delete /policies/{policy_id} Deletes a policy by `policy_id`. Any required detachments from dependent resources must be completed first. # Detach policy from a wallet Source: https://docs.dakota.xyz/api-reference/policies/detach-policy-from-a-wallet /openapi.yaml delete /policies/{policy_id}/wallets/{wallet_id} Detaches a policy from a wallet. # Get a specific policy Source: https://docs.dakota.xyz/api-reference/policies/get-a-specific-policy /openapi.yaml get /policies/{policy_id} Retrieves a single policy by `policy_id`. # Get wallets attached to a policy Source: https://docs.dakota.xyz/api-reference/policies/get-wallets-attached-to-a-policy /openapi.yaml get /policies/{policy_id}/wallets Returns slim references (id + name + family) for the wallets the given policy is currently attached to. # List all policies Source: https://docs.dakota.xyz/api-reference/policies/list-all-policies /openapi.yaml get /policies Returns all policies configured for the authenticated client. # Remove a rule from a policy Source: https://docs.dakota.xyz/api-reference/policies/remove-a-rule-from-a-policy /openapi.yaml delete /policies/{policy_id}/rules/{rule_id} Deletes a policy rule from the specified policy. # Update an existing rule Source: https://docs.dakota.xyz/api-reference/policies/update-an-existing-rule /openapi.yaml patch /policies/{policy_id}/rules/{rule_id} Updates an existing policy rule identified by `rule_id`. # Add a destination for a recipient Source: https://docs.dakota.xyz/api-reference/recipients/add-a-destination-for-a-recipient /openapi.yaml post /recipients/{recipient_id}/destinations Create a new destination for a recipient. The destination type (crypto, fiat_us, fiat_iban) is determined by the destination_type field in the request body. # Create a new recipient Source: https://docs.dakota.xyz/api-reference/recipients/create-a-new-recipient /openapi.yaml post /customers/{customer_id}/recipients Create a new recipient for a given customer # Delete a destination Source: https://docs.dakota.xyz/api-reference/recipients/delete-a-destination /openapi.yaml delete /recipients/{recipient_id}/destinations/{destination_id} Soft-deletes a destination. Blocked if the destination has any active auto accounts or non-terminal one-off transactions. # Delete a recipient Source: https://docs.dakota.xyz/api-reference/recipients/delete-a-recipient /openapi.yaml delete /recipients/{recipient_id} Soft-deletes a recipient and all of its destinations. Blocked if the recipient has any active auto accounts or non-terminal one-off transactions. # Get a specific recipient Source: https://docs.dakota.xyz/api-reference/recipients/get-a-specific-recipient /openapi.yaml get /recipients/{recipient_id} Get details for a specific recipient by ID # List all recipients Source: https://docs.dakota.xyz/api-reference/recipients/list-all-recipients /openapi.yaml get /customers/{customer_id}/recipients Get a list of all recipients for the given customer ID # List destinations Source: https://docs.dakota.xyz/api-reference/recipients/list-destinations /openapi.yaml get /recipients/{recipient_id}/destinations Get a list of destinations for this recipient # Update a recipient Source: https://docs.dakota.xyz/api-reference/recipients/update-a-recipient /openapi.yaml put /recipients/{recipient_id} Update a recipient's details # Advance a paused simulation Source: https://docs.dakota.xyz/api-reference/sandbox/advance-a-paused-simulation /openapi.yaml post /sandbox/simulations/{simulation_id}/advance Unpauses a stateful sandbox simulation that is currently waiting at an intermediate state (awaiting_advance = true). Use this to drive compliance_hold, manual_review, and unconfirmed scenarios past their pause point. Valid actions per scenario: - **compliance_hold**: `release`, `reject` - **manual_review**: `approve`, `reject` - **unconfirmed** (crypto): `confirm`, `expire` The endpoint is concurrency-safe: concurrent advance calls for the same simulation_id are handled atomically — exactly one wins, the rest receive 409 Conflict. Available in sandbox mode only. # Get simulation status Source: https://docs.dakota.xyz/api-reference/sandbox/get-simulation-status /openapi.yaml get /sandbox/simulations/{simulation_id} Returns the current state and callback delivery history for a sandbox simulation. Use this to debug async flows — see whether callbacks have been scheduled, delivered, or failed. Available in sandbox mode only. # List available simulation scenarios Source: https://docs.dakota.xyz/api-reference/sandbox/list-available-simulation-scenarios /openapi.yaml get /sandbox/scenarios Returns the catalog of all scenarios that can be passed as the `scenario` field to `POST /sandbox/simulate/inbound`. Use this to discover valid scenario names and understand their behaviour without reading source code. # Simulate an inbound payment event Source: https://docs.dakota.xyz/api-reference/sandbox/simulate-an-inbound-payment-event /openapi.yaml post /sandbox/simulate/inbound Triggers a simulated payment event through the mock Lead Bank provider. Accepted immediately; callbacks are delivered asynchronously. Idempotent: repeated calls with the same simulation_id and identical parameters return the original response. Conflicting parameters return 409. # Simulate an onboarding state transition Source: https://docs.dakota.xyz/api-reference/sandbox/simulate-an-onboarding-state-transition /openapi.yaml post /sandbox/simulate/onboarding Drives KYB, KYC, or applicant account status through a sandbox transition without waiting for real compliance review. Available in sandbox mode only. **Type → effect mapping:** | type | KYB status set | Application status set | webhooks emitted | |---|---|---|---| | `kyb_approve` | approved (via provisioning) | approved | 2× `customer.kyb_status.created` (one per provider, `kyb_status: approved`) + `recipient.created` | | `kyb_reject` | — | declined | (none — application status only) | | `kyb_info_request` | — | request_for_information | (none — application status only) | | `kyc_approve` | — | approved | (none — application status only) | | `kyc_reject` | — | declined | (none — application status only) | | `kyc_info_request` | — | request_for_information | (none — application status only) | | `applicant_activate` | approved (via provisioning) | approved | 2× `customer.kyb_status.created` (one per provider, `kyb_status: approved`) + `recipient.created` | | `applicant_suspend` | — | declined | (none — application status only) | **Approving customers (individual or business):** Use `kyb_approve` to fully approve any customer type. This triggers the complete onboarding flow including endorsement and recipient creation. The `kyc_*` types only update the individual applicant's KYC application status without triggering the full onboarding flow. **Note:** `applicant_activate` does NOT auto-create payment accounts, wallets, or account numbers. Create those separately via the account creation API after activation. # Create Stripe Checkout session for credit purchase Source: https://docs.dakota.xyz/api-reference/self-serve/create-stripe-checkout-session-for-credit-purchase /openapi.yaml post /self-serve/credits/purchase Creates a Stripe Checkout session for a valid prepaid credit tier for the authenticated self-serve client. # Get prepaid credit balance Source: https://docs.dakota.xyz/api-reference/self-serve/get-prepaid-credit-balance /openapi.yaml get /self-serve/credits/balance Returns the current prepaid credit balance for the authenticated self-serve client. # Get the self-serve client's pricing config (fee schedule) Source: https://docs.dakota.xyz/api-reference/self-serve/get-the-self-serve-clients-pricing-config-fee-schedule /openapi.yaml get /self-serve/credits/pricing Returns the caller's `ClientPricingConfig`. Self-serve clients only (enterprise clients receive 403). Used by the dashboard's Fee Schedule card to render the active fee structure. # List available credit purchase tiers Source: https://docs.dakota.xyz/api-reference/self-serve/list-available-credit-purchase-tiers /openapi.yaml get /self-serve/credits/tiers Returns the available prepaid credit tiers with transfer capacity calculated for the authenticated self-serve client. # List prepaid credit ledger entries Source: https://docs.dakota.xyz/api-reference/self-serve/list-prepaid-credit-ledger-entries /openapi.yaml get /self-serve/credits/ledger Returns paginated credit ledger entries for the authenticated self-serve client. # Add a signer to a signer group Source: https://docs.dakota.xyz/api-reference/signer-groups/add-a-signer-to-a-signer-group /openapi.yaml post /signer-groups/{signer_group_id}/signers Adds an existing signer to a signer group. # Attach a signer group to a wallet Source: https://docs.dakota.xyz/api-reference/signer-groups/attach-a-signer-group-to-a-wallet /openapi.yaml put /wallets/{wallet_id}/signer-groups/{signer_group_id} Attaches a signer group to a wallet, creating or updating the relationship as needed. # Create a new signer Source: https://docs.dakota.xyz/api-reference/signer-groups/create-a-new-signer /openapi.yaml post /signers Creates a signer resource that can be attached to signer groups. # Create a new signer group Source: https://docs.dakota.xyz/api-reference/signer-groups/create-a-new-signer-group /openapi.yaml post /signer-groups Creates a signer group used for multi-party wallet authorization workflows. # Delete a signer by public key Source: https://docs.dakota.xyz/api-reference/signer-groups/delete-a-signer-by-public-key /openapi.yaml delete /signers/{public_key} Soft-deletes all signers with the given public key. Returns `404` when no signer exists for the given public key and `409` when the signer is still a member of an active signer group. # Detach a signer group from a wallet Source: https://docs.dakota.xyz/api-reference/signer-groups/detach-a-signer-group-from-a-wallet /openapi.yaml delete /wallets/{wallet_id}/signer-groups/{signer_group_id} Detaches a signer group from a wallet. # Get a signer group by ID Source: https://docs.dakota.xyz/api-reference/signer-groups/get-a-signer-group-by-id /openapi.yaml get /signer-groups/{signer_group_id} Retrieves a signer group by `signer_group_id`. # Get signer groups attached to a wallet Source: https://docs.dakota.xyz/api-reference/signer-groups/get-signer-groups-attached-to-a-wallet /openapi.yaml get /wallets/{wallet_id}/signer-groups Returns signer groups currently attached to the specified wallet. # Get signer groups for the authenticated client Source: https://docs.dakota.xyz/api-reference/signer-groups/get-signer-groups-for-the-authenticated-client /openapi.yaml get /signer-groups Returns signer groups configured for the authenticated client. # Get wallets attached to a signer group Source: https://docs.dakota.xyz/api-reference/signer-groups/get-wallets-attached-to-a-signer-group /openapi.yaml get /signer-groups/{signer_group_id}/wallets Returns slim references (id + name + family) for the wallets the given signer group is currently attached to. # Remove a signer from a signer group Source: https://docs.dakota.xyz/api-reference/signer-groups/remove-a-signer-from-a-signer-group /openapi.yaml delete /signer-groups/{signer_group_id}/signers/{signer_id} Removes a signer association from a signer group. # State Lifecycles Source: https://docs.dakota.xyz/api-reference/state-lifecycles State transitions and lifecycle diagrams for Dakota API resources Understanding resource state transitions is essential for building robust integrations. This guide documents the lifecycle of key resources in the Dakota API. ## Application Lifecycle Applications go through a defined lifecycle from creation to decision. Understanding these states helps you build proper status handling and user feedback. ### Application-Level States ```mermaid theme={null} stateDiagram-v2 direction LR pending --> submitted submitted --> completed completed --> compliance_review compliance_review --> completed ``` | Status | Description | Allowed Actions | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `pending` | Draft state. Application created but not yet submitted. | Add/edit entities, upload documents, submit | | `submitted` | Application submitted for review. | View only | | `completed` | Review complete. Check `application_decision` for result. | View only (except: individuals may upload a Proof of Address via the original onboarding link to move into `compliance_review`) | | `compliance_review` | An individual customer has uploaded a Proof of Address document and is awaiting compliance review of that PoA. Reached only when the PoA-optional flow is enabled and an approved customer crosses the \$3,000 / 7-day rolling transaction threshold (or uploads a PoA via the original onboarding link). Transitions back to `completed` with an updated `application_decision` once compliance approves or rejects the PoA. | View documents; compliance approves / rejects PoA | ### Application Decisions Once an application reaches `completed` status, the `application_decision` field contains the outcome: | Decision | Description | | ----------- | ------------------------------------------------------- | | `approved` | Application approved. Customer can transact. | | `declined` | Application declined. See compliance notes for details. | | `withdrawn` | Application was withdrawn before a decision was made. | ### Proof of Address State (Individuals) For individual applications, the application-level `poa_status` field exposes the review state of the customer's Proof of Address document. It is `null` for business applications. | `poa_status` value | Meaning | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `missing` | No PoA on file. The customer has crossed the \$3,000 / 7-day rolling threshold and any inbound transactions over the limit are being held until compliance approves a submitted PoA. | | `submitted_pending_review` | PoA uploaded; awaiting compliance review. | | `approved` | PoA approved by compliance. Customer is exempt from the threshold. | | `rejected` | PoA rejected by compliance. Customer must upload a new document; the threshold continues to apply. | The `customer.kyb_status.updated` webhook event surfaces these transitions via its `reason_code` field — see [Webhook events](/documentation/webhooks#customer-events). ### Applicant-Level States (Business & Individuals) Within an application, each entity (business or individual) has its own status: ```mermaid theme={null} stateDiagram-v2 direction LR pending --> submitted submitted --> completed ``` | Status | Description | | ----------- | ------------------------------------------- | | `pending` | Entity data incomplete or not yet submitted | | `submitted` | Entity submitted for review | | `completed` | Review complete for this entity | ## Transaction Lifecycle Transactions progress through multiple states from initiation to completion. ### Transaction States ```mermaid theme={null} stateDiagram-v2 not_started --> pending pending --> processing processing --> in_progress in_progress --> awaiting_confirmation awaiting_confirmation --> broadcasted broadcasted --> completed pending --> failed pending --> canceled processing --> failed processing --> invalid processing --> rejected in_progress --> failed in_progress --> timed_out awaiting_confirmation --> failed broadcasted --> failed completed --> reversed ``` ### Transaction Status Reference | Status | Terminal | Description | | ----------------------- | -------- | -------------------------------------------------- | | `not_started` | No | Transaction created but processing not begun | | `pending` | No | Transaction is queued for processing | | `processing` | No | Transaction is actively being processed | | `in_progress` | No | Transaction is being processed | | `awaiting_confirmation` | No | Waiting for blockchain confirmation | | `broadcasted` | No | Submitted to blockchain, awaiting confirmation | | `completed` | Yes | Transaction completed successfully | | `invalid` | Yes | Transaction parameters were invalid | | `failed` | Yes | Transaction failed during processing | | `canceled` | Yes | Transaction was canceled | | `reversed` | Yes | Transaction was reversed after completion | | `rejected` | Yes | Transaction was rejected by compliance or provider | | `timed_out` | Yes | Transaction timed out | ### Terminal vs Non-Terminal States **Terminal states** indicate the transaction has reached a final outcome and will not change: * `completed`, `invalid`, `failed`, `canceled`, `reversed`, `rejected`, `timed_out` **Non-terminal states** indicate the transaction is still in progress: * `not_started`, `pending`, `processing`, `in_progress`, `awaiting_confirmation`, `broadcasted` ## Auto Transaction Lifecycle Auto transactions have their own lifecycle: ```mermaid theme={null} stateDiagram-v2 pending --> processing pending --> canceled processing --> completed processing --> failed completed --> reversed ``` | Status | Terminal | Description | | ------------ | -------- | ------------------------------- | | `pending` | No | Scheduled but not yet processed | | `processing` | No | Currently being executed | | `completed` | Yes | Successfully completed | | `failed` | Yes | Failed during processing | | `canceled` | Yes | Canceled before processing | | `reversed` | Yes | Reversed after completion | ## Handling State Changes ### Polling for Status Updates For non-terminal states, poll periodically to check for updates: ```javascript theme={null} async function waitForCompletion(transactionId, maxAttempts = 30) { const terminalStates = ['completed', 'failed', 'canceled', 'reversed', 'rejected', 'invalid', 'timed_out']; for (let i = 0; i < maxAttempts; i++) { const response = await fetch(`https://api.platform.dakota.xyz/auto-transactions/${transactionId}`, { headers: { 'x-api-key': API_KEY } }); const transaction = await response.json(); if (terminalStates.includes(transaction.status)) { return transaction; } // Wait before next poll (exponential backoff) await new Promise(r => setTimeout(r, Math.min(1000 * Math.pow(2, i), 30000))); } throw new Error('Transaction did not complete within expected time'); } ``` ### Using Webhooks for State Changes Instead of polling, configure webhooks to receive real-time notifications: ```javascript theme={null} // Webhook handler for transaction status changes app.post('/webhooks/dakota', (req, res) => { const event = req.body; if (event.type === 'transaction.status_changed') { const { transaction_id, old_status, new_status } = event.data; console.log(`Transaction ${transaction_id}: ${old_status} → ${new_status}`); // Handle terminal states if (['completed', 'failed', 'canceled'].includes(new_status)) { // Update your database, notify user, etc. } } res.status(200).send('OK'); }); ``` See [Webhook Integration](/documentation/webhooks) for complete webhook setup. ## State Transition Rules ### Valid Transitions Not all state transitions are valid. The API enforces these rules: **Applications:** * `pending` → `submitted` (via submit endpoint) * `submitted` → `completed` (automatic after review) **Transactions:** * Cannot transition from terminal states * `canceled` can only be triggered while in non-terminal state * `reversed` can only occur after `completed` ### Error Handling See [Error Codes](/api-reference/errors) for all error types and handling best practices. # Cancel a transaction Source: https://docs.dakota.xyz/api-reference/transactions/cancel-a-transaction /openapi.yaml post /transactions/{transaction_id}/cancellations Create a cancellation event for a cancellable transaction. # Create a one-off transfer Source: https://docs.dakota.xyz/api-reference/transactions/create-a-one-off-transfer /openapi.yaml post /transactions Create a one-off transfer. Supports both offramp (destination is a bank account, fiat_us or fiat_iban) and swap (destination is a crypto address) flows; the destination type selects which. # Get a transaction Source: https://docs.dakota.xyz/api-reference/transactions/get-a-transaction /openapi.yaml get /transactions/{transaction_id} Retrieves a single transaction by `transaction_id`, including lifecycle status and provider metadata when available. # List transactions Source: https://docs.dakota.xyz/api-reference/transactions/list-transactions /openapi.yaml get /transactions List transactions across supported transaction resource families. # Create a new user Source: https://docs.dakota.xyz/api-reference/users/create-a-new-user /openapi.yaml post /users Create a new user with the specified details # Delete a user Source: https://docs.dakota.xyz/api-reference/users/delete-a-user /openapi.yaml delete /users/{user_id} Delete an existing user # List users Source: https://docs.dakota.xyz/api-reference/users/list-users /openapi.yaml get /users Get a list of all users # Update a user Source: https://docs.dakota.xyz/api-reference/users/update-a-user /openapi.yaml patch /users/{user_id} Update the name or role of an existing user # Create a new wallet Source: https://docs.dakota.xyz/api-reference/wallets/create-a-new-wallet /openapi.yaml post /wallets Creates a wallet under a customer and returns the wallet configuration used for custody and transaction flows. # Get a single wallet Source: https://docs.dakota.xyz/api-reference/wallets/get-a-single-wallet /openapi.yaml get /wallets/{wallet_id} Returns a single wallet by id. The response includes `customer_name` and `created_at` joined server-side. # Get policies attached to a wallet Source: https://docs.dakota.xyz/api-reference/wallets/get-policies-attached-to-a-wallet /openapi.yaml get /wallets/{wallet_id}/policies Returns slim references (id + name) for the policies currently attached to the specified wallet. # Get wallet balances across all networks Source: https://docs.dakota.xyz/api-reference/wallets/get-wallet-balances-across-all-networks /openapi.yaml get /wallets/{wallet_id}/balances Returns current wallet balances grouped by asset and network for the specified wallet. # Send a transaction from a wallet Source: https://docs.dakota.xyz/api-reference/wallets/send-a-transaction-from-a-wallet /openapi.yaml post /wallets/{wallet_id}/transactions Creates a new outbound transaction from the specified wallet. Policy and signer checks may affect the resulting transaction state. # Create a webhook target Source: https://docs.dakota.xyz/api-reference/webhooks/create-a-webhook-target /openapi.yaml post /webhooks/targets Create a new webhook target for receiving webhooks # Delete a webhook target Source: https://docs.dakota.xyz/api-reference/webhooks/delete-a-webhook-target /openapi.yaml delete /webhooks/targets/{webhook_target_id} Delete a webhook target # Get a webhook delivery record Source: https://docs.dakota.xyz/api-reference/webhooks/get-a-webhook-delivery-record /openapi.yaml get /webhooks/{event_id} Returns the delivery record for the specified event. If the event was delivered to multiple targets, the first target's record is returned. Use `GET /webhooks?event_type=...` to list all deliveries. # Get a webhook target Source: https://docs.dakota.xyz/api-reference/webhooks/get-a-webhook-target /openapi.yaml get /webhooks/targets/{webhook_target_id} Retrieve details of a specific webhook target # List webhook delivery history Source: https://docs.dakota.xyz/api-reference/webhooks/list-webhook-delivery-history /openapi.yaml get /webhooks Returns a paginated list of outbound webhook delivery records for your organization, newest first. Records are retained for 30 days. The date range filter (`from`/`to`) is silently clamped to the 30-day window. # List webhook targets Source: https://docs.dakota.xyz/api-reference/webhooks/list-webhook-targets /openapi.yaml get /webhooks/targets Retrieve all webhook targets # Replay a webhook delivery Source: https://docs.dakota.xyz/api-reference/webhooks/replay-a-webhook-delivery /openapi.yaml post /webhooks/events/{event_id}/replay Re-queues the webhook for delivery to all configured targets for this org. Adds an `X-Webhook-Replay: true` header to the outbound request so your endpoint can distinguish replays from original deliveries. **Idempotency is your responsibility.** Your endpoint MUST deduplicate on the `id` field (event_id) or the event's resource ID + event type to prevent double-processing. Rate limit: 10 replays per event ID (lifetime, not per hour). Requires the **operator** role. # Update a webhook target Source: https://docs.dakota.xyz/api-reference/webhooks/update-a-webhook-target /openapi.yaml patch /webhooks/targets/{webhook_target_id} Update event types for a specific webhook target # auth.md Source: https://docs.dakota.xyz/auth Agent registration and authentication metadata for the Dakota Platform API, following the auth.md discovery convention. # auth.md This document describes how autonomous agents authenticate with the Dakota Platform API. It follows the [auth.md](https://github.com/workos/auth.md) agent-registration discovery convention. Dakota Platform does **not** expose an OAuth authorization server for third-party agents. Programmatic access is provisioned through API keys created in the Dakota Platform dashboard, so the OAuth metadata files (`/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`) are not the canonical entrypoint for agent registration. Use the instructions below. ## Agent audience * **Resource:** `https://api.platform.dakota.xyz` (production), `https://api.platform.sandbox.dakota.xyz` (sandbox) * **Documentation:** `https://docs.dakota.xyz` * **OpenAPI specification:** `https://docs.dakota.xyz/openapi.json` * **MCP server:** `https://mcp.platform.dakota.xyz/mcp` (production, HTTPS only) ## Registration To provision credentials for an agent, a Dakota Platform account is required. 1. **Request a Dakota Platform account.** New customers and partners contact the Dakota team at [https://dakota.xyz/talk-to-sales](https://dakota.xyz/talk-to-sales). Existing customers can invite additional users from the dashboard. 2. **Sign in to the dashboard.** * Production: [https://platform.dakota.xyz](https://platform.dakota.xyz) * Sandbox: [https://platform.sandbox.dakota.xyz](https://platform.sandbox.dakota.xyz) 3. **Create an API key.** Open the **API keys** section in the sidebar, click **Create New API Key**, and give it a descriptive name (e.g. `agent-prod-ingest`). The key value is shown only once — copy it immediately into the agent's secret store. 4. **Scope the key.** The key inherits the permissions of the user that created it. Create a dedicated user with least-privilege access for production agents. 5. **Revocation.** Keys can be revoked at any time from the same dashboard view. Rotate keys on a schedule appropriate to the agent's blast radius. There is no machine-to-machine self-service registration endpoint. All key issuance is gated on a Dakota Platform account. ## Supported authentication methods | Method | Resource | Notes | | -------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | **API key** (`x-api-key` header) | REST API (`api.platform.dakota.xyz`) | Primary mechanism. Required on every request. | | **API key as Bearer token** | MCP server (`mcp.platform.dakota.xyz/mcp`) | Same key, passed as `Authorization: Bearer ` on MCP registration. Recommended for AI clients. | | **OAuth 2.1 (Stytch)** | MCP server only | Browser-based sign-in restricted to allowlisted internal Google domains. Not available to external agents. | API keys are base64-encoded strings, exactly 60 characters (e.g. `AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=`). ## Applying credentials ### REST API All requests require the `x-api-key` header: ```http theme={null} GET /customers HTTP/1.1 Host: api.platform.dakota.xyz x-api-key: AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc= Content-Type: application/json ``` `POST`, `PUT`, and `PATCH` requests additionally require an `x-idempotency-key` header containing a unique UUID per request. See [API Keys & Headers](/documentation/authentication/api-keys-headers) for full examples in cURL, JavaScript, Python, Go, Rust, and Java. ### MCP server Register the Dakota MCP server with your AI client using the API key as a Bearer token: ```json theme={null} { "mcpServers": { "dakota": { "url": "https://mcp.platform.dakota.xyz/mcp", "headers": { "Authorization": "Bearer AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=" } } } } ``` See [MCP Server](/documentation/mcp-server) for client-specific setup (Claude Desktop, Cursor, ChatGPT). ## Security expectations * **Transport:** HTTPS only. Plain HTTP requests are rejected. * **Storage:** Treat API keys as production secrets. Store them in a secret manager, never in source control. * **Rotation:** Rotate keys when an agent's deployment changes hands or on a fixed cadence (90 days recommended). * **Rate limits:** See [Rate Limiting](/documentation/authentication/rate-limiting). * **Incident response:** Compromised keys should be revoked immediately from the dashboard and a new key issued. For broader guidance, see the [Security](/documentation/authentication/security) page. ## Contact * Sales / new accounts: [https://dakota.xyz/talk-to-sales](https://dakota.xyz/talk-to-sales) * Customer support: [https://support.dakota.xyz](https://support.dakota.xyz) # Advanced Flows Source: https://docs.dakota.xyz/documentation/advanced-flows Single-use payouts and swaps, international bank destinations, and other less common Dakota flows. This page covers flows that branch off the core walkthrough in [Common Flows](/documentation/common-flows). Read that page first — it introduces Customer, Recipient, Destination, and Account. Everything here assumes those are already in place. **On this page:** * [One-off Offramp](#one-off-offramp-single-usd-payout) * [One-off Swap](#one-off-swap-single-cross-chain-swap) ## One-off Offramp (Single USD Payout) Use a one-off offramp when you need a single USD payout — a vendor invoice, a customer refund, a scheduled disbursement — and want to embed a human-readable reference on the bank wire. One-offs generate a single-use crypto deposit address that's discarded after the transaction completes. **What one-off offramps give you over a reusable offramp account:** * `payment_reference` — text carried into the bank wire. Per-rail constraints: * **ACH** — up to 18 chars. Letters, digits, and spaces only (no hyphens or punctuation). * **Wire** — up to 140 chars. No character restrictions. * **SEPA** — 6 to 140 chars. Letters, digits, spaces, and `& - . /`. * **SWIFT** — up to 140 chars. Letters, digits, spaces, and `, .`. Your recipient sees this on their statement and uses it to reconcile against an invoice number. * `destination_payment_rail` — override the rail per transaction (e.g. force `fedwire` instead of the destination's default `ach`). * `developer_fee_bps` — per-transaction developer fee override. ```mermaid theme={null} sequenceDiagram participant App as Your App participant Blockchain participant Dakota participant Bank App->>Dakota: POST /transactions (transaction_type: one_off) Dakota-->>App: crypto_address + send_amount App->>Blockchain: Send exactly send_amount to crypto_address Blockchain->>Dakota: Stablecoin received Dakota->>App: Webhook: transaction.one_off.created Dakota->>Bank: Wire USD with payment_reference Dakota->>App: Webhook: transaction.one_off.updated (completed) ``` ### Create the One-off Transaction Prerequisite: a bank Destination (`destination_type: "fiat_us"`) already attached to a Recipient for this Customer — see [Add a Bank Destination](/documentation/common-flows#add-a-bank-destination-for-offramps). ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/transactions \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "transaction_type": "one_off", "customer_id": "2LfTd6QQrUyPKwRR9qMMyk7CMHS", "amount": "1250.00", "source_network_id": "ethereum-mainnet", "source_asset": "USDC", "destination_id": "2LfYm5KMnRvLFtRP7nJJug4zBAN", "destination_asset": "USD", "destination_payment_rail": "ach", "payment_reference": "INV 2024 042" }' ``` **Response:** ```json theme={null} { "id": "2LfZn6LNoSvMGuSQ0pLLof1OneA", "resource_type": "one_off", "status": "awaiting_funds", "crypto_address": "0xabc...", "source_network_id": "ethereum-mainnet", "source_asset": "USDC", "send_amount": "1250.00", "payment_reference": "INV 2024 042" } ``` Send exactly `send_amount` to `crypto_address`. The address is single-use and discarded after the transaction completes. Dakota converts the stablecoin and wires USD to the bank Destination with `INV 2024 042` included as the payment reference. Subscribe to `transaction.one_off.created` and `transaction.one_off.updated` to track status. Sandbox object-create endpoints reject mainnet network IDs and cap per-request `amount` at \$2 USD. USDT is not yet supported in sandbox (coming soon); USD, USDC, and RD work today. See [Testing — Sandbox restrictions](/documentation/testing#sandbox-restrictions). *** ## One-off Swap (Single Cross-Chain Swap) Use a one-off swap when the target network varies per swap (a reusable swap account pins `destination_network_id`) or you want to apply a different `developer_fee_bps` on this specific swap. `payment_reference` and `destination_payment_rail` are offramp-only and do not apply to swaps. ```mermaid theme={null} sequenceDiagram participant App as Your App participant Source as Source chain participant Dakota participant Target as Target chain App->>Dakota: POST /transactions (transaction_type: one_off) Dakota-->>App: crypto_address + send_amount App->>Source: Send exactly send_amount to crypto_address Source->>Dakota: Source stablecoin received Dakota->>App: Webhook: transaction.one_off.created Dakota->>Target: Deliver target stablecoin to destination Dakota->>App: Webhook: transaction.one_off.updated (completed) ``` ### Create the One-off Swap Prerequisite: a crypto Destination on the target network/asset already attached to a Recipient — see [Add a Crypto Destination](/documentation/common-flows#add-a-crypto-destination-for-onramps-and-swaps). ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/transactions \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "transaction_type": "one_off", "customer_id": "2LfTd6QQrUyPKwRR9qMMyk7CMHS", "amount": "500.00", "source_network_id": "ethereum-mainnet", "source_asset": "USDC", "destination_id": "2LfYm5KMnRvLFtRP7nJJug4zJEP", "destination_asset": "USDT", "destination_network_id": "polygon-mainnet", "developer_fee_bps": 25 }' ``` **Response:** ```json theme={null} { "id": "2LfZn6LNoSvMGuSQ0pLLswOffOn", "resource_type": "one_off", "status": "awaiting_funds", "crypto_address": "0xabc...", "source_network_id": "ethereum-mainnet", "source_asset": "USDC", "destination_asset": "USDT", "destination_network_id": "polygon-mainnet", "send_amount": "500.00" } ``` Send exactly `send_amount` to `crypto_address`. Dakota converts and delivers the destination asset on the target network. Same `transaction.one_off.*` webhooks as one-off offramp. Sandbox object-create endpoints reject mainnet network IDs and cap per-request `amount` at \$2 USD. USDT is not yet supported in sandbox (coming soon); USD, USDC, and RD work today. See [Testing — Sandbox restrictions](/documentation/testing#sandbox-restrictions). # Direct Control: Mandates & Scheduled Payments Source: https://docs.dakota.xyz/documentation/agentic-payments/direct-control Create and manage the agentic artifacts straight from the API — no proposals or instructions required The conversation flow — proposals drafted from a prompt, accepted via instructions — is optional. Every artifact it creates has a plain API surface, and when your application already knows exactly what to create, you can drive it directly. Same objects, same guarantees, no LLM in the loop. Two invariants never relax, whichever path you take: * **Dual control**: a mandate activates only when a recognized signer other than the bound one signs it ([Mandate signing](/documentation/agentic-payments/mandate-signing)). * **Fire-time coverage**: the mandate gate matches every payment against active mandates at the moment it fires — nothing is bound in advance. ## Mandates ### Create [`POST /mandates`](/api-reference/mandates/create-a-mandate-directly-alpha) drafts a `pending` mandate bound to a signer — see [choosing targets](/documentation/agentic-payments/examples#choosing-targets) for the three target kinds. Exactly **one binding form** names that signer: * **`payment_agent_id`** — the payment agent convenience: binds the agent's signer and anchors the mandate to the agent's customer. * **`signer_id` + `customer_id`** — binds **any of your signers**. Mandates were always signer-level at the gate; this form just names the signer directly. `customer_id` is required alongside because the customer anchors two things the agent used to supply: the recipient-target scope (names resolve against *this* customer's payees) and the approver set (approval takes a recognized signer of *this* customer, other than the bound one). The signer must be recognized for that customer — a member of a signer group attached to one of its wallets. The hosted-agent form: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/mandates \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "payment_agent_id": "2vWxAgent0000000000000000000", "rule": { "target_type": "recipient", "targets": ["Alice"], "network_id": "base-sepolia", "asset": "USDC", "max_per_tx": "100", "window": "MONTHLY", "max_count_per_target_in_window": 1 }, "valid_until": 1798675200 }' ``` The same mandate bound to a signer named directly: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/mandates \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signer_id": "2vWxSigner000000000000000000", "customer_id": "2vWxCustomer0000000000000000", "rule": { "target_type": "recipient", "targets": ["Alice"], "network_id": "base-sepolia", "asset": "USDC", "max_per_tx": "100", "window": "MONTHLY", "max_count_per_target_in_window": 1 }, "valid_until": 1798675200 }' ``` Either way it stays `pending` — and covers nothing — until the customer approves it with a signed payload (`POST /mandates/{mandate_id}/approve`, walked through in [Mandate signing](/documentation/agentic-payments/mandate-signing)). The returned mandate carries `bound_signer_id` and `customer_id`, whichever form created it. The rule's `asset` is validated at create: it must be a **sendable** asset — deployed on the pinned `network_id` (or on at least one network when the rule leaves the network open). A symbol that can never match a payment (a fiat output like `USD`, a typo) is a 400 here, not a mandate that silently denies every fire. ### List, inspect, cancel ```bash theme={null} # Active mandates for one signer curl "https://api.platform.dakota.xyz/mandates?signer_id=2vWxSigner000000000000000000&status=active" \ -H "X-API-Key: $DAKOTA_API_KEY" # One mandate, full wire shape (rule, validity, audit columns) curl "https://api.platform.dakota.xyz/mandates/2vWxMandate00000000000000000" \ -H "X-API-Key: $DAKOTA_API_KEY" ``` Cancelling is a mandate mutation, so it is §8-signed like approval — same canonical payload, `"action": "cancel"`, signed by a recognized signer other than the bound one. A pending mandate becomes `rejected`, an active one `revoked`: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/mandates/2vWxMandate00000000000000000/cancel \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signer_public_key": "BHkApproverKeyQm", "signature": "MEUCIQExampleSignature" }' ``` ## Scheduled payments ### Create [`POST /scheduled-payments`](/api-reference/agentic-payments/schedule-a-payment-directly-alpha) schedules one or more payments for a signer, without the proposal flow. Name the payee **either** with `destination_id` (an existing crypto destination of the wallet's customer) **or** with a raw `address` + `network_id` — exactly one of the two. The schedule is explicit `dates` (one payment per timestamp), or `count` × `interval_seconds` from `start_at` (`0` = now). One payment to an existing destination: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/scheduled-payments \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signer_id": "2vWxSigner000000000000000000", "wallet_id": "2vWxWallet000000000000000000", "destination_id": "2vWxDestination0000000000000", "amount": "10", "asset": "USDC", "dates": [1784073600] }' ``` Four weekly payments to a raw address, starting now: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/scheduled-payments \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signer_id": "2vWxSigner000000000000000000", "wallet_id": "2vWxWallet000000000000000000", "address": "0xa11ce00000000000000000000000000000000001", "network_id": "base-sepolia", "amount": "10", "asset": "USDC", "count": 4, "interval_seconds": 604800, "start_at": 0 }' ``` The response is one row per date, each `scheduled`. What is validated **at create**: the signer must be permitted to spend on the funding wallet, the wallet must belong to the calling client, the destination must belong to the wallet's customer, and dates must be plausible (not past, not absurdly far). What is **not** validated at create: mandate coverage — that is decided at fire time by the gate, so a payment with no covering active mandate **fails at fire** (emitting [`scheduled_payment.failed`](/documentation/agentic-payments/webhooks)) rather than being rejected here. Schedule first and sign the mandate later, or the other way round — order doesn't matter, coverage at the fire moment does. ### List and cancel ```bash theme={null} # Everything still pending for one wallet curl "https://api.platform.dakota.xyz/scheduled-payments?wallet_id=2vWxWallet000000000000000000&status=scheduled" \ -H "X-API-Key: $DAKOTA_API_KEY" # Cancel one payment while it is still scheduled curl -X POST https://api.platform.dakota.xyz/scheduled-payments/2vWxScheduled000000000000000/cancel \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" ``` List filters compose: `customer_id`, `signer_id`, `wallet_id`, `mandate_id` (which mandate covered an executed payment), and comma-separated `status` values (`scheduled`, `executed`, `cancelled`, `failed`). Executed rows carry `mandate_id` and `wallet_transaction_id` for audit. ## Mixing the two paths Direct creates and agent-drafted instructions produce the same rows — a mandate signed after an instruction covers direct schedules too, and vice versa. Use the agent where natural language earns its keep, and this surface where your application already speaks the schema. # Agentic Payment Examples Source: https://docs.dakota.xyz/documentation/agentic-payments/examples Prompt-to-plan examples: one-off, recurring, standing authorization, cross-chain, and bank offramp Each example shows a customer prompt, the action series the agent drafts, and what the customer ends up signing. See the [Quickstart](/documentation/agentic-payments/quickstart) for the surrounding create → draft → accept → sign mechanics. Every prompt below is sent to the [proposals endpoint](/api-reference/agentic-payments/draft-payment-proposals-from-a-conversation-alpha) — pure drafting, no side effects: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/payment-agents/2vWxAgent0000000000000000000/proposals \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "prompt": "Pay Acme 0.5 USDC on base-sepolia, one-time, from my base-sepolia wallet. 0xad878f82a6549ca1fe977f26bb768bf890420e92" }' ``` The response carries ready-to-review `proposals` — a human-readable `summary` plus the concrete action series each example below walks through. Your application puts them in front of the customer, then submits the approved proposals **verbatim** to [`POST /instructions`](/api-reference/agentic-payments/accept-instructions-%E2%80%94-actuate-proposals-alpha). Only that call creates real objects — and the drafted mandate still activates only with the customer's signature. Amounts are decimal strings in the destination asset; dates are unix seconds. ## 1. One-off payment > **Prompt:** Pay Acme 0.5 USDC on base-sepolia, one-time, from my base-sepolia wallet. 0xad878f82a6549ca1fe977f26bb768bf890420e92 The agent recognizes a funding wallet on the destination's own chain family, so this is a **direct** payment — no conversion account involved. The draft: * `create_recipient` — Acme * `create_crypto_destination` — the address on `base-sepolia` * `create_mandate` — rule targeting the recipient: `asset: USDC`, `network_id: base-sepolia`, `max_per_tx: "0.5"`, `window: NONE`, `max_count_per_target_in_window: 1` * `create_scheduled_payments` — one date **What the customer signs:** a mandate worth at most 0.5 USDC, once, to Acme alone. Even a one-off is mandate-covered — there is no uncovered send path. `window: NONE` is what makes this a one-off. The per-target caps normally meter over a calendar window; `NONE` removes the reset, turning them into **lifetime totals**. Paired with `max_count_per_target_in_window: 1`, the mandate authorizes exactly one payment to Acme — ever. Once that payment fires, the exhausted mandate flips to `done` on its own: the authority is spent with the payment, leaving no standing grant behind. (Compare `WEEKLY` / `MONTHLY` in the next example, whose caps reset each calendar window and so keep authorizing.) ## 2. Recurring series under one mandate > **Prompt:** Pay Beta Corp 1 USDC on base-sepolia every week for 3 weeks. * One recipient + destination as above * `create_mandate` — `window: WEEKLY`, `max_per_tx: "1"`, `max_count_per_target_in_window: 1`, `valid_until` covering the series * `create_scheduled_payments` — **3 dates** in one action **One signature covers the whole series.** Each fire re-checks the same mandate; the weekly window means a duplicate fire inside the same calendar week would be denied, not sent. Windows are calendar-aligned: `WEEKLY` runs Monday 00:00 UTC to Monday, `MONTHLY` from the 1st, and `NONE` makes the caps lifetime totals. ## 3. Standing authorization, schedule under it later A mandate does not need a schedule attached at birth. Have the agent draft one, or create it yourself with [`POST /mandates`](/api-reference/mandates/create-a-mandate-directly-alpha) — the snippet below is that request body: ```json theme={null} { "payment_agent_id": "2vWxAgent0000000000000000000", "rule": { "target_type": "recipient", "targets": ["Alice"], "network_id": "base-sepolia", "asset": "USDC", "max_per_tx": "100", "window": "MONTHLY", "max_count_per_target_in_window": 1 }, "valid_until": 1798761599 } ``` `payment_agent_id` is one way the create call names the signer: the endpoint resolves the agent to its signer and drafts the mandate `pending`, **bound to that signer** (`bound_signer_id` on the returned mandate). The other way is naming the signer directly — `signer_id` + `customer_id` instead of `payment_agent_id` — which binds **any of your signers**, not only a hosted agent's ([Direct Control](/documentation/agentic-payments/direct-control#mandates) shows both forms). Binding — and enforcement at the mandate gate — is per signer, and approval stays the same dual-control step as everywhere else. Once signed, schedule payments under it any time with `POST /scheduled-payments` — coverage is decided **at fire time**, so any scheduled payment that fits an active mandate's rule fires without further signing. This is the "authorize once, pay as you go" shape: up to 100 USDC to Alice, once a month, until the end of 2026. ### Choosing targets The example above targets a **recipient by name**. Three target kinds are supported (a rule holds at most 32 targets): **`recipient`** — the customer's payees, referenced by recipient id *or* name. Names are resolved **at creation**: the rule is stored — and signed — with verified recipient ids, and an unknown name fails the create instead of storing a dangling grant. At fire time a payment matches when its destination address belongs to the target recipient (a direct destination, or the deposit address of a conversion account paying them): ```json theme={null} "rule": { "target_type": "recipient", "targets": ["Alice"], "asset": "USDC", "max_per_tx": "100", "window": "MONTHLY", "max_count_per_target_in_window": 1 } ``` **`address`** — raw destination addresses, bound as given. Matching follows the chain family's semantics: EVM addresses compare case-insensitively, Solana addresses byte-exactly. The same payee's *other* addresses are outside the grant: ```json theme={null} "rule": { "target_type": "address", "targets": ["0xa11ce00000000000000000000000000000000001"], "network_id": "base-sepolia", "asset": "USDC", "max_per_tx": "25", "window": "WEEKLY", "max_count_per_target_in_window": 1 } ``` **`any`** — no target pinning: cap-limited authority over **any** destination. With no targets to key on, the window caps meter over one shared bucket across all payees, not per payee. It is the widest grant a mandate can express — use deliberately, with tight caps: ```json theme={null} "rule": { "target_type": "any", "network_id": "base-sepolia", "asset": "USDC", "max_per_tx": "5", "window": "WEEKLY", "max_amount_per_target_in_window": "20" } ``` For `recipient` and `address` rules the per-window caps apply **per target**, never pooled across them. ### The asset is what leaves the wallet `asset` names what the funding wallet sends, and must match the firing payment's own asset (compared case-insensitively) — in practice, the stablecoin your wallet pays with, like `USDC`. When the payment converts downstream (a cross-chain swap or a bank offramp), the rule still names the deposit asset, never the converted output — see the offramp in example 5. This is enforced **at create**: the asset must be sendable on the rule's pinned network (or on at least one network when the rule leaves it open). Writing `"asset": "USD"` on an offramp mandate — or any symbol that can never match a send — fails the create with an actionable 400 instead of minting a dead grant that denies every fire. ## 4. Cross-chain payment (auto account) > **Prompt:** Pay John Doe 0.30 USDC on solana-devnet, once a month for 4 months, from base-sepolia. 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU The funding wallet is EVM but the destination is Solana — no same-family wallet exists, so the agent drafts a **cross-family swap** via an auto account: * `create_recipient` + `create_crypto_destination` (the Solana address) * `create_auto_account` — an EVM USDC **deposit** address on `base-sepolia` that converts and forwards to USDC on `solana-devnet` * `create_scheduled_payments` — 4 monthly payments that pay the **deposit address** * `create_mandate` — written in **deposit terms**: `asset: USDC`, `network_id: base-sepolia` The mandate governs what the wallet actually spends (the deposit leg), which is what the customer should be capping. One auto account is reused across all 4 fires. On each executed row, `output_network: solana-devnet` and `destination_label` tell your UI where the money ultimately landed; `GET /auto-transactions` lists the conversion legs. If the agent **does** hold a wallet on the destination's family, it always drafts the direct route instead — a swap is proposed only when no same-family wallet exists, and with several candidate wallets it asks which one to use rather than picking one silently. ## 5. Bank offramp (crypto → fiat) > **Prompt:** Pay Zephyr Logistics \$250 into their Chase account 98765432, routing 021000021, ACH — one-time. Beneficiary: 500 Market St, San Francisco, CA 94105, US, from my base-sepolia USDC. * `create_recipient` — Zephyr Logistics * `create_bank_destination` — account + routing number, `rail: ach`, beneficiary address * `create_auto_account` — **offramp**: USDC deposit → USD payout via ACH * `create_scheduled_payments` + `create_mandate` — in deposit terms, as with the swap The mandate's `asset` is the asset of the wallet's transaction intent — whichever supported stablecoin funds the payment (`USDC` in this example; `USDT` or `RD` work the same way) — **not** the final `USD` the bank receives. The gate authorizes and meters what leaves the wallet; the conversion to fiat is downstream routing. Stablecoin conversion is 1:1: the executed payment shows `output_asset: USD`, `destination_type: bank`, `destination_rail: ach`, and a `destination_label` like `Chase ••••5432`. Supported payout rails are `ach`, `fedwire`, and `swift`. Bank-rail constraints (like beneficiary-name length) are validated when the instruction is accepted — a rejection rolls the whole instruction back, leaving no orphan recipient or destination. ## 6. Draft from an invoice Attach a document instead of describing the payment: ```json theme={null} { "prompt": "Draft the payment on this invoice", "attachments": [ { "type": "document", "document": { "media_type": "application/pdf", "data": "", "filename": "invoice-0042.pdf" } } ] } ``` PDF and common image types are accepted, up to 8 MiB per document and 16 MiB per request (decoded). The agent reads the invoice and drafts the same reviewable action series — payee, amount, date — which goes through the identical accept-and-sign flow. The attachment is input to drafting only; it is not stored on the resulting objects. # Agentic Payments Source: https://docs.dakota.xyz/documentation/agentic-payments/index Let an AI agent draft payments in natural language — your customer signs, and nothing moves without them Agentic Payments lets a customer describe a payment in plain language — *"pay Alice 10 USDC on base-sepolia every month until December"* — and have a hosted payment agent turn it into a reviewable, signable payment plan. The agent drafts; the customer authorizes; the platform executes on schedule. **Alpha** — the agentic surface is in early access. Endpoints marked *Alpha* in the API reference follow the same stability and breaking-change policy as the rest of the API. ## The trust model The defining property of the system is that **the agent has no spending authority of its own**: 1. **The agent only drafts.** A conversation produces *proposals* — a structured series of actions (create a payee, create a mandate, schedule payments). Nothing is created until your application submits them as *instructions*. 2. **Nothing fires without a customer-signed mandate.** A mandate is a signed spending authorization with hard caps. It is approved by a **second** recognized signer — never by the agent itself ([dual control](/documentation/agentic-payments/mandate-signing)). 3. **Every payment is re-checked at fire time.** When a scheduled payment comes due, the mandate gate re-evaluates the payment against the mandate's caps *at that moment*. A revoked agent, an expired mandate, or an exhausted cap means the payment fails safely — it never falls through to a send. ## Objects | Object | What it is | Lifecycle | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | **Payment agent** | A hosted agent with its own signer key. It can converse and draft, and its signer is what scheduled payments fire under. | `active` → `revoked` | | **Proposal** | A drafted action series returned by the conversation endpoint, with a human-readable summary. Exists only in the API response — nothing is persisted until accepted. | ephemeral | | **Instruction** | An accepted proposal. Actuating it creates the real objects (recipients, mandates, scheduled payments, auto accounts) transactionally — a mid-actuation failure rolls the instruction's objects back. | `accepted` / `failed` | | **Mandate** | A single-rule spending authorization bound to one signer — the agent's in this flow, or any signer via the [direct create](/documentation/agentic-payments/direct-control#mandates): asset, optional network, per-transaction cap, and per-target caps over a calendar window (`NONE` / `WEEKLY` / `MONTHLY`). Immutable once created — extend authority by creating an additional mandate. | `pending` → `active` → `revoked` / `done` (or `rejected` while pending) | | **Scheduled payment** | One future payment: amount, asset, destination, fire date. Fired by the platform when due, under whatever active mandate covers it at that moment. | `scheduled` → `executed` / `cancelled` / `failed` | ## The flow at a glance ```mermaid theme={null} flowchart TD prompt(["Customer prompt"]) draft["POST /payment-agents/{id}/proposals
the agent drafts (LLM)"] review["Your app shows the plan for review"] actuate["POST /instructions
actuation — creates the real objects"] approve["POST /mandates/{id}/approve
a second signer signs (dual control)"] fire["Platform cron fires each payment on its date"] gate{"Mandate gate re-checks caps at fire time"} executed(["executed
wallet.transaction.created webhook"]) failed(["failed
scheduled_payment.failed webhook"]) prompt --> draft draft -- "proposals[] + summary" --> review review -- "customer approves in your UI" --> actuate actuate -- "mandate created (pending)" --> approve approve -- "mandate active" --> fire fire --> gate gate -- "within caps" --> executed gate -- "denied" --> failed ``` Drafting through conversation is optional — an application that already knows what it wants can `POST /instructions` with a hand-built action series, or use the direct `POST /mandates` and `POST /scheduled-payments` endpoints, and only the signing step remains. ## What the agent can and cannot do | | | | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | ✅ Draft payment plans from text or an attached invoice (PDF/image) | ❌ Move money on its own | | ✅ Answer questions about the account ([Account Insights](/documentation/agentic-payments/insights)) | ❌ Approve its own mandates (the bound signer can never sign §8 actions) | | ✅ Fire customer-approved payments on schedule | ❌ Exceed a mandate cap — the gate denies at fire time | | ✅ Reuse one mandate across a whole recurring series | ❌ Outlive revocation — revoking the agent fails its future fires | ## Where to go next * [Quickstart](/documentation/agentic-payments/quickstart) — agent → wallet access → draft → sign → first payment, end to end * [Examples](/documentation/agentic-payments/examples) — one-off, recurring, standing authorization, cross-chain, bank offramp * [Mandate signing](/documentation/agentic-payments/mandate-signing) — the canonical payload and the dual-control rules * [Webhooks](/documentation/agentic-payments/webhooks) — `scheduled_payment.failed` and the success path * [Account Insights](/documentation/agentic-payments/insights) — the deterministic report + advisory chat * [Limits](/documentation/agentic-payments/limits) — caps, windows, and the support matrix # Account Insights Source: https://docs.dakota.xyz/documentation/agentic-payments/insights A deterministic financial health report over a customer's account activity, plus a read-only advisory chat that narrates it Account Insights answers two questions for a customer: *"how is my account doing?"* and *"what should I do about it?"* — as data your UI can render directly, and as a conversational layer on top. * **`GET /customers/{customer_id}/insights`** — a computed report: a snapshot of balances and obligations, observations about recent activity, and advisory suggestions. **No LLM anywhere in this path** — every number, date, and sentence is computed deterministically from the customer's own objects. * **`POST /customers/{customer_id}/insights/chat`** — an advisory chat that answers questions about the account. The model narrates the same deterministic data the report is built from; it holds **no write capability of any kind**. **Alpha** — part of the agentic early-access surface. Insights are read-only by construction: neither endpoint can move money, create, or change anything. ## The report The report is a **view, not a stored object** — computed on demand from the customer's scheduled payments, mandates, recipients, wallets, and balances. There is nothing to create, poll into existence, or clean up. ```bash theme={null} curl https://api.platform.dakota.xyz/customers/2vWxCustomer0000000000000000/insights \ -H "X-API-Key: $DAKOTA_API_KEY" ``` ```json theme={null} { "customer_id": "2vWxCustomer0000000000000000", "generated_at": 1783075200, "snapshot": { "total_usd": "3100.25", "balances": [ { "wallet_id": "2vWxWallet000000000000000000", "name": "Operating", "network_id": "base-mainnet", "asset": "USDC", "amount_usd": "3100.25" } ], "upcoming": { "days": 14, "count": 3, "totals": { "USDC": "5200" } }, "open_scheduled_payments": 5, "active_mandates": 2 }, "insights": [ { "kind": "upcoming_payments", "severity": "info", "message": "3 payment(s) scheduled in the next 14 days (5200 USDC).", "detail": { "count": 3, "window_days": 14 }, "evidence": [ { "type": "scheduled_payment", "id": "2vWxPayment00000000000000000" } ] } ], "suggestions": [ { "kind": "funding_shortfall", "severity": "critical", "message": "A funding wallet shows $3100.25 of USDC but 5200 USDC of payments fire from it on base-mainnet in the next 7 days — top up at least ~2099.75 USDC by 2026-07-14 or 3 payment(s) may fail.", "detail": { "wallet_id": "2vWxWallet000000000000000000", "shortfall_estimate": "2099.75" }, "evidence": [ { "type": "wallet", "id": "2vWxWallet000000000000000000" }, { "type": "scheduled_payment", "id": "2vWxPayment00000000000000000" } ] } ] } ``` Three parts: * **`snapshot`** — typed facts for stat cards: per-wallet balances (aggregated over all the customer's linked wallets), upcoming-payment totals per asset, open/active counts. * **`insights[]`** — observations: what *is happening*. * **`suggestions[]`** — advice: what the customer *may want to do*. Always advisory — acting on one is a separate, human-gated step through the normal payment flow. Both arrays share **one item schema**: `{kind, severity, message, detail, evidence}`. ### Rendering items * **`severity`** is a closed set: `info | warn | critical`. Sort and color by it. * **`kind`** is an **open set** — new kinds appear over time without a breaking change. Render an unrecognized kind generically from `message` + `severity`; never drop it. * **`message`** is a complete, human-readable sentence. A minimal UI can show messages alone and be correct. * **`detail`** carries the raw computed values behind the sentence, for custom rendering. * **`evidence[]`** contains typed references (`{type, id}` — `wallet`, `scheduled_payment`, `mandate`, `recipient`; up to 10 per item) to the exact objects the item was computed from — deep-link each claim to the data behind it. `evidence.type` is open-set like `kind`. ### The kind catalog | kind | array | severity | fires when | | ---------------------------- | ----------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `upcoming_payments` | insights | info | open payments due within the next 14 days (count + per-asset totals) | | `payment_failures_clustered` | insights | warn | ≥2 recent failures to the same payee sharing one reason — the likely root cause, surfaced once | | `payments_failed` | insights | info | remaining recent singleton failures | | `account_activity` | insights | info | executed volume over the last 30 days + mandates awaiting signature | | `new_counterparty` | insights | info | open payments to a recently added payee | | `counterparty_concentration` | insights | info | one payee dominates recent executed outflow | | `payment_at_risk` | suggestions | warn | open payments already past their scheduled time | | `funding_shortfall` | suggestions | critical | a funding wallet's balance is below what fires from it in the next 7 days — includes the estimated top-up and deadline | | `mandate_expiring` | suggestions | warn / info | an active mandate ends within 14 days — `warn` if open payments depend on it | | `mandate_headroom` | suggestions | critical / warn | a mandate window cannot cover what's planned (`critical` — the excess **will** be denied at fire time) or is ≥80% consumed (`warn`) | A mandate that is exactly on plan — filled by its own scheduled series — stays silent; headroom items fire only on breach or tightness. ### Degradation, not errors A deployment without a balance index simply omits balance-derived parts (snapshot balances, shortfall math); the rest of the report still computes. Missing data degrades the report — it never 500s. ## The advisory chat ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/customers/2vWxCustomer0000000000000000/insights/chat \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "role": "user", "content": "Anything I should know about my account this week?" } ] }' ``` ```json theme={null} { "reply": "You have 3 payments (5200 USDC) due in the next two weeks, and your Operating wallet shows $3100.25 — about 2100 USDC short. Consider topping it up before July 14.", "conversation_status": "ok" } ``` The chat is **stateless**: send the running transcript (up to 40 messages) each turn and append the reply. Replies come back as light GitHub-flavored markdown (bold, bullet lists) ready to render. ### Where the numbers come from The model never computes facts. It reads them from deterministic, server-computed tool data: | It can read | Which is | | ------------------ | ----------------------------------------------------------------------------------------- | | the insight report | byte-identical to what `GET .../insights` returns — chat and dashboard can never disagree | | recent payments | statuses, amounts, payee names, and human-readable dates, resolved server-side | | mandates | with used/remaining headroom, same math as the fire-time gate | There is no write tool, no propose tool, and no way to pass another customer's identifiers — the tools are scoped to the path customer by construction. Asked to *do* something ("pay this invoice"), the assistant declines and points at the payment flow. ### Conversation status `conversation_status` reports where the conversation stands with the assistant's scope guard: | Value | Meaning | | --------- | ----------------------------------------------------------------------------------------------------------------------------- | | `ok` | on-topic | | `warned` | the conversation drifted off account matters — warnings escalate | | `blocked` | the conversation is terminated; further turns get a canned refusal without spending model budget. Start a fresh conversation. | ### Bounds Requests are validated before any model spend: at most **40 messages**, **4,000 characters per message**, **64 KB total**; empty messages are rejected. Chat turns are rate-limited **per customer** — 100/hour and 500/day — returning `429` beyond that. The report endpoint is deterministic and carries no chat-specific limit beyond the standard [API rate limits](/documentation/authentication/rate-limiting). ## Report or chat? Render the **report** — it is structured, deterministic, and cheap. Add the **chat** where a customer benefits from asking follow-ups ("why did my payments to Brightline fail?") — its answers cite the same data the report shows, so the two surfaces always agree. # Agentic Limits & Support Matrix Source: https://docs.dakota.xyz/documentation/agentic-payments/limits What agentic payments supports, and every cap you can hit ## Networks & assets Agentic payments move money through the same rails as the rest of the platform — anything your [wallets](/documentation/wallets) can send, an agent-drafted payment can send: | Route | How | Notes | | ----------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------- | | Same-family crypto (EVM → EVM, Solana → Solana) | direct | the agent always prefers a direct route when a same-family funding wallet exists | | Cross-family crypto (e.g. EVM funding → Solana destination) | auto account (convert & forward) | one auto account is reused across a recurring series; stablecoin conversion is 1:1 | | Crypto → bank (offramp) | auto account | payout rails: `ach`, `fedwire`, `swift`; bank-rail constraints are validated at instruction accept | Sandbox examples throughout these docs use `base-sepolia`, `ethereum-sepolia`, and `solana-devnet` with `USDC`. ## Mandate rule bounds A mandate holds exactly **one rule**; authority is extended by additional mandates, never by editing (mandates are immutable once created). | Dimension | Bound | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `targets` | up to 32 per rule (`recipient` ids or raw `address`es, or `any`) | | `asset` | must be a sendable (deposit) asset — deployed on the rule's `network_id`, or on ≥1 network when the rule leaves it open; validated at create | | `max_per_tx` | per-transaction amount cap, decimal string in the rule's `asset` | | `window` | `NONE` (lifetime) · `WEEKLY` (calendar week, Monday 00:00 UTC) · `MONTHLY` (calendar month, 1st 00:00 UTC) | | `max_amount_per_target_in_window` | cumulative amount cap over the window, **per target** — never pooled across targets | | `max_count_per_target_in_window` | fire-count cap over the window, per target | | `valid_from` / `valid_until` | unix-second validity bounds; an expired mandate can no longer be approved | Every cap is enforced **at fire time** by the mandate gate — a schedule that outgrows its mandate fails with `mandate_denied` rather than firing. ## Conversation & drafting caps | Surface | Cap | | --------------------------------------------------------- | ----------------------------------------------------------------------------- | | Proposal drafting (`POST /payment-agents/{id}/proposals`) | 200 messages / 512 KB text per request | | Attachments | PDF, PNG, JPEG, WebP, GIF · 8 MiB per document · 16 MiB per request (decoded) | | Drafting budget | 30 drafts/hour, 200/day per customer | | Insight chat (`POST /customers/{id}/insights/chat`) | 40 messages / 4,000 chars per message / 64 KB per request | | Insight chat budget | 100 turns/hour, 500/day per customer | Budget overruns return `429` with a `Retry-After`-style problem body; input-cap violations return `400` before any model spend. ## Platform-wide limits that apply here too * **API rate limits** — the standard per-key limits apply to every agentic endpoint; see [Rate limiting](/documentation/authentication/rate-limiting). * **Idempotency** — every agentic `POST` requires `X-Idempotency-Key`, like the rest of the platform. * **Sandbox amount cap** — sandbox caps transactions at **\$2 USD-equivalent** per transaction by default, agentic ones included. Drafts above the cap actuate but fail at fire time, exactly as an over-cap direct send would. ## Alpha scope The published alpha covers **hosted** agents — Dakota manages the agent's signer key. Mandates themselves are not agent-only: the [direct create](/documentation/agentic-payments/direct-control#mandates) binds any of your signers. All endpoints are tagged *Alpha* in the [API reference](/api-reference/introduction) and covered by the standard API stability and breaking-change policy. # Mandate Signing Source: https://docs.dakota.xyz/documentation/agentic-payments/mandate-signing The canonical payload a customer signs to approve or cancel a mandate, and the dual-control rules around it A mandate becomes a live spending authorization only when a customer-controlled signer signs it. This page defines the exact bytes that get signed, so any client — browser passkey, server-held key, hardware signer — can produce a verifiable signature. ## Dual control Every mandate **binds** one signer — a hosted payment agent's, or any signer named directly at creation ([Direct Control](/documentation/agentic-payments/direct-control#mandates)). Approving, and later cancelling, requires a **recognized signer of the mandate's customer other than the bound one**: * The bound signer can never activate its own authority — a second identity must sign. * The same rule applies to cancellation: the bound signer cannot mutate its own mandate, not even to reduce it. * "Recognized" means the signer is in the signer group of a wallet the customer controls — the same recognition model as [endorsed requests](/documentation/signing-guide). ## The canonical payload The signature covers the [RFC 8785 (JCS)](https://www.rfc-editor.org/rfc/rfc8785) canonical JSON of exactly these fields: ```json theme={null} { "action": "approve", "id": "2vWxMandate00000000000000000", "bound_signer": "2vWxSigner000000000000000000", "rule": { "target_type": "recipient", "targets": ["Alice"], "network_id": "base-sepolia", "asset": "USDC", "max_per_tx": "10", "window": "MONTHLY", "max_count_per_target_in_window": 1 }, "valid_from": 0, "valid_until": 1798675200 } ``` Every field comes from the mandate as returned by `GET /mandates/{mandate_id}` — a client reproduces the payload from the API response alone. **Why byte-exactness matters:** the platform verifies your signature against *its own* canonicalization of the same fields. JCS guarantees that both sides — regardless of language, struct field order, or whitespace habits — emit the identical byte sequence: keys sorted, no insignificant whitespace, standard number and string encoding. Use a JCS library (available for every mainstream language) rather than hand-rolling `JSON.stringify` ordering. **The action verb is inside the payload.** `"action": "approve"` and `"action": "cancel"` produce different bytes, so an approval signature can never be replayed as a cancellation, or vice versa. ## Approve ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/mandates/{mandate_id}/approve \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "approver_public_key": "BHkApproverKeyQm", "signature": "MEUCIQExampleSignature" }' ``` * `approver_public_key` — the recognized signer's public key (identifies who is signing; must differ from the bound signer). * `signature` — base64, over the canonical payload with `"action": "approve"`. On success the mandate flips `pending → active`. Mandates and scheduled payments are decoupled — nothing is bound at approval; any payment the rule covers is matched at fire time. Approval is refused when: * the mandate is not `pending` (already active, rejected, or revoked), * its `valid_until` has already passed — a signature over a dead grant authorizes nothing, so the platform refuses to spend one, * its originating instruction failed part-way — such an orphan must never become live authority. ## Cancel ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/mandates/{mandate_id}/cancel \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signer_public_key": "BHkSignerKeyQm", "signature": "MEUCIQExampleSignature" }' ``` Same payload with `"action": "cancel"`. The resulting status records *when* it ended: | Cancelled while | Final status | Meaning | | --------------- | ------------ | ----------------------------------------------------------------------------------- | | `pending` | `rejected` | declined at review — it never held authority | | `active` | `revoked` | authority withdrawn — future fires under it fail with `no_mandate`/`mandate_denied` | The audit trail keeps who did what: `approved_by_signer_id`, `rejected_by_signer_id`, `revoked_by_signer_id` on the mandate. ## Signing with a passkey (WebAuthn) Browser passkeys are supported directly: pass the WebAuthn **assertion** as the `signature`, using the canonical payload as the assertion **challenge**. The platform verifies that the assertion's challenge matches the mandate payload and that the ES256 signature verifies against the enrolled passkey — standard `authenticatorData ‖ SHA-256(clientDataJSON)` WebAuthn semantics, no custom scheme. Server-held ES256 keys sign the payload's SHA-256 digest directly. ## Signing checklist 1. `GET /mandates/{mandate_id}` — read `id`, `bound_signer_id`, `rule`, `valid_from`, `valid_until`. 2. Build the payload object with the intended `action`; canonicalize with a JCS (RFC 8785) library. 3. Sign with a recognized signer key that is **not** the bound signer (or run a WebAuthn assertion with the payload as challenge). 4. `POST .../approve` (or `.../cancel`) with the public key + base64 signature. 5. Confirm `status` in the response — `active` means the mandate can now cover payments at fire time. # Agentic Payments Quickstart Source: https://docs.dakota.xyz/documentation/agentic-payments/quickstart Create an agent, draft a payment plan in natural language, sign the mandate, and watch the payment fire This walkthrough goes from nothing to an executed agent-drafted payment. It assumes an onboarded customer with a funded [non-custodial wallet](/documentation/wallets) and an API key. **Alpha** — the agentic surface is in early access. Run this walkthrough against the [sandbox](/documentation/testing). ## 1. Create a hosted payment agent The agent gets its own signer key, managed by Dakota. That signer is the identity everything else binds to: mandates bind it, scheduled payments fire under it. ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/payment-agents \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "2vWxCustomer0000000000000000", "name": "My Bill Pay", "hosted": true }' ``` ```json theme={null} { "id": "2vWxAgent0000000000000000000", "name": "My Bill Pay", "customer_id": "2vWxCustomer0000000000000000", "hosted": true, "signer_id": "2vWxSigner000000000000000000", "signer_public_key": "BHkExampleKeyQm", "wallet_ids": [], "state": "active" } ``` Keep `signer_public_key` — it is what you grant wallet access to next. ## 2. Grant the agent wallet access The agent's signer must be **recognized** on the funding wallet before anything it drafts can fire. Recognition is signer-group membership, and there are two ways to grant it: **Option A — add the agent to a signer group already attached to the wallet (recommended).** A plain API call, no endorsement required — and the agent operates under whatever policy constraints that group already carries: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/signer-groups/2vWxGroup0000000000000000000/signers \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "member_key": "BHkExampleKeyQm" }' ``` `member_key` is the agent's `signer_public_key` from step 1. **Option B — attach a signer group containing the agent's signer to the wallet.** Changing *which* groups govern a wallet is a wallet-state mutation, so it must be signed by the wallet's existing signers — an [endorsed request](/documentation/signing-guide#attach-signer-group-to-wallet). `wallet_ids` on the agent reflects the wallets that currently recognize it. ## 3. Draft a plan from a prompt ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/payment-agents/2vWxAgent0000000000000000000/proposals \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "prompt": "Pay Alice 10 USDC on base-sepolia 0xa11ce00000000000000000000000000000000001 every month on the 15th until Dec 26" }' ``` The reply contains conversational text plus zero or more `proposals` — each a summary and a concrete action series: ```json theme={null} { "reply": "Drafting a monthly payment to Alice — review and sign below.", "proposals": [ { "summary": "Pay Alice 10 USDC on base-sepolia, on the 15th of every month until Dec 2026 (7 payments)", "actions": [ { "type": "create_recipient", "create_recipient": { "name": "Alice" } }, { "type": "create_crypto_destination", "create_crypto_destination": { "address": "0xa11ce00000000000000000000000000000000001", "network_id": "base-sepolia" } }, { "type": "create_mandate", "create_mandate": { "rule": { "target_type": "recipient", "targets": ["Alice"], "network_id": "base-sepolia", "asset": "USDC", "max_per_tx": "10", "window": "MONTHLY", "max_count_per_target_in_window": 1 }, "valid_until": 1798675200 } }, { "type": "create_scheduled_payments", "create_scheduled_payments": { "amount": "10", "asset": "USDC", "dates": [1781481600, 1784073600, 1786752000, 1789430400, 1792022400, 1794700800, 1797292800] } } ] } ] } ``` Nothing has been created yet — a proposal is a draft your application shows the customer for review. The conversation is **stateless on the platform side** — the agent keeps no memory between calls. To refine a draft with a follow-up ("make it 12 USDC", "start in August"), resend the whole conversation so far in `messages`, in order, with the new turn appended (a `prompt` alone works only for the first turn, or as shorthand for the latest user message alongside `messages`). You can also attach an invoice (PDF or image, up to 8 MiB) and ask the agent to draft from it. ## 4. Accept — turn proposals into real objects Send the reviewed proposals back verbatim: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/instructions \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "payment_agent_id": "2vWxAgent0000000000000000000", "proposals": [ /* the proposals array from step 3 */ ] }' ``` ```json theme={null} { "instruction_ids": ["2vWxInstruction0000000000000"], "mandates": [ { "id": "2vWxMandate00000000000000000", "status": "pending", "bound_signer_id": "2vWxSigner000000000000000000", "rule": { "target_type": "recipient", "targets": ["2vWxRecipient000000000000000"], "network_id": "base-sepolia", "asset": "USDC", "max_per_tx": "10", "window": "MONTHLY", "max_count_per_target_in_window": 1 }, "valid_until": 1798675200 } ] } ``` Actuation creates the recipient, destination, scheduled payments — and the mandate, in status `pending`. A failure mid-way rolls back the instruction's objects (no orphan payees). The response returns each drafted mandate **in full wire shape** (identical to `GET /mandates/{mandate_id}`), so your application can put it in front of the customer to sign immediately — no follow-up fetch or polling. `GET /instructions/{instruction_id}` remains available to audit what an instruction created. ## 5. The customer signs the mandate You already hold the pending mandate from step 4's response. It activates only when a recognized signer **other than the agent's** signs its canonical payload — this is the dual-control step that keeps spending authority with the customer: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/mandates/2vWxMandate00000000000000000/approve \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "approver_public_key": "BHkApproverKeyQm", "signature": "MEUCIQExampleSignature" }' ``` How to construct `signature` — the exact payload bytes, canonicalization, and passkey support — is covered in [Mandate signing](/documentation/agentic-payments/mandate-signing). ```json theme={null} { "id": "2vWxMandate00000000000000000", "status": "active", "approved_by_signer_id": "2vWxApprover0000000000000000" } ``` ## 6. Payments fire on their dates From here the platform does the rest. On each scheduled date the payment is re-checked against the active mandate (caps, target, asset, validity) and sent through the standard money path. Watch progress either way: * **Poll:** `GET /scheduled-payments?status=scheduled,executed,failed` — each row carries its status, and once executed, the covering `mandate_id` and `wallet_transaction_id` for audit. * **Webhooks:** a success emits the standard `wallet.transaction.created`; a failure emits `scheduled_payment.failed` with a machine-readable `failure_code`. See [Agentic webhooks](/documentation/agentic-payments/webhooks). ## Where this leaves you One signed mandate now covers the whole monthly series — no re-signing per payment, and the agent still cannot exceed `10 USDC` per transaction, once per month, to Alice alone. To grant more, draft more and sign again; to end it, `POST /mandates/{mandate_id}/cancel` or revoke the agent. # Agentic Webhooks Source: https://docs.dakota.xyz/documentation/agentic-payments/webhooks Failed payments emit the new scheduled_payment.failed; successful ones emit the standard wallet.transaction.created Agentic payments adds exactly **one** webhook event: `scheduled_payment.failed`. Everything else rides signals you already consume. For delivery mechanics — Ed25519 signature verification, targets, retries — see the main [Webhook Integration](/documentation/webhooks) guide. This page covers the agentic-specific payloads and how to react to them. ## Why only failures get a new event A scheduled payment is the only *asynchronous* agentic actor: the platform fires it later, unattended. Every other agentic operation is synchronous — the caller holds the response. * A scheduled payment that **succeeds** emits the standard **`wallet.transaction.created`** through the shared money path, indistinguishable from any other send. Correlate it via `wallet_transaction_id` on the executed scheduled payment (`GET /scheduled-payments`). * A scheduled payment that **fails** emits **`scheduled_payment.failed`** with a machine-readable `failure_code`. ## `scheduled_payment.failed` Fires on **every** fire-path failure: mandate denial, no covering mandate, agent revoked, wallet no longer recognizing the signer, chain-family mismatch, unsupported network, invalid amount, and money-path send errors. It does not fire for successful payments or for rows already in a terminal state. ### Payload (`data.object`) | Field | Always present | Notes | | ---------------------- | --------------- | -------------------------------------------------------------------------------- | | `scheduled_payment_id` | ✅ | the failed schedule row | | `signer_id` | ✅ | the signer the row fires under | | `wallet_id` | ✅ | the funding wallet | | `address` | ✅ | the crypto address the row pays (for auto-account payments, the deposit address) | | `amount` | ✅ | decimal string, destination asset | | `asset` | ✅ | e.g. `USDC` | | `network_id` | ✅ | e.g. `base-sepolia` | | `scheduled_at` | ✅ | unix seconds | | `failure_code` | ✅ | stable machine-readable code — **react on this** | | `failure_reason` | ✅ | human-readable detail; wording may change, never parse it | | `payment_agent_id` | when applicable | the payment agent whose signer this is; omitted for non-agent signers | | `recipient_id` | when applicable | present when the schedule targets a recipient's destination | | `destination_id` | when applicable | the real destination — bank for an offramp, crypto otherwise | Optional fields are **omitted** when not applicable — treat absence as "not applicable", never as an empty string. ### `failure_code` reference | Code | Meaning | Typical reaction | | ------------------------ | --------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | `mandate_denied` | the mandate gate denied it — cap exhausted, target/asset/network mismatch, or validity window | adjust expectations or sign an additional mandate | | `no_mandate` | no active mandate covered the payment at fire time | create + approve a covering mandate | | `agent_revoked` | the payment agent was revoked | expected if you revoked it; otherwise create a new agent | | `wallet_not_recognized` | the signer is no longer recognized on the funding wallet | re-attach the agent's signer to the wallet's signer group | | `wallet_family_mismatch` | the wallet's chain family can't send on the payment's network | schedule from a compatible wallet | | `unsupported_network` | the payment's network is not supported | fix the schedule | | `invalid_amount` | the amount is unparseable or non-positive | fix the schedule | | `send_error` | pre-checks passed but the send failed (provider/transport) | often transient — re-schedule to retry | | `internal_error` | platform-side error during the fire | typically transient; retry or contact support | `failure_code` is the contract; `failure_reason` is diagnostic color for humans. ## Delivery & idempotency The event is emitted **in the same transaction** as the payment's flip to `failed` — exactly one event exists per status change. Delivery, as with all Dakota webhooks, is at-least-once: **dedupe on the event `id`**. Global webhook targets receive the event automatically; scoped targets subscribe to it by event type like any other. It also appears in the `GET /events` stream, so a poll-based consumer needs no webhook endpoint at all. ## A practical failure handler ```text theme={null} on scheduled_payment.failed: if seen(event.id): ack and stop # at-least-once delivery switch failure_code: case send_error, internal_error: # transient re-schedule the payment (new POST /scheduled-payments) case no_mandate, mandate_denied: # authority problem surface to the customer: review mandates, sign more headroom case wallet_not_recognized, wallet_family_mismatch, agent_revoked: surface to the operator: wiring problem, fix the binding default: # unsupported_network, invalid_amount fix the schedule data before retrying ``` The [Account Insights report](/documentation/agentic-payments/insights) also picks failures up (`payment_failures_clustered` groups repeats by payee and shared reason) — useful as the human-facing view over the same signal. # API Keys & Headers Source: https://docs.dakota.xyz/documentation/authentication/api-keys-headers Learn how to get your API keys and make authenticated requests to the Dakota Platform API. ## Getting Your API Keys ### Step 1: Access the Dakota Dashboard To get an API key, you first need access to the Dakota Platform dashboard: | Environment | Dashboard URL | | ----------- | ------------------------------------------------------------------ | | Production | [platform.dakota.xyz](https://platform.dakota.xyz) | | Sandbox | [platform.sandbox.dakota.xyz](https://platform.sandbox.dakota.xyz) | **New to Dakota?** If you don't have a Dakota account yet, [contact our sales team](https://dakota.xyz/talk-to-sales) to get started. Once your account is set up, you'll receive credentials to access the dashboard. ### Step 2: Create an API Key 1. Log into your Dakota Platform dashboard 2. Navigate to the **API Keys** section in the sidebar 3. Click **Create New API Key** 4. Give your key a descriptive name (e.g., "Production Server", "Development Testing") 5. Copy and store the key securely — **it will only be shown once** API keys are only displayed once when created. Store them securely in your environment variables or secrets management system. If you lose your key, you'll need to create a new one. ## API Key Format API keys are base64-encoded strings with exactly 60 characters, for example: * `AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=` ## API Base URLs Once you have your API key, use it with the corresponding API base URL: | Environment | API Base URL | Use Case | | ----------- | ----------------------------------------- | ----------------------- | | Sandbox | `https://api.platform.sandbox.dakota.xyz` | Development and testing | | Production | `https://api.platform.dakota.xyz` | Live transactions | **Start with Sandbox** — We recommend building and testing your integration in the sandbox environment first. Sandbox uses simulated data and won't process real transactions. ## Required Headers All API requests require the `x-api-key` header for authentication: ```http theme={null} x-api-key: AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc= ``` ### POST Requests Only: x-idempotency-key The `x-idempotency-key` header is **required for POST requests only** to ensure request idempotency: ```http theme={null} x-idempotency-key: 550e8400-e29b-41d4-a716-446655440000 ``` * Use a unique UUID for each POST request * Do **not** include this header for GET, PUT, PATCH, or DELETE requests * Helps prevent duplicate operations if a request is retried ### Content-Type Header For requests with a body (POST, PUT, PATCH), include: ```http theme={null} Content-Type: application/json ``` ## Making Authenticated Requests ### GET Request Example GET requests only need the API key header: ```bash cURL theme={null} curl -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=" \ -H "Content-Type: application/json" ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers', { headers: { 'x-api-key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=', 'Content-Type': 'application/json' } }); ``` ```python Python theme={null} import requests response = requests.get( 'https://api.platform.dakota.xyz/customers', headers={ 'x-api-key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=', 'Content-Type': 'application/json' } ) ``` ```go Go theme={null} package main import ( "net/http" ) func main() { client := &http.Client{} req, _ := http.NewRequest("GET", "https://api.platform.dakota.xyz/customers", nil) req.Header.Add("x-api-key", "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=") req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("x-api-key", HeaderValue::from_static("AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=")); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let response = client .get("https://api.platform.dakota.xyz/customers") .headers(headers) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; public class DakotaGetExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers")) .header("x-api-key", "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=") .header("Content-Type", "application/json") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ### POST Request Example POST requests require both the API key and idempotency key headers: ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/customers \ -H "x-api-key: AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=" \ -H "x-idempotency-key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"name": "Acme Corp", "customer_type": "business"}' ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers', { method: 'POST', headers: { 'x-api-key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=', 'x-idempotency-key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Acme Corp', customer_type: 'business' }) }); ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/customers', headers={ 'x-api-key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=', 'x-idempotency-key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'name': 'Acme Corp', 'customer_type': 'business' } ) ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{"name": "Acme Corp", "customer_type": "business"}`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/customers", body) req.Header.Add("x-api-key", "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=") req.Header.Add("x-idempotency-key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("x-api-key", HeaderValue::from_static("AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=")); headers.insert("x-idempotency-key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "name": "Acme Corp", "customer_type": "business" }); let response = client .post("https://api.platform.dakota.xyz/customers") .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaApiExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "name": "Acme Corp", "customer_type": "business" } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers")) .header("x-api-key", "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=") .header("x-idempotency-key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ## Header Summary | Header | Required For | Purpose | | ------------------- | ------------------ | ---------------------------- | | `x-api-key` | All requests | Authentication | | `x-idempotency-key` | POST requests only | Prevent duplicate operations | | `Content-Type` | Requests with body | Specify JSON content type | > **Remember:** Only include `x-idempotency-key` for POST requests. GET, PUT, PATCH, and DELETE requests should not include this header. # Authentication Source: https://docs.dakota.xyz/documentation/authentication/index Dakota Platform uses API key authentication for secure access to our endpoints. This section covers everything you need to know about authenticating with the Dakota Platform API. # Overview The Dakota Platform API requires API key authentication for all requests. You'll need to: 1. **Get your API key** from the Dakota Platform dashboard 2. **Include proper headers** in your requests 3. **Follow security best practices** to keep your integration secure # What's Covered This authentication guide is organized into the following sections: * **[API Keys & Headers](/documentation/authentication/api-keys-headers)** - How to get API keys, required headers, and request examples * **[Security](/documentation/authentication/security)** - Best practices for secure API key management * **[Troubleshooting](/documentation/authentication/troubleshooting)** - Common errors and how to resolve them # Quick Start For a complete quick start guide including your first API call, see our [Getting Started](/) guide. # Rate Limiting Source: https://docs.dakota.xyz/documentation/authentication/rate-limiting The Dakota Platform API implements rate limiting to ensure fair usage and maintain API performance for all clients. ## Rate Limits API requests are rate limited based on authentication type: | Authentication Type | Rate Limit | Window | | --------------------- | ------------ | ---------- | | API Key (`x-api-key`) | 60 requests | per minute | | JWT (Dashboard users) | 600 requests | per minute | | Unauthenticated | 10 requests | per minute | | Application Token | 250 requests | per hour | ## Rate Limit Headers Every API response includes rate limit information in the headers: | Header | Description | | ----------------------- | --------------------------------------------------------- | | `X-RateLimit-Limit` | Maximum requests allowed in the current one-minute window | | `X-RateLimit-Remaining` | Requests remaining in the current window | | `X-RateLimit-Reset` | Unix timestamp when the current rate-limit window resets | When a request is throttled (`429`), responses also include `Retry-After` with seconds to wait before retrying. ### Example Response Headers ```http theme={null} HTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 47 X-RateLimit-Reset: 1640995260 Content-Type: application/json ``` ## 429 Rate Limit Exceeded When you exceed the rate limit, you'll receive a `429 Too Many Requests` response with RFC 9457 Problem Details: ```http theme={null} HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1640995290 Retry-After: 30 Content-Type: application/problem+json { "type": "https://docs.dakota.xyz/errors/rate-limited", "title": "Rate Limited", "status": 429, "detail": "Too many requests. Please retry later.", "instance": "/customers", "request_id": "req_xyz789" } ``` The `Retry-After` header indicates the number of seconds to wait before retrying. ## Handling Rate Limits ### 1. Monitor Rate Limit Headers Always check the rate limit headers in your responses to avoid hitting limits: ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers', { headers: { 'X-API-Key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=' } }); const remaining = parseInt(response.headers.get('X-RateLimit-Remaining')); const resetTime = parseInt(response.headers.get('X-RateLimit-Reset')); const limit = parseInt(response.headers.get('X-RateLimit-Limit')); // Calculate percentage of rate limit used const usedPercentage = ((limit - remaining) / limit) * 100; if (remaining < 50) { console.warn(`Rate limit critical: ${remaining}/${limit} requests remaining (${usedPercentage.toFixed(1)}% used)`); } else if (remaining < 100) { console.info(`Rate limit warning: ${remaining}/${limit} requests remaining (${usedPercentage.toFixed(1)}% used)`); } // Calculate time until reset const secondsUntilReset = resetTime - Math.floor(Date.now() / 1000); if (secondsUntilReset > 0) { console.log(`Rate limit resets in ${secondsUntilReset} seconds`); } ``` ```python Python theme={null} import requests import time from datetime import datetime response = requests.get( 'https://api.platform.dakota.xyz/customers', headers={'X-API-Key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc='} ) remaining = int(response.headers.get('X-RateLimit-Remaining', 0)) reset_time = int(response.headers.get('X-RateLimit-Reset', 0)) limit = int(response.headers.get('X-RateLimit-Limit', 1000)) # Calculate percentage of rate limit used used_percentage = ((limit - remaining) / limit) * 100 if remaining < 50: print(f'Rate limit critical: {remaining}/{limit} requests remaining ({used_percentage:.1f}% used)') elif remaining < 100: print(f'Rate limit warning: {remaining}/{limit} requests remaining ({used_percentage:.1f}% used)') # Calculate time until reset seconds_until_reset = reset_time - int(time.time()) if seconds_until_reset > 0: reset_datetime = datetime.fromtimestamp(reset_time) print(f'Rate limit resets in {seconds_until_reset} seconds at {reset_datetime}') ``` ```go Go theme={null} package main import ( "fmt" "net/http" "strconv" "time" ) func checkRateLimit(response *http.Response) { remainingStr := response.Header.Get("X-RateLimit-Remaining") resetStr := response.Header.Get("X-RateLimit-Reset") limitStr := response.Header.Get("X-RateLimit-Limit") remaining, _ := strconv.Atoi(remainingStr) resetTime, _ := strconv.ParseInt(resetStr, 10, 64) limit, _ := strconv.Atoi(limitStr) // Calculate percentage of rate limit used usedPercentage := float64(limit-remaining) / float64(limit) * 100 if remaining < 50 { fmt.Printf("Rate limit critical: %d/%d requests remaining (%.1f%% used)\n", remaining, limit, usedPercentage) } else if remaining < 100 { fmt.Printf("Rate limit warning: %d/%d requests remaining (%.1f%% used)\n", remaining, limit, usedPercentage) } // Calculate time until reset now := time.Now().Unix() secondsUntilReset := resetTime - now if secondsUntilReset > 0 { fmt.Printf("Rate limit resets in %d seconds\n", secondsUntilReset) } } func main() { client := &http.Client{} req, _ := http.NewRequest("GET", "https://api.platform.dakota.xyz/customers", nil) req.Header.Add("X-API-Key", "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=") resp, err := client.Do(req) if err != nil { fmt.Printf("Error making request: %v\n", err) return } defer resp.Body.Close() checkRateLimit(resp) } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use std::time::{SystemTime, UNIX_EPOCH}; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=")); let response = client .get("https://api.platform.dakota.xyz/customers") .headers(headers) .send() .await?; // Extract rate limit headers let remaining: i32 = response .headers() .get("X-RateLimit-Remaining") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()) .unwrap_or(0); let reset_time: u64 = response .headers() .get("X-RateLimit-Reset") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()) .unwrap_or(0); let limit: i32 = response .headers() .get("X-RateLimit-Limit") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()) .unwrap_or(1000); // Calculate percentage of rate limit used let used_percentage = ((limit - remaining) as f64 / limit as f64) * 100.0; if remaining < 50 { println!("Rate limit critical: {}/{} requests remaining ({:.1}% used)", remaining, limit, used_percentage); } else if remaining < 100 { println!("Rate limit warning: {}/{} requests remaining ({:.1}% used)", remaining, limit, used_percentage); } // Calculate time until reset let now = SystemTime::now() .duration_since(UNIX_EPOCH)? .as_secs(); if reset_time > now { let seconds_until_reset = reset_time - now; println!("Rate limit resets in {} seconds", seconds_until_reset); } Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.time.Instant; public class DakotaRateLimitMonitor { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers")) .header("X-API-Key", "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); checkRateLimit(response); } public static void checkRateLimit(HttpResponse response) { String remainingStr = response.headers().firstValue("X-RateLimit-Remaining").orElse("0"); String resetStr = response.headers().firstValue("X-RateLimit-Reset").orElse("0"); String limitStr = response.headers().firstValue("X-RateLimit-Limit").orElse("1000"); int remaining = Integer.parseInt(remainingStr); long resetTime = Long.parseLong(resetStr); int limit = Integer.parseInt(limitStr); // Calculate percentage of rate limit used double usedPercentage = ((double)(limit - remaining) / limit) * 100; if (remaining < 50) { System.out.printf("Rate limit critical: %d/%d requests remaining (%.1f%% used)%n", remaining, limit, usedPercentage); } else if (remaining < 100) { System.out.printf("Rate limit warning: %d/%d requests remaining (%.1f%% used)%n", remaining, limit, usedPercentage); } // Calculate time until reset long now = Instant.now().getEpochSecond(); long secondsUntilReset = resetTime - now; if (secondsUntilReset > 0) { System.out.printf("Rate limit resets in %d seconds%n", secondsUntilReset); } } } ``` ### 2. Implement Exponential Backoff When you receive a 429 response, implement exponential backoff with jitter: ```javascript JavaScript theme={null} class DakotaApiClient { constructor(apiKey, baseUrl = 'https://api.platform.dakota.xyz') { this.apiKey = apiKey; this.baseUrl = baseUrl; this.maxRetries = 5; this.baseDelay = 1000; // 1 second } async makeRequest(endpoint, options = {}) { const url = `${this.baseUrl}${endpoint}`; const requestOptions = { ...options, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json', ...options.headers } }; // Add idempotency key for POST requests if (options.method === 'POST') { requestOptions.headers['X-Idempotency-Key'] = crypto.randomUUID(); } return this.makeRequestWithBackoff(url, requestOptions); } async makeRequestWithBackoff(url, options, attempt = 0) { try { const response = await fetch(url, options); // Handle rate limiting if (response.status === 429) { if (attempt >= this.maxRetries) { throw new Error(`Rate limit exceeded after ${this.maxRetries} retries`); } return this.retryWithBackoff(url, options, attempt); } // Handle other 5xx errors with retry if (response.status >= 500 && attempt < this.maxRetries) { return this.retryWithBackoff(url, options, attempt); } return response; } catch (error) { if (error.name === 'TypeError' && attempt < this.maxRetries) { // Network error, retry return this.retryWithBackoff(url, options, attempt); } throw error; } } async retryWithBackoff(url, options, attempt) { const retryAfter = 1; // Default to 1 second if no Retry-After header const exponentialDelay = this.baseDelay * Math.pow(2, attempt); const jitter = Math.random() * 1000; const totalDelay = Math.min(exponentialDelay + jitter, 60000); // Cap at 60s console.log(`Rate limited. Retrying in ${totalDelay}ms (attempt ${attempt + 1}/${this.maxRetries})`); await new Promise(resolve => setTimeout(resolve, totalDelay)); return this.makeRequestWithBackoff(url, options, attempt + 1); } } // Usage example const dakota = new DakotaApiClient('AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc='); try { const customers = await dakota.makeRequest('/customers'); console.log('Customers retrieved:', customers.data); } catch (error) { console.error('Failed after retries:', error.message); } ``` ```python Python theme={null} import time import random import requests import uuid from typing import Optional, Dict, Any class DakotaApiClient: def __init__(self, api_key: str, base_url: str = 'https://api.platform.dakota.xyz'): self.api_key = api_key self.base_url = base_url self.max_retries = 5 self.base_delay = 1 # 1 second self.session = requests.Session() self.session.headers.update({ 'X-API-Key': api_key, 'Content-Type': 'application/json' }) def make_request(self, endpoint: str, method: str = 'GET', data: Optional[Dict[Any, Any]] = None, headers: Optional[Dict[str, str]] = None) -> requests.Response: url = f'{self.base_url}{endpoint}' request_headers = headers or {} # Add idempotency key for POST requests if method.upper() == 'POST': request_headers['X-Idempotency-Key'] = str(uuid.uuid4()) return self._make_request_with_backoff(url, method, data, request_headers) def _make_request_with_backoff(self, url: str, method: str, data: Optional[Dict[Any, Any]], headers: Dict[str, str], attempt: int = 0) -> requests.Response: try: response = self.session.request( method, url, json=data, headers=headers, timeout=30 ) # Handle rate limiting if response.status_code == 429: if attempt >= self.max_retries: raise Exception(f'Rate limit exceeded after {self.max_retries} retries') return self._retry_with_backoff(url, method, data, headers, attempt, response) # Handle server errors with retry if response.status_code >= 500 and attempt < self.max_retries: return self._retry_with_backoff(url, method, data, headers, attempt, response) response.raise_for_status() return response except requests.exceptions.RequestException as e: if attempt < self.max_retries: return self._retry_with_backoff(url, method, data, headers, attempt) raise e def _retry_with_backoff(self, url: str, method: str, data: Optional[Dict[Any, Any]], headers: Dict[str, str], attempt: int, response: Optional[requests.Response] = None) -> requests.Response: retry_after = 1 # Default to 1 second if response and 'Retry-After' in response.headers: retry_after = int(response.headers['Retry-After']) exponential_delay = self.base_delay * (2 ** attempt) jitter = random.uniform(0, 1) total_delay = min(exponential_delay + jitter, 60) # Cap at 60 seconds print(f'Rate limited. Retrying in {total_delay:.2f}s (attempt {attempt + 1}/{self.max_retries})') time.sleep(total_delay) return self._make_request_with_backoff(url, method, data, headers, attempt + 1) # Usage example dakota = DakotaApiClient('AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=') try: response = dakota.make_request('/customers') customers = response.json() print(f'Customers retrieved: {customers["data"]}') except Exception as e: print(f'Failed after retries: {e}') ``` ```go Go theme={null} package main import ( "bytes" "encoding/json" "fmt" "io" "math" "math/rand" "net/http" "strconv" "time" "github.com/google/uuid" ) type DakotaApiClient struct { ApiKey string BaseURL string MaxRetries int BaseDelay time.Duration Client *http.Client } func NewDakotaApiClient(apiKey string) *DakotaApiClient { return &DakotaApiClient{ ApiKey: apiKey, BaseURL: "https://api.platform.dakota.xyz", MaxRetries: 5, BaseDelay: time.Second, Client: &http.Client{Timeout: 30 * time.Second}, } } func (c *DakotaApiClient) MakeRequest(endpoint, method string, data interface{}) (*http.Response, error) { url := c.BaseURL + endpoint var body io.Reader if data != nil { jsonData, err := json.Marshal(data) if err != nil { return nil, err } body = bytes.NewBuffer(jsonData) } req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } req.Header.Set("X-API-Key", c.ApiKey) req.Header.Set("Content-Type", "application/json") if method == "POST" { req.Header.Set("X-Idempotency-Key", uuid.New().String()) } return c.makeRequestWithBackoff(req, 0) } func (c *DakotaApiClient) makeRequestWithBackoff(req *http.Request, attempt int) (*http.Response, error) { resp, err := c.Client.Do(req) if err != nil { if attempt < c.MaxRetries { return c.retryWithBackoff(req, attempt, nil) } return nil, err } // Handle rate limiting if resp.StatusCode == 429 { if attempt >= c.MaxRetries { return nil, fmt.Errorf("rate limit exceeded after %d retries", c.MaxRetries) } return c.retryWithBackoff(req, attempt, resp) } // Handle server errors with retry if resp.StatusCode >= 500 && attempt < c.MaxRetries { return c.retryWithBackoff(req, attempt, resp) } return resp, nil } func (c *DakotaApiClient) retryWithBackoff(req *http.Request, attempt int, resp *http.Response) (*http.Response, error) { retryAfter := 1 * time.Second if resp != nil { if retryAfterHeader := resp.Header.Get("Retry-After"); retryAfterHeader != "" { if seconds, err := strconv.Atoi(retryAfterHeader); err == nil { retryAfter = time.Duration(seconds) * time.Second } } } exponentialDelay := c.BaseDelay * time.Duration(math.Pow(2, float64(attempt))) jitter := time.Duration(rand.Float64() * float64(time.Second)) totalDelay := exponentialDelay + jitter if totalDelay > 60*time.Second { totalDelay = 60 * time.Second } fmt.Printf("Rate limited. Retrying in %v (attempt %d/%d)\n", totalDelay, attempt+1, c.MaxRetries) time.Sleep(totalDelay) return c.makeRequestWithBackoff(req, attempt+1) } func main() { dakota := NewDakotaApiClient("AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=") resp, err := dakota.MakeRequest("/customers", "GET", nil) if err != nil { fmt.Printf("Failed after retries: %v\n", err) return } defer resp.Body.Close() fmt.Println("Request succeeded!") } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use serde_json::Value; use std::time::Duration; use tokio::time::sleep; use uuid::Uuid; use rand::Rng; pub struct DakotaApiClient { api_key: String, base_url: String, max_retries: u32, base_delay: Duration, client: reqwest::Client, } impl DakotaApiClient { pub fn new(api_key: String) -> Self { Self { api_key, base_url: "https://api.platform.dakota.xyz".to_string(), max_retries: 5, base_delay: Duration::from_secs(1), client: reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .unwrap(), } } pub async fn make_request( &self, endpoint: &str, method: reqwest::Method, data: Option<&Value>, ) -> Result> { let url = format!("{}{}", self.base_url, endpoint); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_str(&self.api_key)?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); if method == reqwest::Method::POST { headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); } let mut request_builder = self.client.request(method, &url).headers(headers); if let Some(json_data) = data { request_builder = request_builder.json(json_data); } self.make_request_with_backoff(request_builder, 0).await } async fn make_request_with_backoff( &self, request_builder: reqwest::RequestBuilder, attempt: u32, ) -> Result> { let request = request_builder.try_clone() .ok_or("Failed to clone request")?; let response = self.client.execute(request.build()?).await; match response { Ok(resp) => { if resp.status() == 429 { if attempt >= self.max_retries { return Err(format!("Rate limit exceeded after {} retries", self.max_retries).into()); } return self.retry_with_backoff(request_builder, attempt, Some(&resp)).await; } if resp.status().as_u16() >= 500 && attempt < self.max_retries { return self.retry_with_backoff(request_builder, attempt, Some(&resp)).await; } Ok(resp) } Err(e) => { if attempt < self.max_retries { return self.retry_with_backoff(request_builder, attempt, None).await; } Err(e.into()) } } } async fn retry_with_backoff( &self, request_builder: reqwest::RequestBuilder, attempt: u32, response: Option<&reqwest::Response>, ) -> Result> { let mut retry_after = Duration::from_secs(1); if let Some(resp) = response { if let Some(retry_header) = resp.headers().get("Retry-After") { if let Ok(seconds_str) = retry_header.to_str() { if let Ok(seconds) = seconds_str.parse::() { retry_after = Duration::from_secs(seconds); } } } } let exponential_delay = self.base_delay * 2_u32.pow(attempt); let jitter = Duration::from_millis(rand::thread_rng().gen_range(0..1000)); let total_delay = std::cmp::min(exponential_delay + jitter, Duration::from_secs(60)); println!("Rate limited. Retrying in {:?} (attempt {}/{})", total_delay, attempt + 1, self.max_retries); sleep(total_delay).await; self.make_request_with_backoff(request_builder, attempt + 1).await } } #[tokio::main] async fn main() -> Result<(), Box> { let dakota = DakotaApiClient::new("AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=".to_string()); match dakota.make_request("/customers", reqwest::Method::GET, None).await { Ok(_) => println!("Request succeeded!"), Err(e) => println!("Failed after retries: {}", e), } Ok(()) } ``` ```java Java theme={null} import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.Optional; import java.util.Random; import java.util.UUID; public class DakotaApiClient { private final String apiKey; private final String baseUrl; private final int maxRetries; private final Duration baseDelay; private final HttpClient client; private final Random random; public DakotaApiClient(String apiKey) { this.apiKey = apiKey; this.baseUrl = "https://api.platform.dakota.xyz"; this.maxRetries = 5; this.baseDelay = Duration.ofSeconds(1); this.client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .build(); this.random = new Random(); } public HttpResponse makeRequest(String endpoint, String method, String jsonData) throws IOException, InterruptedException { String url = baseUrl + endpoint; HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) .header("X-API-Key", apiKey) .header("Content-Type", "application/json"); if ("POST".equalsIgnoreCase(method)) { requestBuilder.header("X-Idempotency-Key", UUID.randomUUID().toString()); if (jsonData != null) { requestBuilder.POST(HttpRequest.BodyPublishers.ofString(jsonData)); } else { requestBuilder.POST(HttpRequest.BodyPublishers.noBody()); } } else { requestBuilder.GET(); } return makeRequestWithBackoff(requestBuilder.build(), 0); } private HttpResponse makeRequestWithBackoff(HttpRequest request, int attempt) throws IOException, InterruptedException { try { HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); // Handle rate limiting if (response.statusCode() == 429) { if (attempt >= maxRetries) { throw new RuntimeException("Rate limit exceeded after " + maxRetries + " retries"); } return retryWithBackoff(request, attempt, response); } // Handle server errors with retry if (response.statusCode() >= 500 && attempt < maxRetries) { return retryWithBackoff(request, attempt, response); } return response; } catch (IOException e) { if (attempt < maxRetries) { return retryWithBackoff(request, attempt, null); } throw e; } } private HttpResponse retryWithBackoff(HttpRequest request, int attempt, HttpResponse response) throws IOException, InterruptedException { Duration retryAfter = Duration.ofSeconds(1); if (response != null) { Optional retryAfterHeader = response.headers().firstValue("Retry-After"); if (retryAfterHeader.isPresent()) { try { int seconds = Integer.parseInt(retryAfterHeader.get()); retryAfter = Duration.ofSeconds(seconds); } catch (NumberFormatException ignored) { // Use default retry after } } } long exponentialDelayMs = baseDelay.toMillis() * (long) Math.pow(2, attempt); long jitterMs = random.nextInt(1000); long totalDelayMs = Math.min(exponentialDelayMs + jitterMs, 60000); // Cap at 60s System.out.printf("Rate limited. Retrying in %dms (attempt %d/%d)%n", totalDelayMs, attempt + 1, maxRetries); Thread.sleep(totalDelayMs); return makeRequestWithBackoff(request, attempt + 1); } public static void main(String[] args) { DakotaApiClient dakota = new DakotaApiClient("AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc="); try { HttpResponse response = dakota.makeRequest("/customers", "GET", null); System.out.println("Request succeeded!"); } catch (Exception e) { System.out.println("Failed after retries: " + e.getMessage()); } } } ``` ### 3. Distribute Requests Over Time Instead of making all requests at once, distribute them evenly: ```javascript JavaScript theme={null} class RateLimitedClient { constructor(apiKey, requestsPerMinute = 900) { this.apiKey = apiKey; this.interval = 60000 / requestsPerMinute; // ms between requests this.lastRequest = 0; } async makeRequest(url, options = {}) { const now = Date.now(); const timeToWait = this.interval - (now - this.lastRequest); if (timeToWait > 0) { await new Promise(resolve => setTimeout(resolve, timeToWait)); } this.lastRequest = Date.now(); return fetch(url, { ...options, headers: { 'X-API-Key': this.apiKey, ...options.headers } }); } } ``` ## Best Practices ### 1. Stay Under the Limit * Target 90% of your rate limit (900 requests/minute) to leave buffer room * Monitor your usage patterns and adjust accordingly ### 2. Batch Operations * Use bulk endpoints when available * Group related operations together ### 3. Cache Results * Cache API responses when appropriate to reduce API calls * Use ETags or last-modified headers for efficient caching ### 4. Implement Circuit Breaker * Stop making requests temporarily after multiple rate limit errors * Gradually resume requests after cooling down ```javascript JavaScript theme={null} class DakotaCircuitBreaker { constructor(options = {}) { this.failures = 0; this.successCount = 0; this.threshold = options.threshold || 5; // Failures before opening this.timeout = options.timeout || 60000; // 1 minute this.resetTimeout = options.resetTimeout || 30000; // 30 seconds this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN this.nextAttempt = Date.now(); this.lastFailureTime = null; } async call(fn) { if (this.state === 'OPEN') { if (Date.now() < this.nextAttempt) { throw new Error(`Circuit breaker is OPEN. Next attempt in ${Math.ceil((this.nextAttempt - Date.now()) / 1000)}s`); } this.state = 'HALF_OPEN'; console.log('Circuit breaker is now HALF_OPEN, attempting request...'); } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(error); throw error; } } onSuccess() { if (this.state === 'HALF_OPEN') { this.successCount++; // After 3 successful requests in HALF_OPEN, close the circuit if (this.successCount >= 3) { console.log('Circuit breaker is now CLOSED after successful requests'); this.failures = 0; this.successCount = 0; this.state = 'CLOSED'; } } else { this.failures = 0; this.state = 'CLOSED'; } } onFailure(error) { this.failures++; this.lastFailureTime = Date.now(); // Only count rate limit and server errors towards circuit breaking const isCircuitBreakerError = error.message.includes('429') || error.message.includes('Rate limit') || error.message.includes('500') || error.message.includes('502') || error.message.includes('503'); if (isCircuitBreakerError && this.failures >= this.threshold) { this.state = 'OPEN'; this.nextAttempt = Date.now() + this.timeout; this.successCount = 0; console.log(`Circuit breaker is now OPEN after ${this.failures} failures. Will retry at ${new Date(this.nextAttempt)}`); } } getStatus() { return { state: this.state, failures: this.failures, nextAttempt: this.state === 'OPEN' ? new Date(this.nextAttempt) : null, lastFailure: this.lastFailureTime ? new Date(this.lastFailureTime) : null }; } } // Enhanced Dakota client with circuit breaker class EnhancedDakotaClient extends DakotaApiClient { constructor(apiKey, baseUrl) { super(apiKey, baseUrl); this.circuitBreaker = new DakotaCircuitBreaker({ threshold: 5, timeout: 60000, // 1 minute resetTimeout: 30000 // 30 seconds }); } async makeRequest(endpoint, options = {}) { return this.circuitBreaker.call(async () => { return super.makeRequest(endpoint, options); }); } getCircuitBreakerStatus() { return this.circuitBreaker.getStatus(); } } // Usage example with circuit breaker const dakota = new EnhancedDakotaClient('AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc='); // Make requests with automatic circuit breaking for (let i = 0; i < 20; i++) { try { const response = await dakota.makeRequest('/customers'); console.log(`Request ${i + 1} succeeded`); } catch (error) { console.error(`Request ${i + 1} failed:`, error.message); // Check circuit breaker status const status = dakota.getCircuitBreakerStatus(); if (status.state === 'OPEN') { console.log('Circuit breaker is open, waiting before next attempt...'); await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds } } } ``` ### 5. Request Queuing and Throttling For high-volume applications, implement request queuing to stay within rate limits: ```javascript JavaScript theme={null} class DakotaRequestQueue { constructor(apiClient, requestsPerMinute = 900) { this.apiClient = apiClient; this.queue = []; this.processing = false; this.interval = 60000 / requestsPerMinute; // ms between requests this.lastRequest = 0; } async enqueue(endpoint, options = {}) { return new Promise((resolve, reject) => { this.queue.push({ endpoint, options, resolve, reject }); this.processQueue(); }); } async processQueue() { if (this.processing || this.queue.length === 0) { return; } this.processing = true; while (this.queue.length > 0) { const { endpoint, options, resolve, reject } = this.queue.shift(); try { // Ensure minimum interval between requests const now = Date.now(); const timeToWait = this.interval - (now - this.lastRequest); if (timeToWait > 0) { await new Promise(r => setTimeout(r, timeToWait)); } this.lastRequest = Date.now(); const response = await this.apiClient.makeRequest(endpoint, options); resolve(response); } catch (error) { reject(error); } } this.processing = false; } getQueueStatus() { return { queueLength: this.queue.length, processing: this.processing, estimatedWaitTime: this.queue.length * this.interval }; } } // Usage example with request queue const dakota = new DakotaApiClient('AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc='); const requestQueue = new DakotaRequestQueue(dakota, 900); // 900 requests per minute // Queue multiple requests const requests = []; for (let i = 0; i < 100; i++) { requests.push( requestQueue.enqueue('/customers', { method: 'POST', body: JSON.stringify({ customer_type: 'individual', name: `Customer ${i}` }) }) ); } // Process all requests with automatic rate limiting const results = await Promise.allSettled(requests); console.log(`Processed ${results.filter(r => r.status === 'fulfilled').length} requests successfully`); ``` ## Need Higher Limits? If you need higher rate limits for your use case, contact our support team with: * Your current usage patterns and peak request volumes * Expected request volume and growth projections * Business justification for higher limits * Timeline for when you need the increase * Description of your rate limiting and retry strategies We'll work with you to find a solution that meets your needs while maintaining API performance for all users. ### Enterprise Rate Limits For enterprise customers, we offer: * **Higher base limits**: Up to 5,000 requests per minute * **Burst allowances**: Short-term higher limits for batch operations * **Dedicated rate limit monitoring**: Real-time alerts and usage analytics * **Custom retry strategies**: Optimized backoff algorithms for your use case # Security Source: https://docs.dakota.xyz/documentation/authentication/security Implementing proper security practices is crucial when working with the Dakota Platform API. Follow these guidelines to keep your integration secure. # API Key Management ## Store Keys Securely **Never expose API keys in your code:** ```javascript Bad Example theme={null} // ❌ Never do this const apiKey = 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc='; ``` **Use environment variables instead:** ```bash cURL theme={null} # Use environment variable in shell scripts curl -X GET https://api.platform.dakota.xyz/customers \ -H "X-API-Key: $DAKOTA_API_KEY" ``` ```javascript JavaScript theme={null} // ✅ Use environment variables const apiKey = process.env.DAKOTA_API_KEY; ``` ```python Python theme={null} import os api_key = os.getenv('DAKOTA_API_KEY') ``` ```go Go theme={null} package main import "os" func main() { apiKey := os.Getenv("DAKOTA_API_KEY") } ``` ```rust Rust theme={null} use std::env; fn main() { let api_key = env::var("DAKOTA_API_KEY") .expect("DAKOTA_API_KEY environment variable not set"); } ``` ```java Java theme={null} public class DakotaConfig { public static void main(String[] args) { String apiKey = System.getenv("DAKOTA_API_KEY"); if (apiKey == null) { throw new IllegalStateException("DAKOTA_API_KEY environment variable not set"); } } } ``` ## Environment Variables Setup Set up your environment variables properly: ```bash .env file theme={null} DAKOTA_API_KEY=AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc= ``` ```bash Shell theme={null} export DAKOTA_API_KEY="AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=" ``` ## Key Rotation * Rotate API keys regularly (recommended: every 90 days) * Create new keys before deactivating old ones to avoid downtime * Have a key rotation process documented and tested ## Access Control * Limit API key access to only necessary team members * Use separate keys for different services or applications * Revoke unused or compromised keys immediately # Request Security ## Always Use HTTPS All API requests must use HTTPS. The Dakota Platform API will reject HTTP requests: ```javascript JavaScript theme={null} // ✅ Correct - HTTPS const response = await fetch('https://api.platform.dakota.xyz/customers', { headers: { 'X-API-Key': process.env.DAKOTA_API_KEY } }); // ❌ Wrong - HTTP will be rejected const response = await fetch('http://api.platform.dakota.xyz/customers', { headers: { 'X-API-Key': process.env.DAKOTA_API_KEY } }); ``` ## Validate SSL Certificates Ensure your HTTP client validates SSL certificates: ```bash cURL theme={null} # ✅ Certificate validation enabled by default curl -X GET https://api.platform.dakota.xyz/customers \ -H "X-API-Key: $DAKOTA_API_KEY" # ❌ Never disable certificate verification # curl -k https://api.platform.dakota.xyz/customers # DON'T DO THIS ``` ```javascript Node.js theme={null} // ✅ Certificate validation enabled by default const https = require('https'); // ❌ Never disable certificate validation // process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; // DON'T DO THIS ``` ```python Python theme={null} import requests # ✅ Certificate validation enabled by default response = requests.get( 'https://api.platform.dakota.xyz/customers', headers={'X-API-Key': api_key} ) # ❌ Never disable certificate verification # requests.get(url, verify=False) # DON'T DO THIS ``` ```go Go theme={null} package main import ( "crypto/tls" "net/http" ) func main() { // ✅ Certificate validation enabled by default client := &http.Client{} // ❌ Never disable certificate verification // client := &http.Client{ // Transport: &http.Transport{ // TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // DON'T DO THIS // }, // } } ``` ```rust Rust theme={null} use reqwest::Client; #[tokio::main] async fn main() -> Result<(), Box> { // ✅ Certificate validation enabled by default let client = Client::new(); // ❌ Never disable certificate verification // let client = Client::builder() // .danger_accept_invalid_certs(true) // DON'T DO THIS // .build()?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import javax.net.ssl.SSLContext; public class SecurityExample { public static void main(String[] args) { // ✅ Certificate validation enabled by default HttpClient client = HttpClient.newHttpClient(); // ❌ Never disable certificate verification // Don't create custom trust managers that accept all certificates } } ``` ## Request Logging Security When logging requests for debugging, never log sensitive headers: ```bash cURL theme={null} # When logging cURL commands, use environment variables echo "curl -X GET https://api.platform.dakota.xyz/customers \\ -H \"X-API-Key: \$DAKOTA_API_KEY\"" # Never log actual API keys # echo "curl -H \"X-API-Key: AHGlPZ...\"" # DON'T DO THIS ``` ```javascript JavaScript theme={null} function logRequest(url, headers, body) { const safeHeaders = { ...headers }; // Remove sensitive headers from logs delete safeHeaders['X-API-Key']; delete safeHeaders['Authorization']; console.log('API Request:', { url, headers: safeHeaders, body }); } ``` ```python Python theme={null} import logging def log_request(url, headers, body): safe_headers = headers.copy() # Remove sensitive headers from logs safe_headers.pop('X-API-Key', None) safe_headers.pop('Authorization', None) logging.info(f'API Request: {url}, Headers: {safe_headers}') ``` ```go Go theme={null} package main import ( "log" "net/http" ) func logRequest(req *http.Request) { safeHeaders := make(map[string][]string) // Copy headers except sensitive ones for k, v := range req.Header { if k != "X-Api-Key" && k != "Authorization" { safeHeaders[k] = v } } log.Printf("API Request: %s %s, Headers: %v", req.Method, req.URL, safeHeaders) } ``` ```rust Rust theme={null} use std::collections::HashMap; use log::info; fn log_request(url: &str, headers: &HashMap, body: &str) { let mut safe_headers = headers.clone(); // Remove sensitive headers from logs safe_headers.remove("X-API-Key"); safe_headers.remove("Authorization"); info!("API Request: {}, Headers: {:?}", url, safe_headers); } ``` ```java Java theme={null} import java.util.HashMap; import java.util.Map; import java.util.logging.Logger; public class RequestLogger { private static final Logger logger = Logger.getLogger(RequestLogger.class.getName()); public static void logRequest(String url, Map headers, String body) { Map safeHeaders = new HashMap<>(headers); // Remove sensitive headers from logs safeHeaders.remove("X-API-Key"); safeHeaders.remove("Authorization"); logger.info(String.format("API Request: %s, Headers: %s", url, safeHeaders)); } } ``` # Production Environment ## Base URL Always use the production base URL (Sandbox coming soon): ``` https://api.platform.dakota.xyz ``` ## Network Security * Use private networks or VPNs when possible * Monitor network traffic for unusual patterns ## Error Handling Implement secure error handling that doesn't expose sensitive information: ```bash cURL theme={null} #!/bin/bash # Secure error handling in shell scripts make_secure_request() { local endpoint="$1" local response response=$(curl -s -w "%{http_code}" \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "Content-Type: application/json" \ "https://api.platform.dakota.xyz$endpoint") local http_code="${response: -3}" local body="${response%???}" if [[ "$http_code" -ge 400 ]]; then # Log full error details internally echo "API Error: $http_code" >&2 # Return generic error to user echo "API request failed" >&2 return 1 fi echo "$body" } ``` ```javascript JavaScript theme={null} async function makeSecureRequest(endpoint) { try { const response = await fetch(`https://api.platform.dakota.xyz${endpoint}`, { headers: { 'X-API-Key': process.env.DAKOTA_API_KEY, 'Content-Type': 'application/json' } }); if (!response.ok) { // Log full error details internally console.error('API Error:', response.status, response.statusText); // Return generic error to client throw new Error('API request failed'); } return await response.json(); } catch (error) { // Log error details internally console.error('Request failed:', error.message); // Don't expose internal error details throw new Error('Service temporarily unavailable'); } } ``` ```python Python theme={null} import requests import logging from typing import Dict, Any def make_secure_request(endpoint: str) -> Dict[Any, Any]: try: response = requests.get( f'https://api.platform.dakota.xyz{endpoint}', headers={ 'X-API-Key': os.getenv('DAKOTA_API_KEY'), 'Content-Type': 'application/json' }, timeout=30 ) if not response.ok: # Log full error details internally logging.error(f'API Error: {response.status_code} {response.reason}') # Return generic error to client raise Exception('API request failed') return response.json() except requests.exceptions.RequestException as e: # Log error details internally logging.error(f'Request failed: {str(e)}') # Don't expose internal error details raise Exception('Service temporarily unavailable') ``` ```go Go theme={null} package main import ( "encoding/json" "fmt" "io" "log" "net/http" "os" ) func makeSecureRequest(endpoint string) (map[string]interface{}, error) { client := &http.Client{} req, err := http.NewRequest("GET", "https://api.platform.dakota.xyz"+endpoint, nil) if err != nil { log.Printf("Request creation failed: %v", err) return nil, fmt.Errorf("service temporarily unavailable") } req.Header.Set("X-API-Key", os.Getenv("DAKOTA_API_KEY")) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { // Log error details internally log.Printf("Request failed: %v", err) // Don't expose internal error details return nil, fmt.Errorf("service temporarily unavailable") } defer resp.Body.Close() if resp.StatusCode >= 400 { // Log full error details internally log.Printf("API Error: %d %s", resp.StatusCode, resp.Status) // Return generic error to client return nil, fmt.Errorf("API request failed") } var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { log.Printf("JSON decode failed: %v", err) return nil, fmt.Errorf("service temporarily unavailable") } return result, nil } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use serde_json::Value; use std::env; use log::error; async fn make_secure_request(endpoint: &str) -> Result> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); let api_key = env::var("DAKOTA_API_KEY") .map_err(|_| "API key not configured")?; headers.insert("X-API-Key", HeaderValue::from_str(&api_key)?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let url = format!("https://api.platform.dakota.xyz{}", endpoint); match client.get(&url).headers(headers).send().await { Ok(response) => { if !response.status().is_success() { // Log full error details internally error!("API Error: {} {}", response.status(), response.status().canonical_reason().unwrap_or("Unknown")); // Return generic error to client return Err("API request failed".into()); } match response.json::().await { Ok(data) => Ok(data), Err(e) => { error!("JSON decode failed: {}", e); Err("Service temporarily unavailable".into()) } } } Err(e) => { // Log error details internally error!("Request failed: {}", e); // Don't expose internal error details Err("Service temporarily unavailable".into()) } } } ``` ```java Java theme={null} import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.logging.Logger; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; public class SecureApiClient { private static final Logger logger = Logger.getLogger(SecureApiClient.class.getName()); private final HttpClient client; private final ObjectMapper mapper; public SecureApiClient() { this.client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .build(); this.mapper = new ObjectMapper(); } public JsonNode makeSecureRequest(String endpoint) throws Exception { String apiKey = System.getenv("DAKOTA_API_KEY"); if (apiKey == null) { throw new IllegalStateException("API key not configured"); } HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz" + endpoint)) .header("X-API-Key", apiKey) .header("Content-Type", "application/json") .GET() .build(); try { HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { // Log full error details internally logger.severe("API Error: " + response.statusCode()); // Return generic error to client throw new Exception("API request failed"); } return mapper.readTree(response.body()); } catch (IOException | InterruptedException e) { // Log error details internally logger.severe("Request failed: " + e.getMessage()); // Don't expose internal error details throw new Exception("Service temporarily unavailable"); } } } ``` # Webhook Security ## Verify Webhook Signatures Webhook signatures use **Ed25519** (not HMAC). See [Webhooks → Webhook Signature Verification](/documentation/webhooks#webhook-signature-verification) for the canonical algorithm, headers, and code examples in every supported language. ## Webhook Endpoint Security * Use HTTPS for all webhook endpoints * Implement request size limits * Add rate limiting to webhook endpoints * Validate webhook payload structure # Monitoring and Alerting ## Security Monitoring Set up monitoring for: * Unusual API usage patterns * Failed authentication attempts * Requests from unexpected IP addresses * High error rates that might indicate attacks ## Logging Security Events Log security-relevant events: ```bash cURL theme={null} #!/bin/bash # Security event logging in shell scripts log_security_event() { local event="$1" local endpoint="$2" local ip_address="$3" echo "$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) | SECURITY | $event | endpoint=$endpoint ip=$ip_address source=dakota-shell-client" >> /var/log/dakota-security.log } # Example usage log_security_event "api_key_used" "/customers" "$CLIENT_IP" ``` ```javascript JavaScript theme={null} function logSecurityEvent(event, details) { console.log(JSON.stringify({ timestamp: new Date().toISOString(), event: event, details: details, source: 'dakota-api-client' })); } // Example usage logSecurityEvent('api_key_used', { endpoint: '/customers', ip_address: req.ip, user_agent: req.get('User-Agent') }); ``` ```python Python theme={null} import json import logging from datetime import datetime, timezone def log_security_event(event: str, details: dict): security_log = { 'timestamp': datetime.now(timezone.utc).isoformat(), 'event': event, 'details': details, 'source': 'dakota-api-client' } # Use structured logging logging.info(json.dumps(security_log)) # Example usage log_security_event('api_key_used', { 'endpoint': '/customers', 'ip_address': request.remote_addr, 'user_agent': request.headers.get('User-Agent') }) ``` ```go Go theme={null} package main import ( "encoding/json" "log" "net/http" "time" ) type SecurityEvent struct { Timestamp string `json:"timestamp"` Event string `json:"event"` Details map[string]interface{} `json:"details"` Source string `json:"source"` } func logSecurityEvent(event string, details map[string]interface{}) { securityEvent := SecurityEvent{ Timestamp: time.Now().UTC().Format(time.RFC3339), Event: event, Details: details, Source: "dakota-api-client", } eventJSON, err := json.Marshal(securityEvent) if err != nil { log.Printf("Error marshaling security event: %v", err) return } log.Println(string(eventJSON)) } // Example usage func handleRequest(w http.ResponseWriter, r *http.Request) { details := map[string]interface{}{ "endpoint": r.URL.Path, "ip_address": r.RemoteAddr, "user_agent": r.UserAgent(), } logSecurityEvent("api_key_used", details) } ``` ```rust Rust theme={null} use serde_json::{json, Value}; use chrono::{DateTime, Utc}; use log::info; fn log_security_event(event: &str, details: Value) { let security_event = json!({ "timestamp": Utc::now().to_rfc3339(), "event": event, "details": details, "source": "dakota-api-client" }); info!("{}", security_event.to_string()); } // Example usage fn handle_request(req: &HttpRequest) { let details = json!({ "endpoint": req.uri().path(), "ip_address": req.connection_info().remote_addr(), "user_agent": req.headers().get("user-agent") .and_then(|h| h.to_str().ok()) .unwrap_or("unknown") }); log_security_event("api_key_used", details); } ``` ```java Java theme={null} import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.Instant; import java.util.logging.Logger; import javax.servlet.http.HttpServletRequest; public class SecurityLogger { private static final Logger logger = Logger.getLogger(SecurityLogger.class.getName()); private static final ObjectMapper mapper = new ObjectMapper(); public static void logSecurityEvent(String event, ObjectNode details) { try { ObjectNode securityEvent = mapper.createObjectNode(); securityEvent.put("timestamp", Instant.now().toString()); securityEvent.put("event", event); securityEvent.set("details", details); securityEvent.put("source", "dakota-api-client"); logger.info(securityEvent.toString()); } catch (Exception e) { logger.severe("Error logging security event: " + e.getMessage()); } } // Example usage public static void handleRequest(HttpServletRequest request) { ObjectNode details = mapper.createObjectNode(); details.put("endpoint", request.getRequestURI()); details.put("ip_address", request.getRemoteAddr()); details.put("user_agent", request.getHeader("User-Agent")); logSecurityEvent("api_key_used", details); } } ``` ## Alert Setup Configure alerts for: * Multiple consecutive API authentication failures * Requests from new or suspicious IP addresses * Unusual request volume patterns * Webhook signature validation failures # Data Protection ## Sensitive Data Handling * Never log sensitive customer data * Use data encryption at rest for stored API responses * Implement data retention policies # Security Checklist Before going to production, verify: * [ ] API keys stored as environment variables * [ ] No sensitive data in code or logs * [ ] HTTPS used for all requests * [ ] SSL certificate validation enabled * [ ] Webhook signatures verified * [ ] Error handling doesn't expose sensitive info * [ ] Request/response logging excludes sensitive headers * [ ] Monitoring and alerting configured * [ ] Data retention policies implemented * [ ] Security testing completed # Incident Response If you suspect a security incident: 1. **Immediately rotate** your API keys 2. **Review logs** for suspicious activity 3. **Contact Dakota Platform support** with incident details 4. **Document** the incident and response 5. **Update security measures** to prevent recurrence # Troubleshooting Source: https://docs.dakota.xyz/documentation/authentication/troubleshooting Common authentication issues and their solutions. # HTTP Status Codes ## 401 Unauthorized **Symptoms:** * Request returns `401 Unauthorized` * Error message indicates authentication failure **Common Causes:** ### Missing or Invalid API Key ```json theme={null} { "type": "https://docs.dakota.xyz/errors/authentication-error", "title": "Unauthorized", "status": 401, "detail": "Missing or invalid API key", "instance": "/customers", "request_id": "req_abc123" } ``` **Solutions:** * Verify your API key is exactly 60 characters * Check that you're including the `x-api-key` header * Ensure no extra whitespace or characters in the key ```bash cURL theme={null} # ✅ Correct format curl -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=" # ❌ Common mistakes -H "x-api-key: AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc= " # extra spaces -H "API-Key: AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=" # wrong header name ``` ```javascript JavaScript theme={null} // ✅ Correct format const headers = { 'x-api-key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=' }; // ❌ Common mistakes const headers = { 'x-api-key': ' AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc= ', // extra spaces 'API-Key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=' // wrong header name }; ``` ```python Python theme={null} # ✅ Correct format headers = { 'x-api-key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=' } # ❌ Common mistakes headers = { 'x-api-key': ' AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc= ', # extra spaces 'API-Key': 'AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=' # wrong header name } ``` ```go Go theme={null} // ✅ Correct format headers := map[string]string{ "x-api-key": "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=", } // ❌ Common mistakes headers := map[string]string{ "x-api-key": " AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc= ", // extra spaces "API-Key": "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=", // wrong header name } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; // ✅ Correct format let mut headers = HeaderMap::new(); headers.insert("x-api-key", HeaderValue::from_str("AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc=").unwrap()); // ❌ Common mistakes - extra spaces or wrong header name will cause authentication failures ``` ```java Java theme={null} // ✅ Correct format Map headers = new HashMap<>(); headers.put("x-api-key", "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc="); // ❌ Common mistakes headers.put("x-api-key", " AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc= "); // extra spaces headers.put("API-Key", "AHGlPZaxDSMz8Wf1l8VRH4ObdbHiKsWFWnmRyHtiwAc="); // wrong header name ``` ### Deleted or Expired API Key **Solution:** Create a new API key in your Dakota Platform dashboard ### Wrong Environment **Solution:** Ensure you're using the correct base URL: * Production: `https://api.platform.dakota.xyz` ## 403 Forbidden **Symptoms:** * Request returns `403 Forbidden` * API key is valid but access is denied **Common Causes:** ### Insufficient Permissions ```json theme={null} { "type": "https://docs.dakota.xyz/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "API key lacks required permissions for this endpoint", "instance": "/customers", "request_id": "req_abc123" } ``` **Solutions:** * Check if your account has access to the requested feature * Verify API key permissions in the Dakota Platform dashboard * Contact support if you need additional permissions ### Account Limitations **Solutions:** * Complete required account verification steps * Upgrade your account plan if needed * Contact support for account-specific limitations ## 429 Too Many Requests **Symptoms:** * Request returns `429 Too Many Requests` * No response body, only HTTP status * Rate limit headers indicate limits exceeded **Response Headers:** ``` HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1640995290 Retry-After: 30 ``` **Solutions:** * Implement exponential backoff (see [Rate Limiting guide](rate-limiting)) * Check `Retry-After` header for seconds to wait before retrying * Distribute requests more evenly over time * Contact support if you need higher rate limits # Header Issues ## Missing x-idempotency-key for POST Requests **Error:** ```json theme={null} { "type": "https://docs.dakota.xyz/errors/validation-error", "title": "Validation Error", "status": 400, "detail": "x-idempotency-key header is required for POST requests", "instance": "/customers", "request_id": "req_abc123" } ``` **Solution:** Include a unique UUID in the `x-idempotency-key` header for POST requests only: ```bash cURL theme={null} # ✅ POST request with idempotency key curl -X POST https://api.platform.dakota.xyz/customers \ -H "x-api-key: your-api-key" \ -H "x-idempotency-key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"customer_type": "business", "name": "Acme Corp"}' # ✅ GET request without idempotency key curl -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: your-api-key" ``` ```javascript JavaScript theme={null} // ✅ POST request with idempotency key fetch('https://api.platform.dakota.xyz/customers', { method: 'POST', headers: { 'x-api-key': 'your-api-key', 'x-idempotency-key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({customer_type: 'business', name: 'Acme Corp'}) }); // ✅ GET request without idempotency key fetch('https://api.platform.dakota.xyz/customers', { headers: { 'x-api-key': 'your-api-key' } }); ``` ```python Python theme={null} import requests import uuid # ✅ POST request with idempotency key response = requests.post( 'https://api.platform.dakota.xyz/customers', headers={ 'x-api-key': 'your-api-key', 'x-idempotency-key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={'customer_type': 'business', 'name': 'Acme Corp'} ) # ✅ GET request without idempotency key response = requests.get( 'https://api.platform.dakota.xyz/customers', headers={'x-api-key': 'your-api-key'} ) ``` ```go Go theme={null} package main import ( "bytes" "encoding/json" "net/http" "github.com/google/uuid" ) // ✅ POST request with idempotency key data := map[string]interface{}{ "customer_type": "business", "name": "Acme Corp", } jsonData, _ := json.Marshal(data) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/customers", bytes.NewBuffer(jsonData)) req.Header.Set("x-api-key", "your-api-key") req.Header.Set("x-idempotency-key", uuid.New().String()) req.Header.Set("Content-Type", "application/json") // ✅ GET request without idempotency key req, _ = http.NewRequest("GET", "https://api.platform.dakota.xyz/customers", nil) req.Header.Set("x-api-key", "your-api-key") ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use serde_json::json; use uuid::Uuid; // ✅ POST request with idempotency key let mut headers = HeaderMap::new(); headers.insert("x-api-key", HeaderValue::from_str("your-api-key").unwrap()); headers.insert("x-idempotency-key", HeaderValue::from_str(&Uuid::new_v4().to_string()).unwrap()); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let data = json!({ "customer_type": "business", "name": "Acme Corp" }); let client = reqwest::Client::new(); let response = client .post("https://api.platform.dakota.xyz/customers") .headers(headers) .json(&data) .send() .await?; // ✅ GET request without idempotency key let mut headers = HeaderMap::new(); headers.insert("x-api-key", HeaderValue::from_str("your-api-key").unwrap()); let response = client .get("https://api.platform.dakota.xyz/customers") .headers(headers) .send() .await?; ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.URI; import java.util.UUID; // ✅ POST request with idempotency key String requestBody = """ { "customer_type": "business", "name": "Acme Corp" } """; HttpRequest postRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers")) .header("x-api-key", "your-api-key") .header("x-idempotency-key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); // ✅ GET request without idempotency key HttpRequest getRequest = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers")) .header("x-api-key", "your-api-key") .GET() .build(); ``` ## Invalid Idempotency Key Format **Error:** ```json theme={null} { "type": "https://docs.dakota.xyz/errors/validation-error", "title": "Validation Error", "status": 400, "detail": "x-idempotency-key must be a valid UUID", "instance": "/customers", "request_id": "req_abc123" } ``` **Solution:** Use a properly formatted UUID: ```bash cURL theme={null} # ✅ Valid UUID formats $(uuidgen) # System command '550e8400-e29b-41d4-a716-446655440000' # Manual UUID # ❌ Invalid formats '123' 'not-a-uuid' '550e8400e29b41d4a716446655440000' # Missing hyphens ``` ```javascript JavaScript theme={null} // ✅ Valid UUID formats '550e8400-e29b-41d4-a716-446655440000' crypto.randomUUID() // Browser require('uuid').v4() // Node.js // ❌ Invalid formats '123' 'not-a-uuid' '550e8400e29b41d4a716446655440000' // Missing hyphens ``` ```python Python theme={null} import uuid # ✅ Valid UUID formats str(uuid.uuid4()) # Standard library '550e8400-e29b-41d4-a716-446655440000' # Manual UUID # ❌ Invalid formats '123' 'not-a-uuid' '550e8400e29b41d4a716446655440000' # Missing hyphens ``` ```go Go theme={null} import "github.com/google/uuid" // ✅ Valid UUID formats uuid.New().String() // Google UUID library "550e8400-e29b-41d4-a716-446655440000" // Manual UUID // ❌ Invalid formats "123" "not-a-uuid" "550e8400e29b41d4a716446655440000" // Missing hyphens ``` ```rust Rust theme={null} use uuid::Uuid; // ✅ Valid UUID formats Uuid::new_v4().to_string() // UUID crate "550e8400-e29b-41d4-a716-446655440000" // Manual UUID // ❌ Invalid formats "123" "not-a-uuid" "550e8400e29b41d4a716446655440000" // Missing hyphens ``` ```java Java theme={null} import java.util.UUID; // ✅ Valid UUID formats UUID.randomUUID().toString() // Standard library "550e8400-e29b-41d4-a716-446655440000" // Manual UUID // ❌ Invalid formats "123" "not-a-uuid" "550e8400e29b41d4a716446655440000" // Missing hyphens ``` # Connection Issues ## SSL/TLS Errors **Symptoms:** * Connection fails with SSL certificate errors * "Unable to verify SSL certificate" messages **Solutions:** ```bash cURL theme={null} # ✅ Certificate validation enabled (default) curl -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: your-api-key" # If you have custom certificates curl --cacert /path/to/certificate.pem \ -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: your-api-key" ``` ```javascript JavaScript theme={null} // ✅ Certificate validation is handled by the browser/Node.js const https = require('https'); const agent = new https.Agent({ rejectUnauthorized: true // Default, but make it explicit }); fetch(url, { agent }); // Node.js only ``` ```python Python theme={null} import requests # ✅ Certificate validation enabled (default) response = requests.get(url, verify=True) # If you have custom certificates response = requests.get(url, verify='/path/to/certificate.pem') ``` ```go Go theme={null} import ( "crypto/tls" "net/http" ) // ✅ Certificate validation enabled (default) client := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: false, // Default }, }, } ``` ```rust Rust theme={null} // ✅ Certificate validation enabled by default let client = reqwest::Client::builder() .danger_accept_invalid_certs(false) // Default .build()?; ``` ```java Java theme={null} import javax.net.ssl.SSLContext; // ✅ Certificate validation enabled by default HttpClient client = HttpClient.newBuilder() .sslContext(SSLContext.getDefault()) .build(); ``` ## Network Timeouts **Solutions:** ```bash cURL theme={null} # Set timeout values curl --max-time 30 \ -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: your-api-key" ``` ```javascript JavaScript theme={null} // Note: fetch() doesn't support timeout directly, use AbortController const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); try { const response = await fetch(url, { headers: { 'x-api-key': apiKey }, signal: controller.signal }); clearTimeout(timeoutId); } catch (error) { clearTimeout(timeoutId); } ``` ```python Python theme={null} import requests response = requests.get( url, headers={'x-api-key': api_key}, timeout=30 # 30 seconds ) ``` ```go Go theme={null} import ( "context" "net/http" "time" ) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("x-api-key", apiKey) client := &http.Client{} resp, err := client.Do(req) ``` ```rust Rust theme={null} use std::time::Duration; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build()?; let response = client .get(url) .header("X-API-Key", api_key) .send() .await?; ``` ```java Java theme={null} import java.time.Duration; HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(30)) .header("X-API-Key", apiKey) .GET() .build(); ``` # Debugging Steps ## 1. Verify API Key Format Check your API key meets the requirements: ```bash cURL theme={null} # Check API key length echo "${#DAKOTA_API_KEY}" # Should output 60 # Basic validation if [ ${#DAKOTA_API_KEY} -ne 60 ]; then echo "Error: API key must be exactly 60 characters" fi ``` ```javascript JavaScript theme={null} function validateApiKey(apiKey) { if (!apiKey) { throw new Error('API key is required'); } if (apiKey.length !== 60) { throw new Error(`API key must be exactly 60 characters, got ${apiKey.length}`); } // Check if it's base64 encoded const base64Regex = /^[A-Za-z0-9+/]+=*$/; if (!base64Regex.test(apiKey)) { throw new Error('API key must be base64 encoded'); } return true; } ``` ```python Python theme={null} import re import base64 def validate_api_key(api_key): if not api_key: raise ValueError('API key is required') if len(api_key) != 60: raise ValueError(f'API key must be exactly 60 characters, got {len(api_key)}') # Check if it's base64 encoded base64_regex = re.compile(r'^[A-Za-z0-9+/]+=*$') if not base64_regex.match(api_key): raise ValueError('API key must be base64 encoded') return True ``` ```go Go theme={null} import ( "encoding/base64" "fmt" "regexp" ) func validateAPIKey(apiKey string) error { if apiKey == "" { return fmt.Errorf("API key is required") } if len(apiKey) != 60 { return fmt.Errorf("API key must be exactly 60 characters, got %d", len(apiKey)) } // Check if it's base64 encoded base64Regex := regexp.MustCompile(`^[A-Za-z0-9+/]+=*$`) if !base64Regex.MatchString(apiKey) { return fmt.Errorf("API key must be base64 encoded") } return nil } ``` ```rust Rust theme={null} use regex::Regex; fn validate_api_key(api_key: &str) -> Result { if api_key.is_empty() { return Err("API key is required".to_string()); } if api_key.len() != 60 { return Err(format!( "API key must be exactly 60 characters, got {}", api_key.len() )); } // Check if it's base64 encoded let base64_regex = Regex::new(r"^[A-Za-z0-9+/]+=*$").unwrap(); if !base64_regex.is_match(api_key) { return Err("API key must be base64 encoded".to_string()); } Ok(true) } ``` ```java Java theme={null} import java.util.regex.Pattern; public class APIKeyValidator { private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]+=*$"); public static boolean validateApiKey(String apiKey) throws IllegalArgumentException { if (apiKey == null || apiKey.isEmpty()) { throw new IllegalArgumentException("API key is required"); } if (apiKey.length() != 60) { throw new IllegalArgumentException( String.format("API key must be exactly 60 characters, got %d", apiKey.length()) ); } // Check if it's base64 encoded if (!BASE64_PATTERN.matcher(apiKey).matches()) { throw new IllegalArgumentException("API key must be base64 encoded"); } return true; } } ``` ## 2. Test with cURL Use cURL to isolate issues from your code: ```bash cURL theme={null} # Test GET request curl -v -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" # Test POST request curl -v -X POST https://api.platform.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" \ -H "x-idempotency-key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"customer_type": "business", "name": "Test Company"}' ``` The `-v` flag provides verbose output showing the full HTTP exchange. ## 3. Check Response Headers Always inspect response headers for debugging information: ```bash cURL theme={null} # Use -v for verbose output including headers curl -v -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" # Use -i to include response headers in output curl -i -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" ``` ```javascript JavaScript theme={null} async function debugRequest(url, options) { const response = await fetch(url, options); console.log('Status:', response.status); console.log('Headers:'); response.headers.forEach((value, key) => { console.log(` ${key}: ${value}`); }); const body = await response.text(); console.log('Body:', body); return response; } ``` ```python Python theme={null} import requests def debug_request(url, **kwargs): response = requests.request(**kwargs, url=url) print(f'Status: {response.status_code}') print('Headers:') for key, value in response.headers.items(): print(f' {key}: {value}') print(f'Body: {response.text}') return response ``` ```go Go theme={null} import ( "fmt" "io" "net/http" "net/http/httputil" ) func debugRequest(req *http.Request) (*http.Response, error) { // Dump the request reqDump, _ := httputil.DumpRequestOut(req, true) fmt.Printf("REQUEST:\n%s\n", reqDump) client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } // Dump the response respDump, _ := httputil.DumpResponse(resp, true) fmt.Printf("RESPONSE:\n%s\n", respDump) return resp, nil } ``` ```rust Rust theme={null} use reqwest::Response; async fn debug_request(response: Response) { println!("Status: {}", response.status()); println!("Headers:"); for (key, value) in response.headers() { println!(" {}: {:?}", key, value); } let body = response.text().await.unwrap_or_default(); println!("Body: {}", body); } ``` ```java Java theme={null} import java.net.http.HttpResponse; public void debugRequest(HttpResponse response) { System.out.printf("Status: %d%n", response.statusCode()); System.out.println("Headers:"); response.headers().map().forEach((key, values) -> { values.forEach(value -> { System.out.printf(" %s: %s%n", key, value); }); }); System.out.printf("Body: %s%n", response.body()); } ``` ## 4. Enable Request Logging Log your requests to identify issues: ```bash cURL theme={null} # Log to file for analysis curl -v -X GET https://api.platform.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" \ 2>&1 | tee request.log # Remove sensitive data from logs sed -i 's/x-api-key: .*/x-api-key: [REDACTED]/g' request.log ``` ```javascript JavaScript theme={null} function logRequest(url, options) { const safeHeaders = { ...options.headers }; delete safeHeaders['x-api-key']; // Don't log sensitive data console.log('Making request:', { url, method: options.method || 'GET', headers: safeHeaders, bodyLength: options.body ? options.body.length : 0 }); } ``` ```python Python theme={null} import logging from urllib.parse import urlparse def log_request(method, url, headers, body=None): safe_headers = headers.copy() if 'x-api-key' in safe_headers: safe_headers['x-api-key'] = '[REDACTED]' parsed_url = urlparse(url) logging.info('Making request: %s', { 'method': method, 'url': f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}", 'headers': safe_headers, 'body_length': len(body) if body else 0 }) ``` ```go Go theme={null} import ( "log" "net/url" ) func logRequest(method, urlStr string, headers map[string]string, bodyLength int) { safeHeaders := make(map[string]string) for k, v := range headers { if k == "x-api-key" { safeHeaders[k] = "[REDACTED]" } else { safeHeaders[k] = v } } parsedURL, _ := url.Parse(urlStr) log.Printf("Making request: method=%s url=%s://%s%s headers=%v bodyLength=%d", method, parsedURL.Scheme, parsedURL.Host, parsedURL.Path, safeHeaders, bodyLength, ) } ``` ```rust Rust theme={null} use std::collections::HashMap; use log::info; use url::Url; fn log_request(method: &str, url: &str, headers: &HashMap, body_length: usize) { let mut safe_headers = headers.clone(); if safe_headers.contains_key("x-api-key") { safe_headers.insert("x-api-key".to_string(), "[REDACTED]".to_string()); } if let Ok(parsed_url) = Url::parse(url) { info!( "Making request: method={} url={}://{}{} headers={:?} body_length={}", method, parsed_url.scheme(), parsed_url.host_str().unwrap_or(""), parsed_url.path(), safe_headers, body_length ); } } ``` ```java Java theme={null} import java.net.URI; import java.util.HashMap; import java.util.Map; import java.util.logging.Logger; public class RequestLogger { private static final Logger logger = Logger.getLogger(RequestLogger.class.getName()); public static void logRequest(String method, String url, Map headers, int bodyLength) { Map safeHeaders = new HashMap<>(headers); if (safeHeaders.containsKey("x-api-key")) { safeHeaders.put("x-api-key", "[REDACTED]"); } try { URI uri = URI.create(url); logger.info(String.format( "Making request: method=%s url=%s://%s%s headers=%s bodyLength=%d", method, uri.getScheme(), uri.getHost(), uri.getPath(), safeHeaders, bodyLength )); } catch (Exception e) { logger.warning("Failed to parse URL for logging: " + url); } } } ``` # Error Response Format All Dakota Platform API errors follow [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457.html) format with `application/problem+json` content type: ```json theme={null} { "type": "https://docs.dakota.xyz/errors/authentication-error", "title": "Unauthorized", "status": 401, "detail": "Missing or invalid API key", "instance": "/customers", "request_id": "req_abc123" } ``` Common error types (the `type` field URI): * `https://docs.dakota.xyz/errors/authentication-error` - Authentication failed (401) * `https://docs.dakota.xyz/errors/forbidden` - Access denied (403) * `https://docs.dakota.xyz/errors/validation-error` - Invalid request format (400) * `https://docs.dakota.xyz/errors/not-found` - Resource doesn't exist (404) * `https://docs.dakota.xyz/errors/rate-limited` - Too many requests (429) * `https://docs.dakota.xyz/errors/internal-error` - Server error (500) Rate limiting (429) also returns RFC 9457 Problem Details: ```json theme={null} { "type": "https://docs.dakota.xyz/errors/rate-limited", "title": "Rate Limited", "status": 429, "detail": "Too many requests. Please retry later.", "instance": "/customers", "request_id": "req_xyz789" } ``` # Getting Help If you're still experiencing issues: 1. **Check the API status page** for any ongoing issues 2. **Review recent changes** to your code or configuration 3. **Test with minimal examples** to isolate the problem 4. **Contact support** with: * Complete error messages * Request/response logs (with API keys removed) * Steps to reproduce the issue * A relevant Request ID from the response headers, if available ## Support Information * Email: [support@dakota.xyz](mailto:support@dakota.xyz) * Include "API Authentication Issue" in the subject line * Provide relevant logs with sensitive information removed * Mention this troubleshooting guide and what you've already tried # Quick Checklist Before contacting support, verify: * [ ] API key is exactly 60 characters * [ ] Using correct header name: `x-api-key` * [ ] Including `x-idempotency-key` for POST requests only * [ ] Using HTTPS base URL: `https://api.platform.dakota.xyz` * [ ] API key is active in your dashboard * [ ] Account has necessary permissions * [ ] Not hitting rate limits * [ ] Request format matches API documentation # Common Flows Source: https://docs.dakota.xyz/documentation/common-flows One section per Dakota resource — customer, counterparty, onramp, offramp, swap, wallet. Dakota's API turns a handful of REST resources into fiat-to-crypto onramps, crypto-to-fiat offramps, cross-chain swaps, and non-custodial wallets. Follow the sections end-to-end to set up a fiat-to-crypto onramp, or skip to the section you need — offramp, swap, or non-custodial wallet. URLs are production (`api.platform.dakota.xyz`); swap to `api.platform.sandbox.dakota.xyz` for sandbox testing. For single-use payouts and international rails, see [Advanced Flows](/documentation/advanced-flows). **On this page:** * [Create a Customer](#create-a-customer) * [Create a Recipient and Destination](#create-a-recipient-and-destination) * [Create an Onramp](#create-an-onramp-usd-→-stablecoin) * [Create an Offramp](#create-an-offramp-stablecoin-→-usd) * [Create a Swap](#create-a-swap-stablecoin-→-stablecoin) * [Create a Wallet](#create-a-wallet-non-custodial) * [Dedicated References](#dedicated-references) ### Required Headers Every Dakota request uses the same three headers: | Header | Required | Description | | ------------------- | ------------------ | -------------------------- | | `X-API-Key` | All requests | Your API key | | `X-Idempotency-Key` | All POST requests | UUID to prevent duplicates | | `Content-Type` | Requests with body | `application/json` | ## Create a Customer A **Customer** is the legal entity Dakota processes payments for — typically a business your app onboards. It's a separate object from your internal user record because regulated money movement requires verified KYB, and Dakota attaches the review to this object once and reuses it forever. Until `kyb_status` is `"active"`, no recipients, destinations, accounts, or transactions can be created for the Customer. Map **one Customer per business entity**, not per user session. ```mermaid theme={null} sequenceDiagram participant App as Your App participant EndUser as End User participant Dakota App->>Dakota: POST /customers Dakota-->>App: customer_id + application_url Note over App: Share application_url with end user EndUser->>Dakota: Completes hosted onboarding form Dakota->>App: Webhook: customer.kyb_status.updated App->>Dakota: GET /customers/{id} Dakota-->>App: kyb_status: "active" ``` ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/customers \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corporation", "customer_type": "business", "external_id": "your_internal_id" }' ``` `external_id` is optional; set it to your internal user ID to simplify reconciliation later. **Response:** ```json theme={null} { "id": "2LfTd6QQrUyPKwRR9qMMyk7CMHS", "kyb_links": [], "application_id": "2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm", "application_url": "https://apply.dakota.com/applications/2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm?token=kJ8xN3zQ9mL2pR5vY7wC1aF4dG6hK0sT8uW3nB5eM9", "application_expires_at": 1734567890000000000 } ``` Share `application_url` with the end user. They complete the hosted KYB form (business details, beneficial ownership, legal agreements). ### Complete KYB * **Production:** Dakota's compliance team reviews the application. Subscribe to the `customer.kyb_status.updated` webhook — the payload carries the new `kyb_status`, so no follow-up GET is needed. See [Webhooks](/documentation/webhooks#customer-events) for payload shape and signature verification. * **Sandbox:** advance KYB manually with the simulation endpoint: ```bash theme={null} curl -X POST https://api.platform.sandbox.dakota.xyz/sandbox/simulate/onboarding \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "type": "kyb_approve", "applicant_id": "'"$APPLICANT_ID"'", "simulation_id": "sim_'$(uuidgen)'" }' ``` In sandbox, a single `GET /customers/{id}` after the simulate call is usually simpler than wiring up a webhook listener. Use polling as a reconciliation fallback in production if you miss a webhook (outage, 5xx on your receiver). Operations on non-approved customers return `customer_not_approved`. *** ## Create a Recipient and Destination Before money can move, you need a **Recipient** (the legal beneficiary of the funds — separate from the paying Customer because regulators report on beneficiaries) and a **Destination** (the concrete endpoint where funds land — a crypto address + network, or a bank account). One Customer can own many Recipients; one Recipient can own many Destinations across different networks and currencies. ```mermaid theme={null} sequenceDiagram participant App as Your App participant Dakota App->>Dakota: POST /customers/{id}/recipients Dakota-->>App: ... App->>Dakota: POST /recipients/{id}/destinations (crypto or bank) Dakota-->>App: ... ``` ### Create a Recipient ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/customers/$CUSTOMER_ID/recipients \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Treasury Operations", "recipient_type": "business", "address": { "street1": "123 Main St", "city": "San Francisco", "region": "CA", "postal_code": "94102", "country": "US" } }' ``` **Response:** ```json theme={null} { "id": "2LfXk8NNpSwMHuRP8oKKwi5AKFQ" } ``` Reuse the same Recipient for all Destinations belonging to the same counterparty. ### Add a Crypto Destination (for onramps and swaps) ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/recipients/$RECIPIENT_ID/destinations \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "destination_type": "crypto", "name": "USDC on Ethereum", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" }' ``` **Supported `network_id` values.** USDC and USDT are available on every mainnet. | Mainnet | Sandbox testnet | | ------------------ | ------------------ | | `ethereum-mainnet` | `ethereum-sepolia` | | `polygon-mainnet` | `polygon-amoy` | | `arbitrum-mainnet` | `arbitrum-sepolia` | | `base-mainnet` | `base-sepolia` | | `optimism-mainnet` | `optimism-sepolia` | | `solana-mainnet` | `solana-devnet` | **RD is available only on Base** — use `base-mainnet` (production) or `base-sepolia` (sandbox). A Destination or Account that pairs RD with any other network is rejected when creating the Account (`no provider available for the requested capabilities`). ### Add a Bank Destination (for offramps) ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/recipients/$RECIPIENT_ID/destinations \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "destination_type": "fiat_us", "name": "Business Checking", "aba_routing_number": "021000021", "account_number": "987654321", "account_type": "checking", "account_holder_name": "Acme Corporation", "bank_name": "Chase Bank", "bank_address": { "street1": "383 Madison Ave", "city": "New York", "region": "NY", "postal_code": "10179", "country": "US" } }' ``` For international rails (IBAN / SEPA), use `destination_type: "fiat_iban"` — see [Destinations & Recipients](/documentation/destinations-recipients). *** ## Create an Onramp (USD → Stablecoin) An **onramp account** takes a crypto Destination and returns real ACH or Fedwire bank details. Your end user wires USD to those details; Dakota converts the USD to the stablecoin you configured and delivers it to the Destination automatically. The onramp account is where setup ends and money movement begins. ```mermaid theme={null} sequenceDiagram participant App as Your App participant EndUser as End User participant Bank participant Dakota participant Blockchain App->>Dakota: POST /accounts (onramp) Dakota-->>App: ... + bank_account App->>EndUser: Share bank details EndUser->>Bank: Wire USD Bank->>Dakota: USD received Dakota->>App: Webhook: transaction.auto.created Dakota->>Blockchain: Deliver stablecoin to destination Dakota->>App: Webhook: transaction.auto.updated (completed) ``` ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/accounts \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "account_type": "onramp", "crypto_destination_id": "2LfYm5KMnRvLFtRP7nJJug4zJEP", "source_asset": "USD", "destination_asset": "USDC", "destination_network_id": "ethereum-mainnet", "capabilities": ["ach"], "rail": "ach" }' ``` **Supported `rail` values** (also used by offramps): | Rail | Currencies | Speed | | --------- | ---------- | ----------------- | | `ach` | USD | 1–3 business days | | `fedwire` | USD | Same day | **Response:** ```json theme={null} { "id": "2LfZn6LNoSvMGuSQ0pLLxj6BLGR", "account_type": "onramp", "bank_account": { "aba_routing_number": "021000021", "account_number": "123456789012", "account_type": "checking", "capabilities": ["ach"] } } ``` Share `bank_account` with your end user. Each inbound wire triggers an automatic conversion and on-chain delivery. Subscribe to `auto_account.created`, `transaction.auto.created`, and `transaction.auto.updated`. If the Destination is a Dakota Wallet, `wallet.deposit` also fires on arrival. See [transaction webhooks](/documentation/webhooks#transaction-events). ### Simulate the USD Deposit (Sandbox) You can't actually wire USD into a sandbox account, so you trigger the onramp lifecycle by calling `POST /sandbox/simulate/inbound`. The provider then fires the same webhook sequence (`transaction.auto.created` → `transaction.auto.updated` → `wallet.deposit` if the Destination is a Dakota Wallet) you'd see in production. ```bash theme={null} curl -X POST https://api.platform.sandbox.dakota.xyz/sandbox/simulate/inbound \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "simulation_id": "sim_onramp_001", "type": "ach_inbound", "account_id": "2LfZn6LNoSvMGuSQ0pLLxj6BLGR", "amount": "1.00", "currency": "USD", "scenario": "success_immediate" }' ``` **Response** (synchronous — the deposit event itself processes async): ```json theme={null} { "simulation_id": "sim_onramp_001", "state": "accepted", "trace_id": "trace_abc123" } ``` * `type` — `ach_inbound` or `wire_inbound` for USD rails. * `scenario` — `success_immediate` (default) or `success_delayed` with a `delay_seconds` field (1–86400). * `simulation_id` — your idempotency key for the simulation. Repeating with the same ID + params returns the original response; conflicting params return 409. Sandbox object-create endpoints reject mainnet network IDs and cap per-request `amount` at \$2 USD. USDT is not yet supported in sandbox (coming soon); USD, USDC, and RD work today. See [Testing — Sandbox restrictions](/documentation/testing#sandbox-restrictions). *** ## Create an Offramp (Stablecoin → USD) An **offramp account** takes a bank Destination and returns a crypto deposit address. You send stablecoins there; Dakota converts them to USD and wires to the bank account. ```mermaid theme={null} sequenceDiagram participant App as Your App participant Blockchain participant Dakota participant Bank App->>Dakota: POST /accounts (offramp) Dakota-->>App: ... + source_crypto_address App->>Blockchain: Send stablecoin to source_crypto_address Blockchain->>Dakota: Stablecoin received Dakota->>App: Webhook: transaction.auto.created Dakota->>Bank: Wire USD to destination Dakota->>App: Webhook: transaction.auto.updated (completed) ``` ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/accounts \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "account_type": "offramp", "fiat_destination_id": "2LfYm5KMnRvLFtRP7nJJug4zBAN", "source_asset": "USDC", "source_network_id": "ethereum-mainnet", "destination_asset": "USD", "rail": "ach" }' ``` **Response:** ```json theme={null} { "id": "2LfZn6LNoSvMGuSQ0pLL9Nrw7qZ", "account_type": "offramp", "source_crypto_address": "0xabc...", "source_network_id": "ethereum-mainnet", "source_asset": "USDC" } ``` Each stablecoin transfer to `source_crypto_address` triggers a USD wire to the bank Destination. Same `transaction.auto.*` webhooks as onramp. Sandbox object-create endpoints reject mainnet network IDs and cap per-request `amount` at \$2 USD. USDT is not yet supported in sandbox (coming soon); USD, USDC, and RD work today. See [Testing — Sandbox restrictions](/documentation/testing#sandbox-restrictions). *** ## Create a Swap (Stablecoin → Stablecoin) A **swap account** takes a crypto Destination on the *target* asset and network and returns a `source_crypto_address` on the *source* asset and network. Dakota converts and delivers cross-chain. Because `crypto_destination_id` can point at any Recipient's Destination — including a third party's — a swap is also how you pay a counterparty in their preferred stablecoin while holding a different one. ```mermaid theme={null} sequenceDiagram participant App as Your App participant Source as Source chain participant Dakota participant Target as Target chain App->>Dakota: POST /accounts (swap) Dakota-->>App: ... + source_crypto_address App->>Source: Send source stablecoin to source_crypto_address Source->>Dakota: Source stablecoin received Dakota->>App: Webhook: transaction.auto.created Dakota->>Target: Deliver target stablecoin to destination Dakota->>App: Webhook: transaction.auto.updated (completed) ``` ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/accounts \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "account_type": "swap", "crypto_destination_id": "2LfYm5KMnRvLFtRP7nJJug4zJEP", "source_asset": "USDC", "source_network_id": "ethereum-mainnet", "destination_asset": "USDT", "destination_network_id": "polygon-mainnet" }' ``` **Response:** ```json theme={null} { "id": "2LfZn6LNoSvMGuSQ0pLLsWp42pQ", "account_type": "swap", "source_crypto_address": "0xabc...", "source_network_id": "ethereum-mainnet", "source_asset": "USDC", "destination_asset": "USDT", "destination_network_id": "polygon-mainnet" } ``` Send USDC on Ethereum to `source_crypto_address`; receive USDT on Polygon at the Destination. Same `transaction.auto.*` webhooks. Sandbox object-create endpoints reject mainnet network IDs and cap per-request `amount` at \$2 USD. USDT is not yet supported in sandbox (coming soon); USD, USDC, and RD work today. See [Testing — Sandbox restrictions](/documentation/testing#sandbox-restrictions). *** ## Create a Wallet (Non-Custodial) Non-custodial wallets are a different resource shape: no Recipient or Account chain. A wallet owns an on-chain address and authorizes movements via client-signed intents instead of Dakota-triggered conversions — Dakota never holds the private keys. Architecture: [Wallets](/documentation/wallets). Signing reference (intent schema, RFC 8785 canonicalization, DER encoding, the browser P1363→DER trap): [Wallet Transaction Signing](/documentation/wallet-signing). ```mermaid theme={null} sequenceDiagram participant App as Your App participant Dakota participant Custody as Custody / KMS App->>App: Generate ES256 keypair App->>Dakota: POST /signers (per key) App->>Dakota: POST /signer-groups App->>Dakota: POST /policies (optional) App->>Dakota: POST /wallets Dakota->>Custody: Create wallet Custody-->>Dakota: Wallet address Dakota-->>App: wallet_id + address App->>Dakota: POST /wallets/{id}/transactions (signed intent) ``` ### Register Signers Generate an ECDSA P-256 (ES256) keypair client-side — the private half never leaves the client. Register each public key as a Signer: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/signers \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Alice", "public_key": "MFkw...", "key_type": "ES256" }' ``` **Response:** ```json theme={null} { "id": "2LfNs7VVqTwNJxRP4mFFth3yGDM", "client_id": "1NFHrqBHb3cTfLVkFSGmHZqdDPi", "name": "Alice", "public_key": "MFkw...", "key_type": "ES256" } ``` `public_key` is a base64-encoded X.509 SubjectPublicKeyInfo (raw DER or PEM). ### Create a Signer Group ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/signer-groups \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Treasury Team", "member_keys": [ "MFkw...", "MFkw..." ] }' ``` **Response:** ```json theme={null} { "id": "2LfPqT9VmQzKDvQP9rGHth3yHCN", "client_id": "1NFHrqBHb3cTfLVkFSGmHZqdDPi", "name": "Treasury Team", "members": [ { "id": "2LfNs7VVqTwNJxRP4mFFth3yGDM", "client_id": "1NFHrqBHb3cTfLVkFSGmHZqdDPi", "name": "Alice", "public_key": "MFkw...", "key_type": "ES256" }, { "id": "2LfNt8WWrUxOKyRP5nGGui4zHEN", "client_id": "1NFHrqBHb3cTfLVkFSGmHZqdDPi", "name": "Bob", "public_key": "MFkw...", "key_type": "ES256" } ] } ``` `member_keys` is an array of public-key strings, not objects. The group is the authorization unit — wallets and policies attach to groups, so you can add or remove members without redeploying. ### Create a Policy A **policy** governs which transactions a wallet can authorize. Every wallet must have at least one policy attached before it can sign outbound transactions — Dakota's policy engine default-denies any transaction on a wallet with zero attached policies. Inbound deposits work without a policy: an on-chain address can always receive crypto. A policy is a `signer_group_id` (the group authorized to mutate the policy itself) plus one or more **rules**: | Rule type | Controls | | -------------------- | ---------------------------------------------------------------- | | `approval_threshold` | How many signatures from the policy's signer group are required. | | `amount_threshold` | Per-transaction value limit, in a chosen currency. | | `address_list` | Allow- or deny-listed destination addresses. | Multiple rules can layer in a single policy, and multiple policies can attach to a single wallet — the policy engine evaluates them all and applies **deny-wins-then-allow** logic. Full reference: [Policies](/documentation/policies). The minimal permissive policy — any single member of the signer group can authorize, no other restrictions — is one rule: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/policies \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Default single-signature policy", "description": "Allows any transaction with one signature from the policy'\''s signer group", "signer_group_id": "'"$SIGNER_GROUP_ID"'", "rules": [ { "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 1 } } ] }' ``` **Response:** ```json theme={null} { "id": "2LfQm5KMnRvLFtRP7nJJug4zJEP", "client_id": "1NFHrqBHb3cTfLVkFSGmHZqdDPi", "signer_group_id": "2LfPqT9VmQzKDvQP9rGHth3yHCN", "version": 1, "name": "Default single-signature policy", "description": "Allows any transaction with one signature from the policy's signer group", "rules": [ { "id": "2LfRn6LOoSwMGuSQ8oKKvi5AKFQ", "policy_id": "2LfQm5KMnRvLFtRP7nJJug4zJEP", "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 1 }, "created_at": 1640995200 } ], "created_at": 1640995200, "updated_at": 1640995200 } ``` Save the returned `id` — you'll pass it as the `policy_id` when creating the wallet in the next step. Policy creation itself is a regular API call; subsequent mutations (adding rules, deleting, attaching to a wallet) go through the [endorsed-request flow](/documentation/wallet-signing#modifying-policies-wallets-and-signer-groups). ### Create the Wallet Pass the `policy_id` from the previous step in the `policies` array. The field is **required** — omitting it returns `400 validation_error`. ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/wallets \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Treasury Wallet", "family": "evm", "signer_groups": ["'"$SIGNER_GROUP_ID"'"], "policies": ["'"$POLICY_ID"'"] }' ``` **Response:** ```json theme={null} { "id": "2LfZm5KMnRvLFtRP7nJJug4zJEP", "client_id": "1NFHrqBHb3cTfLVkFSGmHZqdDPi", "name": "Treasury Wallet", "family": "evm", "address": "0x165cd37b4c644c2921454429e7f9358d18a45e14" } ``` **Deposit-only wallets.** It is valid to create a wallet with `"policies": []`. The wallet still gets a real on-chain address and can **receive** crypto deposits — those are not policy-gated, anyone can send to an address. But the wallet cannot **send** transactions until at least one policy is attached: the policy engine returns `403 Forbidden` with `"transaction denied: No policies found for wallet"` on every `POST /wallets/{id}/transactions` call. Use this if you want to provision the address first (e.g. share it for inbound funding) and decide governance later — see [Attach a Policy to an Existing Wallet](#attach-a-policy-to-an-existing-wallet) below. ### Attach a Policy to an Existing Wallet You can attach additional policies to a wallet at any time — to ratchet up controls (amount caps, sanctions blocklists, higher approval thresholds), or to bring a deposit-only wallet online by attaching its first policy. The endpoint is `PUT /policies/{policy_id}/wallets/{wallet_id}`, and the `attach_policy_to_wallet` intent must be signed by a member of a signer group already attached to the **wallet** (not the policy's own signer group). ```bash theme={null} curl -X PUT https://api.platform.dakota.xyz/policies/$POLICY_ID/wallets/$WALLET_ID \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signatures": ["MEUCIQDXA8sfHIe...base64-encoded-DER..."], "intent": { "type": "attach_policy_to_wallet", "policy_id": "'"$POLICY_ID"'", "wallet_id": "'"$WALLET_ID"'", "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0" } }' ``` **Response:** `204 No Content` on success — the empty body is the success signal. The wallet is now bound to the policy; subsequent `GET /wallets/{id}/policies` will include it. The `signatures` array holds base64-encoded ASN.1 DER ECDSA signatures over the canonicalized intent. The signing pipeline (RFC 8785 JCS → SHA-256 → ECDSA P-256 → DER → base64) is the same one used for wallet transactions; the full reference, code samples in Node / Python / Go / browser, and the browser P1363→DER helper live on [Wallet Transaction Signing](/documentation/wallet-signing#modifying-policies-wallets-and-signer-groups). After attachment, the wallet can submit transactions immediately. You can repeat this call with additional `policy_id`s to layer on stricter rules — Dakota's deny-wins evaluation means adding policies can only tighten controls, never relax them. ### Sign and Send a Transaction Dakota has three transaction families — **auto** (triggered by funds arriving at an Account's deposit address, no API call to send), **one-off** (`POST /transactions/one-off`, for single-use payouts and swaps — see [Advanced Flows](/documentation/advanced-flows)), and **wallet** (`POST /wallets/{id}/transactions`, covered here). Only wallet transactions require a Dakota Wallet — auto and one-off transactions accept any on-chain address as source or destination. A **wallet transaction** is a canonical JSON intent plus one or more ECDSA P-256 signatures. You build the intent (what the wallet should do), canonicalize it per RFC 8785 JCS, hash with SHA-256, sign in ASN.1 DER, base64-encode the signature, and POST both intent and signatures together. The server re-canonicalizes and verifies; the policy engine checks approval thresholds; then the transaction executes on-chain. ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/wallets/$WALLET_ID/transactions \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signatures": ["MEQCIEtPHo4edFaeOAWql3CHzcEJTX0MlUxjnqdlQwv+FYbr..."], "intent": { "wallet_id": "2LfZm5KMnRvLFtRP7nJJug4zJEP", "caip2": "eip155:1", "operation": { "kind": "transfer", "from": "0xYourWalletAddress...", "to": "0xDestinationAddress...", "amount": "10.5", "asset_id": "USDC" }, "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0" } }' ``` **Response:** ```json theme={null} { "id": "2LfWtXnLkRvMFuPR8mLLjgHZ4QE", "resource_type": "wallet", "wallet_id": "2LfZm5KMnRvLFtRP7nJJug4zJEP", "network_id": "ethereum-mainnet", "from": "0x165cd37b4c644c2921454429e7f9358d18a45e14", "to": "0xDestinationAddress...", "transaction_type": "transfer", "amount": "10.5", "asset": "USDC", "status": "pending" } ``` Subscribe to `wallet.transaction.created` and `wallet.transaction.updated` to follow status through `pending → in_progress → completed | failed`. Full walkthrough — intent schema, canonicalization code in Node / Python / Go / browser, and the browser P1363→DER conversion helper — lives on [Wallet Transaction Signing](/documentation/wallet-signing). *** ## Dedicated References * [Wallets](/documentation/wallets) — architecture, signer groups, policies * [Wallet Transaction Signing](/documentation/wallet-signing) — intent schemas, canonicalization, signing code in Node / Python / Go / browser * [Webhooks](/documentation/webhooks) — event types, delivery, signature verification * [API Reference](/api-reference/introduction) — full endpoint reference and OpenAPI spec # Create Your Flow Source: https://docs.dakota.xyz/documentation/create-your-flow Use the Dakota Funds Flow Builder to visualize and design your payment flows. This interactive tool helps you understand how funds move through the Dakota platform and generates the corresponding API reference for your implementation.
Dakota Funds Flow Builder
Click to Launch Interactive Tool
## How to Use 1. **Click the image above** to launch the interactive Funds Flow Builder 2. **Design your flow** - Use the visual builder to create your payment flow 3. **Configure steps** - Set up each step in your funds flow with the appropriate parameters 4. **Review API calls** - See the corresponding API reference for your flow 5. **Share your flow** - Click the "Share" button to copy a link to your flow. You can share this link with our support or sales team to discuss your specific use case or ask questions 6. **Implement** - Use the generated API calls in your integration ## Need Help? If you have questions about your specific flow or need assistance with your integration, click the **Share** button in the Funds Flow Builder to copy a link to your configuration. Share this link with: * Our [support team](https://dakota.xyz/talk-to-sales) for technical assistance * Our [sales team](https://dakota.xyz/talk-to-sales) to discuss your use case and requirements This allows our team to see exactly what you're building and provide more accurate guidance. For detailed API documentation, visit the [API Reference](/api-reference/introduction) section. # Customer Onboarding Source: https://docs.dakota.xyz/documentation/customer-onboarding Before processing payments, customers must complete KYB (Know Your Business) onboarding. This guide explains how to integrate Dakota Platform's onboarding flow. **Looking for detailed onboarding guides?** Check out our support articles for step-by-step walkthroughs: * [Onboarding your business account customers](https://support-platform.dakota.xyz/articles/2551929093-onboarding-your-business-account-cust) * [Onboarding your individual account customers](https://support-platform.dakota.xyz/articles/4406934078-onboarding-your-individual-account-customers) * [Platform Client Onboarding - Information & Documentation](https://support-platform.dakota.xyz/articles/2518037375-platform-client-onboarding) # Overview The onboarding process involves: 1. Creating a customer record (KYB automatically initiated) 2. Redirecting customer to complete verification 3. Monitoring KYB status via API or webhooks # Step 1: Create a Customer First, create a customer record with basic business information. ## Customer Creation Fields | Field | Type | Required | Description | Example | | --------------- | ------ | -------- | ---------------------------------------------------------------- | ------------- | | `name` | string | ✅ | Legal name of the business or individual | `"Acme Corp"` | | `customer_type` | string | ✅ | Type of customer account. Must be `"business"` or `"individual"` | `"business"` | | `external_id` | string | ❌ | Your internal identifier for this customer | `"acme_001"` | ## Request Example ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/customers \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "customer_type": "business" }' ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Acme Corp', customer_type: 'business' }) }); const customer = await response.json(); console.log('Created customer:', customer.data.id); ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/customers', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'name': 'Acme Corp', 'customer_type': 'business' } ) customer = response.json() print(f'Created customer: {customer["data"]["id"]}') ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "name": "Acme Corp", "customer_type": "business" }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/customers", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "name": "Acme Corp", "customer_type": "business" }); let response = client .post("https://api.platform.dakota.xyz/customers") .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaCustomerExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "name": "Acme Corp", "customer_type": "business" } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` Response: ```json theme={null} { "id": "2Nq8G7xK9mR4Ls6DyJ1Uf3Tp", "kyb_links": [], "application_id": "2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm", "application_url": "https://apply.dakota.com/applications/2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm?token=kJ8xN3zQ9mL2pR5vY7wC1aF4dG6hK0sT8uW3nB5eM9", "application_expires_at": 1734567890000000000 } ``` # Step 2: Application Process (Automatic) An onboarding application is automatically created when you create a customer. The response includes an `application_url` that you can use to redirect your customer to Dakota's hosted onboarding form. ## Automatic Application Creation When you create a customer, the system automatically: * Creates an onboarding application * Generates a secure `application_url` with an embedded token * Sets the initial application status to `"pending"` ## Response Fields The customer creation response includes: | Field | Description | Example | | ------------------------ | --------------------------------------------------------------- | ------------------------------------------------------- | | `id` | Unique identifier for the customer | `"2Nq8G7xK9mR4Ls6DyJ1Uf3Tp"` | | `kyb_links` | Array of KYB provider links (if any) | `[]` | | `application_id` | Unique identifier for the onboarding application | `"2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm"` | | `application_url` | Dakota-hosted onboarding URL with embedded auth token | `"https://apply.dakota.com/applications/...?token=..."` | | `application_expires_at` | Unix timestamp (nanoseconds) when the application token expires | `1734567890000000000` | ## Using the Application URL From the customer creation response, extract the onboarding URL: ```javascript theme={null} const customer = await response.json(); const onboardingUrl = customer.application_url; // Redirect customer to complete onboarding window.location.href = onboardingUrl; ``` # Step 3: Handle the Onboarding Flow Direct your customer to the `application_url` to complete verification using Dakota's hosted onboarding form. The customer will: 1. Provide business information and documentation 2. Add beneficial owners and control persons 3. Upload required documents (ID, proof of address, etc.) 4. Accept legal agreements (Terms of Service, Privacy Policy, etc.) 5. Submit the application for review 6. Receive approval or requests for additional information ## Proof of Address for Individual Customers For **individual** customer onboarding, Proof of Address (PoA) is **not strictly required to complete onboarding**. An individual can submit identity documents alone and reach `application_status: submitted` without uploading PoA. However, PoA is required for a customer to transact more than **\$3,000 in any rolling 7-day window**. Customers who cross that threshold without an approved PoA on file are frozen — inbound transactions over the limit are held in compliance review until they provide one. Encourage customers to upload PoA at onboarding when possible; the field is presented with a note explaining the threshold rather than as a low-priority optional step. ### PoA State The application-level `poa_status` field on `GET /applications/{id}` exposes the current review state for individual customers: | `poa_status` | Meaning | | -------------------------- | --------------------------------------------------------------------------------- | | `missing` | No PoA on file. Inbound transactions over the rolling-7-day threshold are paused. | | `submitted_pending_review` | PoA uploaded; awaiting compliance review. | | `approved` | PoA approved. Customer is exempt from the threshold gate. | | `rejected` | Compliance rejected the PoA. The customer must upload a new document. | | `null` | Field not applicable (business application). | ### Three Ways to Submit PoA After Onboarding If a customer didn't upload PoA at onboarding but later needs to: 1. **Client API call.** Use the standard document-upload endpoint: ``` POST /applications/{applicationId}/associated-individuals/{individualId}/documents ``` Document type can be `proof_of_address`, `bank_statement`, or `utility_bill` — all three are accepted as PoA-equivalent. 2. **Customer self-serve via the original onboarding link.** The link returned in `application_url` remains valid for 90 days after creation. When a previously-approved individual reopens it, the form locks every already-approved field and exposes only the PoA upload, so the customer can submit a PoA without re-attesting or re-entering personal data. 3. **Compliance manual upload.** A reviewer with the `compliance_documents:manage` permission can upload PoA on the customer's behalf from the compliance dashboard. Any of these paths transitions the application from `completed` to `compliance_review` (or to `submitted_pending_review` at the PoA level if the app hasn't yet been decided) and emits a `customer.kyb_status.updated` webhook with `reason_code: pending_proof_of_address`. Once compliance reviews, the customer receives a follow-up event with `reason_code: proof_of_address_approved` or `proof_of_address_rejected`. See [State lifecycles → Proof of Address State](/api-reference/state-lifecycles#proof-of-address-state-individuals) for the full set of transitions, and [Webhooks → Customer Events](/documentation/webhooks#customer-events) for the corresponding event payloads. ## Legal Agreements & Attestations **Compliance requirement:** Before an application can be submitted, your customer must accept Dakota's legal agreements. If you are building a custom onboarding flow using the API (rather than the hosted form), you are responsible for presenting these documents to your end users and collecting their consent before submitting attestations. During onboarding, the following legal agreements must be presented and accepted: | Attestation Type | Document | Description | | -------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------- | | `e_sign` | [E-Sign & Electronic Communications Notice](https://www.dakota.xyz/e-sign) | Consent to use electronic signatures — **must be accepted first** | | `terms_of_service` | [Terms of Service](https://www.dakota.xyz/terms) | Dakota's terms of service | | `privacy_policy` | [Privacy Policy](https://www.dakota.xyz/privacy) | Dakota's privacy policy | | `funds_transfer_agreement` | [Funds Transfer Agreement](https://www.dakota.xyz/funds-transfer) | Agreement governing fund transfers | | `lead_bank_privacy_policy` | [Banking partner privacy policy](https://www.lead.bank/privacy-and-terms) | Banking partner's privacy policy | | `information_accuracy` | — | Attestation that all provided information is accurate | ### Submitting Attestations via API If you are using the API to build a custom onboarding experience, you must submit attestations using the [Submit Attestation](/api-reference/onboarding/submit-an-attestation-for-an-application) endpoint. The `e_sign` attestation **must be submitted first** — all other attestations will be rejected until e-sign consent is recorded. ```bash cURL theme={null} # Step 1: Submit e_sign first (capture timestamp) E_SIGN_TIMESTAMP=$(date +%s) curl -s -X POST "https://api.platform.dakota.xyz/applications/${APPLICATION_ID}/attestations" \ -H "X-Application-Token: ${APPLICATION_TOKEN}" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d "{ \"attestation_type\": \"e_sign\", \"timestamp\": ${E_SIGN_TIMESTAMP}, \"applicant_id\": \"${APPLICANT_ID}\" }" # Step 2: Wait to ensure a different timestamp, then submit remaining attestations sleep 1 OTHER_TIMESTAMP=$(date +%s) for TYPE in information_accuracy terms_of_service privacy_policy funds_transfer_agreement lead_bank_privacy_policy; do curl -s -X POST "https://api.platform.dakota.xyz/applications/${APPLICATION_ID}/attestations" \ -H "X-Application-Token: ${APPLICATION_TOKEN}" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d "{ \"attestation_type\": \"${TYPE}\", \"timestamp\": ${OTHER_TIMESTAMP}, \"applicant_id\": \"${APPLICANT_ID}\" }" done ``` ```javascript JavaScript theme={null} // Step 1: Submit e_sign first const eSignTimestamp = Math.floor(Date.now() / 1000); await fetch(`https://api.platform.dakota.xyz/applications/${applicationId}/attestations`, { method: 'POST', headers: { 'X-Application-Token': applicationToken, 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ attestation_type: 'e_sign', timestamp: eSignTimestamp, applicant_id: applicantId }) }); // Step 2: Submit remaining attestations with a later timestamp const otherTimestamp = eSignTimestamp + 1; const otherTypes = [ 'information_accuracy', 'terms_of_service', 'privacy_policy', 'funds_transfer_agreement', 'lead_bank_privacy_policy' ]; for (const type of otherTypes) { await fetch(`https://api.platform.dakota.xyz/applications/${applicationId}/attestations`, { method: 'POST', headers: { 'X-Application-Token': applicationToken, 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ attestation_type: type, timestamp: otherTimestamp, applicant_id: applicantId }) }); } ``` ```python Python theme={null} import requests import uuid import time # Step 1: Submit e_sign first e_sign_timestamp = int(time.time()) requests.post( f'https://api.platform.dakota.xyz/applications/{application_id}/attestations', headers={ 'X-Application-Token': application_token, 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json', }, json={ 'attestation_type': 'e_sign', 'timestamp': e_sign_timestamp, 'applicant_id': applicant_id, }, ) # Step 2: Submit remaining attestations with a later timestamp other_timestamp = e_sign_timestamp + 1 other_types = [ 'information_accuracy', 'terms_of_service', 'privacy_policy', 'funds_transfer_agreement', 'lead_bank_privacy_policy', ] for attestation_type in other_types: requests.post( f'https://api.platform.dakota.xyz/applications/{application_id}/attestations', headers={ 'X-Application-Token': application_token, 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json', }, json={ 'attestation_type': attestation_type, 'timestamp': other_timestamp, 'applicant_id': applicant_id, }, ) ``` * The `timestamp` is the Unix epoch time (in seconds) when the user accepted the agreement. * The `applicant_id` is the individual making the attestation. For business applications, this must be a control person. For individual applications, this must be the individual themselves. * Each attestation's timestamp must be **strictly after** the `e_sign` attestation timestamp — equal timestamps will be rejected. * All 6 attestation types are **required** before an application can be submitted. ### Checking Missing Attestations You can check which attestations are still needed by retrieving the application with the `validation` include parameter: ```bash theme={null} curl "https://api.platform.dakota.xyz/applications/{application_id}?include=attestations,validation" \ -H "X-Application-Token: {application_token}" ``` The response `validation.attestations` field shows completed and missing attestations: ```json theme={null} { "attestations": { "completed": [ { "type": "e_sign", "attested_at": "2024-01-17T15:30:00Z", "attested_by": "John Doe" } ], "missing": [ "terms_of_service", "privacy_policy" ] } } ``` **Using the hosted onboarding form?** If you redirect customers to the `application_url`, Dakota's hosted form handles presenting all legal agreements and collecting attestations automatically. You only need to manage attestations manually if you are building a custom onboarding UI. # Step 4: Monitor Application Status Check onboarding status programmatically: ```bash cURL theme={null} curl -X GET https://api.platform.dakota.xyz/customers/{customer_id} \ -H "X-API-Key: your-api-key" ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/customers/${customerId}`, { headers: { 'X-API-Key': 'your-api-key' } }); const customer = await response.json(); console.log('KYB Status:', customer.kyb_status); ``` ```python Python theme={null} import requests response = requests.get( f'https://api.platform.dakota.xyz/customers/{customer_id}', headers={'X-API-Key': 'your-api-key'} ) customer = response.json() print(f'KYB Status: {customer["kyb_status"]}') ``` ```go Go theme={null} package main import ( "net/http" ) func main() { client := &http.Client{} req, _ := http.NewRequest("GET", "https://api.platform.dakota.xyz/customers/" + customerId, nil) req.Header.Add("X-API-Key", "your-api-key") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); let response = client .get(&format!("https://api.platform.dakota.xyz/customers/{}", customer_id)) .headers(headers) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; public class DakotaKYBStatusExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers/" + customerId)) .header("X-API-Key", "your-api-key") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` Response: ```json theme={null} { "id": "2Nq8G7xK9mR4Ls6DyJ1Uf3Tp", "name": "Acme Corp", "customer_type": "business", "kyb_status": "active", "kyb_links": [], "application_id": "2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm", "created_at": 1705320600, "updated_at": 1705324200 } ``` # KYB Status Values The `kyb_status` field indicates the customer's verification status: | Status | Description | | ---------------- | ---------------------------------- | | `pending` | KYB verification in progress | | `active` | Customer approved and can transact | | `partner_review` | Under manual review | | `rejected` | Verification failed | | `frozen` | Account suspended | You can also check the application status directly via the [Get Application](/api-reference/onboarding/get-an-onboarding-application) endpoint for more detailed status information. # Best Practices ## User Experience * Clearly communicate the onboarding requirements * Provide progress indicators * Set expectations for processing time * Offer support contact information ## Error Handling * Handle rejection gracefully * Provide clear next steps * Allow customers to restart if needed * Log all onboarding events ## Compliance * Present all required legal agreements (ToS, Privacy Policy, etc.) to end users before collecting attestations * Record the exact timestamp when each agreement was accepted * Store audit trails * Monitor for suspicious activity * Keep records for compliance reporting * Regular review of rejected applications # Required Documents Typical documents required for business verification: * **Certificate of Incorporation** * **Bank Statements** * **Director/Officer Identification** * **Beneficial Ownership Information** # Troubleshooting ## Common Issues **Onboarding Stuck in "Pending"** * Check if customer has accessed the onboarding URL **Repeated Rejections** * Ensure document quality meets requirements * Consider manual review process **Webhook Not Received** * Verify webhook endpoint is responding with 200 * Check webhook signature validation * Review webhook logs in dashboard Any issues not covered here can be addressed by contacting Dakota Platform support. # Next Steps After successful customer onboarding: 1. **[Set up Recipients & Destinations](destinations-recipients)** - Configure payment targets 2. **[Create Transactions](/documentation/common-flows)** - Process payments for approved customers 3. **[Webhook Integration](webhooks)** - Get real-time KYB status updates (see the [full event list](/documentation/webhooks#event-types-reference) for all available events) 4. **[Testing](testing)** - Test your onboarding flow # API Reference For detailed endpoint documentation, see: * [Customers API Reference](/api-reference/customers/create-a-customer-record) - Customer creation and management * [Webhooks API Reference](/api-reference/webhooks/create-a-webhook-target) - Real-time onboarding notifications # Destinations & Recipients Source: https://docs.dakota.xyz/documentation/destinations-recipients Before processing transactions, you need to set up recipients and their associated destinations. Recipients represent entities that can receive payments, while destinations specify the exact accounts (crypto addresses, bank accounts) where funds should be sent. # Overview The Dakota Platform uses a two-level structure for payment routing: * **Recipients**: Business entities or individuals that can receive payments * **Destinations**: Specific accounts (crypto addresses, bank accounts) associated with recipients This structure allows you to manage multiple payment destinations for the same recipient while maintaining clear records for compliance and reporting. # Conceptual Model: The Payment Chain Understanding the complete payment chain is crucial for successful integration: ``` Customer → Recipient → Destination ↓ ↓ ↓ Who pays Who receives Where money goes ``` ## The Three-Level Hierarchy 1. **Customer** (Your User) * Your application's user who wants to send money * Must complete KYB verification * Can have multiple recipients * Example: "Acme Corp" (your business customer) 2. **Recipient** (Payment Target Entity) * Legal entity that will receive the payment * Has compliance information (name, address) * Can have multiple destinations for different asset types * Example: "Supplier XYZ Ltd" (the business being paid) 3. **Destination** (Specific Account) * The actual account where money is sent * Can be crypto address, bank account, etc. * Linked to exactly one recipient * Example: "0x742d..." (USDC wallet address) ## Why This Structure? **Compliance & Reporting** * Recipients provide legal entity information for compliance * Clear audit trail for who is being paid * Supports anti-money laundering requirements **Flexibility** * One recipient can have multiple destinations (different currencies/networks) * Easy to add new payment methods to existing relationships * Supports complex business relationships **Organization** * Group related payment accounts by business relationship * Cleaner user interfaces for managing multiple accounts * Better record-keeping for accounting purposes ## Real-World Example Let's say your customer "Acme Corp" wants to pay their supplier "Global Manufacturing Ltd": ``` Customer: Acme Corp └── Recipient: Global Manufacturing Ltd ├── Destination 1: Bank Account (USD payments) │ ├── Type: fiat_us │ ├── Account: ****6789 │ └── Routing: 021000021 ├── Destination 2: Ethereum Wallet (USDC payments) │ ├── Type: crypto │ ├── Address: 0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2 │ └── Network: ethereum-mainnet └── Destination 3: Solana Wallet (SOL/USDC payments) ├── Type: crypto ├── Address: 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU └── Network: solana ``` When processing a transaction, you specify: * **Customer ID**: Who is paying (31Tgw0zSyDVo4Az66kmzUjMuwxx) * **Destination ID**: Where money should go (31TgvweTHSjWNxfARrnhjmBKSem) The system automatically knows the recipient context through the destination relationship. # Complete Workflow Guide Follow this step-by-step process to set up the complete payment chain: ## Step 1: Start with an Approved Customer First, verify that your customer exists and has been KYB-approved: ```bash cURL theme={null} curl -X GET https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx \ -H "X-API-Key: your-api-key" ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx', { headers: { 'X-API-Key': 'your-api-key' } }); const customer = await response.json(); console.log('Customer KYB Status:', customer.data.kyb_status); ``` ```python Python theme={null} import requests response = requests.get( 'https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx', headers={'X-API-Key': 'your-api-key'} ) customer = response.json() print(f'Customer KYB Status: {customer["data"]["kyb_status"]}') ``` ```go Go theme={null} package main import ( "net/http" ) func main() { client := &http.Client{} req, _ := http.NewRequest("GET", "https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx", nil) req.Header.Add("X-API-Key", "your-api-key") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); let response = client .get("https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx") .headers(headers) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; public class DakotaCustomerStatus { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx")) .header("X-API-Key", "your-api-key") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` Expected response: ```json theme={null} { "id": "cust_acme123", "name": "Acme Corp", "customer_type": "business", "kyb_status": "active", "kyb_links": [], "application_id": "2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm", "created_at": 1705320600, "updated_at": 1705324200 } ``` ## Step 2: Create a Recipient ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx/recipients \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Global Manufacturing Ltd", "address": { "street1": "456 Industrial Blvd", "city": "Detroit", "region": "MI", "postal_code": "48201", "country": "US" } }' ``` ```javascript JavaScript theme={null} const recipient = await fetch('https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx/recipients', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Global Manufacturing Ltd', address: { street1: '456 Industrial Blvd', city: 'Detroit', region: 'MI', postal_code: '48201', country: 'US' } }) }); // Result: 31TgvwFzi3rstV0DEDzQtuBfwFR ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx/recipients', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'name': 'Global Manufacturing Ltd', 'address': { 'street1': '456 Industrial Blvd', 'city': 'Detroit', 'region': 'MI', 'postal_code': '48201', 'country': 'US' } } ) # Result: recp_global_mfg_789 ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "name": "Global Manufacturing Ltd", "address": { "street1": "456 Industrial Blvd", "city": "Detroit", "region": "MI", "postal_code": "48201", "country": "US" } }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx/recipients", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() // Result: 31TgvwFzi3rstV0DEDzQtuBfwFR } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "name": "Global Manufacturing Ltd", "address": { "street1": "456 Industrial Blvd", "city": "Detroit", "region": "MI", "postal_code": "48201", "country": "US" } }); let response = client .post("https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx/recipients") .headers(headers) .json(&body) .send() .await?; // Result: 31TgvwFzi3rstV0DEDzQtuBfwFR Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaRecipientCreator { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "name": "Global Manufacturing Ltd", "address": { "street1": "456 Industrial Blvd", "city": "Detroit", "region": "MI", "postal_code": "48201", "country": "US" } } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers/31Tgw0zSyDVo4Az66kmzUjMuwxx/recipients")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); // Result: 31TgvwFzi3rstV0DEDzQtuBfwFR } } ``` ## Step 3: Add Payment Destinations ### Crypto Destination ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "destination_type": "crypto", "name": "USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" }' ``` ```javascript JavaScript theme={null} const cryptoDestination = await fetch('https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_type: 'crypto', name: 'USDC Wallet', crypto_address: '0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2', network_id: 'ethereum-mainnet' }) }); // Result: 31TgvySz1ARnqMZUdbuxykqqxGV ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'destination_type': 'crypto', 'name': 'USDC Wallet', 'crypto_address': '0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2', 'network_id': 'ethereum-mainnet' } ) # Result: dest_crypto_usdc_456 ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "destination_type": "crypto", "name": "USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() // Result: 31TgvySz1ARnqMZUdbuxykqqxGV } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "destination_type": "crypto", "name": "USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" }); let response = client .post("https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations") .headers(headers) .json(&body) .send() .await?; // Result: 31TgvySz1ARnqMZUdbuxykqqxGV Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaCryptoDestination { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "destination_type": "crypto", "name": "USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); // Result: 31TgvySz1ARnqMZUdbuxykqqxGV } } ``` ### Bank Destination ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "021000021", "account_number": "987654321", "account_type": "checking" }' ``` ```javascript JavaScript theme={null} const bankDestination = await fetch('https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_type: 'fiat_us', name: 'Primary Business Account', aba_routing_number: '021000021', account_number: '987654321', account_type: 'checking' }) }); // Result: 31TgvtxUdXi95dUN4M8X1rhSCNS ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'destination_type': 'fiat_us', 'name': 'Primary Business Account', 'aba_routing_number': '021000021', 'account_number': '987654321', 'account_type': 'checking' } ) # Result: dest_bank_usd_789 ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "021000021", "account_number": "987654321", "account_type": "checking" }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() // Result: 31TgvtxUdXi95dUN4M8X1rhSCNS } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "021000021", "account_number": "987654321", "account_type": "checking" }); let response = client .post("https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations") .headers(headers) .json(&body) .send() .await?; // Result: 31TgvtxUdXi95dUN4M8X1rhSCNS Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaBankDestination { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "021000021", "account_number": "987654321", "account_type": "checking" } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/recipients/recp_global_mfg_789/destinations")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); // Result: 31TgvtxUdXi95dUN4M8X1rhSCNS } } ``` ## Step 4: Create Transaction Accounts ### Crypto Transaction ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/transactions \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "31Tgw0zSyDVo4Az66kmzUjMuwxx", "destination_id": "31TgvySz1ARnqMZUdbuxykqqxGV", "source_asset": "USD", "source_network_id": "fiat", "destination_asset": "USDC", "destination_network_id": "ethereum-mainnet", "amount": "1000.00" }' ``` ```javascript JavaScript theme={null} const cryptoTransaction = await fetch('https://api.platform.dakota.xyz/transactions', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: '31Tgw0zSyDVo4Az66kmzUjMuwxx', destination_id: 'dest_crypto_usdc_456', source_asset: 'USD', source_network_id: 'fiat', destination_asset: 'USDC', destination_network_id: 'ethereum-mainnet', amount: '1000.00' }) }); ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/transactions', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'customer_id': '31Tgw0zSyDVo4Az66kmzUjMuwxx', 'destination_id': 'dest_crypto_usdc_456', 'source_asset': 'USD', 'source_network_id': 'fiat', 'destination_asset': 'USDC', 'destination_network_id': 'ethereum-mainnet', 'amount': '1000.00' } ) ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "customer_id": "31Tgw0zSyDVo4Az66kmzUjMuwxx", "destination_id": "31TgvySz1ARnqMZUdbuxykqqxGV", "source_asset": "USD", "source_network_id": "fiat", "destination_asset": "USDC", "destination_network_id": "ethereum-mainnet", "amount": "1000.00" }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/transactions", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "customer_id": "31Tgw0zSyDVo4Az66kmzUjMuwxx", "destination_id": "31TgvySz1ARnqMZUdbuxykqqxGV", "source_asset": "USD", "source_network_id": "fiat", "destination_asset": "USDC", "destination_network_id": "ethereum-mainnet", "amount": "1000.00" }); let response = client .post("https://api.platform.dakota.xyz/transactions") .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaCryptoTransaction { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "customer_id": "31Tgw0zSyDVo4Az66kmzUjMuwxx", "destination_id": "31TgvySz1ARnqMZUdbuxykqqxGV", "source_asset": "USD", "source_network_id": "fiat", "destination_asset": "USDC", "destination_network_id": "ethereum-mainnet", "amount": "1000.00" } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/transactions")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ### Bank Transaction ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/transactions \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "31Tgw0zSyDVo4Az66kmzUjMuwxx", "destination_id": "31TgvtxUdXi95dUN4M8X1rhSCNS", "source_asset": "USDC", "source_network_id": "ethereum-mainnet", "destination_asset": "USD", "destination_network_id": "fiat", "amount": "500.00" }' ``` ```javascript JavaScript theme={null} const bankTransaction = await fetch('https://api.platform.dakota.xyz/transactions', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: '31Tgw0zSyDVo4Az66kmzUjMuwxx', destination_id: 'dest_bank_usd_789', source_asset: 'USDC', source_network_id: 'ethereum-mainnet', destination_asset: 'USD', destination_network_id: 'fiat', amount: '500.00' }) }); ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/transactions', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'customer_id': '31Tgw0zSyDVo4Az66kmzUjMuwxx', 'destination_id': 'dest_bank_usd_789', 'source_asset': 'USDC', 'source_network_id': 'ethereum-mainnet', 'destination_asset': 'USD', 'destination_network_id': 'fiat', 'amount': '500.00' } ) ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "customer_id": "31Tgw0zSyDVo4Az66kmzUjMuwxx", "destination_id": "31TgvtxUdXi95dUN4M8X1rhSCNS", "source_asset": "USDC", "source_network_id": "ethereum-mainnet", "destination_asset": "USD", "destination_network_id": "fiat", "amount": "500.00" }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/transactions", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "customer_id": "31Tgw0zSyDVo4Az66kmzUjMuwxx", "destination_id": "31TgvtxUdXi95dUN4M8X1rhSCNS", "source_asset": "USDC", "source_network_id": "ethereum-mainnet", "destination_asset": "USD", "destination_network_id": "fiat", "amount": "500.00" }); let response = client .post("https://api.platform.dakota.xyz/transactions") .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaBankTransaction { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "customer_id": "31Tgw0zSyDVo4Az66kmzUjMuwxx", "destination_id": "31TgvtxUdXi95dUN4M8X1rhSCNS", "source_asset": "USDC", "source_network_id": "ethereum-mainnet", "destination_asset": "USD", "destination_network_id": "fiat", "amount": "500.00" } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/transactions")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` # Best Practices for Payment Chain Management ## Recipient Organization * Use clear, descriptive recipient names matching legal business names * Keep recipient addresses current for compliance * Group destinations by recipient to maintain clear relationships ## Destination Naming * Use descriptive destination names: "Primary USDC Wallet", "Backup Bank Account" * Include asset type in names for clarity: "EUR Business Account", "USDC Wallet" * Maintain consistent naming conventions across your application ## Relationship Management * One recipient per business entity (don't create duplicates) * Multiple destinations per recipient for different payment methods * Delete old destinations for security ## Error Prevention * Validate crypto addresses before creating destinations * Test small transactions to new destinations first * Implement approval workflows for new high-value destinations * Monitor destination usage patterns for anomalies This hierarchical structure provides the foundation for secure, compliant, and organized payment processing through Dakota Platform's API. # Recipients Recipients represent the entities that will receive payments. Each recipient must have a name and physical address for compliance purposes. ## Creating Recipients Create a recipient for a customer. ### Recipient Fields | Field | Type | Required | Description | Example | | ------------- | ------ | -------- | ------------------------------------------- | ------------------------ | | `name` | string | ✅ | Legal name of the recipient entity | `"Acme Corporation"` | | `address` | object | ✅ | Physical address for compliance | See address fields below | | `external_id` | string | ❌ | Your internal identifier for this recipient | `"acme_recipient_001"` | ### Address Fields | Field | Type | Required | Description | Example | | ------------- | ------ | -------- | ------------------------------- | -------------------- | | `street1` | string | ✅ | Street address line 1 | `"123 Business Ave"` | | `street2` | string | ❌ | Street address line 2 | `"Suite 456"` | | `street3` | string | ❌ | Street address line 3 | `"Floor 2"` | | `city` | string | ✅ | City name | `"New York"` | | `region` | string | ✅ | State/province/region code | `"NY"` | | `postal_code` | string | ❌ | ZIP/postal code | `"10001"` | | `country` | string | ✅ | ISO 3166-1 alpha-2 country code | `"US"` | ### Request Example ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/customers/{customer_id}/recipients \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corporation", "address": { "street1": "123 Business Ave", "city": "New York", "region": "NY", "postal_code": "10001", "country": "US" } }' ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/customers/${customerId}/recipients`, { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Acme Corporation', address: { street1: '123 Business Ave', city: 'New York', region: 'NY', postal_code: '10001', country: 'US' } }) }); const recipient = await response.json(); console.log('Created recipient:', recipient.data.id); ``` ```python Python theme={null} import requests import uuid response = requests.post( f'https://api.platform.dakota.xyz/customers/{customer_id}/recipients', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'name': 'Acme Corporation', 'address': { 'street1': '123 Business Ave', 'city': 'New York', 'region': 'NY', 'postal_code': '10001', 'country': 'US' } } ) recipient = response.json() print(f'Created recipient: {recipient["data"]["id"]}') ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "name": "Acme Corporation", "address": { "street1": "123 Business Ave", "city": "New York", "region": "NY", "postal_code": "10001", "country": "US" } }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/customers/" + customerId + "/recipients", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "name": "Acme Corporation", "address": { "street1": "123 Business Ave", "city": "New York", "region": "NY", "postal_code": "10001", "country": "US" } }); let response = client .post(&format!("https://api.platform.dakota.xyz/customers/{}/recipients", customer_id)) .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaRecipientsExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "name": "Acme Corporation", "address": { "street1": "123 Business Ave", "city": "New York", "region": "NY", "postal_code": "10001", "country": "US" } } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers/" + customerId + "/recipients")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` Response: ```json theme={null} { "data": { "id": "31TgvwFzi3rstV0DEDzQtuBfwFR", "name": "Acme Corporation", "address": { "street1": "123 Business Ave", "city": "New York", "region": "NY", "postal_code": "10001", "country": "US" }, "created_at": "2024-01-15T10:30:00Z" } } ``` ## Listing Recipients Get all recipients for a customer: ```bash cURL theme={null} curl -X GET https://api.platform.dakota.xyz/customers/{customer_id}/recipients \ -H "X-API-Key: your-api-key" ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/customers/${customerId}/recipients`, { headers: { 'X-API-Key': 'your-api-key' } }); const recipients = await response.json(); ``` ```python Python theme={null} import requests response = requests.get( f'https://api.platform.dakota.xyz/customers/{customer_id}/recipients', headers={'X-API-Key': 'your-api-key'} ) recipients = response.json() ``` ```go Go theme={null} package main import ( "net/http" ) func main() { client := &http.Client{} req, _ := http.NewRequest("GET", "https://api.platform.dakota.xyz/customers/" + customerId + "/recipients", nil) req.Header.Add("X-API-Key", "your-api-key") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); let response = client .get(&format!("https://api.platform.dakota.xyz/customers/{}/recipients", customer_id)) .headers(headers) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; public class DakotaListRecipientsExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/customers/" + customerId + "/recipients")) .header("X-API-Key", "your-api-key") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` Response: ```json theme={null} { "data": [ { "id": "31TgvwFzi3rstV0DEDzQtuBfwFR", "name": "Global Manufacturing Ltd", "address": { "street1": "456 Industrial Blvd", "city": "Detroit", "region": "MI", "postal_code": "48201", "country": "US" }, "external_id": "supplier_001", "created_at": "2024-01-10T14:20:00Z" }, { "id": "31TgvwGHm8PqtV2DEDzQtuBfwFS", "name": "Tech Solutions Inc", "address": { "street1": "789 Silicon Valley Dr", "street2": "Suite 200", "city": "San Francisco", "region": "CA", "postal_code": "94105", "country": "US" }, "external_id": null, "created_at": "2024-01-12T09:15:00Z" }, { "id": "31TgvwJKn9QrtV3DEDzQtuBfwFT", "name": "European Consulting GmbH", "address": { "street1": "Hauptstraße 123", "city": "Berlin", "region": "BE", "postal_code": "10117", "country": "DE" }, "external_id": "vendor_eu_007", "created_at": "2024-01-14T16:45:00Z" } ] } ``` # Destinations Destinations are the specific accounts where funds will be sent. Each destination is associated with a recipient and specifies either a cryptocurrency address or traditional bank account details. ## Destination Types Dakota Platform supports three destination types: * **Crypto**: Cryptocurrency addresses on supported networks * **Fiat US**: US bank accounts (ACH transfers) * **Fiat IBAN**: International bank accounts ## Creating Crypto Destinations Add a cryptocurrency destination to a recipient. ### Crypto Destination Fields | Field | Type | Required | Description | Example | | ------------------ | ------ | -------- | ------------------------------------------------------ | ----------------------- | | `destination_type` | string | ✅ | Must be `"crypto"` for cryptocurrency destinations | `"crypto"` | | `name` | string | ✅ | Descriptive name for the destination | `"Primary USDC Wallet"` | | `crypto_address` | string | ✅ | Valid cryptocurrency address for the specified network | `"0x742d35Cc..."` | | `network_id` | string | ✅ | Network identifier. See supported networks below | `"ethereum-mainnet"` | | `assets` | array | ❌ | Specific assets this destination accepts (optional) | `["USDC", "USDT"]` | ### Supported Networks | Network ID | Name | Supported Assets | | ------------------ | -------- | ---------------- | | `ethereum-mainnet` | Ethereum | USDC, USDT | | `polygon-mainnet` | Polygon | USDC | | `arbitrum-mainnet` | Arbitrum | USDC | | `base-mainnet` | Base | USDC | | `optimism-mainnet` | Optimism | USDC | | `solana-mainnet` | Solana | USDC | In sandbox, replace each mainnet ID with its testnet equivalent (`ethereum-sepolia`, `polygon-amoy`, `arbitrum-sepolia`, `base-sepolia`, `solana-devnet`); mainnet IDs are rejected. See [Testing — Sandbox restrictions](/documentation/testing#sandbox-restrictions). ### Request Example ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/recipients/{recipient_id}/destinations \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "destination_type": "crypto", "name": "Primary USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" }' ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/recipients/${recipientId}/destinations`, { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_type: 'crypto', name: 'Primary USDC Wallet', crypto_address: '0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2', network_id: 'ethereum-mainnet' }) }); ``` ```python Python theme={null} import requests import uuid response = requests.post( f'https://api.platform.dakota.xyz/recipients/{recipient_id}/destinations', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'destination_type': 'crypto', 'name': 'Primary USDC Wallet', 'crypto_address': '0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2', 'network_id': 'ethereum-mainnet' } ) ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "destination_type": "crypto", "name": "Primary USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/recipients/" + recipientId + "/destinations", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "destination_type": "crypto", "name": "Primary USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" }); let response = client .post(&format!("https://api.platform.dakota.xyz/recipients/{}/destinations", recipient_id)) .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaCryptoDestinationExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "destination_type": "crypto", "name": "Primary USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet" } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/recipients/" + recipientId + "/destinations")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ### Response Example ```json theme={null} { "data": { "id": "31TgvySz1ARnqMZUdbuxykqqxGV", "destination_type": "crypto", "name": "Primary USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet", "assets": ["USDC", "USDT"], "status": "active", "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:00Z" } } ``` > **API Reference**: For complete details on crypto destination creation, see [Create Crypto Destination](/api-reference/recipients/add-a-destination-for-a-recipient) ## Creating US Bank Account Destinations Add a US bank account destination. ### US Bank Account Destination Fields | Field | Type | Required | Description | Example | | -------------------- | ------ | -------- | ---------------------------------------------------- | ---------------------------- | | `destination_type` | string | ✅ | Must be `"fiat_us"` for US bank accounts | `"fiat_us"` | | `name` | string | ✅ | Descriptive name for the destination | `"Primary Business Account"` | | `aba_routing_number` | string | ✅ | 9-digit ABA routing number | `"123456789"` | | `account_number` | string | ✅ | Bank account number | `"9876543210"` | | `account_type` | string | ✅ | Type of account. Must be `"checking"` or `"savings"` | `"checking"` | | `assets` | array | ❌ | Supported fiat currencies (optional) | `["USD"]` | ### Request Example ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/recipients/{recipient_id}/destinations \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "123456789", "account_number": "9876543210", "account_type": "checking", "assets": ["USD"] }' ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/recipients/${recipientId}/destinations`, { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_type: 'fiat_us', name: 'Primary Business Account', aba_routing_number: '123456789', account_number: '9876543210', account_type: 'checking', assets: ['USD'] }) }); ``` ```python Python theme={null} import requests import uuid response = requests.post( f'https://api.platform.dakota.xyz/recipients/{recipient_id}/destinations', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'destination_type': 'fiat_us', 'name': 'Primary Business Account', 'aba_routing_number': '123456789', 'account_number': '9876543210', 'account_type': 'checking', 'assets': ['USD'] } ) ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "123456789", "account_number": "9876543210", "account_type": "checking", "assets": ["USD"] }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/recipients/" + recipientId + "/destinations", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "123456789", "account_number": "9876543210", "account_type": "checking", "assets": ["USD"] }); let response = client .post(&format!("https://api.platform.dakota.xyz/recipients/{}/destinations", recipient_id)) .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaBankDestinationExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "123456789", "account_number": "9876543210", "account_type": "checking", "assets": ["USD"] } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/recipients/" + recipientId + "/destinations")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ### Response Example ```json theme={null} { "data": { "id": "31TgvtxUdXi95dUN4M8X1rhSCNS", "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "*****6789", "account_number": "*******3210", "account_type": "checking", "assets": ["USD"], "status": "active", "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:00Z" } } ``` > **API Reference**: For complete details on US bank account destination creation, see [Create US Bank Destination](/api-reference/recipients/add-a-destination-for-a-recipient) ## Creating IBAN Destinations Add an IBAN destination for international transfers. ### IBAN Destination Fields | Field | Type | Required | Description | Example | | ------------------ | ------ | -------- | ---------------------------------------------- | ----------------------------- | | `destination_type` | string | ✅ | Must be `"fiat_iban"` for IBAN accounts | `"fiat_iban"` | | `name` | string | ✅ | Descriptive name for the destination | `"European Business Account"` | | `iban` | string | ✅ | Valid IBAN (International Bank Account Number) | `"DE89370400440532013000"` | | `bic` | string | ✅ | Bank Identifier Code | `"COBADEFFXXX"` | | `assets` | array | ❌ | Supported fiat currencies | `["EUR", "USD"]` | ### Request Example ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/recipients/{recipient_id}/destinations \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "destination_type": "fiat_iban", "name": "European Business Account", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX", "assets": ["EUR", "USD"] }' ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/recipients/${recipientId}/destinations`, { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ destination_type: 'fiat_iban', name: 'European Business Account', iban: 'DE89370400440532013000', bic: 'COBADEFFXXX', assets: ['EUR', 'USD'] }) }); ``` ```python Python theme={null} import requests import uuid response = requests.post( f'https://api.platform.dakota.xyz/recipients/{recipient_id}/destinations', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'destination_type': 'fiat_iban', 'name': 'European Business Account', 'iban': 'DE89370400440532013000', 'bic': 'COBADEFFXXX', 'assets': ['EUR', 'USD'] } ) ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "destination_type": "fiat_iban", "name": "European Business Account", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX", "assets": ["EUR", "USD"] }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/recipients/" + recipientId + "/destinations", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "destination_type": "fiat_iban", "name": "European Business Account", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX", "assets": ["EUR", "USD"] }); let response = client .post(&format!("https://api.platform.dakota.xyz/recipients/{}/destinations", recipient_id)) .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaIBANDestinationExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "destination_type": "fiat_iban", "name": "European Business Account", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX", "assets": ["EUR", "USD"] } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/recipients/" + recipientId + "/destinations")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ### Response Example ```json theme={null} { "data": { "id": "31TgvuBMp7RstU4XEDzQtuBfwGU", "destination_type": "fiat_iban", "name": "European Business Account", "iban": "DE89****0440****3000", "bic": "COBADEFFXXX", "assets": ["EUR", "USD"], "status": "active", "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:00Z" } } ``` > **API Reference**: For complete details on IBAN destination creation, see [Create IBAN Destination](/api-reference/recipients/add-a-destination-for-a-recipient) ## Listing Destinations Get all destinations for a recipient: ```bash cURL theme={null} curl -X GET https://api.platform.dakota.xyz/recipients/{recipient_id}/destinations \ -H "X-API-Key: your-api-key" ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/recipients/${recipientId}/destinations`, { headers: { 'X-API-Key': 'your-api-key' } }); const destinations = await response.json(); ``` ```python Python theme={null} import requests response = requests.get( f'https://api.platform.dakota.xyz/recipients/{recipient_id}/destinations', headers={'X-API-Key': 'your-api-key'} ) destinations = response.json() ``` ```go Go theme={null} package main import ( "net/http" ) func main() { client := &http.Client{} req, _ := http.NewRequest("GET", "https://api.platform.dakota.xyz/recipients/" + recipientId + "/destinations", nil) req.Header.Add("X-API-Key", "your-api-key") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); let response = client .get(&format!("https://api.platform.dakota.xyz/recipients/{}/destinations", recipient_id)) .headers(headers) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; public class DakotaListDestinationsExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/recipients/" + recipientId + "/destinations")) .header("X-API-Key", "your-api-key") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ### Response Example ```json theme={null} { "data": [ { "id": "31TgvySz1ARnqMZUdbuxykqqxGV", "destination_type": "crypto", "name": "Primary USDC Wallet", "crypto_address": "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "network_id": "ethereum-mainnet", "assets": ["USDC", "USDT"], "status": "active", "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-15T10:30:00Z" }, { "id": "31TgvtxUdXi95dUN4M8X1rhSCNS", "destination_type": "fiat_us", "name": "Primary Business Account", "aba_routing_number": "*****6789", "account_number": "*******3210", "account_type": "checking", "assets": ["USD"], "status": "active", "created_at": "2024-01-15T11:15:00Z", "updated_at": "2024-01-15T11:15:00Z" }, { "id": "31TgvuBMp7RstU4XEDzQtuBfwGU", "destination_type": "fiat_iban", "name": "European Business Account", "iban": "DE89****0440****3000", "bic": "COBADEFFXXX", "assets": ["EUR", "USD"], "status": "active", "created_at": "2024-01-15T12:00:00Z", "updated_at": "2024-01-15T12:00:00Z" } ] } ``` # Best Practices ## Recipient Management * Use descriptive names for recipients * Keep address information current for compliance * Organize recipients logically (e.g., by business relationship) * Regular audits of recipient data ## Destination Security * Validate cryptocurrency addresses before saving * Use test transactions for new crypto destinations * Implement approval workflows for new destinations * Monitor destination usage for suspicious activity ## Compliance Considerations * Maintain accurate recipient addresses for reporting * Document the business relationship with each recipient * Regular compliance reviews of recipient and destination data * Implement proper access controls for destination management # Next Steps Once you have recipients and destinations set up: 1. **[Create Transactions](/documentation/common-flows)** - Process payments to your configured destinations 2. **[Webhook Integration](webhooks)** - Get notified about payment status changes (see the [full event list](/documentation/webhooks#event-types-reference) for all available events) 3. **[Testing](testing)** - Test your integration with sandbox data For detailed API reference information, see the [Recipients API Reference](/api-reference/recipients/create-a-new-recipient) and [Destinations API Reference](/api-reference/recipients/add-a-destination-for-a-recipient) sections. # MCP Server Source: https://docs.dakota.xyz/documentation/mcp-server Connect AI clients like Claude and ChatGPT to Dakota Platform data via the Model Context Protocol. The Dakota Platform MCP server exposes platform data (customers, accounts, transactions) to AI clients like Claude Desktop, Claude Code, and ChatGPT via the [Model Context Protocol](https://modelcontextprotocol.io/docs). The server is **read-only** today. Future versions will support orchestration commands (creating transactions, managing accounts). ## Using ChatGPT? Install the Dakota app ChatGPT ChatGPT One-click install, sign in with Google, and start asking ChatGPT about your customers, accounts, and transactions. No configuration required. For everything else (Claude Desktop, Claude Code, Cursor, custom MCP clients), keep reading. ## Hosted endpoint | Environment | URL | | ----------- | ------------------------------------- | | Production | `https://mcp.platform.dakota.xyz/mcp` | HTTPS-only. You need to authenticate — pick one of the two methods below. ## Authentication The server supports two methods. Most users want **OAuth (Google login)** — it's a one-click sign-in with no tokens to copy around. Use the API-key fallback only if OAuth doesn't work for your client. ### Option 1 — Google OAuth (recommended) Add the server with no extra configuration: ```bash theme={null} claude mcp add --transport http dakota-platform https://mcp.platform.dakota.xyz/mcp ``` In a fresh Claude Code session, run `/mcp` → pick `dakota-platform` → **Authenticate**. A browser tab opens, you sign in with Google, and you're done. Verify with `claude mcp list`. Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): ```json theme={null} { "mcpServers": { "dakota-platform": { "url": "https://mcp.platform.dakota.xyz/mcp" } } } ``` Restart Claude Desktop. The first time you call a Dakota tool, it'll open a browser for you to sign in with Google. Point your client at `https://mcp.platform.dakota.xyz/mcp` with no static auth header. The server speaks **Streamable HTTP** (HTTP + SSE) on a single endpoint: * `POST /mcp` — client messages * `GET /mcp` — SSE stream * Required header: `Accept: application/json, text/event-stream` * Session via `Mcp-Session-Id` header The client's first request will get a 401 with `WWW-Authenticate: Bearer realm="mcp"`, which kicks off the OAuth flow per the MCP spec. Sign in with **any Google account** that's been invited to your Dakota organization. The session lasts a week; you'll only re-sign in if you're idle for that long or revoke access from the dashboard. ### Option 2 — API key (fallback) Use this if OAuth doesn't work in your client (some older or custom integrations), or if you need a long-lived, headless credential (e.g. for a server-side agent). **1. Create a key.** Go to [https://platform.dakota.xyz](https://platform.dakota.xyz) → **API keys** → create a new one. Copy it the moment it's shown — the dashboard won't display it again. The key carries the same permissions as your user account. Store it in a password manager, never in plain text or git. Revoke it from the dashboard if it leaks. **2. Register the server with the key as a static `Authorization` header.** The `apikey:` prefix tells the MCP server to forward the key to the Platform API as `X-API-Key`: ```bash theme={null} claude mcp add --transport http dakota-platform \ https://mcp.platform.dakota.xyz/mcp \ --header "Authorization: Bearer apikey:YOUR_PLATFORM_API_KEY" ``` ```json theme={null} { "mcpServers": { "dakota-platform": { "url": "https://mcp.platform.dakota.xyz/mcp", "headers": { "Authorization": "Bearer apikey:YOUR_PLATFORM_API_KEY" } } } } ``` Send `Authorization: Bearer apikey:YOUR_PLATFORM_API_KEY` on every request to the MCP endpoint. No browser flow is involved. ### Smoke-test it Ask your agent: *"List the first 3 customers from Dakota."* If you see real data, you're done. If you see "Authentication failed" or "Access denied", double-check that you signed in with the right account (OAuth) or that the header includes the `apikey:` prefix (API key). ## Available tools ### Customers | Tool | Description | | ---------------- | --------------------------------------------------------------------------- | | `list_customers` | List customers with filtering by name, type, KYB status, and creation date | | `get_customer` | Get detailed customer information including KYB links and provider statuses | ### Orchestration accounts | Tool | Description | | ----------------------- | --------------------------------------------- | | `list_onramp_accounts` | List onramp accounts (bank transfer → crypto) | | `get_onramp_account` | Get onramp account details | | `list_offramp_accounts` | List offramp accounts (crypto → fiat) | | `get_offramp_account` | Get offramp account details | | `list_swap_accounts` | List swap accounts (crypto → crypto) | | `get_swap_account` | Get swap account details | ### Transactions | Tool | Description | | --------------------------- | -------------------------------------------------------------------------- | | `list_auto_transactions` | List auto transactions with filtering by account, status, type, date range | | `get_auto_transaction` | Get auto transaction details including receipt and blockchain info | | `list_one_off_transactions` | List one-off offramp transactions (single-use crypto → fiat) | | `get_one_off_transaction` | Get one-off transaction details | ## Example prompts Once connected, you can ask your AI agent things like: * *"List the 10 most recent customers that completed KYB this month."* * *"Show me all failed auto transactions for customer `cust_xxx` in the past 7 days."* * *"What's the total volume across all onramp accounts for `cust_xxx`?"* * *"Get details for offramp account `acct_xxx` and summarize its recent activity."* The agent picks the right tool, calls the MCP server, and synthesizes a response from the structured data. ## Security notes * All connections are HTTPS-only. The server validates `Origin` headers to prevent DNS-rebinding attacks. * The server is **read-only** — no mutations are possible today. * Treat API keys as credentials — revoke from the dashboard if leaked. * Never paste session tokens, API keys, or customer PII into prompts. ## Reference * **MCP protocol**: [modelcontextprotocol.io/docs](https://modelcontextprotocol.io/docs) # Policies Source: https://docs.dakota.xyz/documentation/policies Wallet governance — what policies are, how rules work, and how policy evaluation decides whether a transaction is authorized. A **policy** governs which transactions a wallet can authorize. Every wallet must have at least one policy attached before it can sign outbound transactions — Dakota's policy engine **default-denies** any transaction on a wallet with zero attached policies (`"transaction denied: No policies found for wallet"`, HTTP 403). Inbound deposits are independent of policies: an on-chain address can always receive crypto regardless of the wallet's governance. Policies are versioned, mutable resources. You attach them to wallets, and a wallet can have multiple policies layered on top of one another. The policy engine evaluates all attached policies for every transaction and applies the result. ## Anatomy of a Policy A policy is three things: 1. **A signer group that governs the policy itself.** Future mutations of the policy — adding rules, deleting it, updating limits — must be signed by members of this group via the [endorsed-request flow](/documentation/wallet-signing#modifying-policies-wallets-and-signer-groups). It can be the same group attached to the wallet, or a separate admin group for stricter compartmentalization. 2. **One or more rules.** Each rule has a `rule_type`, an `action` (`allow` or `deny`), and a `definition` whose shape depends on the rule type. 3. **Metadata.** A human-readable `name` and optional `description`. ```json theme={null} { "id": "pol_2LfQm5KMnRvLFtRP7nJJug4zJEP", "client_id": "1NFHrqBHb3cTfLVkFSGmHZqdDPi", "signer_group_id": "grp_2LfPqT9VmQzKDvQP9rGHth3yHCN", "version": 1, "name": "Treasury default policy", "description": "Single-signature for transfers under $10k; two signatures otherwise", "rules": [ { "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 1 } }, { "rule_type": "amount_threshold", "action": "deny", "definition": { "min_amount": 1000000, "threshold": 0, "asset": { "id": "USD", "name": "US Dollar" } } } ], "created_at": 1640995200, "updated_at": 1640995200 } ``` ## Rule Types There are three rule types. Each evaluates a different aspect of the transaction. ### `approval_threshold` Requires a minimum number of valid signatures from the policy's signer group. ```json theme={null} { "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 2, "description": "Requires 2-of-N approval" } } ``` * `threshold` — minimum number of signers required. * The rule **always applies** to every transaction (it's a count check, not a conditional). * If the threshold is met → emit the rule's `action` (allow or deny). If not met → emit the inverted action. The minimum permissive policy — *"any single member can authorize anything"* — is `approval_threshold` with `threshold: 1` and `action: allow`. ### `amount_threshold` Sets transaction-value limits. Useful for tiered approval (small transfers single-sig, large transfers multi-sig) or hard caps. ```json theme={null} { "rule_type": "amount_threshold", "action": "deny", "definition": { "min_amount": 1000000, "threshold": 0, "asset": { "id": "USD", "name": "US Dollar" } } } ``` * `min_amount` — integer in the smallest currency unit (USD → cents). The example above is \$10,000.00. * `threshold` — number of approvals required to override. `0` means hard deny; higher values mean N approvers can let it through. * `asset` — object with `id` (asset symbol, e.g. `USD`) and `name` (display name). * The rule applies only when the transaction's amount in `asset` is at or above `min_amount`. Below the threshold, the rule does not apply (does not contribute allow or deny). ### `address_list` Restricts which on-chain destinations a wallet can send to. Allow-list (only these) or deny-list (block these). ```json theme={null} { "rule_type": "address_list", "action": "allow", "definition": { "addresses": [ "0x742d35Cc6634C0532925a3b8D404fA40b5398Ad2", "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" ] } } ``` * The rule applies only when the destination address is in the `addresses` list. * For an allow-list, set `action: allow` and pair with a default-deny in another rule (or rely on the policy engine's default-deny on no-applicable-policy). * For a deny-list, set `action: deny` and the listed addresses are blocked outright. ## Evaluation Order For a given transaction, the policy engine: 1. **Loads every policy attached to the wallet.** If zero policies, return `deny` immediately (`"No policies found for wallet"`). 2. **For each policy, evaluates each rule in turn.** A rule produces one of: applied + allow, applied + deny, or not-applicable. 3. **Aggregates per policy:** if any rule denies → policy = deny. Else if any rule allows → policy = allow. Else (no rule applied) → policy = not-applicable. 4. **Aggregates across policies:** if any policy denies → transaction = deny. Else if any policy allows → transaction = allow. Else (no applicable policy) → transaction = deny (default). Translation: **deny wins, allow loses, silence is deny**. Layering policies is safe — adding more policies can only block transactions, never relax existing controls. ## Lifecycle | Operation | Endpoint | Endorsed? | Signed by | | ------------------ | -------------------------------------------------- | --------- | ----------------------------------------- | | Create policy | `POST /policies` | No | API key only | | Add a rule | `POST /policies/{policy_id}/rules` | Yes | Policy's own signer group | | Update a rule | `PATCH /policies/{policy_id}/rules/{rule_id}` | Yes | Policy's own signer group | | Remove a rule | `DELETE /policies/{policy_id}/rules/{rule_id}` | Yes | Policy's own signer group | | Delete policy | `DELETE /policies/{policy_id}` | Yes | Policy's own signer group | | Attach to wallet | `PUT /policies/{policy_id}/wallets/{wallet_id}` | Yes | A signer group attached to the **wallet** | | Detach from wallet | `DELETE /policies/{policy_id}/wallets/{wallet_id}` | Yes | A signer group attached to the **wallet** | Policy creation itself is **not** endorsed — it's a regular API request authenticated only by your API key. Once the policy exists, every subsequent mutation goes through the [endorsed-request flow](/documentation/wallet-signing#modifying-policies-wallets-and-signer-groups): canonicalize the intent per RFC 8785 JCS, sign with ECDSA P-256, encode as DER + base64. ## Practical Examples ### "Default permissive" — single signature, no restrictions For sandbox testing or low-risk accounts: ```json theme={null} { "name": "Default single-signature policy", "signer_group_id": "grp_admin", "rules": [ { "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 1 } } ] } ``` ### "Tiered approval by amount" — single-sig under \$10k, dual-sig above ```json theme={null} { "name": "Tiered approval policy", "signer_group_id": "grp_admin", "rules": [ { "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 1 } }, { "rule_type": "amount_threshold", "action": "deny", "definition": { "min_amount": 1000000, "threshold": 0, "asset": { "id": "USD", "name": "US Dollar" } } }, { "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 2 } } ] } ``` The deny-wins logic blocks single-sig above \$10k; the second `approval_threshold` rule lets dual-sig through. ### "Sanctions deny-list" — block specific addresses outright ```json theme={null} { "name": "OFAC blocklist", "signer_group_id": "grp_compliance", "rules": [ { "rule_type": "address_list", "action": "deny", "definition": { "addresses": ["0xSanctionedAddress1", "0xSanctionedAddress2"] } } ] } ``` Stack this alongside other policies — its deny will override any allow from sibling policies. ## Related * [Common Flows — Create a Policy](/documentation/common-flows#create-a-policy) — happy-path curl walk-through. * [Wallet Transaction Signing](/documentation/wallet-signing#modifying-policies-wallets-and-signer-groups) — endorsed-request flow for mutating an existing policy or attaching/detaching to a wallet. * [Wallets](/documentation/wallets) — architectural overview of signer groups, policies, and intents. # SDKs & Libraries Source: https://docs.dakota.xyz/documentation/sdks Official Dakota SDKs — TypeScript (github.com/dakota-xyz/dakota-ts-sdk) and Go (github.com/dakota-xyz/go-sdk). Dakota provides official SDKs to simplify integration with our platform. Both SDKs are auto-generated from our OpenAPI specification. `@dakota-xyz/ts-sdk` `github.com/dakota-xyz/go-sdk` *** ## TypeScript SDK Installation, examples, API reference, and changelog ### Install ```bash theme={null} npm install @dakota-xyz/ts-sdk ``` ### Quick Start ```typescript theme={null} import { DakotaClient, Environment } from '@dakota-xyz/ts-sdk'; const client = new DakotaClient({ apiKey: process.env.DAKOTA_API_KEY, environment: Environment.Sandbox, }); for await (const customer of client.customers.list()) { console.log(customer.name); } ``` *** ## Go SDK Installation, examples, API reference, and changelog ### Install ```bash theme={null} go get github.com/dakota-xyz/go-sdk ``` ### Quick Start ```go theme={null} import ( "github.com/dakota-xyz/go-sdk/client" "github.com/dakota-xyz/go-sdk/client/gen" ) c, _ := client.New(client.WithAPIKey("your_api_key")) resp, _ := client.CheckResponse( c.Raw().ListCustomersWithResponse(ctx, &gen.ListCustomersParams{}), ) for _, customer := range resp.JSON200.Data { fmt.Println(customer.Name) } ``` *** ## Environments | Environment | Base URL | | --------------------- | ----------------------------------------- | | **Sandbox** (default) | `https://api.platform.sandbox.dakota.xyz` | | **Production** | `https://api.platform.dakota.xyz` | ## Supported Networks | Network | Production | Sandbox | | -------- | ------------------ | ------------------ | | Ethereum | `ethereum-mainnet` | `ethereum-sepolia` | | Polygon | `polygon-mainnet` | `polygon-amoy` | | Arbitrum | `arbitrum-mainnet` | `arbitrum-sepolia` | | Base | `base-mainnet` | `base-sepolia` | | Optimism | `optimism-mainnet` | `optimism-sepolia` | | Solana | `solana-mainnet` | `solana-devnet` | # Signing & Endorsed Requests Source: https://docs.dakota.xyz/documentation/signing-guide 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. 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. *** ## 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": [ "" ], "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 ``` 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). ### Code Examples ```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]); } ``` **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. | 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` | 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). ### 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" } ``` `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)}` ### Delete Policy ```json theme={null} { "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. ```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 | **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. ```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 | **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 | 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). | # Sub-Clients Source: https://docs.dakota.xyz/documentation/sub-clients A **sub-client** is a customer that has its own customers grouped underneath it. Use sub-clients when one of your customers is itself a business that onboards or serves its own end customers — for example a partner, reseller, or sub-developer building on top of your integration. # What is a sub-client? Dakota organizes accounts in a three-level hierarchy: ``` Client (you) └── Sub-Client ← a business customer you designate at creation └── Customer ← a regular customer associated with that sub-client └── Customer ``` | Entity | Who it is | How it's created | | ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | | **Client** | You — the business integrating with Dakota (the holder of the API key). | Your Dakota account. | | **Sub-Client** | A business customer of yours that, in turn, has its own customers. | `POST /customers` with `is_sub_client: true`. | | **Customer of a sub-client** | A regular customer that belongs to one of your sub-clients. | `POST /customers` with `sub_client_id` set to the sub-client's ID. | A sub-client is a real customer record: it completes the **same KYB onboarding** as any other business customer (see [Customer Onboarding](/documentation/customer-onboarding)). The only difference is that other customers can be associated with it. Because of their role, sub-clients are typically subject to **enhanced due diligence (EDD)** during compliance review. This may extend the time and documentation required to approve a sub-client compared to a regular business customer. ## Rules & constraints * A sub-client must be a **business** customer (`customer_type: "business"`). Individuals cannot be sub-clients. * The sub-client designation is set **at creation only** and is **immutable** — a regular customer cannot be promoted to a sub-client later, and a sub-client cannot be demoted. * `is_sub_client` and `sub_client_id` are **mutually exclusive** on a single `POST /customers` request — a customer is either a sub-client *or* a customer of a sub-client, never both. * A `sub_client_id` must reference a customer that belongs to your client **and** is itself a sub-client. * The hierarchy is **one level deep** — a customer of a sub-client cannot itself be a sub-client. # Step 1: Create a sub-client Create the sub-client like any business customer, with `is_sub_client: true`. ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/customers \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Partner Corp", "customer_type": "business", "is_sub_client": true }' ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Partner Corp', customer_type: 'business', is_sub_client: true }) }); const subClient = await response.json(); console.log('Created sub-client:', subClient.id); ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/customers', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'name': 'Partner Corp', 'customer_type': 'business', 'is_sub_client': True } ) sub_client = response.json() print(f'Created sub-client: {sub_client["id"]}') ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "name": "Partner Corp", "customer_type": "business", "is_sub_client": true }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/customers", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` Response: ```json theme={null} { "id": "2ABCrqBHb3cTfLVkFSGmHZqdXYZ", "kyb_links": [], "application_id": "2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm", "application_url": "https://apply.dakota.com/applications/2WGC9cKv9P4K8eGzqY6qJ3Xz7Qm?token=...", "application_expires_at": 1734567890000000000 } ``` The sub-client now onboards exactly like any other business customer: redirect it to the returned `application_url` (or build a custom flow) and monitor its `kyb_status`. See [Customer Onboarding](/documentation/customer-onboarding) for the full flow and KYB status values. Keep the returned `id` (`2ABCrqBHb3cTfLVkFSGmHZqdXYZ` above) — this is the sub-client's customer ID, which you'll pass as `sub_client_id` when adding customers to it. # Step 2: Add a customer to the sub-client Create a customer as usual, but set `sub_client_id` to the sub-client's ID. The customer can be a `business` or `individual`. ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/customers \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "customer_type": "business", "sub_client_id": "2ABCrqBHb3cTfLVkFSGmHZqdXYZ" }' ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/customers', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Acme Corp', customer_type: 'business', sub_client_id: '2ABCrqBHb3cTfLVkFSGmHZqdXYZ' }) }); const customer = await response.json(); console.log('Created customer under sub-client:', customer.id); ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/customers', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'name': 'Acme Corp', 'customer_type': 'business', 'sub_client_id': '2ABCrqBHb3cTfLVkFSGmHZqdXYZ' } ) customer = response.json() print(f'Created customer under sub-client: {customer["id"]}') ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "name": "Acme Corp", "customer_type": "business", "sub_client_id": "2ABCrqBHb3cTfLVkFSGmHZqdXYZ" }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/customers", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` This customer onboards through the standard flow. The association is fixed at creation — there is no endpoint to move a customer between sub-clients afterward. The legacy `PATCH /customers/{customer_id}/sub-client` endpoint is **deprecated and non-functional** — it now rejects all requests with `400`. Set `sub_client_id` on `POST /customers` instead. # Step 3: View sub-clients and their customers Every customer record now carries three sub-client fields: | Field | Description | | ----------------- | ------------------------------------------------------------------------------------------ | | `is_sub_client` | `true` when this customer is itself a sub-client. | | `sub_client_id` | The parent sub-client's ID when this customer belongs to a sub-client; otherwise `null`. | | `sub_client_name` | The parent sub-client's name when this customer belongs to a sub-client; otherwise `null`. | ## List all your sub-clients ```bash cURL theme={null} curl "https://api.platform.dakota.xyz/customers?is_sub_client=true" \ -H "X-API-Key: your-api-key" ``` ## List the customers of a sub-client Filter the customer list by `sub_client_id` to return only the customers associated with that sub-client. ```bash cURL theme={null} curl "https://api.platform.dakota.xyz/customers?sub_client_id=2ABCrqBHb3cTfLVkFSGmHZqdXYZ" \ -H "X-API-Key: your-api-key" ``` ## Get a sub-client summary `GET /customers/sub-client-summary` returns every sub-client along with the number of customers associated with it — useful for dashboards and reporting. ```bash cURL theme={null} curl "https://api.platform.dakota.xyz/customers/sub-client-summary" \ -H "X-API-Key: your-api-key" ``` Response: ```json theme={null} { "data": [ { "sub_client_id": "2ABCrqBHb3cTfLVkFSGmHZqdXYZ", "sub_client_name": "Partner Corp", "customer_count": 15 } ] } ``` # Next Steps 1. **[Customer Onboarding](/documentation/customer-onboarding)** — the KYB flow every sub-client and customer completes 2. **[Webhook Integration](/documentation/webhooks)** — track KYB status changes for sub-clients and their customers 3. **[Testing](/documentation/testing)** — exercise sub-client creation in sandbox # API Reference * [Create a customer record](/api-reference/customers/create-a-customer-record) — `is_sub_client` and `sub_client_id` fields * [List all customer records](/api-reference/customers/list-all-customer-records) — `is_sub_client` and `sub_client_id` filters * [Get sub-client summary](/api-reference/customers/get-sub-client-summary) # Sumsub Token Sharing Source: https://docs.dakota.xyz/documentation/sumsub-token-sharing Reuse a customer's existing Sumsub verification to onboard them to Dakota without re-collecting identity data or documents. If your customer has already completed KYC with another Sumsub client, you don't have to make them go through verification again. Dakota supports Sumsub's **Reusable KYC** flow: the Sumsub client that originally verified the customer (the *donor*) generates a single-use share token, hands it to you, and you redeem it on Dakota to create a customer and individual application pre-filled with the customer's identity data and documents. This guide explains how the flow works end-to-end, what data transfers (and what doesn't), and how to redeem tokens on Dakota. **Sumsub references:** This flow is built on Sumsub's [Reusable KYC](https://docs.sumsub.com/docs/reusable-kyc) feature. The token-generation endpoint used by the donor is documented at [Generate share token](https://docs.sumsub.com/reference/generate-share-token). **Scope:** Token sharing is currently supported for **individual applicants only**. Business onboarding is not in scope for this flow. # How It Works The share token flow has three parties: * **Donor** — the Sumsub client that originally verified the customer. They generate the share token, scoped to Dakota's Sumsub `clientId` (`dakota.xyz_158913`). * **Dakota** — the recipient. You redeem the token on Dakota's side, which creates a fresh applicant on Dakota's Sumsub account with the donor's data copied over. * **Sumsub** — handles the transfer of identity data and documents between the donor's account and Dakota's account. ```mermaid theme={null} sequenceDiagram autonumber participant Donor participant Dakota participant Sumsub Donor->>Sumsub: Generate share token
(applicantId, forClientId=Dakota) Sumsub-->>Donor: token (single-use, 20min TTL) Donor->>Dakota: Hand off token (out-of-band) Dakota->>Sumsub: Redeem token
(POST /reusableIdentity/reuse) Sumsub-->>Dakota: New applicant + copied data & documents Dakota->>Dakota: Map Sumsub data → individual application,
download documents to GCS ``` Tokens are **single-use** and have a default TTL of 1200 seconds (20 minutes). Once redeemed, the token is consumed and cannot be reused. # Before You Start: Establish the Sharing Relationship Two things must be in place before a share token can be generated. The first is entirely on your side; the second is an exchange between you and Dakota, carried out in each party's own Sumsub Dashboard. ## 1. The "Share applicants data" permission Sumsub's share-token endpoint requires a specific dashboard permission: > *"This method requires the **Share applicants data** dashboard permission. App tokens and team members must have a role that includes this permission; otherwise, the request fails with a permission error."* — [Generate share token](https://docs.sumsub.com/reference/generate-share-token) If your token-generation call fails with a permission error, check the role attached to the app token you are signing the request with. ## 2. Add Dakota as a recipient using a partner token Before any share token can be generated, you must add Dakota to your **sharing partners** in Sumsub. This requires a **partner token**, which is a different thing from the share tokens you will generate later — and it comes **from Dakota, not from Sumsub**. **Request the partner token from Dakota.** Sumsub generates the partner token on the recipient's side when we add you as a donor, and we then pass it to you. Contact your Dakota representative to request it — you cannot generate it yourself, and Sumsub support cannot issue it for you. How the exchange works, per Sumsub's [Partner management](https://docs.sumsub.com/docs/manage-sharing-partners) documentation: ```mermaid theme={null} sequenceDiagram autonumber participant You as You (Donor) participant Dakota as Dakota (Recipient) participant Sumsub You->>Dakota: Request a partner token Dakota->>Sumsub: Reusable identity → Partners → Donor tab → Add donor Sumsub-->>Dakota: Partner token generated automatically Dakota-->>You: Partner token (out-of-band) You->>Sumsub: Partners → Recipients tab → Add recipient → paste token Sumsub-->>You: Dakota added to your sharing partners ``` On your side the steps are: 1. In the Sumsub Dashboard, open the **Reusable identity** section and go to the **Partners** page. 2. Select the **Recipients** tab and click **Add recipient**. 3. Paste the partner token Dakota gave you. 4. Confirm that you want to add Dakota as a recipient. **Match the environment, and mind the expiry.** Sumsub notes: *"When generating a partner token, make sure to use the token in the same environment where it was created."* Production tokens carry an `lv` prefix and Sandbox tokens an `sbx` prefix — a token from the wrong environment will not create the partnership. Partner tokens also **expire 30 days after creation**, so request a fresh one if yours has gone stale. Once Dakota appears in your Recipients list, you can generate share tokens with `forClientId: dakota.xyz_158913` as described in Step 1 below. Sumsub's own references for this setup: * [Partner management](https://docs.sumsub.com/docs/manage-sharing-partners) — adding donors and recipients, and the partner token exchange * [Reusable KYC](https://docs.sumsub.com/docs/reusable-kyc) — overview of the feature and its prerequisites * [Reusable KYC Share](https://docs.sumsub.com/docs/reusable-kyc-share) — the donor-recipient relationship and the full token lifecycle **`forClientId` is not the setup step.** The `forClientId` parameter you pass when generating each token scopes *that token* to Dakota. It presumes the sharing relationship already exists — it does not create it. If you are being asked for a partner token or a recipient registration step that isn't covered here, that is a Sumsub-side configuration item; take it to Sumsub with the `clientId` above. Once Sumsub confirms the relationship is active, continue with Step 1 below. # Step 1: The Donor Generates the Share Token The donor generates a share token on their Sumsub account, scoped to Dakota's Sumsub `clientId`. This is a Sumsub-side operation — Dakota is not involved at this stage. The full request and response schema is documented in Sumsub's [Generate share token](https://docs.sumsub.com/reference/generate-share-token) reference. A typical request looks like: ```bash cURL theme={null} curl -X POST 'https://api.sumsub.com/resources/accessTokens/shareToken' \ -H 'Content-Type: application/json' \ -H 'X-App-Token: ' \ -H 'X-App-Access-Sig: ' \ -H 'X-App-Access-Ts: ' \ -d '{ "applicantId": "", "forClientId": "dakota.xyz_158913", "ttlInSecs": 1200 }' ``` The response contains a `token` field. The donor sends this token to you out-of-band — typically via secure email, your dashboard, or a private API call. **Dakota's Sumsub `clientId` is `dakota.xyz_158913`.** Pass this exact value as `forClientId` when generating the token — it scopes the token to Dakota, and Sumsub will reject the token if any other client tries to redeem it. **Relationship required first.** This request only succeeds once the donor has added Dakota as a sharing partner in Sumsub — see [Before You Start](#before-you-start-establish-the-sharing-relationship) above. **Tokens expire fast.** Sumsub share tokens default to a **1200-second (20-minute) TTL** and are single-use — once redeemed they cannot be reused. The donor can extend the TTL up to Sumsub's maximum via the `ttlInSecs` parameter, but the token will still expire. Plan to redeem tokens as soon as the donor hands them over; if a token expires before you import it, ask the donor to generate a new one. See the [Sumsub share-token reference](https://docs.sumsub.com/reference/generate-share-token) for the exact TTL bounds. # Step 2: Redeem the Tokens on Dakota Hand the share tokens off to Dakota to create the customers and applications. Customer names are derived automatically from each token's Sumsub data — you don't supply them. There are two ways to redeem tokens. **The dashboard is the recommended path** for one-off migrations; the API exists for when you want to automate it. ## From the dashboard (CSV upload) 1. In the Dakota dashboard, open the **Customers** page and click **Add customer**. 2. Switch the toggle from **Manual** to **Sumsub Import**. 3. Click **Download template** to get a starter CSV. The format is one column called `token` with one share token per row: ```csv theme={null} token _act-sbx-jwt-paste-first-token-here _act-sbx-jwt-paste-second-token-here ``` 4. Paste the tokens generated by the donor into the CSV (one per line), then upload the file. 5. Click **Import**. The dashboard parses the CSV client-side and submits the tokens. Per-row results — success, customer / application IDs, or the error message — are shown when the request finishes. The dashboard upload calls the same `POST /customers/bulk-import-sumsub-tokens` endpoint described below, so the limits and behavior are identical. ## From the API ```http theme={null} POST /customers/bulk-import-sumsub-tokens ``` ```bash cURL theme={null} curl -X POST 'https://api.platform.dakota.xyz/customers/bulk-import-sumsub-tokens' \ -H 'X-API-Key: ' \ -H "X-Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "tokens": [ "_act-sbx-jwt-eyJhbGciOiJub25lIn0...", "_act-sbx-jwt-eyKhbGciOiJub25lIn0..." ] }' ``` **Limits:** between 1 and 100 tokens per request. **Response:** ```json theme={null} { "total": 2, "succeeded": 1, "failed": 1, "results": [ { "name": "Sarah Williams", "success": true, "customer_id": "1NFHrqBHb3cTfLVkFSGmHZqdDPi", "application_id": "2hCjxJzUAW6JVRkZqaF9E0KpM3b" }, { "name": "Unknown", "success": false, "error": "Share token expired" } ] } ``` The bulk endpoint always returns `200`. Check `success` on each result individually — failed tokens don't roll back successful ones. # Step 3: Complete the Application After import, the application is in a draft/pending state with most identity data filled in. Before it can be submitted, the admin or the customer needs to complete the Dakota-specific fields that aren't part of any Sumsub verification. What's typically still needed: * `employment_status`, `purpose_of_account`, and `source_of_wealth` (Dakota-specific — not part of any Sumsub verification) * SSN if the applicant is a US person and Sumsub's TIN field didn't carry a properly formatted SSN * Source-of-wealth documents (Dakota-specific — never transferred from Sumsub) * Attestations (terms of service, privacy policy, information accuracy) These can be filled in through the standard application editing endpoints or through the dashboard. Once complete, submit the application as you would any other. **Address fallback:** When Sumsub doesn't return an address (rare, but possible for minimal verification levels), Dakota inserts a placeholder country code so the application stays loadable. The address will fail validation on submit, forcing the admin to enter the real one. # Error Handling Token-level problems do **not** produce an HTTP error. The endpoint returns `200` and reports each token's outcome in its own `results[]` entry, so one bad token never affects the others — check `success` per row rather than branching on the status code. ## Per-token outcomes (HTTP `200`, reported in `results[].error`) When Sumsub rejects a token, its own message is passed through verbatim as the row's `error` — so the exact wording comes from Sumsub, not Dakota. | Scenario | `error` value | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | Invalid, expired, or already-redeemed token | Sumsub's own message (e.g. `Share token expired`) | | Incompatible data (Sumsub error `10513`) | Sumsub's own message — the donor's verification doesn't satisfy Dakota's level; no applicant is created | | Sumsub rejected the token without a message | `Sumsub rejected this share token` | | Sumsub unreachable | `Could not reach Sumsub — please retry` | | Redemption failed for any other reason | `Could not redeem share token` | | Nothing usable in the Sumsub payload | `No data could be mapped from Sumsub` | | Applicant has no date of birth | `Cannot import applicant without a date of birth (required for sanctions screening)` | | Customer record could not be created | `Failed to create customer: ...` | | Applicant data could not be saved | `Failed to save: ...` | ## Request-level errors | Scenario | Status | Detail | | --------------------------------------- | ------ | ------------------------------------------------------- | | Empty or missing `tokens` array | `400` | `Request must contain at least one token` | | Caller lacks the required permission | `401` | Token import requires the `create_customers` permission | | `X-Idempotency-Key` is not a valid UUID | `400` | `Invalid X-Idempotency-Key: must be a valid UUID` | If the redemption succeeds but the donor's documents don't fully satisfy Dakota's verification level, the application is still created and the import is reported as a success. The gap surfaces when the application is reviewed or submitted, and the customer will need to upload the missing documents through the standard flow. # Things to Know **Tokens are scoped to Dakota.** When the donor generates the token, they specify Dakota's Sumsub `clientId` as `forClientId`. Tokens cannot be redirected to other recipients. **Tokens are single-use and time-limited.** Default TTL is 20 minutes. If a token expires, the donor must generate a new one — there's no extension. **Verification level matters.** Dakota redeems tokens against its `id-only` verification level. If the donor's level required strictly more (e.g. enhanced due diligence), the extra data is ignored. If it required strictly less, the customer may end up in `documents_requested` status until the gap is closed. **Permission.** Redeeming tokens requires the `create_customers` permission. **Idempotency.** The endpoint honors the `X-Idempotency-Key` header, and the value **must be a valid UUID** — anything else is rejected with a `400`. Sending the same key twice with the same body returns the original result rather than redeeming the tokens a second time. Use a fresh key per logical operation. **Sumsub applicant ID is recorded.** The new applicant created on Dakota's side is stored as `provider_applicant_id` on the individual applicant record, so you can correlate Dakota applications back to their Sumsub origin. # What's Next * Review the [Customer Onboarding](/documentation/customer-onboarding) guide for the standard onboarding flow that imported applications complete with. * See the [API Reference](/api-reference/introduction) for the full request/response schema of `bulkImportFromSumsubTokens`. * Subscribe to [Webhooks](/documentation/webhooks) to receive notifications when imported applications progress through review and approval. # Terminology & Key Concepts Source: https://docs.dakota.xyz/documentation/terminology Understanding Dakota Platform terminology is essential for successful API integration. This glossary defines the key concepts and entities you'll encounter throughout the documentation and API. # Core Entities ## Client **Your business** - The developer or company integrating with Dakota Platform. This is you, building payment functionality into your application or service. ## Customer **Your end user** - A business or individual that you (the client) want to process payments for. Customers must complete KYB/KYC verification before processing transactions. *Example: If you're building an invoicing app, your customers would be the businesses using your app who need to receive crypto payments.* ## Sub-Client **A customer that has its own customers** - A business customer you designate (at creation) as an intermediary, so that other customers can be grouped underneath it. Use sub-clients when one of your customers is itself a business that serves its own end customers — such as a partner, reseller, or sub-developer. *Example: You onboard "Partner Corp" as a sub-client; the businesses Partner Corp serves are then onboarded as customers associated with that sub-client. See [Sub-Clients](/documentation/sub-clients).* ## Recipient **Payment receiver** - A person or entity that will receive payments on behalf of a customer. A single customer can have multiple recipients for different use cases. *Example: A customer (Acme Corp) might have recipients for "Marketing Department" and "Sales Department" to organize incoming payments.* ## Destination **Payment endpoint** - The specific account, wallet address, or bank account where funds will be sent for a recipient. Each recipient can have multiple destinations across different networks and assets. *Example: A recipient might have a USDC destination on Ethereum (0x123...) and a separate USDC destination on Polygon (0xabc...).* # Account Types & Operations ## Auto Account **Automated recurring account setup** - A special account configuration that provides funding details for automated, recurring payment processing. Creating an auto account doesn't initiate transactions - it sets up the funding infrastructure. Actual transactions occur when funds are received at the provided addresses. *Use case: Subscription billing, payroll payments, or scheduled vendor payments.* ## One-off Transaction **Single payment setup** - Individual transaction configuration that provides funding details for on-demand payments. Creating a one-off transaction doesn't initiate the payment - it generates the funding instructions. The actual transaction happens when funds are sent to the provided address. *Use case: Invoice payments, ad-hoc purchases, or manual transfers.* # Transaction Types ## Onramp **Fiat to crypto conversion** - Converting traditional currency (USD, EUR, etc.) into cryptocurrency or stablecoins. Customers use onramps to fund their crypto activities. *Flow: USD bank account → USDC wallet* ## Offramp **Crypto to fiat conversion** - Converting cryptocurrency or stablecoins back into traditional currency. Customers use offramps to cash out their crypto holdings. *Flow: USDC wallet → USD bank account* ## Swap **Crypto to crypto exchange** - Converting one cryptocurrency or stablecoin into another without touching fiat currency. This enables portfolio management and asset optimization. *Flow: ETH → USDC, or USDC → USDT* # Compliance & Verification ## KYB (Know Your Business) **Business verification process** - Required compliance procedure for business customers. Includes verifying business registration, ownership, financial standing, and regulatory compliance. *Typical requirements: Certificate of incorporation, bank statements, director identification, beneficial ownership disclosure.* ## KYC (Know Your Customer) **Individual verification process** - Required compliance procedure for individual customers. Includes identity verification, address confirmation, and risk assessment. *Typical requirements: Government-issued ID, proof of address, source of funds documentation.* # Technical Infrastructure ## Provider **Service integration** - Services that Dakota Platform integrates with to provide specific functionality. Each provider specializes in different aspects of payment processing. Could also be Dakota itself. ## Network **Blockchain infrastructure** - The underlying blockchain where cryptocurrency transactions are processed. ## Asset **Currency or token** - Any form of value that can be transferred through the platform. Includes both fiat currencies and cryptocurrencies. *Examples: USD, EUR (fiat); ETH (crypto); USDC, USDT (stablecoins).* # API Integration Concepts ## Idempotency **Safe request retrying** - A mechanism ensuring that the same API request can be made multiple times without creating duplicate resources or side effects. Required for all state-changing operations. *Implementation: Include a unique `X-Idempotency-Key` header with each POST request. Use UUIDs for guaranteed uniqueness.* ## Webhook **Real-time notifications** - HTTP callbacks sent by Dakota Platform to your application when specific events occur. Essential for tracking transaction status and KYB/KYC completion. *Examples: Customer KYB approved, transaction completed, payment failed.* ## Rate Limiting **Request throttling** - Protective measures that limit how many API requests you can make within a specific time period. Prevents system overload and ensures fair resource usage. *Typical limits: 100 requests per minute per API key. Exceeded limits return HTTP 429 status.* # Business Workflow Terms ## External ID **Your internal reference** - An optional identifier you can assign to Dakota Platform resources (customers, transactions, etc.) to link them with your internal systems. *Use case: Link Dakota customer ID with your database's user ID for easier reconciliation.* ## Sandbox vs Production **Development environments** - Separate environments for testing (sandbox) and live operations (production). Sandbox runs against real upstream provider integrations on testnets, with two safeguards: object-create endpoints reject mainnet networks, and per-request `amount` is capped at \$2 USD. The only simulation needed for money movement is `POST /sandbox/simulate/inbound` (mocked inbound deposit) and `POST /sandbox/simulate/onboarding` (KYB/KYC lifecycle). See [Testing](/documentation/testing). *Sandbox: real provider integrations on testnets, no real money* *Production: live environment with real transactions and full compliance* ## Status States **Resource lifecycle tracking** - Standardized status values that indicate where a resource is in its processing lifecycle. *Common states: pending, in\_progress, completed, failed, requires\_info, approved, rejected.* # Advanced Concepts ## Multi-signature (Multisig) **Shared custody control** - A security mechanism requiring multiple cryptographic signatures to authorize transactions. Provides enhanced security for high-value operations. *Coming soon to Dakota Platform* ## Compliance Monitoring **Ongoing risk assessment** - Continuous monitoring of transactions and customer activity for suspicious patterns, regulatory compliance, and risk management. *Automated by Dakota Platform using integrated compliance providers* ## Settlement **Final transaction completion** - The process of actually moving funds from source to destination after all verification and processing steps are complete. *Timeline varies by transaction type and network: onramps (1-3 days), crypto transfers (minutes to hours).* *** # Quick Reference | Term | Category | Key Point | | ----------- | ----------- | ---------------------------------------------- | | Client | Entity | You (the developer/company) | | Customer | Entity | Your end users who need payments | | Sub-Client | Entity | A customer that has its own customers under it | | Recipient | Entity | Who receives payments | | Destination | Entity | Where payments are sent | | Onramp | Transaction | Fiat → Crypto | | Offramp | Transaction | Crypto → Fiat | | Swap | Transaction | Crypto → Crypto | | KYB | Compliance | Business verification | | KYC | Compliance | Individual verification | | Provider | Technical | Third-party integrations | | Idempotency | API | Safe request retrying | # Next Steps Now that you understand Dakota Platform terminology: 1. **[Review API Concepts](/api-reference/introduction)** - Learn about response formats, pagination, and error handling 2. **[Set Up Your Environment](/documentation/testing)** - Configure API keys and choose sandbox vs production 3. **[Common Flows](/documentation/common-flows)** - Follow our step-by-step implementation guide Understanding these terms will make the rest of the documentation much clearer and help you communicate effectively with our support team. # Testing Your Integration Source: https://docs.dakota.xyz/documentation/testing This guide covers how to test your Dakota Platform integration in our sandbox environment. # Sandbox Philosophy Dakota's sandbox runs **real crypto custody on testnets** and **mocks the banking layer**. Wallets sign and broadcast real testnet transactions; the policy engine runs unmodified. Fiat rails (ACH, Fedwire) are mocked. What that means per flow: * **Onramp** — `POST /sandbox/simulate/inbound` mocks the USD deposit. The follow-on stablecoin transfer to the Destination is a real testnet transaction. * **Offramp** — send real testnet crypto to the offramp's `source_crypto_address`. The on-chain inbound is real; the USD outbound to the bank Destination is mocked. * **Swap** — fully on-chain on testnets, end to end. Onboarding lifecycle (KYB / KYC) is driven by `POST /sandbox/simulate/onboarding` instead of waiting for real compliance review. * **Base URL:** `https://api.platform.sandbox.dakota.xyz` * **Dashboard:** `https://platform.sandbox.dakota.xyz` * **API keys:** Base64-encoded strings, generated from the dashboard. # Sandbox Restrictions Two safeguards run on every object-create request in sandbox to prevent accidental mainnet activity and unbounded test spend. ## Network Restriction Object-create endpoints — `POST /customers/{id}/recipients`, `POST /recipients/{id}/destinations`, `POST /accounts`, and `POST /transactions/one-off` — reject mainnet networks in sandbox and reject testnet networks in production. The `evm` wildcard is allowed in every environment. | Environment | Accepted | Rejected | | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | -------- | | Sandbox | testnets (`ethereum-sepolia`, `polygon-amoy`, `arbitrum-sepolia`, `base-sepolia`, `optimism-sepolia`, `solana-devnet`), `evm` | mainnets | | Production | mainnets (`ethereum-mainnet`, `polygon-mainnet`, `arbitrum-mainnet`, `base-mainnet`, `optimism-mainnet`, `solana-mainnet`), `evm` | testnets | A request that violates the rule returns `400 Bad Request`. Use the testnet equivalent for the chain you're targeting. ## Amount Cap Per-transaction amounts in sandbox are capped at **\$2 USD** per request. The cap applies to USD, USDC, and RD (all treated 1:1 to USD). USDT is not supported in sandbox at the moment — it will be enabled in a near-future release. Production has no cap and supports the full asset set. The cap applies at: * `POST /transactions/one-off` — the `amount` field. Rejected synchronously with `400 Bad Request`. * `POST /sandbox/simulate/inbound` — the `amount` field. Rejected synchronously with `400 Bad Request`. * **Auto-account inbound deposits** — when a sandbox crypto deposit lands on an auto-account's source address and the converted USD value exceeds the cap, the auto-account transaction is created and then transitioned to `failed` with a `failure_reason` indicating the cap was exceeded. The failure surfaces via the standard `transaction.auto.updated` webhook (and on `GET /auto-transactions/{id}`); there is no synchronous rejection because the deposit has already settled on-chain. Use small values (`"1.00"`, `"2.00"`) in your sandbox test fixtures. # Simulation Endpoints ## Simulate an Inbound Payment `POST /sandbox/simulate/inbound` injects an inbound USD payment event (ACH or Fedwire) against an onramp account, mocking what would normally arrive from a real bank wire. The call is accepted synchronously; webhook callbacks fire asynchronously and match the production event shape exactly. Crypto deposits aren't simulated — send real testnet crypto to the offramp/swap/one-off `source_crypto_address` directly. ```bash theme={null} # Inbound ACH against an onramp account curl -X POST https://api.platform.sandbox.dakota.xyz/sandbox/simulate/inbound \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "simulation_id": "sim_onramp_001", "type": "ach_inbound", "account_id": "2LfZn6LNoSvMGuSQ0pLLxj6BLGR", "amount": "1.00", "currency": "USD", "scenario": "success_immediate" }' ``` Key fields: * `type` — `ach_inbound` or `wire_inbound` for USD rails. * `account_id` — required for `ach_inbound` and `wire_inbound`. Use the onramp account ID. * `scenario` — `success_immediate` (default) or `success_delayed` with a `delay_seconds` field. * `simulation_id` — your idempotency key for the simulation. Repeating the call with the same ID and identical params returns the original response; conflicting params return `409`. The full schema and the catalog of available scenarios live in the API reference: [Simulate an Inbound Payment Event](/api-reference/sandbox/simulate-an-inbound-payment-event), [List Simulation Scenarios](/api-reference/sandbox/list-available-simulation-scenarios). Sandbox only — production returns `403 Forbidden`. ## Simulate an Onboarding Transition `POST /sandbox/simulate/onboarding` drives KYB or KYC application status through a sandbox transition without waiting for real compliance review. ```bash theme={null} # Approve a customer's KYB curl -X POST https://api.platform.sandbox.dakota.xyz/sandbox/simulate/onboarding \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "type": "kyb_approve", "applicant_id": "01HABCDEFG1234567890XYZ", "simulation_id": "sim_kyb_approve_001" }' ``` ```bash theme={null} # Reject KYB with a reason code curl -X POST https://api.platform.sandbox.dakota.xyz/sandbox/simulate/onboarding \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "type": "kyb_reject", "applicant_id": "01HABCDEFG1234567890XYZ", "simulation_id": "sim_kyb_reject_001", "reason_code": "MISSING_EIN" }' ``` ```bash theme={null} # Request additional information curl -X POST https://api.platform.sandbox.dakota.xyz/sandbox/simulate/onboarding \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "type": "kyb_info_request", "applicant_id": "01HABCDEFG1234567890XYZ", "simulation_id": "sim_kyb_info_001", "info_request_fields": ["ssn", "address"] }' ``` `type` values: | `type` | Effect | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `kyb_approve` | Fully approves the customer (any type). Triggers endorsement, recipient creation, and `customer.kyb_status.created` webhooks. | | `kyb_reject` | Sets application status to `declined`. | | `kyb_info_request` | Sets application status to `request_for_information`. | | `kyc_approve`, `kyc_reject`, `kyc_info_request` | Updates the individual applicant's KYC status only. Does not trigger the full onboarding flow. | | `applicant_activate` | Same effect as `kyb_approve`. Does not auto-create accounts, wallets, or account numbers — create those separately after activation. | | `applicant_suspend` | Sets application status to `declined`. | Full schema: [Simulate an Onboarding State Transition](/api-reference/sandbox/simulate-onboarding). Sandbox only — production returns `403 Forbidden`. ## Tracking Simulation State Stateful scenarios (e.g. `compliance_hold`, `manual_review`, `unconfirmed`) pause partway through the lifecycle and wait for an explicit advance call: * `GET /sandbox/simulations/{simulation_id}` — returns current state and the callback delivery history. * `POST /sandbox/simulations/{simulation_id}/advance` — unpauses with action `release`, `reject`, `approve`, etc. (valid actions vary per scenario). # Sandbox Behavior Control Headers Sandbox accepts a small set of HTTP request headers that override the default success path on a per-request basis. They let you exercise error branches and edge cases without contriving the upstream conditions. These headers are sandbox-only — they have no effect against `api.platform.dakota.xyz`. They are also intentionally not part of the OpenAPI spec; this section is the canonical reference. ## Available Headers | Header | Type | Effect | | ------------------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `X-Sandbox-Error-Status` | integer | HTTP status code returned at the configured error step. | | `X-Sandbox-Error-Message` | string (≤ 500 chars) | Error message body returned at the configured error step. | | `X-Sandbox-Error-Step` | string (≤ 100 chars) | Pipeline step at which the injected error fires. Valid values: `transaction_processing`, `compliance_check`, `account_validation`, `provider_call`, `kyb_submission`, `kyb_approval`, `network_call`. | | `X-Sandbox-Scenario` | string enum | Applies a preset failure mode. See the Scenarios table below. | | `X-Sandbox-Instant-Completion` | boolean | When `true`, async simulation flows complete immediately rather than requiring a manual advance call. Default `false`. | | `X-Sandbox-Skip-Auto-Approval` | boolean | When `true`, suppresses the 5-second KYB auto-approval. Use to test pending KYB states. Default `false`. | `X-Sandbox-Error-Status`, `X-Sandbox-Error-Message`, and `X-Sandbox-Error-Step` work together — set all three to inject a custom error at a specific pipeline step. `X-Sandbox-Scenario` is the higher-level alternative: pick a named scenario and the platform fills in step, status, and message for you. ```bash theme={null} # Apply a preset scenario and skip the manual advance step curl -X POST https://api.platform.sandbox.dakota.xyz/transactions/one-off \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "X-Sandbox-Scenario: insufficient_funds" \ -H "X-Sandbox-Instant-Completion: true" \ -H "Content-Type: application/json" \ -d '{ ... }' ``` ```bash theme={null} # Inject a bespoke 503 at the provider_call step curl -X POST https://api.platform.sandbox.dakota.xyz/transactions/one-off \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "X-Sandbox-Error-Status: 503" \ -H "X-Sandbox-Error-Message: Provider degraded" \ -H "X-Sandbox-Error-Step: provider_call" \ -H "Content-Type: application/json" \ -d '{ ... }' ``` ## Scenarios Valid values for the `X-Sandbox-Scenario` header. The same list is also available programmatically via `GET /sandbox/scenarios`. | Scenario | Behavior | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | | `happy_path` | Everything succeeds immediately. | | `delayed_settlement` | Succeeds but pauses for manual progression via `POST /sandbox/simulations/{id}/advance`. | | `insufficient_funds` | Returns `400` at `transaction_processing` with "Source account has insufficient funds for this transaction". | | `compliance_block` | Returns `403` at `compliance_check` with "Transaction blocked: compliance review required. Contact support." | | `invalid_account` | Returns `400` at `account_validation` with "Invalid destination account or address". | | `provider_maintenance` | Returns `503` at `provider_call` with "Provider temporarily unavailable for maintenance. Please try again later." | | `network_congestion` | Returns `429` at `transaction_processing` with "Network congested, please retry later". | | `kyb_manual_review` | KYB stays pending at `kyb_approval`; no auto-approval fires. | | `kyb_rejected` | Returns `403` at `kyb_submission` with "KYB verification failed: Unable to verify business information". | | `kyb_expired` | Returns `400` at `kyb_submission` with "KYB session expired. Please start a new verification." | | `network_timeout` | Returns `504` at `network_call` with "Request timed out. Please try again." | | `intermittent_errors` | Returns `500` at `provider_call` roughly 30% of the time with "Temporary error occurred. Please retry." | | `account_frozen` | Returns `403` at `provider_call` with "Account is frozen pending review". | | `document_expired` | Returns `400` at `kyb_submission` with "Document verification expired, please resubmit". | | `invalid_swift` | Returns `400` at `account_validation` with "Invalid SWIFT code provided". | # TRM Compliance Mock Patterns Sandbox runs a mock TRM Labs compliance client that returns deterministic risk classifications based on patterns in the address being screened. This lets you exercise high-risk and manual-review code paths without sourcing real flagged wallets. | Address pattern | Classification | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Contains `hack` (case-insensitive) | `RiskLevelHigh`, score `75`, categories `[KNOWN_SCAMMER, BLACKLISTED_ADDRESS]`, `is_blocked: false`, `should_alert: true`. | | Contains `scam` (case-insensitive) | Same as `hack`. | | EVM address starting `0xBAD0000…` | Same as `hack`. | | EVM address starting `0xSCAM0000…` | Same as `hack`. | | Bitcoin address starting `bc1qBADBADB…` | Same as `hack`. | | Contains `review` (case-insensitive) | `RiskLevelMedium`, score `50`, categories `[UNUSUAL_ACTIVITY, REQUIRES_REVIEW]`, not blocked, not alerted. | | Contains `manual` (case-insensitive) | Same as `review`. | | Exact address `0xMANUAL000000000` | Same as `review`. | | Anything else | `RiskLevelLow`, score `10`, no categories, not blocked, not alerted. | ```bash theme={null} # Create a destination using a high-risk-classified address curl -X POST https://api.platform.sandbox.dakota.xyz/recipients/$RECIPIENT_ID/destinations \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "network": "ethereum-sepolia", "address": "0xBAD0000000000000000000000000000000000000" }' ``` The `compliance_block` scenario (set via `X-Sandbox-Scenario`) overrides the address-pattern logic and forces a `RiskLevelCritical` blocked response on every screening call — useful when you want to test the blocked-transaction branch without crafting a specific address. # Webhook Testing Sandbox webhooks fire from the same delivery pipeline as production — same envelope, same signing key derivation, same retry behaviour. Use `X-Dakota-Event-ID` for idempotency and verify signatures the same way you will in production. See [Webhooks](/documentation/webhooks) for the full reference. ## ngrok for Local Development ```bash theme={null} # Expose your local server ngrok http 3000 # Register the HTTPS tunnel as a webhook target curl -X POST https://api.platform.sandbox.dakota.xyz/webhooks/targets \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"url": "https://abc123.ngrok.io/webhooks", "active": true}' ``` ## Webhook.site for Quick Testing 1. Visit `https://webhook.site` and copy the unique URL. 2. Register it as a webhook target. 3. Trigger a simulation; observe the live deliveries. # Required Headers **`X-Idempotency-Key` is required for ALL POST endpoints.** Per the OpenAPI spec, every POST request must include an `X-Idempotency-Key` header — a unique UUID per logical request. Requests without it fail with `400`. Repeating the same key within the dedupe window returns the original response. ```bash theme={null} curl -X POST https://api.platform.sandbox.dakota.xyz/customers \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{...}' ``` # Rate Limit Testing Sandbox enforces the same rate limits as production. Send a burst of read requests to verify your client's backoff: ```bash theme={null} for i in {1..150}; do curl -s -o /dev/null -w "%{http_code}\n" \ https://api.platform.sandbox.dakota.xyz/customers \ -H "X-API-Key: $DAKOTA_API_KEY" & done wait ``` Expect `429` responses once the per-key limit is hit. # Common Issues ## HTTP 400 on object create with a `*-mainnet` network The sandbox network restriction rejects mainnet network IDs. Use the testnet equivalent (`ethereum-sepolia`, `base-sepolia`, etc.) or `evm` for chain-agnostic destinations. ## HTTP 400 on a one-off transaction or simulate inbound with `amount > 2.00` The sandbox amount cap rejects per-request amounts above \$2 USD. Cap your test fixtures at `"1.00"` or `"2.00"`. ## HTTP 400 on POST requests in general `X-Idempotency-Key` is required on every POST request. Generate a fresh UUID per logical request. ## Webhooks not received Confirm your endpoint is publicly reachable, returns `2xx` within 30 seconds, and has been registered with `POST /webhooks/targets`. ngrok or webhook.site are the easiest local test loops. ## Transactions stuck in processing Use `POST /sandbox/simulate/inbound` to push the lifecycle forward. For paused stateful scenarios, use `POST /sandbox/simulations/{id}/advance`. # Migration to Production When you're ready to switch: 1. **Update configuration** — change the base URL to `https://api.platform.dakota.xyz` and replace sandbox API keys with production keys. Update webhook URLs to your production receiver. 2. **Environment variables** ```bash theme={null} DAKOTA_API_KEY=your_production_api_key DAKOTA_BASE_URL=https://api.platform.dakota.xyz DAKOTA_PUBLIC_KEY=your_production_webhook_public_key ``` 3. **Switch network IDs** — replace testnet IDs (`*-sepolia`, `*-amoy`, `*-devnet`) with their mainnet counterparts. Production rejects testnet IDs. 4. **Remove cap-imposed amount limits** — the \$2 cap is sandbox-only. Use real transaction amounts in production. 5. **Roll out gradually** — start with low-value transactions, monitor error rates, and have a rollback plan. # Next Steps * [Authentication](authentication) — production security practices. * [Customer Onboarding](customer-onboarding) — production KYB flows. * [Common Flows](/documentation/common-flows) — onramp, offramp, swap, and wallet flows end-to-end. * [Webhooks](webhooks) — webhook signature verification and event reference. # API Reference * [Simulate an Inbound Payment Event](/api-reference/sandbox/simulate-an-inbound-payment-event) * [Simulate an Onboarding State Transition](/api-reference/sandbox/simulate-onboarding) * [Get Simulation Status](/api-reference/sandbox/get-simulation-status) * [Advance a Paused Simulation](/api-reference/sandbox/advance-a-paused-simulation) * [List Available Simulation Scenarios](/api-reference/sandbox/list-available-simulation-scenarios) # Interactive API Playground Source: https://docs.dakota.xyz/documentation/try-it-now Run every Dakota flow hands-on in the Interactive API Playground. Every flow runs hands-on in the [Interactive API Playground](https://docs.playground.dakota.xyz) — prebuilt `curl` requests and AI prompts you can copy and run. * [Customer Onboarding](https://docs.playground.dakota.xyz?flow=onboarding) * [On-Ramp (USD to Crypto)](https://docs.playground.dakota.xyz?flow=onramp) * [Off-Ramp (Crypto to USD)](https://docs.playground.dakota.xyz?flow=offramp) * [Swap (USDC to USDT)](https://docs.playground.dakota.xyz?flow=swap) * [Crypto to Crypto Transfer](https://docs.playground.dakota.xyz?flow=crypto-transfer) * [Transactions](https://docs.playground.dakota.xyz?flow=transactions) * [Wallets API](https://docs.playground.dakota.xyz?flow=wallets) * [Money Movement](https://docs.playground.dakota.xyz?flow=money) # Using LLMs Source: https://docs.dakota.xyz/documentation/using-llms Dakota has made its documentation AI-friendly through multiple mechanisms designed to help Large Language Models understand and integrate with the platform. ## LLM Feed Files To help LLMs stay current on how Dakota works, we expose two continuously updated files for ingestion: * [`llms.txt`](/llms.txt) - A concise, high-signal list of top-level docs pages, great for smaller models or quick context building. * [`llms-full.txt`](/llms-full.txt) - A more exhaustive listing that includes nearly all pages, ideal for full-context indexing. You can regularly ingest these URLs into your custom GPTs or other LLM apps to ensure Dakota-specific questions are grounded in accurate technical detail. ## Contextual Deep Links The documentation supports "contextual" features allowing you to: ### Export as Markdown Export any Dakota documentation page as Markdown for: * Custom GPT training data * Internal knowledge bases * Team documentation * Offline reference ### AI Chat Integration Launch pre-loaded chat sessions with Claude or ChatGPT for specific documentation pages. This enables: * Instant troubleshooting * Code generation with proper context * Deeper topic exploration * Interactive learning ### Use Cases **Troubleshooting**: Open a docs page about webhooks, click "Ask Claude", and get immediate help with your specific webhook implementation issue. **Code Generation**: Load the API reference page, start a chat, and generate production-ready code that follows Dakota's best practices. **Learning**: Explore complex topics like transaction flows by chatting with an AI that has full context of Dakota's documentation. ## Best Practices ### Regular Ingestion For custom GPTs or internal tools: * Fetch llms.txt or llms-full.txt regularly (daily or weekly) * Update your knowledge base with the latest documentation * Ensure accurate, current technical information ### Context Management * Use llms.txt for general queries and overviews * Use llms-full.txt when detailed implementation guidance is needed * Combine with live API testing for verification ### Security Considerations * Never share API keys with AI assistants * Use sandbox credentials when generating code examples * Review AI-generated code before production deployment * Verify security recommendations against official docs ## Getting Started 1. **Choose Your Integration Method** * Quick start: Use llms.txt with your AI assistant * Full context: Ingest llms-full.txt into custom GPTs 2. **Test Your Setup** * Ask basic questions about Dakota concepts * Request code examples for common operations * Verify responses against official documentation 3. **Build with Confidence** * Generate boilerplate integration code * Get instant answers to API questions * Troubleshoot issues with AI assistance ## Resources * [Concise documentation feed](/llms.txt) * [Complete documentation feed](/llms-full.txt) * [Complete API documentation](/api-reference/introduction) ## Support Need help integrating Dakota documentation with your AI tools? Contact our support team at [dakota.xyz/talk-to-sales](https://dakota.xyz/talk-to-sales) for assistance. # Wallet Transaction Signing Source: https://docs.dakota.xyz/documentation/wallet-signing How to build, canonicalize, and sign wallet transaction intents — including the browser DER-encoding trap. Every wallet transaction in Dakota is an **endorsed request**: a canonical JSON intent plus one or more ECDSA P-256 signatures. This page is the reference for doing that correctly. If you only want the conceptual model and the signer/policy lifecycles, read [Signing & Endorsed Requests](/documentation/signing-guide). If you've just stood up a wallet in [Common Flows](/documentation/common-flows#create-a-wallet-non-custodial), this is where you continue. **Prerequisites.** You already have a wallet, a signer group, and at least one ES256 private key. If not, start with [Create a Wallet (Non-Custodial)](/documentation/common-flows#create-a-wallet-non-custodial). **Signing with a passkey or hardware authenticator?** This page covers raw `ES256` keys. If your signer is a WebAuthn credential (`key_type: WEBAUTHN`), the signature is a WebAuthn assertion bundle instead of a DER signature — follow [WebAuthn & Passkey Signing](/documentation/webauthn-signing) instead. ## The Three Parts of a Signed Transaction Dakota wallets use an **intent-based** model: every transaction begins with a canonical JSON description of what the wallet should do, signed with the private key of at least one signer in the wallet's signer group. Platform forwards the signed intent to the policy engine, which verifies each signature against the stored public keys and — if the approval threshold is satisfied — executes the transaction on-chain. A signed wallet transaction has three parts: 1. **The intent** — a JSON object describing the operation. Fields use `snake_case`, amounts are strings. 2. **The canonical digest** — the intent canonicalized per [RFC 8785 JCS](https://www.rfc-editor.org/rfc/rfc8785), then hashed with SHA-256. 3. **The signature(s)** — ECDSA P-256 signatures of that digest in ASN.1 DER encoding, each base64-encoded. ## The Intent ```json theme={null} { "wallet_id": "2LfZm5KMnRvLFtRP7nJJug4zJEP", "caip2": "eip155:1", "operation": { "kind": "transfer", "from": "0xYourWalletAddress...", "to": "0xDestinationAddress...", "amount": "10.5", "asset_id": "USDC" }, "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0" } ``` Field names are `snake_case` (`wallet_id`, `asset_id`, `idempotency_key`). Amounts are **strings** (`"10.5"`, not `10.5`). The `operation.kind` field determines which sub-fields are required: `transfer` uses `from` / `to` / `amount` / `asset_id`; `evm_contract_call` additionally accepts `method` / `args` / `data`. Omit any field that doesn't apply — canonicalization drops unset fields and sorts keys alphabetically before hashing. ## Canonicalize, Hash, and Sign Canonicalization is what ensures that a client and the policy engine hash the same bytes regardless of JSON key ordering or whitespace. All three reference libraries below implement RFC 8785 JCS, so their output is byte-identical for the same input. ```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); // dsaEncoding 'der' matches Go's ecdsa.SignASN1 / the server's verify path. 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) # cryptography's ec.ECDSA(SHA256) signs the digest and returns ASN.1 DER. 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'; // works in browsers via bundlers 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) { // Prepend 0x00 if high bit is set so the INTEGER stays positive. const needsPad = (bytes[0] & 0x80) !== 0; const body = needsPad ? new Uint8Array([0, ...bytes]) : bytes; return new Uint8Array([0x02, body.length, ...body]); } ``` **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. ## Submit the Signed Transaction ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/wallets/2LfZm5KMnRvLFtRP7nJJug4zJEP/transactions \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signatures": [ "MEQCIEtPHo4edFaeOAWql3CHzcEJTX0MlUxjnqdlQwv+FYbrAiAhRAXEiruewidHx1JTofP1QQ+mJnRx6cXQ6vjCHp9wlQ==" ], "intent": { "wallet_id": "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 `signatures` array holds as many entries as the signer group's approval threshold requires; each entry is a DER-then-base64 ECDSA signature from a distinct signer in the group. The `intent` object must be byte-equivalent to what was canonicalized and signed — platform re-canonicalizes the intent on the server side before signature verification, so ordering and whitespace in the wire JSON do not matter, but field values must match exactly. ## Modifying Policies, Wallets, and Signer Groups Adding rules to a policy, attaching a signer group to a wallet, and any other mutation of a wallet's authorization graph after creation go through the same endorsed-request pattern: 1. Build an intent JSON object with a `type` discriminator field 2. Canonicalize it per RFC 8785 JCS and sign with ECDSA P-256 (use the same `signIntent` function above — it works for every intent type) 3. POST to the mutation endpoint with `{ "signatures": [...], "intent": {...} }` The signing process is identical to transactions — only the intent schema and endpoint differ. Each intent carries a `type` field that tells the server which schema to expect. | Operation | Endpoint | `type` value | | ----------------------------------- | ------------------------------------------------------------- | --------------------------- | | Add a rule to a policy | `POST /policies/{policy_id}/rules` | `add_policy_rule` | | Update a rule's definition | `PATCH /policies/{policy_id}/rules/{rule_id}` | `update_policy_rule` | | Remove a rule from a policy | `DELETE /policies/{policy_id}/rules/{rule_id}` | `remove_policy_rule` | | Delete a policy | `DELETE /policies/{policy_id}` | `delete_policy` | | Attach a policy to a wallet | `PUT /policies/{policy_id}/wallets/{wallet_id}` | `attach_policy_to_wallet` | | Detach a policy from a wallet | `DELETE /policies/{policy_id}/wallets/{wallet_id}` | `detach_policy_from_wallet` | | Attach a signer group to a wallet | `PUT /wallets/{wallet_id}/signer-groups/{signer_group_id}` | `attach_group_to_wallet` | | Detach a signer group from a wallet | `DELETE /wallets/{wallet_id}/signer-groups/{signer_group_id}` | `detach_group_from_wallet` | Adding or removing signers from a signer group (`POST /signer-groups/{signer_group_id}/signers` and `DELETE /signer-groups/{signer_group_id}/signers/{signer_id}`) is a **plain API call** authenticated with your API key — it does not require an endorsed intent. See [Signing & Endorsed Requests](/documentation/signing-guide) for the full list of which mutations require signatures. The signers that must endorse each intent are determined by the resource being mutated: * **Policy mutations** (`add_policy_rule`, `remove_policy_rule`, `update_policy_rule`, `delete_policy`) must be signed by members of the **policy's own signer group**. * **Wallet mutations** (`attach_policy_to_wallet`, `detach_policy_from_wallet`, `attach_group_to_wallet`, `detach_group_from_wallet`) must be signed by members of a signer group already attached to the wallet. The number of signatures required matches the approval threshold of the relevant policy. ### Example: Add a Rule to an Existing Policy The `AddPolicyRuleIntent` shape: ```json theme={null} { "type": "add_policy_rule", "policy_id": "2LfQm5KMnRvLFtRP7nJJug4zJEP", "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 2 }, "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0" } ``` Field shape reminders: * `type` is required on every intent and must match the operation (e.g. `"add_policy_rule"`) — it's part of what you sign * `action` is `"allow"` or `"deny"` * `rule_type` is `"approval_threshold"`, `"amount_threshold"`, or `"address_list"` * `definition` is a flat object for the chosen `rule_type` — e.g. `{"threshold": 2}` for `approval_threshold`, `{"addresses": [...]}` for `address_list` Sign the intent with the same `signIntent` function from above, then submit: ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/policies/2LfQm5KMnRvLFtRP7nJJug4zJEP/rules \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signatures": [ "MEYCIQCr24vqv9xdz92Kj8xMsTxd8cOalqiRCuXzjYdDSA/VtgIhAPzJqR/tvG8eUgX/b4sTL6/+bCpaliRa/r5Y1toKJkSl" ], "intent": { "type": "add_policy_rule", "policy_id": "2LfQm5KMnRvLFtRP7nJJug4zJEP", "rule_type": "approval_threshold", "action": "allow", "definition": { "threshold": 2 }, "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0" } }' ``` The intent schemas for the other mutations are listed in the [OpenAPI reference](/api-reference). They follow the same conventions: snake\_case fields, a `type` discriminator matching the operation, and string enum values. # Wallets Source: https://docs.dakota.xyz/documentation/wallets # Dakota Wallet Infrastructure Welcome to Dakota's wallet infrastructure—a sophisticated yet accessible platform for managing digital assets across blockchain networks. In the rapidly evolving world of digital finance, the challenge isn't just securing assets; it's creating systems that are secure, flexible, and genuinely usable by organizations of all sizes. Dakota's wallet infrastructure represents a fundamental reimagining of how institutional wallets should work. ## How Dakota Wallets Work At the heart of Dakota's design is a simple but powerful idea: separate the concerns of identity, authorization, and policy. This separation creates unprecedented flexibility while maintaining absolute security. ### True Non-Custodial Architecture When you create a Dakota wallet, you're not trusting us with your assets. We never have access to your private keys—not even temporarily, not even encrypted. Instead, our infrastructure coordinates between your authorized signers, each maintaining their own keys, to execute transactions according to your defined policies. This non-custodial approach means that even in the impossible scenario where Dakota's entire infrastructure was compromised, your assets remain secure. The cryptographic signatures required to move funds never leave your control. We facilitate coordination; you maintain ownership. ### Universal Addresses Across Networks One of Dakota's most elegant innovations is our approach to multi-chain support. Within a blockchain family (like all EVM-compatible chains), a single Dakota wallet provides one consistent address. Your Ethereum address is your Polygon address is your Arbitrum address. No more managing different wallets for different networks, no more confusion about which address to use where. This universality extends to operations as well. The same signer groups, the same policies, the same governance rules apply across all supported networks in a family. Deploy once, use everywhere. ### The Intent Model Rather than directly executing blockchain transactions, Dakota uses an intent-based model. Every action—from simple transfers to complex DeFi operations—begins with an intent: a cryptographically signed declaration of what you want to accomplish. This intent model provides multiple layers of security and flexibility. Intents are human-readable, so signers know exactly what they're authorizing. They're cryptographically signed, preventing any tampering or replay attacks. They include idempotency keys, ensuring operations can't be accidentally duplicated. And they're evaluated by our policy engine before execution, enforcing your governance rules automatically. For the full technical details on how to sign intents and build endorsed requests, see the [Signing & Endorsed Requests](/documentation/signing-guide) guide. ## The Building Blocks Dakota's wallet infrastructure consists of four interconnected components, each serving a specific purpose in the overall security model: ### Wallets: Your Digital Vaults A Dakota wallet is more than an address on a blockchain—it's a complete financial instrument with built-in governance. Each wallet maintains a single address across its blockchain family, managed by your chosen signer groups and governed by your defined policies. Wallets can represent different organizational units (departments, projects, purposes) while maintaining consistent security standards. ### Signer Groups: Distributed Authority Signer groups transform the single point of failure in traditional wallets into distributed, resilient authority. Each group consists of multiple signers, each with their own cryptographic keys. You might have an operations group for routine transactions, an executive group for large transfers, and a compliance group for regulated operations. Groups are dynamic—add new members when people join, remove them when they leave, all without changing wallet addresses or deploying new contracts. ### Policies: Automated Governance Policies encode your organization's rules directly into the wallet infrastructure. Need two signatures for transfers over $10,000? Three for anything over $100,000? Restrict certain operations to business hours? Block transfers to sanctioned addresses? Policies handle all of this automatically, evaluating every transaction against your rules in milliseconds. No human intervention required, no possibility of oversight or exception. ### Intents: Cryptographic Instructions Every operation begins with an intent—a signed message describing the desired action. Intents ensure that operations are explicit (signers know what they're signing), authentic (cryptographic signatures prevent forgery), and controlled (policies evaluate intents before execution). This intent layer provides the perfect balance between security and usability. ## Your Journey with Dakota Wallets Getting started with Dakota wallets is straightforward, but the platform's depth supports even the most complex requirements. Here's how organizations typically evolve their wallet infrastructure: **Start Simple**: Begin with basic multi-signature wallets. Create a signer group with your key stakeholders, set up simple approval thresholds, and start transacting. See [How Dakota Wallets Work](#how-dakota-wallets-work) for a walkthrough of the core concepts. **Add Governance**: As transaction volume grows, add policies to automate approval workflows. Different amounts require different approvals. Certain addresses need special handling. Time-based rules control when transactions can occur. The [Policies](#policies-automated-governance) section shows you how. **Scale Operations**: Expand to multiple wallets for different purposes. Create specialized signer groups for different roles. Implement sophisticated transaction patterns for DeFi integration. The [Intent Model](#the-intent-model) section explores these advanced patterns. **Ensure Security**: Throughout your journey, Dakota's cryptographic foundation ensures security. Understanding this foundation helps you make informed decisions about key management and signing strategies. The [Non-Custodial Architecture](#true-non-custodial-architecture) section provides this deeper understanding. ## Explore the Documentation This documentation provides everything you need to integrate and operate Dakota wallets: * **[Building Blocks](#the-building-blocks)** - Deep dive into the architectural components and how they work together * **[How Wallets Work](#how-dakota-wallets-work)** - Overview of wallet concepts and architecture * **[Signer Groups](#signer-groups-distributed-authority)** - Understanding and managing distributed authority * **[Policies](#policies-automated-governance)** - Implementing automated governance rules * **[Intent Model](#the-intent-model)** - Executing operations through the intent model * **[Non-Custodial Architecture](#true-non-custodial-architecture)** - Technical details of our security model # WebAuthn & Passkey Signing Source: https://docs.dakota.xyz/documentation/webauthn-signing How to register a WEBAUTHN signer and endorse wallet transactions with a WebAuthn assertion instead of a raw ECDSA signature. Dakota signers can be **WebAuthn credentials** (passkeys, platform authenticators, or roaming keys like a YubiKey) in addition to raw `ES256` keys. A WebAuthn signer endorses an intent by producing a standard [WebAuthn assertion](https://www.w3.org/TR/webauthn-2/) — the same object a browser returns from `navigator.credentials.get()` — rather than a bare ECDSA signature over the intent hash. This page is the reference for the WebAuthn path. If you sign with raw `ES256` keys, read [Wallet Transaction Signing](/documentation/wallet-signing) instead. For the conceptual model, intent catalogue, and signer-group / policy lifecycles (which are identical for both signer types), read [Signing & Endorsed Requests](/documentation/signing-guide). **Why WebAuthn is different.** An authenticator never signs an arbitrary hash. It signs `authenticatorData ‖ SHA-256(clientDataJSON)`, and the intent reaches the signature only as the **challenge** embedded inside `clientDataJSON`. The verifier therefore needs the whole assertion bundle (`authenticatorData`, `clientDataJSON`, `signature`) — not a DER signature. Everything below exists to thread the intent through that bundle correctly. ## The model at a glance A WebAuthn integration has two phases: 1. **Register** — create a WebAuthn credential, extract its COSE public key, and register it as a `key_type: WEBAUTHN` signer. 2. **Sign** — build a transaction intent, **JCS-canonicalize it, use those exact bytes as the WebAuthn challenge**, produce an assertion, and submit it in an endorsed request. Once a WebAuthn signer is in a signer group attached to a wallet, it participates in approval thresholds exactly like an `ES256` signer. The signer-group and policy mechanics in [Signing & Endorsed Requests](/documentation/signing-guide) apply unchanged. **Supported algorithms.** A `WEBAUTHN` signer's COSE key must be **ES256** (EC2 / P-256, COSE `alg` `-7`) or **RS256** (RSA, 2048-bit minimum, COSE `alg` `-257`). Other algorithms and curves — including **ES384/ES512, Ed25519, and secp256k1** — are rejected at verification time. Virtually all passkeys are ES256, so request `[-7, -257]` and you will get an ES256 credential. *** ## 1. Register a WEBAUTHN signer ### 1a. Create the credential (browser) Run a normal WebAuthn registration ceremony. The relying-party options come from your own server; the only Dakota-specific constraint is `pubKeyCredParams` (i.e. `supportedAlgorithmIDs`) limited to ES256 / RS256. ```typescript theme={null} import { create } from '@github/webauthn-json/browser-ponyfill'; // creationOptions come from your relying-party server and set // rpId, challenge, user, and pubKeyCredParams ([-7, -257]). const credential = await create({ publicKey: creationOptions }); // credential.id — base64url credential ID. SAVE THIS — you need it to sign. // credential.response — contains the attestationObject (CBOR) holding the COSE key. ``` ### 1b. Extract the COSE public key (server) The public key Dakota stores is the **raw COSE key** (CBOR) embedded in the attestation object — not a PKIX/SPKI blob. The simplest way to get it is to verify the registration with [`@simplewebauthn/server`](https://simplewebauthn.dev/) and read the credential public key it returns: ```typescript theme={null} import { verifyRegistrationResponse } from '@simplewebauthn/server'; const verification = await verifyRegistrationResponse({ response: credential, // the PublicKeyCredential from 1a expectedChallenge, // the challenge you issued expectedOrigin: 'https://your-app.example', expectedRPID: 'your-app.example', }); // COSE public key (Uint8Array). Field name varies by library version: // @simplewebauthn/server v7+ : registrationInfo.credential.publicKey // v6 and below : registrationInfo.credentialPublicKey const cosePublicKey = verification.registrationInfo.credential.publicKey; // base64url-encode for the API (Node 14.18+). const publicKeyB64 = Buffer.from(cosePublicKey).toString('base64url'); ``` If you are not using `@simplewebauthn/server`, the COSE key is the `credentialPublicKey` field of `attestedCredentialData` inside the `authData` of the `attestationObject`. Decode the attestation CBOR with any CBOR library and walk the standard authenticator-data layout to extract it. The bytes you register are exactly that COSE key, base64-encoded. ### 1c. Register the signer ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/signers \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Alice'\''s passkey", "key_type": "WEBAUTHN", "public_key": "" }' ``` Save both the returned signer `id` (for building signer groups) and the `credential.id` from step 1a (for signing). Add the signer to a signer group and attach that group to a wallet exactly as you would for an `ES256` signer — see the [Signer Group Lifecycle](/documentation/signing-guide#signer-group-lifecycle). `public_key` for a `WEBAUTHN` signer is the **raw CBOR COSE key**, base64-encoded. This is a different encoding from `key_type: ES256`, which expects an X.509 SubjectPublicKeyInfo (PKIX) DER/PEM blob. Sending a PKIX key for a WebAuthn signer (or vice-versa) fails verification later, not at registration. *** ## 2. Sign and submit a transaction The intent and `operation` schema are identical to the [ECDSA flow](/documentation/wallet-signing) — only the signing step changes. ### 2a. Build and canonicalize the intent ```typescript theme={null} import canonicalize from 'canonicalize'; // RFC 8785 JCS; npm install canonicalize const intent = { wallet_id: 'wal_2LfZm5KMnRvLFtRP7nJJug4zJEP', caip2: 'eip155:1', operation: { kind: 'transfer', from: '0xYourWalletAddress...', to: '0xDestinationAddress...', amount: '10.5', // strings, never numbers asset_id: 'USDC', }, idempotency_key: crypto.randomUUID(), // context_digest is optional — include it only if an upstream service // hands you one. It is part of the intent, so it is covered automatically. }; // The RFC 8785 canonical bytes. These bytes ARE the WebAuthn challenge. const canonicalBytes = new TextEncoder().encode(canonicalize(intent)); ``` ### 2b. The challenge — the one thing that trips integrators up The challenge is the **raw bytes of the RFC 8785 (JCS) canonical intent JSON** — **not** `SHA-256(intent)`, and **not** any other digest. Do not hash the intent before using it as the challenge. The verifier re-derives the hash itself. What the verifier does, step by step: ```text theme={null} decoded = base64url_decode(clientDataJSON.challenge) // → canonical intent bytes intent_obj = JSON.parse(decoded) intent_obj = removeEmptyStrings(intent_obj) // matches server omitempty intent_obj = normalizeDecimals(intent_obj) // "0.10" → "0.1" challenge_hash = SHA-256( JCS(intent_obj) ) stored_hash = SHA-256( JCS(submitted endorsed_request.intent) ) assert challenge_hash == stored_hash // constant-time ``` Because the verifier re-canonicalizes whatever it decodes from the challenge, minor formatting differences (key order, trailing decimal zeros, empty strings) are tolerated. Signing the output of your JCS library directly is the safest approach. **`context_digest` participates automatically.** It is an optional field on `SendTransactionIntent`; because it lives inside the intent object, it is part of the canonical JSON the challenge commits to — no separate handling. **The hash is a single SHA-256**, not a double hash. ### 2c. Produce the assertion (browser) ```typescript theme={null} import { get as getAssertion, parseRequestOptionsFromJSON, } from '@github/webauthn-json/browser-ponyfill'; /** * Sign a canonicalized intent with a registered WebAuthn credential. * Returns one entry suitable for EndorsedRequest.signatures[]. */ async function signIntent( canonicalBytes: Uint8Array, credentialId: string, // the base64url credential.id from registration ): Promise { // parseRequestOptionsFromJSON expects the challenge as a base64url string // and decodes it back to the raw bytes the authenticator signs over. const requestOptions = parseRequestOptionsFromJSON({ publicKey: { challenge: base64url(canonicalBytes), // the canonical intent bytes allowCredentials: [{ type: 'public-key', id: credentialId }], userVerification: 'preferred', timeout: 300_000, }, }); // The browser prompts the user, then signs authenticatorData ‖ SHA-256(clientDataJSON). const assertion = await getAssertion(requestOptions); // assertion.toJSON() is the CredentialAssertionResponse shape, with every // binary field inside `response` already base64url-encoded: // { id, rawId, type: "public-key", // response: { authenticatorData, clientDataJSON, signature } } const assertionJson = JSON.stringify(assertion.toJSON()); // A signatures[] entry is base64url( JSON(assertion) ). return base64url(new TextEncoder().encode(assertionJson)); } // RFC 4648 §5 base64url, no padding. function base64url(bytes: Uint8Array): string { let binary = ''; for (const b of bytes) binary += String.fromCharCode(b); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } ``` ### 2d. Submit the endorsed request The request body **is the endorsed request** — `intent` and `signatures` at the top level. There is no wrapper object. ```bash theme={null} curl -X POST https://api.platform.dakota.xyz/wallets/wal_2LfZm5KMnRvLFtRP7nJJug4zJEP/transactions \ -H "X-API-Key: $DAKOTA_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signatures": [ "" ], "intent": { "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 `intent` you submit must be the same object you canonicalized and signed — the verifier independently canonicalizes it and compares its hash against the challenge. The `signatures` array holds one entry per endorsing signer, up to the signer group's approval threshold. ### End-to-end ```typescript theme={null} import canonicalize from 'canonicalize'; async function sendSignedTransaction(params: { walletId: string; caip2: string; fromAddress: string; toAddress: string; amount: string; assetId: string; credentialId: string; // base64url WebAuthn credential ID from registration }) { const intent = { wallet_id: params.walletId, caip2: params.caip2, operation: { kind: 'transfer', from: params.fromAddress, to: params.toAddress, amount: params.amount, asset_id: params.assetId, }, idempotency_key: crypto.randomUUID(), }; const canonicalBytes = new TextEncoder().encode(canonicalize(intent)); const signature = await signIntent(canonicalBytes, params.credentialId); return api.post(`/wallets/${params.walletId}/transactions`, { signatures: [signature], intent, }); } ``` *** ## What goes in `signatures[]` Each entry is the WebAuthn assertion **serialized as JSON, then base64-encoded** — there is no CBOR, no custom concatenation, and no proprietary envelope: ```text theme={null} signatures[i] = base64url( JSON(CredentialAssertionResponse) ) ``` The decoded JSON: ```json theme={null} { "id": "", "rawId": "", "type": "public-key", "response": { "authenticatorData": "", "clientDataJSON": "", "signature": "" } } ``` | Field | Requirement | | ---------------------------- | ------------------------------------------------------------------------------------------- | | `id` | Required. Base64url (no padding) — this is validated. | | `type` | Required. Must be `"public-key"`. | | `rawId` | Recommended (standard `toJSON()` output). | | `response.authenticatorData` | Required. Base64url of the raw authenticator data. | | `response.clientDataJSON` | Required. Base64url; decodes to JSON containing `type: "webauthn.get"` and the `challenge`. | | `response.signature` | Required. Base64url; DER for ES256, PKCS#1 v1.5 for RS256. | JSON field order is irrelevant. The fields inside `response` must be **base64url**; the outer `signatures[]` string itself may be standard base64 or base64url, padded or not — the decoder accepts all four. *** ## rpId and origin Dakota does **not** re-validate `rpId` or `origin` server-side. The verifier extracts `authenticatorData`, `clientDataJSON`, and `signature`, checks the challenge binding, and verifies the signature against the registered COSE key. The `rpId` and `origin` are cryptographically bound into the signature but are not compared against an expected value. You still need a valid, stable `rpId` configured in your relying party to complete the browser ceremony in the first place — use your real domain (and keep it consistent between registration and signing). `clientDataJSON.type` will be `"webauthn.get"` naturally. *** ## Troubleshooting | Symptom | Likely cause | Fix | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `WebAuthn challenge hash does not match intentHash` | Challenge was set to `SHA-256(intent)` instead of the **raw** canonical JSON bytes; or the `intent` was mutated between signing and submission | Use the canonical bytes directly as the challenge, and submit the same intent object you signed. | | `failed to parse WebAuthn response` | A `response` field (or `id`) is not base64url, or `authenticatorData` / `clientDataJSON` is malformed | Use the standard `assertion.toJSON()` output; do not re-encode its inner fields. `id` must be base64url with no padding. | | `failed to unmarshal WebAuthn signature` | The decoded `signatures[]` entry is not the assertion JSON | Ensure each entry is `base64( JSON(assertion) )`, not the raw assertion object and not just the `signature` field. | | `failed to parse WebAuthn public key` | The registered `public_key` is a PKIX/PEM key, or the COSE algorithm is unsupported | Register the **raw COSE** key (1b), and use an **ES256** or **RS256** credential. | | `signature verification failed` | `allowCredentials` referenced a credential whose key does not match the registered signer | Confirm the `credential.id` you sign with corresponds to the COSE key you registered. | **Multi-signer approvals.** When a wallet's policy requires more than one approval, collect one `signatures[]` entry per signer (each a base64url assertion JSON) and submit them together. WebAuthn and `ES256` signatures can be mixed in the same `signatures` array. # Webhook Integration Source: https://docs.dakota.xyz/documentation/webhooks Webhooks allow Dakota Platform to send real-time notifications about events in your account directly to your application. # Overview Instead of constantly polling our API for updates, webhooks deliver event notifications instantly when something happens: * Transaction status changes * Customer onboarding updates * Account changes * System events # Webhook Signature Verification Dakota signs every webhook with an **Ed25519** signature (not HMAC). To verify a delivery, you need Dakota's **public key** — set it in your environment as `DAKOTA_WEBHOOK_PUBLIC_KEY`. ## Dakota Public Keys | Environment | Public Key (hex-encoded) | | -------------- | ------------------------------------------------------------------ | | **Production** | `65b797d688ed4991ecc0d922f360bd9b4c3d68e5a36ce2b1307cc8547bd68be4` | | **Sandbox** | `7a2f771f3a7ac9ae2a95066df35dc0261d7ce354214736cc232d70b3c66f8a5f` | Each key is 64 hex characters (32 raw bytes). ## Signature Headers Dakota includes these headers in every webhook request: | Header | Description | | --------------------- | --------------------------------------------------------------------------------------------------------- | | `X-Webhook-Signature` | Base64-encoded Ed25519 signature over `timestamp + body` | | `X-Webhook-Timestamp` | Unix timestamp when the webhook was sent — reject anything older than 5 minutes to prevent replay attacks | | `X-Dakota-Event-ID` | Unique identifier for the event (optional, useful for idempotency) | Full verification implementations for Node.js, Python, Go, Rust, and Java are in [Verification Code Examples](#verification-code-examples) below. # Setting Up Webhooks ## 1. Create a Webhook Endpoint Create an endpoint in your application to receive webhook notifications: ```javascript JavaScript theme={null} // Express.js example app.post('/webhooks/dakota', (req, res) => { const event = req.body; // Verify webhook signature (required for security) const isValid = verifyWebhookSignature( req.headers['x-webhook-signature'], req.headers['x-webhook-timestamp'], JSON.stringify(req.body), process.env.DAKOTA_WEBHOOK_PUBLIC_KEY ); if (!isValid) { console.error('Webhook signature verification failed'); return res.status(401).send('Unauthorized'); } // Process the event handleWebhookEvent(event); // Respond with 200 to acknowledge receipt res.status(200).send('OK'); }); ``` ```python Python theme={null} # Flask example from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/webhooks/dakota', methods=['POST']) def handle_webhook(): event = request.get_json() # Verify webhook signature (required for security) signature = request.headers.get('X-Webhook-Signature') timestamp = request.headers.get('X-Webhook-Timestamp') is_valid = verify_webhook_signature( signature, timestamp, request.get_data(as_text=True), os.getenv('DAKOTA_WEBHOOK_PUBLIC_KEY') ) if not is_valid: print('Webhook signature verification failed') return jsonify({'error': 'Unauthorized'}), 401 # Process the event handle_webhook_event(event) # Respond with 200 to acknowledge receipt return jsonify({'status': 'ok'}), 200 ``` ```go Go theme={null} package main import ( "encoding/json" "io" "log" "net/http" "os" ) func webhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Read request body body, err := io.ReadAll(r.Body) if err != nil { log.Printf("Failed to read request body: %v", err) http.Error(w, "Bad request", http.StatusBadRequest) return } defer r.Body.Close() signature := r.Header.Get("X-Webhook-Signature") timestamp := r.Header.Get("X-Webhook-Timestamp") // Verify webhook signature (required for security) isValid := verifyWebhookSignature( signature, timestamp, string(body), os.Getenv("DAKOTA_WEBHOOK_PUBLIC_KEY") ) if !isValid { log.Println("Webhook signature verification failed") http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Parse webhook event var event map[string]interface{} if err := json.Unmarshal(body, &event); err != nil { log.Printf("Failed to parse webhook event: %v", err) http.Error(w, "Bad request", http.StatusBadRequest) return } // Process the event handleWebhookEvent(event) // Respond with 200 to acknowledge receipt w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } func main() { http.HandleFunc("/webhooks/dakota", webhookHandler) log.Fatal(http.ListenAndServe(":3000", nil)) } ``` ```rust Rust theme={null} use axum::{extract::Request, response::Json, http::StatusCode}; use serde_json::Value; use std::env; async fn webhook_handler(req: Request) -> Result { // Extract headers and body let signature = req.headers() .get("X-Webhook-Signature") .and_then(|h| h.to_str().ok()) .ok_or((StatusCode::BAD_REQUEST, "Missing signature header"))?; let timestamp = req.headers() .get("X-Webhook-Timestamp") .and_then(|h| h.to_str().ok()) .ok_or((StatusCode::BAD_REQUEST, "Missing timestamp header"))?; let body = axum::body::to_bytes(req.into_body(), usize::MAX) .await .map_err(|_| (StatusCode::BAD_REQUEST, "Failed to read body"))?; let body_str = String::from_utf8(body.to_vec()) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid UTF-8 in body"))?; // Verify webhook signature (required for security) let public_key = env::var("DAKOTA_WEBHOOK_PUBLIC_KEY") .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Missing public key"))?; let is_valid = verify_webhook_signature(signature, timestamp, &body_str, &public_key) .map_err(|_| (StatusCode::UNAUTHORIZED, "Signature verification failed"))?; if !is_valid { return Err((StatusCode::UNAUTHORIZED, "Invalid signature")); } // Parse webhook event let event: Value = serde_json::from_str(&body_str) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid JSON"))?; // Process the event handle_webhook_event(event) .await .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process event"))?; Ok(StatusCode::OK) } ``` ```java Java theme={null} import org.springframework.web.bind.annotation.*; import org.springframework.http.ResponseEntity; import org.springframework.http.HttpStatus; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.util.Map; @RestController public class WebhookController { @PostMapping("/webhooks/dakota") public ResponseEntity handleWebhook( @RequestBody Map event, HttpServletRequest request) throws IOException { String signature = request.getHeader("X-Webhook-Signature"); String timestamp = request.getHeader("X-Webhook-Timestamp"); // Get raw body for signature verification String rawBody = objectMapper.writeValueAsString(event); // Verify webhook signature (required for security) String publicKey = System.getenv("DAKOTA_WEBHOOK_PUBLIC_KEY"); boolean isValid = verifyWebhookSignature(signature, timestamp, rawBody, publicKey); if (!isValid) { System.err.println("Webhook signature verification failed"); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } // Process the event handleWebhookEvent(event); // Respond with 200 to acknowledge receipt return ResponseEntity.ok("OK"); } private void handleWebhookEvent(Map event) { // Your event processing logic here } private boolean verifyWebhookSignature(String signature, String timestamp, String body, String publicKey) { // Signature verification implementation return true; // Placeholder } } ``` ## 2. Register Your Webhook [Register your endpoint](/api-reference/webhooks/create-a-webhook-target) with Dakota Platform: ### Webhook Target Fields | Field | Type | Required | Description | Example | | ------------- | -------------- | -------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------- | | `url` | string | ✅ | HTTPS endpoint to receive webhooks | `"https://your-app.com/webhooks/dakota"` | | `global` | boolean | ❌ | Whether webhook receives events for all customers (default: false) | `false` | | `event_types` | array\[string] | ❌ | Array of event types to subscribe to (defaults to all events if not specified) | `["transaction.auto.updated", "customer.kyb_status.updated"]` | ```bash cURL theme={null} curl -X POST https://api.platform.dakota.xyz/webhooks/targets \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/dakota", "global": false, "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"] }' ``` Response: ```json theme={null} { "data": { "id": "wh_1234567890", "url": "https://your-app.com/webhooks/dakota", "global": false, "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"], "created_at": "2024-01-15T10:30:00Z" } } ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/webhooks/targets', { method: 'POST', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://your-app.com/webhooks/dakota', global: false, event_types: ['transaction.auto.updated', 'customer.kyb_status.updated'] }) }); const webhook = await response.json(); console.log('Created webhook target:', webhook.data.id); ``` ```python Python theme={null} import requests import uuid response = requests.post( 'https://api.platform.dakota.xyz/webhooks/targets', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={ 'url': 'https://your-app.com/webhooks/dakota', 'global': False, 'event_types': ['transaction.auto.updated', 'customer.kyb_status.updated'] } ) webhook = response.json() print(f'Created webhook target: {webhook["data"]["id"]}') ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{ "url": "https://your-app.com/webhooks/dakota", "global": false, "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"] }`) req, _ := http.NewRequest("POST", "https://api.platform.dakota.xyz/webhooks/targets", body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({ "url": "https://your-app.com/webhooks/dakota", "global": false, "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"] }); let response = client .post("https://api.platform.dakota.xyz/webhooks/targets") .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaWebhookExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = """ { "url": "https://your-app.com/webhooks/dakota", "global": false, "event_types": ["transaction.auto.updated", "customer.kyb_status.updated"] } """; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/webhooks/targets")) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` # Webhook Events ## Transaction Events Transaction lifecycle events fire for auto-account (onramp/offramp/swap) and one-off transactions. There is **no separate `failed` event** — failure is reflected in the `status` field of the `*.updated` event. **transaction.auto.created** — emitted when Dakota creates a transaction for an onramp/offramp/swap account (e.g., after detecting a bank deposit). There is no top-level `amount` — amount-like fields live inside `data.object.receipt`. ```json theme={null} { "id": "2tQRvU7tHKLBcd3qXrPp4jYsKXm", "type": "transaction.auto.created", "created": 1737457500, "api_version": "1.0.0", "data": { "object": { "id": "2tQRvvnYkN6edEJUTmF1LzTj2ug", "auto_account_id": "2tQRvK9pRzM4nVbW8sHqL5jXmYt", "destination_id": "2tQRvD3xFcJ7bKpW9qNsT4hZmYr", "type": "onramp", "status": "pending", "created_at": 1737457500, "updated_at": 1737457500, "receipt": { "input_currency": "USD", "output_currency": "USDC", "initial_amount": "1000.00", "subtotal_amount": "999.00", "converted_amount": "999.00", "outgoing_amount": "999.00", "external_fee": "1.00", "client_fee": "0.00", "dakota_fee": "0.00", "exchange_rate": "1.00" }, "crypto_details": { "destination_network_id": "ethereum", "destination_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1" }, "sender_details": { "sender_type": "individual", "sender_account_holder_name": "Jane Doe", "sender_bank_name": "Example Bank", "sender_routing_number": "021000021", "sender_account_number": "123456789", "sender_account_type": "checking" } } } } ``` **transaction.auto.updated** — emitted on every status change or detail update (inspect `data.object.status` to distinguish `processing`, `completed`, `failed`). When a status change triggered the update, the prior value is included under `data.previous_attributes`. ```json theme={null} { "id": "2tQRvB8mTpYxZ4nWgK6sLvHqRdJ", "type": "transaction.auto.updated", "created": 1737457620, "api_version": "1.0.0", "data": { "object": { "id": "2tQRvvnYkN6edEJUTmF1LzTj2ug", "auto_account_id": "2tQRvK9pRzM4nVbW8sHqL5jXmYt", "destination_id": "2tQRvD3xFcJ7bKpW9qNsT4hZmYr", "type": "onramp", "status": "completed", "created_at": 1737457500, "updated_at": 1737457620, "completed_at": 1737457620, "receipt": { "input_currency": "USD", "output_currency": "USDC", "initial_amount": "1000.00", "subtotal_amount": "999.00", "converted_amount": "999.00", "outgoing_amount": "999.00", "external_fee": "1.00", "client_fee": "0.00", "dakota_fee": "0.00", "exchange_rate": "1.00" }, "crypto_details": { "destination_network_id": "ethereum", "destination_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1", "tx_hash": "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456" }, "sender_details": { "sender_type": "individual", "sender_account_holder_name": "Jane Doe", "sender_bank_name": "Example Bank", "sender_routing_number": "021000021", "sender_account_number": "123456789", "sender_account_type": "checking" } }, "previous_attributes": { "status": "processing" } } } ``` **transaction.one\_off.created** / **transaction.one\_off.updated** — same shape, emitted for single-use transactions created via `POST /transactions/one-off`. Covers both off-ramp (crypto → fiat with `payment_reference` support) and swap (crypto → crypto with a destination network override). The one-off artifact is discarded after the transaction completes. ## Customer Events **customer.kyb\_status.updated** ```json theme={null} { "event": "customer.kyb_status.updated", "data": { "customer_id": "31TgvufZK3gDXBcA3BnSeLWiSn7", "kyb_status": "active" } } ``` Some `customer.kyb_status.updated` events include an additional `reason_code` field that disambiguates a status change driven by the Proof-of-Address (PoA) flow. The field is present only on PoA-related transitions; on all other status updates it is absent. | `reason_code` value | Fires when | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `pending_proof_of_address` | The customer is frozen because their rolling 7-day inbound volume crossed the \$3,000 threshold and no PoA is on file. | | `proof_of_address_rejected` | Compliance rejected a submitted PoA document. | | `proof_of_address_approved` | Compliance approved a submitted PoA, unfreezing the customer. | Example payload with `reason_code`: ```json theme={null} { "event": "customer.kyb_status.updated", "data": { "customer_id": "31TgvufZK3gDXBcA3BnSeLWiSn7", "kyb_status": "frozen", "reason_code": "pending_proof_of_address" } } ``` **customer.created** ```json theme={null} { "event": "customer.created", "data": { "id": "31TgvufZK3gDXBcA3BnSeLWiSn7" } } ``` **customer.kyb\_application.submitted** ```json theme={null} { "event": "customer.kyb_application.submitted", "data": { "customer_id": "31TgvufZK3gDXBcA3BnSeLWiSn7", "type": "business" } } ``` # Verification Code Examples Ed25519 verification implementations — pick your language. See the public keys and required headers in [Webhook Signature Verification](#webhook-signature-verification) above. ```javascript Node.js theme={null} const crypto = require('crypto'); const { Buffer } = require('buffer'); function verifyWebhookSignature(signature, timestamp, payload, dakotaPublicKeyHex) { if (!signature || !timestamp || !payload || !dakotaPublicKeyHex) { console.error('Missing required parameters for signature verification'); return false; } // Check timestamp to prevent replay attacks (within 5 minutes) const currentTime = Math.floor(Date.now() / 1000); const webhookTime = parseInt(timestamp); const timeDifference = Math.abs(currentTime - webhookTime); if (timeDifference > 300) { // 5 minutes console.error('Webhook timestamp too old or too far in future'); return false; } // Create the signed payload (timestamp + body) const signedPayload = timestamp + payload; try { // Decode the public key from hex and signature from base64 const publicKeyBuffer = Buffer.from(dakotaPublicKeyHex, 'hex'); const signatureBuffer = Buffer.from(signature, 'base64'); // Validate key length (Ed25519 public key should be 32 bytes) if (publicKeyBuffer.length !== 32) { console.error('Invalid Ed25519 public key length:', publicKeyBuffer.length); return false; } // Wrap raw Ed25519 key in DER format for Node.js crypto API const derPrefix = Buffer.from([ 0x30, 0x2A, // SEQUENCE, 42 bytes 0x30, 0x05, // SEQUENCE, 5 bytes 0x06, 0x03, 0x2B, 0x65, 0x70, // OID 1.3.101.112 (Ed25519) 0x03, 0x21, 0x00 // BIT STRING, 33 bytes (including unused bits byte) ]); const derKey = Buffer.concat([derPrefix, publicKeyBuffer]); const publicKey = crypto.createPublicKey({ key: derKey, format: 'der', type: 'spki' }); // Verify the signature using Ed25519 const isValid = crypto.verify( null, // Ed25519 doesn't use a digest algorithm Buffer.from(signedPayload, 'utf8'), publicKey, signatureBuffer ); return isValid; } catch (error) { console.error('Signature verification failed:', error); return false; } } // Complete Express.js webhook handler example const express = require('express'); const app = express(); // Middleware to capture raw body for signature verification app.use('/webhooks/dakota', express.raw({ type: 'application/json' })); app.post('/webhooks/dakota', (req, res) => { const signature = req.headers['x-webhook-signature']; const timestamp = req.headers['x-webhook-timestamp']; const rawBody = req.body.toString('utf8'); // Verify webhook signature const isValid = verifyWebhookSignature( signature, timestamp, rawBody, process.env.DAKOTA_WEBHOOK_PUBLIC_KEY ); if (!isValid) { console.error('Webhook signature verification failed'); return res.status(401).send('Unauthorized'); } try { const event = JSON.parse(rawBody); console.log('Received verified webhook:', event.event, event.id); // Process the event handleWebhookEvent(event); // Respond with 200 to acknowledge receipt res.status(200).send('OK'); } catch (error) { console.error('Error processing webhook:', error); res.status(400).send('Bad Request'); } }); function handleWebhookEvent(event) { // Make processing idempotent if (isEventAlreadyProcessed(event.id)) { console.log('Event already processed:', event.id); return; } switch (event.event) { case 'transaction.auto.updated': handleTransactionUpdate(event.data); break; case 'customer.kyb_status.updated': handleCustomerUpdate(event.data); break; default: console.log(`Unknown event type: ${event.event}`); } // Mark as processed markEventAsProcessed(event.id); } function isEventAlreadyProcessed(eventId) { // Check your database/cache for this event ID // Return true if already processed return false; } function markEventAsProcessed(eventId) { // Store the event ID in your database/cache console.log('Marking event as processed:', eventId); } function handleTransactionUpdate(data) { console.log('Processing transaction update:', data.id, data.status); // Your transaction handling logic here } function handleCustomerUpdate(data) { console.log('Processing customer update:', data.id, data.kyb_status); // Your customer handling logic here } ``` ```python Python theme={null} import base64 import json import time import os from flask import Flask, request, jsonify from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from cryptography.exceptions import InvalidSignature def verify_webhook_signature(signature, timestamp, body, dakota_public_key_hex): if not all([signature, timestamp, body, dakota_public_key_hex]): print('Missing required parameters for signature verification') return False try: # Check timestamp to prevent replay attacks (within 5 minutes) current_time = int(time.time()) webhook_time = int(timestamp) time_difference = abs(current_time - webhook_time) if time_difference > 300: # 5 minutes print('Webhook timestamp too old or too far in future') return False # Create the signed payload (timestamp + body) signed_payload = timestamp + body # Decode the public key from hex and signature from base64 public_key_bytes = bytes.fromhex(dakota_public_key_hex) signature_bytes = base64.b64decode(signature) # Validate key length (Ed25519 public key should be 32 bytes) if len(public_key_bytes) != 32: print(f'Invalid Ed25519 public key length: {len(public_key_bytes)}') return False # Create Ed25519 public key object public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) # Verify the signature public_key.verify(signature_bytes, signed_payload.encode('utf-8')) return True except (InvalidSignature, ValueError, Exception) as e: print(f'Signature verification failed: {e}') return False # Complete Flask webhook handler example app = Flask(__name__) processed_events = set() # Simple in-memory store (use database in production) @app.route('/webhooks/dakota', methods=['POST']) def handle_webhook(): signature = request.headers.get('X-Webhook-Signature') timestamp = request.headers.get('X-Webhook-Timestamp') raw_body = request.get_data(as_text=True) # Verify webhook signature is_valid = verify_webhook_signature( signature, timestamp, raw_body, os.getenv('DAKOTA_WEBHOOK_PUBLIC_KEY') ) if not is_valid: print('Webhook signature verification failed') return jsonify({'error': 'Unauthorized'}), 401 try: event = json.loads(raw_body) print(f'Received verified webhook: {event.get("event")} {event.get("id")}') # Process the event handle_webhook_event(event) # Respond with 200 to acknowledge receipt return jsonify({'status': 'ok'}), 200 except (json.JSONDecodeError, KeyError) as e: print(f'Error processing webhook: {e}') return jsonify({'error': 'Bad Request'}), 400 def handle_webhook_event(event): event_id = event.get('id') # Make processing idempotent if is_event_already_processed(event_id): print(f'Event already processed: {event_id}') return event_type = event.get('event') event_data = event.get('data', {}) if event_type == 'transaction.auto.updated': handle_transaction_update(event_data) elif event_type == 'customer.kyb_status.updated': handle_customer_update(event_data) else: print(f'Unknown event type: {event_type}') # Mark as processed mark_event_as_processed(event_id) def is_event_already_processed(event_id): # Check your database/cache for this event ID return event_id in processed_events def mark_event_as_processed(event_id): # Store the event ID in your database/cache processed_events.add(event_id) print(f'Marking event as processed: {event_id}') def handle_transaction_update(data): transaction_id = data.get('id') status = data.get('status') print(f'Processing transaction update: {transaction_id} -> {status}') # Your transaction handling logic here def handle_customer_update(data): customer_id = data.get('id') kyb_status = data.get('kyb_status') print(f'Processing customer update: {customer_id} -> {kyb_status}') # Your customer handling logic here if __name__ == '__main__': app.run(port=3000, debug=True) ``` ```rust Rust theme={null} use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use base64::{Engine as _, engine::general_purpose}; use std::time::{SystemTime, UNIX_EPOCH}; use warp::Filter; use serde_json::{Value, json}; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use std::convert::Infallible; #[derive(Debug)] pub struct WebhookVerifier { public_key: VerifyingKey, processed_events: Arc>>, } impl WebhookVerifier { pub fn new(public_key_hex: &str) -> Result> { let public_key_bytes = hex::decode(public_key_hex)?; // Validate key length (Ed25519 public key should be 32 bytes) if public_key_bytes.len() != 32 { return Err(format!("Invalid Ed25519 public key length: {}", public_key_bytes.len()).into()); } let public_key = VerifyingKey::from_bytes( &public_key_bytes.try_into().map_err(|_| "Invalid key length")? )?; Ok(Self { public_key, processed_events: Arc::new(Mutex::new(HashSet::new())), }) } pub fn verify_signature(&self, signature: &str, timestamp: &str, body: &str) -> Result> { if signature.is_empty() || timestamp.is_empty() || body.is_empty() { println!("Missing required parameters for signature verification"); return Ok(false); } // Check timestamp to prevent replay attacks (within 5 minutes) let current_time = SystemTime::now() .duration_since(UNIX_EPOCH)? .as_secs(); let webhook_time: u64 = timestamp.parse()?; let time_difference = (current_time as i64 - webhook_time as i64).abs(); if time_difference > 300 { println!("Webhook timestamp too old or too far in future"); return Ok(false); } // Create the signed payload (timestamp + body) let signed_payload = format!("{}{}", timestamp, body); // Decode signature let signature_bytes = general_purpose::STANDARD.decode(signature)?; let signature = Signature::from_bytes(&signature_bytes.try_into().map_err(|_| "Invalid signature length")?); // Verify signature match self.public_key.verify(signed_payload.as_bytes(), &signature) { Ok(_) => Ok(true), Err(e) => { println!("Signature verification failed: {}", e); Ok(false) } } } pub fn is_event_processed(&self, event_id: &str) -> bool { self.processed_events.lock().unwrap().contains(event_id) } pub fn mark_event_processed(&self, event_id: &str) { self.processed_events.lock().unwrap().insert(event_id.to_string()); println!("Marking event as processed: {}", event_id); } pub fn handle_webhook_event(&self, event: &Value) -> Result<(), Box> { let event_id = event["id"].as_str().unwrap_or(""); // Make processing idempotent if self.is_event_processed(event_id) { println!("Event already processed: {}", event_id); return Ok(()); } let event_type = event["event"].as_str().unwrap_or(""); let event_data = &event["data"]; match event_type { "transaction.auto.updated" => { self.handle_transaction_update(event_data)?; } "customer.kyb_status.updated" => { self.handle_customer_update(event_data)?; } _ => { println!("Unknown event type: {}", event_type); } } // Mark as processed self.mark_event_processed(event_id); Ok(()) } fn handle_transaction_update(&self, data: &Value) -> Result<(), Box> { let transaction_id = data["id"].as_str().unwrap_or(""); let status = data["status"].as_str().unwrap_or(""); println!("Processing transaction update: {} -> {}", transaction_id, status); // Your transaction handling logic here Ok(()) } fn handle_customer_update(&self, data: &Value) -> Result<(), Box> { let customer_id = data["id"].as_str().unwrap_or(""); let kyb_status = data["kyb_status"].as_str().unwrap_or(""); println!("Processing customer update: {} -> {}", customer_id, kyb_status); // Your customer handling logic here Ok(()) } } // Complete Warp webhook handler example #[tokio::main] async fn main() -> Result<(), Box> { let public_key_hex = std::env::var("DAKOTA_WEBHOOK_PUBLIC_KEY") .expect("DAKOTA_WEBHOOK_PUBLIC_KEY environment variable is required"); let verifier = Arc::new(WebhookVerifier::new(&public_key_hex)?); let webhook_route = warp::path("webhooks") .and(warp::path("dakota")) .and(warp::post()) .and(warp::header::("x-webhook-signature")) .and(warp::header::("x-webhook-timestamp")) .and(warp::body::bytes()) .and_then({ let verifier = verifier.clone(); move |signature: String, timestamp: String, body: bytes::Bytes| { let verifier = verifier.clone(); async move { handle_webhook(verifier, signature, timestamp, body).await } } }); let port = std::env::var("PORT") .unwrap_or_else(|_| "3000".to_string()) .parse::() .unwrap_or(3000); println!("Starting webhook server on port {}", port); warp::serve(webhook_route) .run(([0, 0, 0, 0], port)) .await; Ok(()) } async fn handle_webhook( verifier: Arc, signature: String, timestamp: String, body: bytes::Bytes, ) -> Result { let body_str = match String::from_utf8(body.to_vec()) { Ok(s) => s, Err(_) => { return Ok(warp::reply::with_status( "Invalid UTF-8 in body", warp::http::StatusCode::BAD_REQUEST, )); } }; // Verify webhook signature let is_valid = match verifier.verify_signature(&signature, ×tamp, &body_str) { Ok(valid) => valid, Err(e) => { println!("Signature verification error: {}", e); false } }; if !is_valid { println!("Webhook signature verification failed"); return Ok(warp::reply::with_status( "Unauthorized", warp::http::StatusCode::UNAUTHORIZED, )); } // Parse webhook event let event: Value = match serde_json::from_str(&body_str) { Ok(e) => e, Err(e) => { println!("Failed to parse webhook event: {}", e); return Ok(warp::reply::with_status( "Bad Request", warp::http::StatusCode::BAD_REQUEST, )); } }; println!("Received verified webhook: {} {}", event["event"].as_str().unwrap_or("unknown"), event["id"].as_str().unwrap_or("unknown") ); // Process the event if let Err(e) = verifier.handle_webhook_event(&event) { println!("Error processing webhook event: {}", e); return Ok(warp::reply::with_status( "Internal Server Error", warp::http::StatusCode::INTERNAL_SERVER_ERROR, )); } Ok(warp::reply::with_status( "OK", warp::http::StatusCode::OK, )) } ``` ```java Java theme={null} import java.security.*; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.time.Instant; import java.nio.charset.StandardCharsets; import org.springframework.web.bind.annotation.*; import org.springframework.http.ResponseEntity; import org.springframework.http.HttpStatus; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.util.Map; public class WebhookVerifier { private final PublicKey publicKey; private final Set processedEvents = ConcurrentHashMap.newKeySet(); public WebhookVerifier(String publicKeyHex) throws Exception { byte[] publicKeyBytes = hexToBytes(publicKeyHex); // Validate key length (Ed25519 public key should be 32 bytes) if (publicKeyBytes.length != 32) { throw new IllegalArgumentException("Invalid Ed25519 public key length: " + publicKeyBytes.length); } // Raw Ed25519 key - wrap in DER format for Java crypto API byte[] derKey = wrapRawKeyInDER(publicKeyBytes); X509EncodedKeySpec keySpec = new X509EncodedKeySpec(derKey); KeyFactory keyFactory = KeyFactory.getInstance("EdDSA"); this.publicKey = keyFactory.generatePublic(keySpec); } private byte[] hexToBytes(String hex) { int len = hex.length(); byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + Character.digit(hex.charAt(i+1), 16)); } return data; } private byte[] wrapRawKeyInDER(byte[] rawKey) { // Ed25519 DER prefix for public key byte[] derPrefix = { 0x30, 0x2A, // SEQUENCE, 42 bytes 0x30, 0x05, // SEQUENCE, 5 bytes 0x06, 0x03, 0x2B, 0x65, 0x70, // OID 1.3.101.112 (Ed25519) 0x03, 0x21, 0x00 // BIT STRING, 33 bytes (including unused bits byte) }; byte[] derKey = new byte[derPrefix.length + rawKey.length]; System.arraycopy(derPrefix, 0, derKey, 0, derPrefix.length); System.arraycopy(rawKey, 0, derKey, derPrefix.length, rawKey.length); return derKey; } public boolean verifySignature(String signature, String timestamp, String body) { if (signature == null || timestamp == null || body == null || signature.isEmpty() || timestamp.isEmpty() || body.isEmpty()) { System.err.println("Missing required parameters for signature verification"); return false; } try { // Check timestamp to prevent replay attacks (within 5 minutes) long currentTime = Instant.now().getEpochSecond(); long webhookTime = Long.parseLong(timestamp); long timeDifference = Math.abs(currentTime - webhookTime); if (timeDifference > 300) { // 5 minutes System.err.println("Webhook timestamp too old or too far in future"); return false; } // Create the signed payload (timestamp + body) String signedPayload = timestamp + body; // Decode signature byte[] signatureBytes = Base64.getDecoder().decode(signature); // Verify signature using Ed25519 Signature verifier = Signature.getInstance("EdDSA"); verifier.initVerify(publicKey); verifier.update(signedPayload.getBytes(StandardCharsets.UTF_8)); return verifier.verify(signatureBytes); } catch (Exception e) { System.err.println("Signature verification failed: " + e.getMessage()); return false; } } public boolean isEventProcessed(String eventId) { return processedEvents.contains(eventId); } public void markEventProcessed(String eventId) { processedEvents.add(eventId); System.out.println("Marking event as processed: " + eventId); } public void handleWebhookEvent(Map event) throws Exception { String eventId = (String) event.get("id"); // Make processing idempotent if (isEventProcessed(eventId)) { System.out.println("Event already processed: " + eventId); return; } String eventType = (String) event.get("event"); @SuppressWarnings("unchecked") Map eventData = (Map) event.get("data"); switch (eventType) { case "transaction.auto.updated": handleTransactionUpdate(eventData); break; case "customer.kyb_status.updated": handleCustomerUpdate(eventData); break; default: System.out.println("Unknown event type: " + eventType); } // Mark as processed markEventProcessed(eventId); } private void handleTransactionUpdate(Map data) { String transactionId = (String) data.get("id"); String status = (String) data.get("status"); System.out.println("Processing transaction update: " + transactionId + " -> " + status); // Your transaction handling logic here } private void handleCustomerUpdate(Map data) { String customerId = (String) data.get("id"); String kybStatus = (String) data.get("kyb_status"); System.out.println("Processing customer update: " + customerId + " -> " + kybStatus); // Your customer handling logic here } } @RestController public class WebhookController { private final WebhookVerifier webhookVerifier; public WebhookController() throws Exception { String publicKeyHex = System.getenv("DAKOTA_WEBHOOK_PUBLIC_KEY"); if (publicKeyHex == null || publicKeyHex.isEmpty()) { throw new IllegalStateException("DAKOTA_WEBHOOK_PUBLIC_KEY environment variable is required"); } this.webhookVerifier = new WebhookVerifier(publicKeyHex); } @PostMapping("/webhooks/dakota") public ResponseEntity handleWebhook( @RequestBody Map event, HttpServletRequest request) throws IOException { String signature = request.getHeader("X-Webhook-Signature"); String timestamp = request.getHeader("X-Webhook-Timestamp"); // Get raw body for signature verification String rawBody = new com.fasterxml.jackson.databind.ObjectMapper() .writeValueAsString(event); // Verify webhook signature boolean isValid = webhookVerifier.verifySignature(signature, timestamp, rawBody); if (!isValid) { System.err.println("Webhook signature verification failed"); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Unauthorized"); } System.out.println("Received verified webhook: " + event.get("event") + " " + event.get("id")); try { // Process the event webhookVerifier.handleWebhookEvent(event); // Respond with 200 to acknowledge receipt return ResponseEntity.ok("OK"); } catch (Exception e) { System.err.println("Error processing webhook event: " + e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body("Internal Server Error"); } } } ``` ```go Go theme={null} package main import ( "crypto/ed25519" "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "os" "strconv" "sync" "time" ) type WebhookEvent struct { ID string `json:"id"` Event string `json:"event"` Data map[string]interface{} `json:"data"` CreatedAt string `json:"created_at"` } type WebhookHandler struct { publicKey ed25519.PublicKey processedEvents map[string]bool mutex sync.RWMutex } func NewWebhookHandler(publicKeyHex string) (*WebhookHandler, error) { publicKeyBytes, err := hex.DecodeString(publicKeyHex) if err != nil { return nil, fmt.Errorf("failed to decode public key: %v", err) } // Validate key length (Ed25519 public key should be 32 bytes) if len(publicKeyBytes) != 32 { return nil, fmt.Errorf("invalid Ed25519 public key length: %d", len(publicKeyBytes)) } return &WebhookHandler{ publicKey: ed25519.PublicKey(publicKeyBytes), processedEvents: make(map[string]bool), }, nil } func (wh *WebhookHandler) verifySignature(signature, timestamp, body string) bool { if signature == "" || timestamp == "" || body == "" { log.Println("Missing required parameters for signature verification") return false } // Check timestamp to prevent replay attacks (within 5 minutes) webhookTime, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { log.Printf("Invalid timestamp format: %v", err) return false } currentTime := time.Now().Unix() timeDifference := currentTime - webhookTime if timeDifference > 300 || timeDifference < -300 { // 5 minutes log.Println("Webhook timestamp too old or too far in future") return false } // Create the signed payload signedPayload := timestamp + body // Decode signature signatureBytes, err := base64.StdEncoding.DecodeString(signature) if err != nil { log.Printf("Failed to decode signature: %v", err) return false } // Verify signature return ed25519.Verify(wh.publicKey, []byte(signedPayload), signatureBytes) } func (wh *WebhookHandler) isEventProcessed(eventID string) bool { wh.mutex.RLock() defer wh.mutex.RUnlock() return wh.processedEvents[eventID] } func (wh *WebhookHandler) markEventProcessed(eventID string) { wh.mutex.Lock() defer wh.mutex.Unlock() wh.processedEvents[eventID] = true log.Printf("Marking event as processed: %s", eventID) } func (wh *WebhookHandler) handleWebhookEvent(event WebhookEvent) { // Make processing idempotent if wh.isEventProcessed(event.ID) { log.Printf("Event already processed: %s", event.ID) return } switch event.Event { case "transaction.auto.updated": wh.handleTransactionUpdate(event.Data) case "customer.kyb_status.updated": wh.handleCustomerUpdate(event.Data) default: log.Printf("Unknown event type: %s", event.Event) } // Mark as processed wh.markEventProcessed(event.ID) } func (wh *WebhookHandler) handleTransactionUpdate(data map[string]interface{}) { transactionID, _ := data["id"].(string) status, _ := data["status"].(string) log.Printf("Processing transaction update: %s -> %s", transactionID, status) // Your transaction handling logic here } func (wh *WebhookHandler) handleCustomerUpdate(data map[string]interface{}) { customerID, _ := data["id"].(string) kybStatus, _ := data["kyb_status"].(string) log.Printf("Processing customer update: %s -> %s", customerID, kybStatus) // Your customer handling logic here } func (wh *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Read request body body, err := io.ReadAll(r.Body) if err != nil { log.Printf("Failed to read request body: %v", err) http.Error(w, "Bad request", http.StatusBadRequest) return } defer r.Body.Close() signature := r.Header.Get("X-Webhook-Signature") timestamp := r.Header.Get("X-Webhook-Timestamp") // Verify webhook signature if !wh.verifySignature(signature, timestamp, string(body)) { log.Println("Webhook signature verification failed") http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Parse webhook event var event WebhookEvent if err := json.Unmarshal(body, &event); err != nil { log.Printf("Failed to parse webhook event: %v", err) http.Error(w, "Bad request", http.StatusBadRequest) return } log.Printf("Received verified webhook: %s %s", event.Event, event.ID) // Process the event wh.handleWebhookEvent(event) // Respond with 200 to acknowledge receipt w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } func main() { publicKeyHex := os.Getenv("DAKOTA_WEBHOOK_PUBLIC_KEY") if publicKeyHex == "" { log.Fatal("DAKOTA_WEBHOOK_PUBLIC_KEY environment variable is required") } webhookHandler, err := NewWebhookHandler(publicKeyHex) if err != nil { log.Fatalf("Failed to create webhook handler: %v", err) } http.Handle("/webhooks/dakota", webhookHandler) port := os.Getenv("PORT") if port == "" { port = "3000" } log.Printf("Starting webhook server on port %s", port) log.Fatal(http.ListenAndServe(":"+port, nil)) } ``` # Managing Webhooks ## List Webhooks [List all webhook targets](/api-reference/webhooks/list-webhook-targets) configured for your account: ```bash cURL theme={null} curl -X GET https://api.platform.dakota.xyz/webhooks/targets \ -H "X-API-Key: your-api-key" ``` ```javascript JavaScript theme={null} const response = await fetch('https://api.platform.dakota.xyz/webhooks/targets', { headers: { 'X-API-Key': 'your-api-key' } }); const webhooks = await response.json(); console.log(`Found ${webhooks.data.length} webhook targets`); ``` ```python Python theme={null} import requests response = requests.get( 'https://api.platform.dakota.xyz/webhooks/targets', headers={'X-API-Key': 'your-api-key'} ) webhooks = response.json() print(f'Found {len(webhooks["data"])} webhook targets') ``` ```go Go theme={null} package main import ( "net/http" ) func main() { client := &http.Client{} req, _ := http.NewRequest("GET", "https://api.platform.dakota.xyz/webhooks/targets", nil) req.Header.Add("X-API-Key", "your-api-key") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); let response = client .get("https://api.platform.dakota.xyz/webhooks/targets") .headers(headers) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; public class DakotaListWebhooksExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/webhooks/targets")) .header("X-API-Key", "your-api-key") .GET() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ## Update Webhook [Update an existing webhook target](/api-reference/webhooks/update-a-webhook-target): ```bash cURL theme={null} curl -X PATCH https://api.platform.dakota.xyz/webhooks/targets/{webhook_id} \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "global": false }' ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/webhooks/targets/${webhookId}`, { method: 'PATCH', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' }, body: JSON.stringify({ global: false }) }); ``` ```python Python theme={null} import requests import uuid response = requests.patch( f'https://api.platform.dakota.xyz/webhooks/targets/{webhook_id}', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json' }, json={'global': False} ) ``` ```go Go theme={null} package main import ( "bytes" "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} body := bytes.NewBufferString(`{"global": false}`) req, _ := http.NewRequest("PATCH", "https://api.platform.dakota.xyz/webhooks/targets/" + webhookId, body) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) req.Header.Add("Content-Type", "application/json") resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); headers.insert("Content-Type", HeaderValue::from_static("application/json")); let body = json!({"global": false}); let response = client .patch(&format!("https://api.platform.dakota.xyz/webhooks/targets/{}", webhook_id)) .headers(headers) .json(&body) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaUpdateWebhookExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); String body = "{\"global\": false}"; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/webhooks/targets/" + webhookId)) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .header("Content-Type", "application/json") .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` ## Delete Webhook [Delete a webhook target](/api-reference/webhooks/delete-a-webhook-target): ```bash cURL theme={null} curl -X DELETE https://api.platform.dakota.xyz/webhooks/targets/{webhook_id} \ -H "X-API-Key: your-api-key" \ -H "X-Idempotency-Key: $(uuidgen)" ``` ```javascript JavaScript theme={null} const response = await fetch(`https://api.platform.dakota.xyz/webhooks/targets/${webhookId}`, { method: 'DELETE', headers: { 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': crypto.randomUUID() } }); if (response.ok) { console.log('Webhook deleted successfully'); } ``` ```python Python theme={null} import requests import uuid response = requests.delete( f'https://api.platform.dakota.xyz/webhooks/targets/{webhook_id}', headers={ 'X-API-Key': 'your-api-key', 'X-Idempotency-Key': str(uuid.uuid4()) } ) if response.status_code == 204: print('Webhook deleted successfully') ``` ```go Go theme={null} package main import ( "net/http" "github.com/google/uuid" ) func main() { client := &http.Client{} req, _ := http.NewRequest("DELETE", "https://api.platform.dakota.xyz/webhooks/targets/" + webhookId, nil) req.Header.Add("X-API-Key", "your-api-key") req.Header.Add("X-Idempotency-Key", uuid.New().String()) resp, _ := client.Do(req) defer resp.Body.Close() } ``` ```rust Rust theme={null} use reqwest::header::{HeaderMap, HeaderValue}; use uuid::Uuid; #[tokio::main] async fn main() -> Result<(), Box> { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); headers.insert("X-API-Key", HeaderValue::from_static("your-api-key")); headers.insert("X-Idempotency-Key", HeaderValue::from_str(&Uuid::new_v4().to_string())?); let response = client .delete(&format!("https://api.platform.dakota.xyz/webhooks/targets/{}", webhook_id)) .headers(headers) .send() .await?; Ok(()) } ``` ```java Java theme={null} import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.URI; import java.util.UUID; public class DakotaDeleteWebhookExample { public static void main(String[] args) throws Exception { HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.platform.dakota.xyz/webhooks/targets/" + webhookId)) .header("X-API-Key", "your-api-key") .header("X-Idempotency-Key", UUID.randomUUID().toString()) .DELETE() .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); } } ``` # Webhook Delivery ## Retry Policy Dakota automatically retries failed webhook deliveries using exponential backoff over approximately 48 hours: | Attempt | Delay After Failure | | ---------- | ------------------- | | 1 | 5 minutes | | 2 | 10 minutes | | 3 | 15 minutes | | 4 | 30 minutes | | 5 | 1 hour | | 6 | 2 hours | | 7 | 4 hours | | 8 | 8 hours | | 9 | 12 hours | | 10 (final) | 20 hours | **Retry behavior:** * **Max attempts**: 10 total * **Total retry window**: \~48 hours * **Timeout per attempt**: 20 seconds ## Success Criteria A webhook delivery is considered **successful** when: * Your endpoint returns an HTTP status code in the `2xx` range (200-299) * Response is received within 20 seconds A webhook delivery **fails** and triggers a retry when: * Your endpoint returns a non-2xx status code (4xx, 5xx) * Connection timeout (20 seconds exceeded) * Connection refused or DNS failure ## Failure Handling If all 10 delivery attempts fail: * Webhook is marked as permanently failed * You can view failed webhooks in the dashboard * Failed webhooks can be manually retried ## Event Ordering **Webhooks are not guaranteed to arrive in lifecycle order.** Two events fired close together for the same resource (e.g. `wallet.transaction.updated` for `Broadcasted` and `Success` on a fast testnet, or `transaction.auto.deposit.received` and `transaction.auto.deposit.succeeded` for an instant rail) may reach your endpoint out of order due to network races, retry timing, and the fact that the envelope `created` field is second-resolution. This is most visible in **sandbox** because testnet broadcasts and the simulate endpoints transition states sub-second. In production, longer chain confirmations and bank-rail latencies usually space the events out enough that ordering is incidental — but the contract still does not guarantee it. **How to handle it:** * **Treat `status` as the source of truth, not the order of arrival.** Each event carries the full resource state; the latest event by terminal-state semantics wins. * **Use `X-Dakota-Event-ID` for idempotency** — duplicate deliveries (retries or out-of-order replays) carry the same ID. * **For ordered processing, derive a sequence from the resource's own state machine.** For wallet transactions: `Waiting For Signature → Broadcasted → Success | Failed`. For auto-account deposits: `received → succeeded | failed`. If you receive a "later" status before an "earlier" one, accept it; the earlier one is informational. * **Don't strictly require monotonic timestamps** — `created` and `X-Webhook-Timestamp` are second-resolution, and two events can share the same value. If your processing must be strictly serial per resource, queue incoming events by resource ID (e.g. `wallet_id`, `transaction_id`) and reorder by status before applying. ## Webhook Headers Every webhook request includes these headers: | Header | Description | | --------------------------- | ---------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `Dakota-Webhooks/1.0` | | `X-Dakota-Event-ID` | Unique event identifier (use for idempotency) | | `X-Dakota-Event-Type` | The type of event (e.g., `transaction.auto.updated`) | | `X-Dakota-Delivery-Attempt` | Current attempt number (1-10) | | `X-Webhook-Signature` | Ed25519 signature (base64 encoded) | | `X-Webhook-Timestamp` | Unix timestamp when webhook was sent | Use `X-Dakota-Event-ID` for idempotency - it remains the same across all retry attempts for the same event. # Best Practices ## Endpoint Requirements * **HTTPS only**: Dakota Platform only sends webhooks to HTTPS endpoints * **Fast response**: Respond within 30 seconds * **2xx status codes**: Return 200-299 status for successful processing * **Idempotent**: Handle duplicate webhooks gracefully ## Security * Always verify webhook signatures * Use HTTPS for your webhook endpoint * Validate event data before processing * Log webhook events for debugging ## Processing Best Practices Implement robust webhook event processing with error handling, retries, and idempotency: ```javascript Node.js theme={null} // Robust webhook event processing with error handling class WebhookProcessor { constructor() { this.processedEvents = new Set(); // Use Redis/database in production this.maxRetries = 3; } async handleWebhookEvent(event) { const eventId = event.id; // Make processing idempotent if (this.isEventAlreadyProcessed(eventId)) { console.log('Event already processed:', eventId); return { success: true, message: 'Already processed' }; } try { await this.processEventWithRetry(event); this.markEventAsProcessed(eventId); return { success: true, message: 'Event processed successfully' }; } catch (error) { console.error('Failed to process event after retries:', error); await this.storeFailedEvent(event, error); throw error; } } async processEventWithRetry(event, attempt = 1) { try { await this.processEvent(event); } catch (error) { if (attempt < this.maxRetries) { const delay = Math.pow(2, attempt) * 1000; // Exponential backoff console.log(`Retrying event processing in ${delay}ms (attempt ${attempt + 1})`); await new Promise(resolve => setTimeout(resolve, delay)); return this.processEventWithRetry(event, attempt + 1); } throw error; } } async processEvent(event) { console.log(`Processing event: ${event.event} (${event.id})`); switch (event.event) { case 'transaction.auto.created': await this.handleTransactionCreated(event.data); break; case 'transaction.auto.updated': // Inspect event.data.status to distinguish processing/completed/failed await this.handleTransactionUpdate(event.data); break; case 'customer.kyb_status.updated': await this.handleCustomerKybUpdate(event.data); break; case 'customer.created': await this.handleCustomerCreated(event.data); break; default: console.log(`Unknown event type: ${event.event}`); } } async handleTransactionUpdate(data) { console.log(`Transaction ${data.id} status: ${data.previous_status} → ${data.status}`); await this.updateTransactionStatus(data.id, data.status); if (data.status === 'completed') { await this.notifyTransactionCompleted(data); } else if (data.status === 'failed') { await this.notifyTransactionFailed(data); } } async handleTransactionCompleted(data) { console.log(`Transaction completed: ${data.id}`); await this.updateTransactionStatus(data.id, 'completed'); await this.notifyTransactionCompleted(data); await this.triggerPostCompletionActions(data); } async handleTransactionFailed(data) { console.log(`Transaction failed: ${data.id}`, data.error); await this.updateTransactionStatus(data.id, 'failed'); await this.notifyTransactionFailed(data); await this.handleTransactionFailure(data); } async handleCustomerKybUpdate(data) { console.log(`Customer ${data.id} KYB status: ${data.previous_status} → ${data.kyb_status}`); await this.updateCustomerKybStatus(data.id, data.kyb_status); if (data.kyb_status === 'active') { await this.notifyCustomerApproved(data); await this.enableCustomerFeatures(data.id); } else if (data.kyb_status === 'rejected') { await this.notifyCustomerRejected(data); await this.handleKybRejection(data); } } async handleCustomerCreated(data) { console.log(`New customer created: ${data.id}`); await this.syncCustomerToDatabase(data); await this.sendWelcomeEmail(data); } isEventAlreadyProcessed(eventId) { return this.processedEvents.has(eventId); } markEventAsProcessed(eventId) { this.processedEvents.add(eventId); console.log('Marked event as processed:', eventId); } async storeFailedEvent(event, error) { console.log('Storing failed event for review:', event.id, error.message); // Store in database for manual review } // Business logic methods (implement according to your needs) async updateTransactionStatus(transactionId, status) { /* Your implementation */ } async notifyTransactionCompleted(data) { /* Your implementation */ } async notifyTransactionFailed(data) { /* Your implementation */ } async triggerPostCompletionActions(data) { /* Your implementation */ } async handleTransactionFailure(data) { /* Your implementation */ } async updateCustomerKybStatus(customerId, status) { /* Your implementation */ } async notifyCustomerApproved(data) { /* Your implementation */ } async notifyCustomerRejected(data) { /* Your implementation */ } async enableCustomerFeatures(customerId) { /* Your implementation */ } async handleKybRejection(data) { /* Your implementation */ } async syncCustomerToDatabase(data) { /* Your implementation */ } async sendWelcomeEmail(data) { /* Your implementation */ } } // Usage in Express route const webhookProcessor = new WebhookProcessor(); app.post('/webhooks/dakota', async (req, res) => { // ... signature verification code ... try { const result = await webhookProcessor.handleWebhookEvent(event); console.log(result.message); res.status(200).send('OK'); } catch (error) { console.error('Webhook processing failed:', error); res.status(200).send('Processing failed but acknowledged'); } }); ``` ```python Python theme={null} # Python implementation here - abbreviated for clarity ``` ```go Go theme={null} # Go implementation here - abbreviated for clarity ``` ```rust Rust theme={null} # Rust implementation here - abbreviated for clarity ``` ```java Java theme={null} # Java implementation here - abbreviated for clarity ``` # Testing Webhooks ## Local Development Use tools like ngrok to expose local endpoints: ```bash theme={null} ngrok http 3000 # Use the HTTPS URL for webhook registration ``` ## Webhook Testing Test your webhook endpoint manually: ```bash theme={null} curl -X POST https://your-app.com/webhooks/dakota \ -H "Content-Type: application/json" \ -H "X-Webhook-Signature: base64_encoded_signature" \ -H "X-Webhook-Timestamp: $(date +%s)" \ -d '{ "event": "transaction.auto.updated", "data": { "id": "31TgvtxUdXi95dUN4M8X1rhSCNS", "status": "completed" } }' ``` # Troubleshooting ## Common Issues **Webhooks Not Received** * Check that your endpoint returns 2xx status * Verify your URL is accessible from the internet * Ensure HTTPS is properly configured * Check firewall/proxy settings **Signature Verification Failing** * Ensure you're using Dakota Platform's correct Ed25519 public key * Verify the signature calculation matches our Ed25519 implementation * Check that the timestamp and payload haven't been modified * Ensure you're using the correct header names (`X-Webhook-Signature`, `X-Webhook-Timestamp`) **Duplicate Webhooks** * Implement idempotency using the event ID * Store processed event IDs to prevent duplicates * Use database constraints where possible ## Monitoring * Set up alerts for webhook failures * Log all webhook events for debugging * Monitor endpoint response times * Track webhook delivery success rates # Event Types Reference Based on the platform's actual event definitions, here are the available webhook event types: | Event | Description | | ------------------------------------ | --------------------------------------------- | | **Customer Events** | | | `customer.created` | New customer created | | `customer.updated` | Customer information updated | | `customer.kyb_status.created` | Customer KYB status created | | `customer.kyb_status.updated` | Customer KYB status changed | | `customer.kyb_link.created` | Customer KYB link created | | `customer.kyb_link.updated` | Customer KYB link updated | | `customer.kyb_application.submitted` | Customer KYB application submitted for review | | **Transaction Events** | | | `transaction.auto.created` | Auto account transaction created | | `transaction.auto.updated` | Auto account transaction updated | | `transaction.one_off.created` | One-off transaction created | | `transaction.one_off.updated` | One-off transaction updated | | **Account Events** | | | `auto_account.created` | Auto account created | | `auto_account.updated` | Auto account updated | | `auto_account.deleted` | Auto account deleted | | **Wallet Events** | | | `wallet.created` | Non-custodial wallet created | | `wallet.updated` | Wallet metadata or configuration changed | | `wallet.deposit` | On-chain deposit detected at a wallet address | | `wallet.transaction.created` | Wallet transaction submitted | | `wallet.transaction.updated` | Wallet transaction status or details changed | | `wallet.policy.created` | Policy created and attached to a wallet | | `wallet.policy.updated` | Policy rules or metadata updated | | `wallet.signer_group.created` | Signer group created | | `wallet.signer_group.updated` | Signer group membership or metadata updated | | **Other Events** | | | `user.created` | User created | | `user.updated` | User updated | | `user.deleted` | User deleted | | `api_key.created` | API key created | | `api_key.deleted` | API key deleted | | `recipient.created` | Recipient created | | `recipient.updated` | Recipient updated | | `recipient.deleted` | Recipient deleted | | `destination.created` | Destination created | | `destination.deleted` | Destination deleted | | `target.created` | Target created | | `target.updated` | Target updated | | `target.deleted` | Target deleted | | `exception.created` | Exception created | | `exception.cleared` | Exception cleared | # Next Steps After setting up webhooks: 1. **[Testing Your Integration](testing)** - Test webhook delivery and processing 2. **[Transactions](/documentation/common-flows)** - Process transactions that trigger webhook events 3. **[Customer Onboarding](customer-onboarding)** - Set up KYB status webhooks # API Reference For detailed endpoint documentation, see: * [Webhooks API Reference](/api-reference/webhooks/create-a-webhook-target) - Webhook target management * [Events API Reference](/api-reference/events/list-events) - Event types and payload formats # Get Started Source: https://docs.dakota.xyz/index Welcome to Dakota!
Dakota Platform
Dakota is a regulated stablecoin infrastructure platform that enables fintechs and enterprises to embed programmable global money movement through APIs Whether you’re building a global treasury, a payments product, a fintech app, or embedding stablecoin-powered accounts into your own software, Dakota gives you the primitives to move, hold, and manage USD globally. Seamlessly create stablecoin-backed business accounts, issue and manage wallets, orchestrate on- and off-ramps, move funds globally in real time, and automate money flows through a single, unified API. Set granular controls, compliance guardrails, and settlement logic while Dakota abstracts banking partners, custody, and blockchain infrastructure behind the scenes. Our documentation makes it easy to integrate Dakota into your product, so you can ship global financial workflows that feel like software, not banking. ## Quick Start [Contact our sales team](https://dakota.xyz/talk-to-sales) to set up your Dakota account. Once approved, you'll receive credentials to access the dashboard. Log into the Dakota Dashboard and navigate to **API Keys** to create a new key: * **Sandbox**: [platform.sandbox.dakota.xyz](https://platform.sandbox.dakota.xyz) * **Production**: [platform.dakota.xyz](https://platform.dakota.xyz) For TypeScript and Go, an official SDK is the fastest way to integrate: * **TypeScript** — [github.com/dakota-xyz/dakota-ts-sdk](https://github.com/dakota-xyz/dakota-ts-sdk) ```bash theme={null} npm install @dakota-xyz/ts-sdk ``` * **Go** — [github.com/dakota-xyz/go-sdk](https://github.com/dakota-xyz/go-sdk) ```bash theme={null} go get github.com/dakota-xyz/go-sdk ``` See the [SDKs & Libraries guide](/documentation/sdks) for installation, authentication, and quick-start code. You can skip this step and call the REST API directly with any HTTP client. Use your API key with the sandbox base URL to test your integration. **Raw HTTP:** ```bash theme={null} curl -X GET https://api.platform.sandbox.dakota.xyz/customers \ -H "x-api-key: YOUR_API_KEY" ``` **TypeScript SDK:** ```typescript theme={null} import { DakotaClient, Environment } from '@dakota-xyz/ts-sdk'; const client = new DakotaClient({ apiKey: process.env.DAKOTA_API_KEY, environment: Environment.Sandbox, }); for await (const customer of client.customers.list()) { console.log(customer.name); } ``` Learn how to authenticate your API requests Explore all available API endpoints ***