Documentation Index
Fetch the complete documentation index at: https://docs.dakota.xyz/llms.txt
Use this file to discover all available pages before exploring further.
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.
On this page:
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.
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:
{
"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 for payload shape and signature verification.
- Sandbox: advance KYB manually with the simulation endpoint:
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.
Create a Recipient
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:
{
"id": "2LfXk8NNpSwMHuRP8oKKwi5AKFQ"
}
Reuse the same Recipient for all Destinations belonging to the same counterparty.
Add a Crypto Destination (for onramps and swaps)
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 | — |
solana-mainnet | solana-devnet |
Add a Bank Destination (for offramps)
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.
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.
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:
{
"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.
Simulate the USD Deposit (Sandbox)
In Sandbox you can fire the whole flow end-to-end without actually wiring money. POST /sandbox/simulate/inbound triggers a simulated ACH or Fedwire arrival against your onramp account; the normal webhooks (transaction.auto.created → transaction.auto.updated → wallet.deposit if the destination is a Dakota Wallet) follow asynchronously.
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": "100.00",
"currency": "USD",
"scenario": "success_immediate"
}'
Response (synchronous — the deposit event itself processes async):
{
"simulation_id": "sim_onramp_001",
"state": "accepted",
"trace_id": "trace_abc123"
}
type — ach_inbound or wire_inbound for USD rails; crypto_inbound for wallet-level deposits (uses wallet_address instead of account_id).
scenario — success_immediate (default) or success_delayed with a delay_seconds field (1–86400). Crypto-specific scenarios: wrong_chain, unsupported_token, address_mismatch, partial_crypto, unconfirmed.
simulation_id — your idempotency key for the simulation. Repeating with the same ID + params returns the original response; conflicting params return 409.
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.
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:
{
"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.
Simulate the Crypto Deposit (Sandbox)
In Sandbox you can fire the offramp flow end-to-end without sending real testnet crypto. POST /sandbox/simulate/inbound with type: crypto_inbound accepts the source_crypto_address the offramp account returned as wallet_address, and the normal webhooks (transaction.auto.created → transaction.auto.updated) follow asynchronously.
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_offramp_001",
"type": "crypto_inbound",
"wallet_address": "0xabc...",
"amount": "100.00",
"currency": "USDC",
"scenario": "success_immediate"
}'
Response (synchronous; the deposit event itself processes async):
{
"simulation_id": "sim_offramp_001",
"state": "accepted",
"trace_id": "trace_abc123"
}
wallet_address — the source_crypto_address from the offramp account response. Provider accepts either a raw on-chain address or a Privy external_wallet_id here.
currency — the source stablecoin symbol (e.g. USDC, USDT), matching the offramp account’s source_asset.
scenario — success_immediate (default) or success_delayed with a delay_seconds field (1–86400). Failure scenarios: wrong_chain, unsupported_token, address_mismatch, partial_crypto (with partial_amount), unconfirmed.
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.
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:
{
"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.
Simulate the Crypto Deposit (Sandbox)
In Sandbox you can fire the swap flow end-to-end without sending real testnet crypto. POST /sandbox/simulate/inbound with type: crypto_inbound accepts the source_crypto_address the swap account returned as wallet_address, and the normal webhooks (transaction.auto.created → transaction.auto.updated) follow asynchronously as Dakota converts the source asset and delivers the destination asset to the Destination.
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_swap_001",
"type": "crypto_inbound",
"wallet_address": "0xabc...",
"amount": "100.00",
"currency": "USDC",
"scenario": "success_immediate"
}'
Response (synchronous; the deposit event itself processes async):
{
"simulation_id": "sim_swap_001",
"state": "accepted",
"trace_id": "trace_abc123"
}
wallet_address — the source_crypto_address from the swap account response. Provider accepts either a raw on-chain address or a Privy external_wallet_id here.
currency — the source stablecoin symbol (e.g. USDC, USDT), matching the swap account’s source_asset.
scenario — success_immediate (default) or success_delayed with a delay_seconds field (1–86400). Failure scenarios: wrong_chain, unsupported_token, address_mismatch, partial_crypto (with partial_amount), unconfirmed.
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. Signing reference (intent schema, RFC 8785 canonicalization, DER encoding, the browser P1363→DER trap): Wallet Transaction Signing.
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:
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:
{
"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
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:
{
"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.
The minimal permissive policy — any single member of the signer group can authorize, no other restrictions — is one rule:
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:
{
"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.
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.
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:
{
"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 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).
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.
After attachment, the wallet can submit transactions immediately. You can repeat this call with additional policy_ids 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), 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.
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:
{
"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.
Try It Live
Every flow above runs hands-on in the Interactive API Playground — no code required.
Dedicated References