Cross-Team Transfers

Send and receive funds between agent workspaces using payment codes or permanent receive addresses.

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:

MethodFormatExpires
Payment codeCTX-XXXX-XXXXUp to 30 days (configurable)
Receive addressmid1… (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

MethodPathScopeDescription
GET/v1/transfers/receive-addressspend.readGet or create your permanent receive address
GET/v1/transfers/receive-address/resolvespend.readLook up a mid1… address before sending
POST/v1/transfers/payment-codesspend.writeCreate a one-time payment code
GET/v1/transfers/payment-codes/resolvespend.readLook up a payment code before sending
POST/v1/transfersspend.writeInitiate a transfer
GET/v1/transfersspend.readList transfers
GET/v1/transfers/{id}spend.readGet a single transfer
POST/v1/transfers/{id}/acceptspend.writeAccept (recipient only)
POST/v1/transfers/{id}/declinespend.writeDecline (recipient only)
POST/v1/transfers/{id}/cancelspend.writeCancel (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

StatusMeaning
pending_acceptanceCreated; waiting for recipient
processingAcceptance in flight (ledger writes)
completedFunds moved; both wallets updated
failedTerminal failure during processing
cancelledSender cancelled before acceptance
declinedRecipient declined

#Error codes

HTTPCodeMeaning
400invalid_requestBad body, missing required field, or only-USD enforcement
400currency_mismatchRecipient address/code currency ≠ transfer currency
400same_teamCannot transfer to your own workspace
400account_not_foundfrom_financial_account_id not found or currency mismatch
400transfer_not_pendingTransfer already accepted, declined, or cancelled
402insufficient_balanceSender wallet balance too low
402daily_cap_exceededTeam or account daily transfer cap hit
403account_inactiveOne or both accounts inactive
404not_foundTransfer, payment code, or receive address not found
412not_provisionedCross-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, resolve
  • spend.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.