Skip to main content
Portfolio wallet withdrawals can require cryptographic approval through Turnkey before execution. When that happens, Ground creates a Turnkey signing activity and your server approves it using the Turnkey SDK.
Ground uses Turnkey to manage signing flows, but you do not need a direct relationship with Turnkey to sign approvals.

When approvals happen

  • Withdrawals (POST /v2/wallets/{id}/withdraw): each payout leg transitions to pending_customer_approval when it’s ready for signing. Strategy updates (PATCH /v2/wallets/{id}/strategy) return the updated wallet object directly. They do not surface a Turnkey payout approval flow.
At most one customer approval can be outstanding per walletId + chain. If you create multiple withdrawals rapidly, later payouts queue behind the earlier approval automatically.

Detecting a pending approval

When a payout leg needs approval, it exposes three fields:
FieldDescription
turnkeyActivityIdThe Turnkey activity ID to approve
approvalRequestedAtWhen the approval was requested
approvalValidBeforeISO-8601 deadline — the approval must be submitted before this time
These fields are only populated while the payout status is pending_customer_approval. Once the payout advances (e.g. broadcasted, completed), they return null. Poll GET /v2/wallets/:id/withdrawals/:withdrawalId or subscribe to the portfolio_wallet.withdrawal.status_changed webhook to detect when a payout enters pending_customer_approval.

Approving an activity

  1. Capture the turnkeyActivityId from a payout leg in the withdrawal response or webhook.
  2. Fetch the activity from Turnkey to get its fingerprint.
  3. Verify the transaction details (see best practice below).
  4. Submit an approval vote via the Turnkey SDK.
  5. Processing continues automatically once approved.
Node
import { Turnkey } from "@turnkey/sdk-server";

// Replace with your real credentials
const TURNKEY_API_PUBLIC_KEY = "04aa...your_public_key...ff";
const TURNKEY_API_PRIVATE_KEY = "11bb...your_private_key...ee";
const TURNKEY_SUB_ORGANIZATION_ID = "your_turnkey_sub_organization_id";

const turnkey = new Turnkey({
  apiBaseUrl: "https://api.turnkey.com",
  defaultOrganizationId: TURNKEY_SUB_ORGANIZATION_ID,
  apiPublicKey: TURNKEY_API_PUBLIC_KEY,
  apiPrivateKey: TURNKEY_API_PRIVATE_KEY,
});

const apiClient = turnkey.apiClient();

export async function approveTurnkeyActivity(turnkeyActivityId) {
  // 1) Fetch the activity to get its fingerprint
  const { activity } = await apiClient.getActivity({
    organizationId: TURNKEY_SUB_ORGANIZATION_ID,
    activityId: turnkeyActivityId,
  });

  const fingerprint = activity?.fingerprint;
  if (!fingerprint) {
    throw new Error("Turnkey activity fingerprint missing");
  }

  // 2) Approve it (registers your approval vote in Turnkey)
  await apiClient.approveActivity({
    type: "ACTIVITY_TYPE_APPROVE_ACTIVITY",
    timestampMs: String(Date.now()),
    organizationId: TURNKEY_SUB_ORGANIZATION_ID,
    parameters: { fingerprint },
  });
}
If Turnkey returns activity.status: ACTIVITY_STATUS_CONSENSUS_NEEDED, additional approval votes are required per your Turnkey policy/quorum. Poll getActivity until approved, or subscribe to Turnkey webhooks.

Verify before you approve

Never blindly approve a Turnkey activity. Always verify the transaction details match what you expect before signing.
When your server receives a pending_customer_approval signal, it should programmatically verify the transaction before approving. This is critical because an approval is an irreversible cryptographic signature — once signed and broadcast, the transaction cannot be reversed. What to verify:
  1. Destination address — confirm the payout’s destinationAddress matches the address your system originally submitted in the withdrawal request. Reject if it doesn’t match.
  2. Amount — confirm the payout amount is reasonable for the withdrawal and yield source. Do not assume it must exactly match the originally requested amount for asynchronous sources.
  3. Chain and token — confirm chain and token match your expectations.
  4. Withdrawal correlation — match the turnkeyActivityId back to a withdrawal your system actually initiated. Reject orphaned or unexpected approval requests.
Example verification flow:
async function verifyAndApprove(payout, originalWithdrawal) {
  // 1. Correlate: did we actually request this withdrawal?
  if (!originalWithdrawal) {
    throw new Error("No matching withdrawal found -- rejecting approval");
  }

  // 2. Verify destination address
  if (payout.destinationAddress.toLowerCase() !== originalWithdrawal.destinationAddress.toLowerCase()) {
    throw new Error("Destination address mismatch -- rejecting approval");
  }

  // 3. Verify chain
  if (payout.chain !== originalWithdrawal.destinationChain) {
    throw new Error("Chain mismatch -- rejecting approval");
  }

  // 4. Approve after your amount checks for this yield source pass
  await approveTurnkeyActivity(payout.turnkeyActivityId);
}
This pattern protects against compromised intermediaries, replay attacks, and bugs that could cause funds to be sent to unintended destinations. Treat every approval request as untrusted input until your system has independently verified it.

Automation patterns

Two patterns work well for handling approvals:
  1. Webhook-driven — subscribe to portfolio_wallet.withdrawal.status_changed. When a payout enters pending_customer_approval, verify and approve it.
  2. Polling-driven — poll GET /v2/wallets/:id/withdrawals/:withdrawalId and check payout statuses.
Recommended: use webhooks as the primary trigger, with polling as a backstop (webhooks are best-effort delivery). See Webhooks for how to register and verify webhook deliveries.

Status updates

Subscribe to these events to monitor progress:
  • portfolio_wallet.withdrawal.status_changed
  • portfolio_wallet.withdrawal.payout.status_changed
If approval-related execution fails, the withdrawal eventually moves to failed with a failureReason.