Portfolio wallet withdrawals and strategy updates require cryptographic approval through Turnkey before execution. 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): returns status: pending_approval when the activity requires approval.
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:
| Field | Description |
|---|
turnkeyActivityId | The Turnkey activity ID to approve |
approvalRequestedAt | When the approval was requested |
approvalValidBefore | ISO-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
- Capture the
turnkeyActivityId from the response or webhook.
- Fetch the activity from Turnkey to get its
fingerprint.
- Verify the transaction details (see best practice below).
- Submit an approval vote via the Turnkey SDK.
- Processing continues automatically once approved.
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:
- Destination address — confirm the payout’s
destinationAddress matches the address your system originally submitted in the withdrawal request. Reject if it doesn’t match.
- Amount — confirm the payout
amount is consistent with the withdrawal you requested. Flag unexpected discrepancies.
- Chain and token — confirm
chain and token match your expectations.
- 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. Verify amount is within acceptable tolerance
const tolerance = 0.01; // 1%
const ratio = payout.amount / parseFloat(originalWithdrawal.amountUsd);
if (Math.abs(1 - ratio) > tolerance) {
throw new Error(`Amount deviation too large (${ratio}) -- rejecting approval`);
}
// All checks passed -- approve
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:
- Webhook-driven — subscribe to
portfolio_wallet.withdrawal.status_changed. When a payout enters pending_customer_approval, verify and approve it.
- 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.strategy.status_changed
portfolio_wallet.withdrawal.status_changed
portfolio_wallet.withdrawal.payout.status_changed
If an activity fails, the withdrawal or strategy update moves to failed with a failureReason.