Cross-team transfers let two separate Dino workspaces move funds between their wallets. The sender initiates a transfer; the recipient must explicitly accept before any balance moves. Both sides get a full ledger record.
This is a platform book transfer — no Stripe transfer or wire occurs. Only USD is supported for now.
#How it works
Sender creates transfer → pending_acceptance
Recipient accepts → completed (both wallets update atomically)
Recipient declines → declined (no funds move)
Sender cancels → cancelled (no funds move)
The sender identifies the recipient using one of two methods:
| Method | Format | Expires |
|---|---|---|
| Payment code | CTX-XXXX-XXXX | Up to 30 days (configurable) |
| Receive address | mid1… (36 chars) | Never |
Use payment codes for one-off requests. Use receive addresses when you want a stable identifier the sender can save — like a wallet address.
#Dashboard
Go to Agent Bank → Transfers (/treasury/transfers).
Send tab — paste a payment code or mid1… address into the input field. The recipient workspace name resolves automatically. Enter an amount and send. The transfer appears under Pending until the recipient acts.
Request tab — share your payment code or permanent address with the sender. Payment codes auto-generate and expire after 30 days. Your permanent mid1… address never expires and can be rotated any time.
Pending section — incoming transfers show Accept and Decline buttons. Outgoing transfers show a Cancel button.
History section — completed, declined, and cancelled transfers appear below the pending list, sorted newest-first.
#REST API
All endpoints are under https://api.dino.id/v1/transfers. Authenticate with a Dino spending key (din_…) or a workspace API key (mid_…) with the appropriate scope.
#Endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /v1/transfers/receive-address | spend.read | Get or create your permanent receive address |
GET | /v1/transfers/receive-address/resolve | spend.read | Look up a mid1… address before sending |
POST | /v1/transfers/payment-codes | spend.write | Create a one-time payment code |
GET | /v1/transfers/payment-codes/resolve | spend.read | Look up a payment code before sending |
POST | /v1/transfers | spend.write | Initiate a transfer |
GET | /v1/transfers | spend.read | List transfers |
GET | /v1/transfers/{id} | spend.read | Get a single transfer |
POST | /v1/transfers/{id}/accept | spend.write | Accept (recipient only) |
POST | /v1/transfers/{id}/decline | spend.write | Decline (recipient only) |
POST | /v1/transfers/{id}/cancel | spend.write | Cancel (sender only) |
#Get your receive address
curl -sS "https://api.dino.id/v1/transfers/receive-address" \
-H "Authorization: Bearer YOUR_KEY"
{
"id": "...",
"financial_account_id": "...",
"encoded_address": "mid1qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9xs",
"status": "active",
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}
Share encoded_address with anyone who wants to send you funds. It never expires.
#Create a payment code
curl -sS -X POST "https://api.dino.id/v1/transfers/payment-codes" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "expires_in_hours": 72 }'
{
"id": "...",
"code": "ctx_A1b2C3d4E5f6G7h8",
"financial_account_id": "...",
"expires_at": "2026-01-04T00:00:00.000Z",
"created_at": "2026-01-01T00:00:00.000Z"
}
#Initiate a transfer
Identify the recipient by payment code or receive address. Always provide an idempotency_key.
# Via receive address
curl -sS -X POST "https://api.dino.id/v1/transfers" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"from_financial_account_id": "YOUR_FINANCIAL_ACCOUNT_UUID",
"recipient_receive_address": "mid1qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9xs",
"amount_cents": 5000,
"currency": "usd",
"idempotency_key": "transfer-order-42-v1"
}'
# Via payment code
curl -sS -X POST "https://api.dino.id/v1/transfers" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"from_financial_account_id": "YOUR_FINANCIAL_ACCOUNT_UUID",
"recipient_payment_code": "ctx_A1b2C3d4E5f6G7h8",
"amount_cents": 5000,
"currency": "usd",
"idempotency_key": "transfer-order-42-v1"
}'
Response (201 for new, 200 for idempotent replay):
{
"id": "3f4e5a6b-...",
"status": "pending_acceptance",
"from_team_id": "...",
"from_financial_account_id": "...",
"to_team_id": "...",
"to_financial_account_id": "...",
"amount_cents": 5000,
"currency": "usd",
"idempotency_key": "transfer-order-42-v1",
"idempotent_replay": false,
"accepted_at": null,
"completed_at": null,
"cancelled_at": null,
"declined_at": null,
"created_at": "2026-01-01T00:00:00.000Z",
"updated_at": "2026-01-01T00:00:00.000Z"
}
#Recipient accepts
curl -sS -X POST "https://api.dino.id/v1/transfers/3f4e5a6b-.../accept" \
-H "Authorization: Bearer RECIPIENT_KEY"
Balances update atomically. The response is the same transfer object with status: "completed" and completed_at set.
#List transfers
# All transfers for this team
curl -sS "https://api.dino.id/v1/transfers" \
-H "Authorization: Bearer YOUR_KEY"
# Only incoming
curl -sS "https://api.dino.id/v1/transfers?direction=incoming" \
-H "Authorization: Bearer YOUR_KEY"
{
"data": [
{ "id": "...", "status": "completed", "amount_cents": 5000, ... }
]
}
Query params: direction (incoming | outgoing | all, default all), limit (max 200, default 100).
#TypeScript SDK
Install:
npm install @dino/agent-spend-sdk
import { DinoAgentSpendClient } from "@dino/agent-spend-sdk";
const sender = new DinoAgentSpendClient({ apiKey: "din_..." });
const recipient = new DinoAgentSpendClient({ apiKey: "din_..." });
// Recipient: get permanent address
const { encoded_address } = await recipient.getReceiveAddress();
// Sender: optional preflight — confirm who you're sending to
const info = await sender.resolveReceiveAddress(encoded_address);
console.log(info.to_team_display_name); // "Acme Corp"
// Sender: initiate transfer
const transfer = await sender.createTransfer({
from_financial_account_id: "YOUR_WALLET_UUID",
recipient_receive_address: encoded_address,
amount_cents: 5000,
currency: "usd",
idempotency_key: crypto.randomUUID(),
});
// Recipient: accept
await recipient.acceptTransfer(transfer.id);
// Either side: check status
const updated = await sender.getTransfer(transfer.id);
console.log(updated.status); // "completed"
// Either side: list history
const { data } = await sender.listTransfers({ direction: "outgoing" });
#Payment code flow (alternative to receive address)
// Recipient: create a one-time code
const { code } = await recipient.createPaymentCode({ expires_in_hours: 72 });
// Share code out-of-band
// Sender: resolve code (optional preflight)
const info = await sender.resolvePaymentCode(code);
// Sender: send
await sender.createTransfer({
from_financial_account_id: "...",
recipient_payment_code: code,
amount_cents: 5000,
currency: "usd",
idempotency_key: crypto.randomUUID(),
});
#Transfer statuses
| Status | Meaning |
|---|---|
pending_acceptance | Created; waiting for recipient |
processing | Acceptance in flight (ledger writes) |
completed | Funds moved; both wallets updated |
failed | Terminal failure during processing |
cancelled | Sender cancelled before acceptance |
declined | Recipient declined |
#Error codes
| HTTP | Code | Meaning |
|---|---|---|
400 | invalid_request | Bad body, missing required field, or only-USD enforcement |
400 | currency_mismatch | Recipient address/code currency ≠ transfer currency |
400 | same_team | Cannot transfer to your own workspace |
400 | account_not_found | from_financial_account_id not found or currency mismatch |
400 | transfer_not_pending | Transfer already accepted, declined, or cancelled |
402 | insufficient_balance | Sender wallet balance too low |
402 | daily_cap_exceeded | Team or account daily transfer cap hit |
403 | account_inactive | One or both accounts inactive |
404 | not_found | Transfer, payment code, or receive address not found |
412 | not_provisioned | Cross-team tables not yet migrated — contact support |
Insufficient balance and daily cap errors are hard failures. Do not auto-retry — the balance or cap state will not change without external action.
#Daily transfer caps
Dino enforces per-team and per-account daily transfer caps to limit blast radius. If your team hits a cap, POST /v1/transfers/{id}/accept returns 402 daily_cap_exceeded. The cap resets at UTC midnight.
Cap amounts depend on your team's plan. Contact support to discuss higher limits.
#Workspace API keys
You can use a workspace API key (mid_…) instead of a spending key. Workspace keys require explicit scopes:
spend.read— list, get, resolvespend.write— create, accept, decline, cancel, create payment codes
When using a workspace key, always provide from_financial_account_id explicitly — there is no default agent account to infer from.