Withdrawals via API
If you haven't already, Sign Up for Sandbox Access to get your client ID and secret to work through this Build Guide!
What are Withdrawals?
Withdrawals allow customers to send funds from their Rail accounts to external bank accounts or crypto wallets. The withdrawal process involves creating a counterparty (the external destination), initiating the withdrawal, and accepting it for execution.
Rail supports withdrawals via multiple rails:
- FIAT - Bank transfers (ACH, Wire, SWIFT, etc.)
- CRYPTO - Blockchain withdrawals to external wallets
Auto-RFI (Request for Information)
Rail's Auto-RFI functionality automatically triggers document requests when withdrawals exceed certain thresholds. This ensures compliance requirements are met before processing large transactions.
How Auto-RFI Works:
- Below Threshold - Withdrawal proceeds normally without additional documents
-
Above Threshold
- Withdrawal enters
CHANGES_REQUESTEDstatus and requires documents (e.g., invoices) before it can be accepted
Core API Flow Sequence
The standard withdrawal flow follows these key steps:
-
POST
/v1/counterparties- Create a counterparty (external destination) -
POST
/v1/withdrawals- Initiate the withdrawal -
GET
/v1/withdrawals/{id}/status- Check withdrawal status (if Auto-RFI triggered) -
POST
/v1/documents/{document_id}- Upload required documents (if needed) -
POST
/v1/withdrawals/{id}/accept- Accept the withdrawal for execution
Build Guide
This guide covers two scenarios:
By the end of this guide you will have:
- Authenticated your request
- Created a counterparty
- Initiated a withdrawal
- Handled Auto-RFI document requests (if triggered)
- Accepted the withdrawal
- Monitored withdrawal status
Let's go!
Authenticating your request
Every request you make to a Rail API endpoint requires an AUTH_TOKEN. We secure our endpoints using standards-based OAuth2 Client Credentials Grant and scopes.
To obtain the AUTH_TOKEN, you will need to authorize your account using your BASE_64_ENCODED_CLIENTID_AND_SECRET with the scopes you require:
curl --location --request POST 'https://auth.layer2financial.com/oauth2/ausbdqlx69rH6OjWd696/v1/token?grant_type=client_credentials&scope=counterparties:read+counterparties:write+withdrawals:read+withdrawals:write+accounts:read' \
--header 'Accept: application/json' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Cache-Control: no-cache' \
--header 'Authorization: Basic {BASE_64_ENCODED_CLIENTID_AND_SECRET}'This gives you a response object containing your AUTH_TOKEN:
{
"token_type": "Bearer",
"expires_in": 43200,
"access_token": {AUTH_TOKEN},
"scope": "counterparties:read counterparties:write withdrawals:read withdrawals:write accounts:read"
}The scopes we'll need for this tutorial are:
-
counterparties:readandcounterparties:writeto create and manage counterparties -
withdrawals:readandwithdrawals:writeto create and manage withdrawals -
accounts:readto verify account balances
The full list of scopes are available here.
Scenario 1: Simple Withdrawal (Below Threshold)
This scenario covers a straightforward withdrawal that doesn't trigger Auto-RFI.
Step 1: Create a Counterparty
A counterparty represents the external destination where funds will be sent. You must create a counterparty before initiating a withdrawal.
Creating a Fiat Counterparty (Bank Account)
Create a counterparty for a US bank account using the /v1/counterparties endpoint:
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/counterparties' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customer_id": "INDIVIDUAL_CUSTOMER_001",
"description": "John'\''s Chase Checking Account",
"counterparty_type": "FIAT_US",
"is_international": false,
"supported_rails": ["ACH", "FEDWIRE"],
"profile": {
"name": "John Doe",
"address": {
"address_line1": "456 Bank Street",
"city": "New York",
"state": "NY",
"postal_code": "10001",
"country_code": "US"
},
"relationship_to_customer": "SELF"
},
"account_information": {
"asset_type_id": "FIAT_TESTNET_USD",
"account_number": "1234567890",
"routing_number": "021000021",
"account_type": "CHECKING"
}
}'The response includes the counterparty_id you'll need for the withdrawal:
{
"data": {
"id": "cpty_abc123456",
"status": "ACTIVE"
}
}Important: Certain counterparty types require specific asset types in the
account_information.asset_type_idfield. For example,FIAT_CNcounterparties must use Chinese Yuan asset types. See the Counterparties Guide for details.
Creating a Crypto Counterparty (Wallet Address)
Create a counterparty for a crypto wallet:
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/counterparties' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customer_id": "INDIVIDUAL_CUSTOMER_001",
"description": "Customer'\''s Personal USDC Wallet",
"counterparty_type": "CRYPTO",
"is_international": false,
"supported_rails": ["CRYPTO"],
"profile": {
"name": "John Doe",
"address": {
"country_code": "US"
},
"relationship_to_customer": "SELF"
},
"wallet_information": {
"asset_type_id": "ETHEREUM_GOERLI_USDC",
"blockchain_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
"wallet_type": "OTHER",
"institution_name": "Personal Wallet",
"institution_address": {
"country_code": "US"
}
}
}'Response:
{
"data": {
"id": "cpty_xyz789012",
"status": "ACTIVE"
}
}Step 2: Create a Withdrawal
Initiate a withdrawal using the /v1/withdrawals endpoint:
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/withdrawals' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
"withdrawal_rail": "ACH",
"description": "Payment to vendor",
"source_account_id": "CUSTOMER_USD_001",
"amount": 500.00,
"destination_counterparty_id": "cpty_abc123456",
"memo": "Invoice #12345"
}'Important: Use
destination_counterparty_id(not justcounterparty_id) and ensurewithdrawal_railis specified as it's a mandatory field.
The response includes the withdrawal_id and initial status:
{
"data": {
"id": "withdrawal_abc123",
"status": "REQUESTED",
"created_timestamp": "2023-11-20T14:32:18.123456Z",
"source_details": {
"source_account_id": "CUSTOMER_USD_001",
"asset_type_id": "FIAT_TESTNET_USD",
"amount_to_debit": 500.00
},
"destination_details": {
"destination_counterparty_id": "cpty_abc123456",
"withdrawal_rail": "ACH"
}
}
}Step 3: Check Withdrawal Status
For withdrawals below the Auto-RFI threshold, the status will be REQUESTED. Check the status using the /v1/withdrawals/{id}/status endpoint:
curl --location --request GET 'https://sandbox.layer2financial.com/api/v1/withdrawals/withdrawal_abc123/status' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Response for a withdrawal below threshold:
{
"data": {
"id": "withdrawal_abc123",
"status": "REQUESTED",
"created_timestamp": "2023-11-20T14:32:18.123456Z",
"withdrawal_validation_errors": [],
"withdrawal_document_errors": []
}
}Since the status is REQUESTED and there are no validation or document errors, you can proceed to accept the withdrawal.
Step 4: Accept the Withdrawal
Accept the withdrawal to queue it for execution:
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/withdrawals/withdrawal_abc123/accept' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Response:
{
"data": {
"id": "withdrawal_abc123",
"status": "ACCEPTED"
}
}Step 5: Monitor Withdrawal Status
The withdrawal will progress through various statuses:
curl --location --request GET 'https://sandbox.layer2financial.com/api/v1/withdrawals/withdrawal_abc123' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Response showing progression:
{
"data": {
"id": "withdrawal_abc123",
"status": "PROCESSING", // Then moves to COMPLETED or FAILED
"created_timestamp": "2023-11-20T14:32:18.123456Z",
"accepted_timestamp": "2023-11-20T14:33:00.000000Z",
"source_details": {
"source_account_id": "CUSTOMER_USD_001",
"asset_type_id": "FIAT_TESTNET_USD",
"amount_to_debit": 500.00
},
"destination_details": {
"destination_counterparty_id": "cpty_abc123456",
"withdrawal_rail": "ACH"
}
}
}Scenario 2: Withdrawal with Auto-RFI (Above Threshold)
This scenario covers withdrawals that exceed the Auto-RFI threshold and require document uploads before acceptance.
Step 1: Create a Counterparty
Create a counterparty the same way as in Scenario 1:
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/counterparties' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
"customer_id": "INDIVIDUAL_CUSTOMER_001",
"description": "Vendor Payment Account",
"counterparty_type": "FIAT_US",
"is_international": false,
"supported_rails": ["FEDWIRE"],
"profile": {
"name": "ABC Corp",
"address": {
"address_line1": "789 Business Ave",
"city": "San Francisco",
"state": "CA",
"postal_code": "94105",
"country_code": "US"
},
"relationship_to_customer": "VENDOR"
},
"account_information": {
"asset_type_id": "FIAT_TESTNET_USD",
"account_number": "9876543210",
"routing_number": "121000248",
"account_type": "BUSINESS_CHECKING"
}
}'Response:
{
"data": {
"id": "cpty_vendor_123",
"status": "ACTIVE"
}
}Step 2: Create a Withdrawal (Above Threshold)
Create a withdrawal for an amount that exceeds the Auto-RFI threshold:
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/withdrawals' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
"withdrawal_rail": "FEDWIRE",
"description": "Large vendor payment",
"source_account_id": "CUSTOMER_USD_001",
"amount": 25000.00,
"destination_counterparty_id": "cpty_vendor_123",
"memo": "Invoice #INV-2023-1234"
}'Response indicating Auto-RFI triggered:
{
"data": {
"id": "withdrawal_large_456",
"status": "CHANGES_REQUESTED",
"created_timestamp": "2023-11-20T15:00:00.123456Z",
"source_details": {
"source_account_id": "CUSTOMER_USD_001",
"asset_type_id": "FIAT_TESTNET_USD",
"amount_to_debit": 25000.00
},
"destination_details": {
"destination_counterparty_id": "cpty_vendor_123",
"withdrawal_rail": "FEDWIRE"
}
}
}Notice: The status is
CHANGES_REQUESTEDinstead ofREQUESTED. This indicates that Auto-RFI has been triggered and documents are required.
Step 3: Get Document Requirements
Similar to the application onboarding process, check the withdrawal status to see which documents are required:
curl --location --request GET 'https://sandbox.layer2financial.com/api/v1/withdrawals/withdrawal_large_456/status' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Response showing required documents:
{
"data": {
"id": "withdrawal_large_456",
"status": "CHANGES_REQUESTED",
"created_timestamp": "2023-11-20T15:00:00.123456Z",
"withdrawal_validation_errors": [],
"withdrawal_document_errors": [
{
"document_id": "doc_withdrawal_001",
"document_type": "INVOICE",
"status": "MISSING",
"description": "Invoice or receipt for this transaction"
},
{
"document_id": "doc_withdrawal_002",
"document_type": "CONTRACT",
"status": "MISSING",
"description": "Contract or agreement with vendor"
}
]
}
}The withdrawal_document_errors array contains the document_ids you need for uploading documents.
Important: This is the same status endpoint used throughout the withdrawal lifecycle. It serves a dual purpose - providing status updates and identifying required documents (similar to how the application status endpoint works in customer onboarding).
Step 4: Upload Required Documents
Upload the required documents using the document_ids from the status response:
# Upload invoice
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/documents/doc_withdrawal_001' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--form 'file=@"/path/to/invoice.pdf"'
# Upload contract
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/documents/doc_withdrawal_002' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--form 'file=@"/path/to/contract.pdf"'Each successful upload returns:
{
"data": {
"id": "doc_withdrawal_001",
"status": "UPLOADED"
}
}Step 5: Verify Withdrawal is Ready
After uploading documents, check the withdrawal status again:
curl --location --request GET 'https://sandbox.layer2financial.com/api/v1/withdrawals/withdrawal_large_456/status' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Once all documents are uploaded, the status returns to REQUESTED:
{
"data": {
"id": "withdrawal_large_456",
"status": "REQUESTED",
"created_timestamp": "2023-11-20T15:00:00.123456Z",
"withdrawal_validation_errors": [],
"withdrawal_document_errors": []
}
}Step 6: Accept the Withdrawal
Now that the status is REQUESTED and all documents are uploaded, you can accept the withdrawal:
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/withdrawals/withdrawal_large_456/accept' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Response:
{
"data": {
"id": "withdrawal_large_456",
"status": "ACCEPTED"
}
}The withdrawal is now queued for execution and will be processed by Rail's banking partners.
Withdrawal Status Flow
Withdrawals progress through different statuses during their lifecycle:
Without Auto-RFI (Below Threshold)
REQUESTED → ACCEPTED → PROCESSING → COMPLETED / FAILED
With Auto-RFI (Above Threshold)
CHANGES_REQUESTED → (upload documents) → REQUESTED → ACCEPTED → PROCESSING → COMPLETED / FAILED
Status Meanings
| Status | Description | Action Required |
|---|---|---|
REQUESTED |
Withdrawal created and ready to accept | Call /accept endpoint |
CHANGES_REQUESTED |
Auto-RFI triggered, documents required | Upload required documents via status endpoint |
ACCEPTED |
Withdrawal accepted and queued for execution | Monitor status for completion |
PROCESSING |
Withdrawal being processed by banking partners | Wait for completion |
COMPLETED |
Withdrawal successfully processed | None - funds sent |
FAILED |
Withdrawal failed | Review error details, may need to retry |
CANCELLED |
Withdrawal cancelled | None - no funds sent |
Important: You cannot accept a withdrawal while it's in
CHANGES_REQUESTEDstatus. The withdrawal must return toREQUESTEDstatus after all required documents are uploaded.
Managing Counterparties
Retrieve All Counterparties
List all counterparties for a customer:
curl --location --request GET 'https://sandbox.layer2financial.com/api/v1/counterparties?customer_id=INDIVIDUAL_CUSTOMER_001' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Response:
{
"data": {
"counterparties": [
{
"id": "cpty_abc123456",
"customer_id": "INDIVIDUAL_CUSTOMER_001",
"description": "John's Chase Checking Account",
"counterparty_type": "FIAT_US",
"status": "ACTIVE",
"supported_rails": ["ACH", "FEDWIRE"]
},
{
"id": "cpty_xyz789012",
"customer_id": "INDIVIDUAL_CUSTOMER_001",
"description": "Customer's Personal USDC Wallet",
"counterparty_type": "CRYPTO",
"status": "ACTIVE",
"supported_rails": ["CRYPTO"]
}
]
}
}Retrieve a Specific Counterparty
Get details for a single counterparty:
curl --location --request GET 'https://sandbox.layer2financial.com/api/v1/counterparties/cpty_abc123456' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Update a Counterparty
Update counterparty details:
curl --location --request PUT 'https://sandbox.layer2financial.com/api/v1/counterparties/cpty_abc123456' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
"description": "John'\''s Updated Checking Account",
"profile": {
"name": "John Doe",
"address": {
"address_line1": "789 New Address St",
"city": "New York",
"state": "NY",
"postal_code": "10002",
"country_code": "US"
}
}
}'Delete a Counterparty
Remove a counterparty:
curl --location --request DELETE 'https://sandbox.layer2financial.com/api/v1/counterparties/cpty_abc123456' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json'Response:
{
"data": {
"id": "cpty_abc123456",
"status": "DELETED"
}
}Withdrawal Best Practices
1. Verify Account Balance Before Creating Withdrawals
Always check that the source account has sufficient available balance:
curl --location --request GET 'https://sandbox.layer2financial.com/api/v1/accounts/deposits/CUSTOMER_USD_001' \
--header 'Authorization: Bearer {AUTH_TOKEN}'Ensure available_balance is greater than or equal to the withdrawal amount.
2. Use the Status Endpoint Throughout the Lifecycle
The /v1/withdrawals/{id}/status endpoint serves multiple purposes:
- Check if Auto-RFI was triggered
- Get document IDs for required documents
- Verify all requirements are met before accepting
- Monitor withdrawal progress
# Always check status before attempting to accept
GET /v1/withdrawals/{id}/status
# If status is "REQUESTED" and no errors, proceed to accept
POST /v1/withdrawals/{id}/accept3. Handle Auto-RFI Similar to Application Onboarding
The Auto-RFI process mirrors the application onboarding flow:
- Check status endpoint for required documents
-
Upload documents using the provided
document_ids - Re-check status to verify all requirements are met
- Only then can you accept the withdrawal
4. Provide Meaningful Descriptions and Memos
Include clear descriptions and memos for audit trails and customer communication:
{
"description": "Payment for Invoice #INV-2023-1234",
"memo": "Q4 2023 Services - Net 30"
}5. Match Asset Types Between Accounts and Counterparties
Ensure the asset_type_id of the source account matches the counterparty's expected asset type:
- Fiat withdrawals: Account and counterparty must use the same fiat currency
- Crypto withdrawals: Account and wallet must use the same blockchain and token
6. Set Up Webhook Subscriptions for Withdrawal Events
Monitor withdrawal lifecycle events in real-time:
# Subscribe to withdrawal events
curl --location --request POST 'https://sandbox.layer2financial.com/api/v1/subscriptions' \
--header 'Authorization: Bearer {AUTH_TOKEN}' \
--header 'Content-Type: application/json' \
--data-raw '{
"event_type": "WITHDRAWAL_COMPLETED",
"callback_url": "https://your-domain.com/webhooks/withdrawals"
}'Recommended withdrawal webhooks:
-
WITHDRAWAL_REQUESTED- Withdrawal initiated -
WITHDRAWAL_ACCEPTED- Withdrawal accepted for processing -
WITHDRAWAL_PROCESSING- Withdrawal being processed -
WITHDRAWAL_COMPLETED- Withdrawal successfully sent -
WITHDRAWAL_FAILED- Withdrawal failed -
WITHDRAWAL_CANCELLED- Withdrawal cancelled
7. Implement Proper Error Handling
Handle common withdrawal errors:
try {
const withdrawal = await createWithdrawal({
amount: 25000,
destination_counterparty_id: counterpartyId
});
const status = await getWithdrawalStatus(withdrawal.id);
if (status.status === 'CHANGES_REQUESTED') {
// Auto-RFI triggered - handle document upload
await handleDocumentRequirements(status.withdrawal_document_errors);
} else if (status.status === 'REQUESTED') {
// Ready to accept
await acceptWithdrawal(withdrawal.id);
}
} catch (error) {
if (error.code === 'INSUFFICIENT_BALANCE') {
// Handle insufficient funds
} else if (error.code === 'INVALID_COUNTERPARTY') {
// Handle invalid counterparty
}
// Log and notify
}8. Store Counterparties for Repeat Withdrawals
Save frequently used counterparties to simplify repeat withdrawals:
// First time - create and store counterparty
const counterparty = await createCounterparty({...});
await database.saveCounterparty(customerId, counterparty.id);
// Subsequent withdrawals - reuse counterparty
const storedCounterpartyId = await database.getCounterparty(customerId);
await createWithdrawal({
destination_counterparty_id: storedCounterpartyId,
amount: 1000
});Summary
Let's review the withdrawal process:
Simple Withdrawal (Below Threshold)
-
Authenticate
- Get your
AUTH_TOKENusing OAuth2 -
Create Counterparty
- POST to
/v1/counterpartieswith destination details -
Create Withdrawal
- POST to
/v1/withdrawalswithdestination_counterparty_idandwithdrawal_rail -
Check Status
- GET
/v1/withdrawals/{id}/statusto verify status isREQUESTED -
Accept Withdrawal
- POST to
/v1/withdrawals/{id}/acceptto queue for execution -
Monitor Progress
- Track withdrawal through
PROCESSINGtoCOMPLETED
Withdrawal with Auto-RFI (Above Threshold)
-
Authenticate
- Get your
AUTH_TOKENusing OAuth2 -
Create Counterparty
- POST to
/v1/counterparties -
Create Withdrawal
- POST to
/v1/withdrawals -
Check Status
- GET
/v1/withdrawals/{id}/status- status will beCHANGES_REQUESTED -
Upload Documents
- POST to
/v1/documents/{document_id}for each required document -
Re-check Status
- Verify status has returned to
REQUESTED -
Accept Withdrawal
- POST to
/v1/withdrawals/{id}/accept -
Monitor Progress
- Track withdrawal through
PROCESSINGtoCOMPLETED
Key Takeaways
- ✅ Create counterparties before initiating withdrawals
-
✅ Use
destination_counterparty_id(notcounterparty_id) in withdrawal requests -
✅ Always specify
withdrawal_railas it's mandatory - ✅ Match asset types between accounts and counterparties
- ✅ Use the status endpoint to check for Auto-RFI document requirements
-
✅ Handle
CHANGES_REQUESTEDstatus similar to application onboarding - ✅ Upload all required documents before attempting to accept
- ✅ Set up webhooks to monitor withdrawal progress
- ✅ Verify account balance before creating withdrawals
API Endpoints Used
| Endpoint | Purpose | Documentation |
|---|---|---|
/v1/counterparties |
Create and manage external destinations | Create Counterparty, Counterparties Guide |
/v1/withdrawals |
Create withdrawal requests | Create Withdrawal |
/v1/withdrawals/{id}/status |
Check withdrawal status and document requirements | Get Withdrawal Status |
/v1/withdrawals/{id}/accept |
Accept withdrawal for execution | Accept Withdrawal |
/v1/documents/{document_id} |
Upload required documents | Upload Document |
To dive deeper into what you can do on the Rail platform, head to our API documentation.
Contact Us - We'd love to hear your thoughts, and you can contact the team via slack, website or email support@layer2financial.com.