Crypto-as-a-Service
Transfer Between Go Accounts
Overview
You can transfer assets between Go Accounts off chain. Off-chain transfers (also known as book transfers) are ledger-based movements that BitGo processes internally with no on-chain transaction involved. Because the transfer stays within the BitGo ledger, it settles instantly, incurs no blockchain fees, and assets never leave BitGo custody.
Book transfers use the same build-authenticate-send flow as on-chain withdrawals, with one key difference - you pass the destination Go Account wallet ID as the value of the address field in the recipient object. Because transfers between Go Accounts are off-chain, the don't trigger the Transaction Request webhook. Use the Transfer webhook to monitor these transfers.
Note
When transacting from Go Accounts, ensure ticker symbols use the ofc prefix, which denotes the tokenized asset is off chain. To learn more, see Asset Ticker Symbols.these transfers.
Prerequisites
Cookbook
Need just the steps? Expand the cookbook below to get started:
Transfer Between Go AccountsOpen Cookbook1. Build Transaction
Build a transaction from the source Go Account to the destination Go Account. Pass the destination Go Account wallet ID as the value of the address field in the recipient object — the recipient object uses address, not walletId.
Endpoint: Build a Transaction
export COIN="<CRYPTO_ASSET_ID>" # Crypto asset IDs in Go Accounts always start with "ofc" (such as ofctbtc)
export WALLET_ID="<SOURCE_GO_ACCOUNT_WALLET_ID>"
export ACCESS_TOKEN="<SERVICE_USER_ACCESS_TOKEN>"
export AMOUNT="<AMOUNT_IN_BASE_UNITS>"
export DESTINATION_WALLET_ID="<DESTINATION_GO_ACCOUNT_WALLET_ID>"
curl -X POST \
https://app.bitgo-test.com/api/v2/$COIN/wallet/$WALLET_ID/tx/build \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"recipients": [
{
"amount": "'"$AMOUNT"'",
"address": "'"$DESTINATION_WALLET_ID"'"
}
]
}'
let params = {
recipients: [
{
amount: 1000,
address: '654ec786c07fe6dc0dcfe03916ec4bb0',
},
],
};
wallet.prebuildTransaction(params).then(function (transaction) {
console.dir(transaction);
});
Step Result
The response payload contains shortCircuitBlockchainTransfer: true and isIntraJXTransfer: true, confirming that BitGo recognized this as an off-chain book transfer.
{
"payload": "{\"coin\":\"ofctbtc\",\"recipients\":[{\"address\":\"2NDBpypweakRZzgbWqxEK42pnxgfYzd5iuY\",\"amount\":\"10000\"}],\"fromAccount\":\"63ff43c474598902272894f328452ad7\",\"nonce\":\"80bc93b5-eeec-4c11-afca-f6dae4bba2f2\",\"timestamp\":\"2023-09-28T10:55:39.135Z\",\"feeString\":\"0\",\"shortCircuitBlockchainTransfer\":true,\"isIntraJXTransfer\":true}",
"feeInfo": { "feeString": "0" },
"coin": "ofc",
"token": "ofctbtc"
}
2. Authenticate Transaction
Use your Go Account passphrase to authenticate the transaction. To ensure your passphrase isn't passed over the Internet, you must use BitGo Express in external-signing mode, the JavaScript SDK, or Java. If you're using Express in external-signing mode, configure your Go Account passphrase as the WALLET_<walletId>_PASSPHRASE environment variable.
export BITGO_EXPRESS_HOST="<YOUR_LOCAL_HOST>"
export ACCESS_TOKEN="<SERVICE_USER_ACCESS_TOKEN>"
export WALLET_ID="<SOURCE_GO_ACCOUNT_WALLET_ID>"
export WALLET_PASSPHRASE="<YOUR_GO_ACCOUNT_PASSPHRASE>"
curl -X POST \
http://$BITGO_EXPRESS_HOST/api/v2/ofc/signPayload \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"walletId": "'"$WALLET_ID"'",
"walletPassphrase": "'"$WALLET_PASSPHRASE"'",
"payload": "{\"coin\":\"ofctbtc\",\"recipients\":[{\"address\":\"2NDBpypweakRZzgbWqxEK42pnxgfYzd5iuY\",\"amount\":\"10000\"}],\"fromAccount\":\"63ff43c474598902272894f328452ad7\",\"nonce\":\"80bc93b5-eeec-4c11-afca-f6dae4bba2f2\",\"timestamp\":\"2023-09-28T10:55:39.135Z\",\"feeString\":\"0\",\"shortCircuitBlockchainTransfer\":true,\"isIntraJXTransfer\":true}"
}'
const wallet = await bitgo.coin('ofc').wallets().get({ id: goAccountId });
if (wallet === undefined) {
throw new Error(`Could not find OFC wallet ${goAccountId}`);
}
const walletPassphrase = "<YOUR_WALLET_PASSPHRASE>";
const tradingAccount = wallet.toTradingAccount();
const stringifiedPayload = JSON.stringify(<payload_returned_by_tx/build_api>);
const signature = await tradingAccount.signPayload({
payload: stringifiedPayload,
walletPassphrase,
});
import javax.crypto.*;
import javax.crypto.spec.*;
import java.security.*;
import java.security.spec.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import com.google.gson.*;
import org.bitcoinj.core.ECKey;
import org.bitcoinj.core.Sha256Hash;
import org.bitcoinj.crypto.DeterministicKey;
public class SignPayload {
public static void main(String[] args) throws Exception {
System.out.println("=== Sign Payload for Go Account Withdrawal ===\n");
// Configuration - replace with your values
String walletPassphrase = "your_wallet_passphrase";
String encryptedPrivateKey = "{\"iv\":...";
String payloadToSign = "{\"coin\":...";
// Step 1: Decrypt private key
System.out.println("Step 1: Decrypt Private Key");
System.out.println("-".repeat(50));
Gson gson = new Gson();
JsonObject data = gson.fromJson(encryptedPrivateKey, JsonObject.class);
byte[] salt = Base64.getDecoder().decode(data.get("salt").getAsString());
byte[] iv = Base64.getDecoder().decode(data.get("iv").getAsString());
byte[] ciphertextWithTag = Base64.getDecoder().decode(data.get("ct").getAsString());
int iterations = data.get("iter").getAsInt();
int keySize = data.get("ks").getAsInt();
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
KeySpec spec = new PBEKeySpec(walletPassphrase.toCharArray(), salt, iterations, keySize);
SecretKey tmp = factory.generateSecret(spec);
SecretKeySpec secretKey = new SecretKeySpec(tmp.getEncoded(), "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec gcmSpec = new GCMParameterSpec(128, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec);
byte[] decrypted = cipher.doFinal(ciphertextWithTag);
String decryptedPrv = new String(decrypted, StandardCharsets.UTF_8);
System.out.println("✓ Private key decrypted");
System.out.println();
// Step 2: Sign payload
System.out.println("Step 2: Sign Payload");
System.out.println("-".repeat(50));
DeterministicKey key = DeterministicKey.deserializeB58(decryptedPrv, org.bitcoinj.params.MainNetParams.get());
ECKey ecKey = ECKey.fromPrivate(key.getPrivKeyBytes());
// Bitcoin message format
String messagePrefix = "\u0018Bitcoin Signed Message:\n";
byte[] payloadBytes = payloadToSign.getBytes(StandardCharsets.UTF_8);
byte[] prefixBytes = messagePrefix.getBytes(StandardCharsets.UTF_8);
byte[] lengthVarint = encodeVarint(payloadBytes.length);
byte[] messageToSign = new byte[prefixBytes.length + lengthVarint.length + payloadBytes.length];
System.arraycopy(prefixBytes, 0, messageToSign, 0, prefixBytes.length);
System.arraycopy(lengthVarint, 0, messageToSign, prefixBytes.length, lengthVarint.length);
System.arraycopy(payloadBytes, 0, messageToSign, prefixBytes.length + lengthVarint.length, payloadBytes.length);
// Double SHA-256 hash
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] firstHash = digest.digest(messageToSign);
byte[] messageHash = digest.digest(firstHash);
// Sign
ECKey.ECDSASignature signature = ecKey.sign(Sha256Hash.wrap(messageHash));
// Create 65-byte recoverable signature
byte[] result = new byte[65];
// Calculate recovery ID
int recoveryId = -1;
for (int i = 0; i < 4; i++) {
try {
ECKey recovered = ECKey.recoverFromSignature(i, signature, Sha256Hash.wrap(messageHash), true);
if (recovered != null && Arrays.equals(recovered.getPubKey(), ecKey.getPubKey())) {
recoveryId = i;
break;
}
} catch (Exception e) {
// Continue trying
}
}
if (recoveryId == -1) {
throw new RuntimeException("Could not determine signature recovery ID");
}
result[0] = (byte) (31 + recoveryId);
copyBigIntegerTo32Bytes(signature.r.toByteArray(), result, 1);
copyBigIntegerTo32Bytes(signature.s.toByteArray(), result, 33);
String hexSignature = bytesToHex(result);
System.out.println("✓ Payload signed");
System.out.println();
System.out.println("Signature (hex):");
System.out.println(hexSignature);
System.out.println();
System.out.println("✓ Use this signature with the /tx/send endpoint");
}
private static byte[] encodeVarint(long value) {
if (value < 253) {
return new byte[] { (byte) value };
} else if (value <= 0xFFFF) {
byte[] result = new byte[3];
result[0] = (byte) 0xFD;
result[1] = (byte) (value & 0xFF);
result[2] = (byte) ((value >> 8) & 0xFF);
return result;
}
throw new RuntimeException("Payload too large");
}
private static void copyBigIntegerTo32Bytes(byte[] source, byte[] dest, int destOffset) {
int sourceOffset = 0;
int sourceLength = source.length;
if (sourceLength > 32) {
sourceOffset = 1;
sourceLength = 32;
}
int destStart = destOffset + (32 - sourceLength);
System.arraycopy(source, sourceOffset, dest, destStart, sourceLength);
}
private static String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}
Step Result
{
"payload": "{\"coin\":\"ofctbtc\",\"recipients\":[{\"address\":\"2NDBpypweakRZzgbWqxEK42pnxgfYzd5iuY\",\"amount\":\"10000\"}],\"fromAccount\":\"63ff43c474598902272894f328452ad7\",\"nonce\":\"80bc93b5-eeec-4c11-afca-f6dae4bba2f2\",\"timestamp\":\"2023-09-28T10:55:39.135Z\",\"feeString\":\"0\",\"shortCircuitBlockchainTransfer\":true,\"isIntraJXTransfer\":true}",
"signature": "1f989926e3fc8ed0138bd0e188e90e2c5321ef86734896886c004005740a005dfd2008142dbc48aee68d1f576c4a9ac558e5b7b4acbd5aa765710ea636aca9d032"
}
3. Send Transaction
Send the signed payload to BitGo. The halfSigned object takes only the two values returned by Step 2: payload (the transaction payload string from Step 1, returned verbatim by Step 2) and signature (the hex-encoded signature from Step 2). All transaction details — coin, recipients, nonce, fee, and transfer flags — are already encoded inside the payload string and do not need to be repeated as separate fields.
Endpoint: Send Half-Signed Transaction
export COIN="<CRYPTO_ASSET_ID>"
export WALLET_ID="<SOURCE_GO_ACCOUNT_WALLET_ID>"
export ACCESS_TOKEN="<SERVICE_USER_ACCESS_TOKEN>"
export PAYLOAD="<PAYLOAD_FROM_STEP_2>"
export SIGNATURE="<SIGNATURE_FROM_STEP_2>"
curl -X POST \
https://app.bitgo-test.com/api/v2/$COIN/wallet/$WALLET_ID/tx/send \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"halfSigned": {
"payload": "'"$PAYLOAD"'",
"signature": "'"$SIGNATURE"'"
}
}'
// stringifiedPayload and signature come from Steps 1 and 2
const sendUrl = wallet.baseCoin.url('/wallet/' + wallet.id() + '/tx/send');
await bitgo
.post(sendUrl)
.send({ halfSigned: { payload: stringifiedPayload, signature } })
.result();
Step Result
The transactionType field confirms that BitGo processed this as a BOOK_TRANSFER.
{
"transfer": {
"id": "65155c4a72fddb000774edbee5fa75fd",
"coin": "ofctbtc",
"wallet": "63ff43c474598902272894f328452ad7",
"walletType": "trading",
"enterprise": "63f31592e277e50837712300495902c7",
"txid": "ddd6875812fa59f899a68059ff303bfa6d39fc350ee687c407383d0444b43d2b",
"height": 999999999,
"heightId": "999999999-65155c4a72fddb000774edbe",
"date": "2023-09-28T10:58:18.272Z",
"type": "send",
"value": -10000,
"valueString": "-10000",
"baseValue": -10000,
"baseValueString": "-10000",
"feeString": "0",
"payGoFee": 0,
"payGoFeeString": "0",
"state": "signed",
"instant": false,
"isReward": false,
"isFee": false,
"tags": [
"63ff43c474598902272894f328452ad7",
"63f31592e277e50837712300495902c7"
],
"history": [
{
"date": "2023-09-28T10:58:18.340Z",
"action": "signed"
},
{
"date": "2023-09-28T10:58:18.272Z",
"user": "628ca09d1b78a6022750254f0777561a",
"action": "created"
}
],
"signedDate": "2023-09-28T10:58:18.340Z",
"entries": [
{
"address": "63ff43c474598902272894f328452ad7",
"wallet": "63ff43c474598902272894f328452ad7",
"value": -10000,
"valueString": "-10000"
},
{
"address": "2NDBpypweakRZzgbWqxEK42pnxgfYzd5iuY",
"value": 10000,
"valueString": "10000"
}
],
"signedTime": "2023-09-28T10:58:18.340Z",
"createdTime": "2023-09-28T10:58:18.272Z"
},
"txid": "ddd6875812fa59f899a68059ff303bfa6d39fc350ee687c407383d0444b43d2b",
"tx": {
"transaction": {
"coin": "ofctbtc",
"recipients": [
{
"address": "2NDBpypweakRZzgbWqxEK42pnxgfYzd5iuY",
"amount": "10000"
}
],
"fromAccount": "63ff43c474598902272894f328452ad7",
"nonce": "80bc93b5-eeec-4c11-afca-f6dae4bba2f2",
"timestamp": "2023-09-28T10:55:39.135Z",
"idfSignedTimestamp": "2023-09-28T10:55:38.732Z",
"idfVersion": 1,
"idfUserId": "628ca09d1b78a6022750254f0777561a",
"feeString": "0",
"shortCircuitBlockchainTransfer": true,
"isIntraJXTransfer": true
},
"signature": "1f989926e3fc8ed0138bd0e188e90e2c5321ef86734896886c004005740a005dfd2008142dbc48aee68d1f576c4a9ac558e5b7b4acbd5aa765710ea636aca9d032",
"transactionType": "BOOK_TRANSFER"
},
"status": "signed"
}
4. Approve Transaction (Optional)
Note
If you configure an approval requirement for transfers, you can't approve your own transactions — another admin must approve them.
Endpoint: Update Pending Approval
export APPROVAL_ID="<APPROVAL_ID>"
export ACCESS_TOKEN="<SERVICE_USER_ACCESS_TOKEN>"
export OTP="<OTP>"
curl -X PUT \
https://app.bitgo-test.com/api/v2/pendingApprovals/$APPROVAL_ID \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{
"state": "approved",
"otp": "'"$OTP"'"
}'
const baseCoin = bitgo.coin(initialPendingApproval.coin);
const pendingApproval = await baseCoin.pendingApprovals().get({ id: initialPendingApproval.id });
const result = await pendingApproval.approve(params);
Step Result
Once approved, BitGo processes the book transfer and updates the ledger balances for both Go Accounts.
{
"id": "65155c4a72fddb000774edbee5fa75fd",
"coin": "ofctbtc",
"wallet": "63ff43c474598902272894f328452ad7",
"enterprise": "63f31592e277e50837712300495902c7",
"creator": "628ca09d1b78a6022750254f0777561a",
"createDate": "2023-09-28T10:58:18.272Z",
"info": {
"type": "transactionRequest",
"transactionRequest": {
"coinSpecific": {
"ofctbtc": {
"payload": {
"coin": "ofctbtc",
"recipients": [
{
"address": "2NDBpypweakRZzgbWqxEK42pnxgfYzd5iuY",
"amount": "10000"
}
],
"fromAccount": "63ff43c474598902272894f328452ad7",
"nonce": "80bc93b5-eeec-4c11-afca-f6dae4bba2f2",
"timestamp": "2023-09-28T10:55:39.135Z",
"idfSignedTimestamp": "2023-09-28T10:55:38.732Z",
"idfVersion": 1,
"idfUserId": "628ca09d1b78a6022750254f0777561a",
"feeString": "0",
"shortCircuitBlockchainTransfer": true,
"isIntraJXTransfer": true
},
"signature": "1f989926e3fc8ed0138bd0e188e90e2c5321ef86734896886c004005740a005dfd2008142dbc48aee68d1f576c4a9ac558e5b7b4acbd5aa765710ea636aca9d032",
"transactionType": "BOOK_TRANSFER"
}
},
"verificationItems": ["2NDBpypweakRZzgbWqxEK42pnxgfYzd5iuY"],
"videoApprovers": [],
"requestedAmount": "10000",
"fee": 0,
"sourceWallet": "63ff43c474598902272894f328452ad7",
"recipients": [
{
"_id": "65155c4a68fddb000865edbf",
"address": "2NDBpypweakRZzgbWqxEK42pnxgfYzd5iuY",
"amount": "10000"
}
]
}
},
"state": "approved",
"scope": "wallet",
"userIds": [
"628ca09d1b78a6022750254f0777561a",
"5bd8bdb0925d7c9823d4fc823e9e2fd3"
],
"approvalsRequired": 1,
"resolvers": [
{
"user": "5bd8bdb0925d7c9823d4fc823e9e2fd3",
"date": "2023-09-28T11:15:24.644Z",
"resolutionType": "pending"
}
]
}