Split an incoming payment
Imagine making a purchase from an online marketplace. From your perspective, you’re sending a single payment to a merchant in exchange for a good. Behind the scenes, the marketplace receives a portion of the payment as a service fee.
There’s a few ways for the marketplace to collect their fee. For example, it could receive the full amount, deduct the fee, then send the rest to the merchant. However, holding funds for the merchant, even for a second, requires compliance with certain financial rules and regulations. A better way is to ensure both parties only receive the amount they’re supposed to receive, directly from the user.
Remember, Open Payments doesn’t execute payments or touch money in any way. It’s used to issue payment instructions before any money movement occurs. An example of a payment instruction is, “of the $6 purchase, pay the marketplace $1 and the merchant $5.” This way, funds meant for one party never pass through the other party.
Scenario
Section titled “Scenario”For this guide, you’ll assume the role of a platform operator of an online marketplace. The guide explains how to split a customer’s $100 USD payment into two incoming payments. The merchant will receive 99% of the payment while you keep 1% as a fee.
The three parties involved in the transaction are the:
- Customer: the purchaser of a good or service on the marketplace
- Merchant: the seller of a good or service on the marketplace
- Platform operator: you, as the operator of the marketplace
Endpoints
Section titled “Endpoints”- GET Get Wallet Address
- POST Grant Request
- POST Create Incoming Payment
- POST Create a Quote
- POST Create an Outgoing Payment
1. Get wallet address information
Section titled “1. Get wallet address information”When a customer initiates a payment, your platform must get wallet address information for the customer, the merchant, and you, as the operator.
Let’s assume your wallet address is already saved to your platform, as is the merchant’s. Let’s also assume the customer provided their wallet address at the beginning of the checkout flow.
Call the GET Get Wallet Address API for each address.
const customerWalletAddress = await client.walletAddress.get({ url: 'https://cloudninebank.example.com/customer'})const merchantWalletAddress = await client.walletAddress.get({ url: 'https://happylifebank.example.com/merchant'})const platformWalletAddress = await client.walletAddress.get({ url: 'https://coolwallet.example.com/platform'})let customer_wallet_address = client.wallet_address().get("https://cloudninebank.example.com/customer").await?;let merchant_wallet_address = client.wallet_address().get("https://happylifebank.example.com/merchant").await?;let platform_wallet_address = client.wallet_address().get("https://coolwallet.example.com/platform").await?;Example response
The following shows an example response from the customer’s wallet provider. Similar responses will be returned from the merchant’s and platform’s wallet provider.
{ "id": "https://cloudninebank.example.com/customer", "assetCode": "USD", "assetScale": 2, "authServer": "https://auth.cloudninebank.example.com/", "resourceServer": "https://cloudninebank.example.com/op"}2. Request incoming payment grants
Section titled “2. Request incoming payment grants”Use the merchant and platform authServer details, received in the previous step, to call the POST Grant Request API.
These calls obtain access tokens that allow your platform to request an incoming payment resource be created on the merchant’s wallet account and your wallet account.
// Merchantconst merchantIncomingPaymentGrant = await client.grant.request( { url: merchantWalletAddress.authServer }, { access_token: { access: [ { type: "incoming-payment", actions: ["create"], }, ], }, },);// Platformconst platformIncomingPaymentGrant = await client.grant.request( { url: platformWalletAddress.authServer }, { access_token: { access: [ { type: "incoming-payment", actions: ["create"], }, ], }, },);use open_payments::types::{AccessTokenRequest, AccessItem, IncomingPaymentAction, GrantRequest};let incoming_access = AccessTokenRequest { access: vec![AccessItem::IncomingPayment { actions: vec![IncomingPaymentAction::Create], identifier: None }],};let merchant_grant_request = GrantRequest::new(incoming_access.clone(), None);let platform_grant_request = GrantRequest::new(incoming_access, None);let merchant_incoming_payment_grant = client .grant() .request(&merchant_wallet_address.auth_server, &merchant_grant_request) .await?;let platform_incoming_payment_grant = client .grant() .request(&platform_wallet_address.auth_server, &platform_grant_request) .await?;Example response
The following shows an example response from the merchant’s wallet provider. A similar response will be returned from your wallet provider.
{ "access_token": { "value": "...", // access token value for incoming payment grant "manage": "https://happylifebank.example.com/token/{...}", // management uri for access token "access": [ { "type": "incoming-payment", "actions": ["create"] } ] }, "continue": { "access_token": { "value": "..." // access token for continuing the request }, "uri": "https://happylifebank.example.com/continue/{...}" // continuation request uri }}3. Request the creation of incoming payment resources
Section titled “3. Request the creation of incoming payment resources”Use the access tokens returned in the previous responses to call the POST Create Incoming Payment API.
This call requests an incoming payment resource be created on the merchant’s wallet account and your wallet account.
Remember that the merchant is receiving 99% of the payment ($99.00 or 9900) while you are keeping 1% as a fee ($1.00 or 100).
// Merchantconst merchantIncomingPayment = await client.incomingPayment.create( { url: merchantWalletAddress.resourceServer, accessToken: merchantIncomingPaymentGrant.access_token.value }, { walletAddress: merchantWalletAddress.id, incomingAmount: { value: '9900', assetCode: 'USD', assetScale: 2 }, },)// Platformconst platformIncomingPayment = await client.incomingPayment.create( { url: platformWalletAddress.resourceServer, accessToken: platformIncomingPaymentGrant.access_token.value }, { walletAddress: platformWalletAddress.id, incomingAmount: { value: '100', assetCode: 'USD', assetScale: 2 }, },)use open_payments::types::{IncomingPaymentRequest, Amount};let merchant_request = IncomingPaymentRequest { wallet_address: merchant_wallet_address.id.clone(), incoming_amount: Some(Amount { value: "9900".into(), asset_code: "USD".into(), asset_scale: 2 }), expires_at: None, metadata: None,};let platform_request = IncomingPaymentRequest { wallet_address: platform_wallet_address.id.clone(), incoming_amount: Some(Amount { value: "100".into(), asset_code: "USD".into(), asset_scale: 2 }), expires_at: None, metadata: None,};let merchant_incoming_payment = client .incoming_payments() .create(&merchant_wallet_address.resource_server, &merchant_request, Some(&merchant_incoming_payment_grant.access_token.value)) .await?;let platform_incoming_payment = client .incoming_payments() .create(&platform_wallet_address.resource_server, &platform_request, Some(&platform_incoming_payment_grant.access_token.value)) .await?;Example response
The following shows an example response from the merchant’s wallet provider. A similar response will be returned from your wallet provider, but your incoming amount value will be 100.
{ "id": "https://happylifebank.example.com/incoming-payments/{...}", "walletAddress": "https://happylifebank.example.com/merchant", "incomingAmount": { "value": "9900", "assetCode": "USD", "assetScale": 2 }, "receivedAmount": { "value": "0", "assetCode": "USD", "assetScale": 2 }, "completed": false, "createdAt": "2025-03-12T23:20:50.52Z", "methods": [ { "type": "ilp", "ilpAddress": "...", "sharedSecret": "..." } ]}4. Request a quote grant
Section titled “4. Request a quote grant”Use the customer’s authServer details, received in Step 1, to call the POST Grant Request API.
This call obtains an access token that allows your platform to request quote resources be created on the customer’s wallet account.
const customerQuoteGrant = await client.grant.request( { url: customerWalletAddress.authServer }, { access_token: { access: [ { type: 'quote', actions: ['create'] } ] } })use open_payments::types::{AccessTokenRequest, AccessItem, QuoteAction, GrantRequest};let quote_access = AccessTokenRequest { access: vec![AccessItem::Quote { actions: vec![QuoteAction::Create] }],};let customer_grant_request = GrantRequest::new(quote_access, None);let customer_quote_grant = client .grant() .request(&customer_wallet_address.auth_server, &customer_grant_request) .await?;Example response
{ "access_token": { "value": "...", // access token value for quote grant "manage": "https:/cloudninebank.example.com/token/{...}", // management uri for access token "access": [ { "type": "quote", "actions": ["create"] } ] }, "continue": { "access_token": { "value": "..." // access token for continuing the request }, "uri": "https://auth.cloudninebank.example.com/continue/{...}" // continuation request uri }}5. Request the creation of quote resources
Section titled “5. Request the creation of quote resources”Use the access token, received in the previous step, to call the POST Create Quote API.
This call requests a quote resource be created on the customer’s wallet account. Since the customer needs to get a quote for both of the incoming payments at the merchant and the platform, we’ll call the API twice using the same access token.
First, let’s request a quote resource associated with the merchant. The request must contain the receiver, which is the merchant’s incoming payment id, along with any other required parameters. The id was returned in the Create an Incoming Payment API response in Step 3.
Next, call the POST Create Quote API again and request a quote resource associated with the platform’s incoming payment id.
// Merchantconst merchantQuote = await client.quote.create( { url: customerWalletAddress.resourceServer, accessToken: customerQuoteGrant.access_token.value }, { method: 'ilp', walletAddress: customerWalletAddress.id, receiver: merchantIncomingPayment.id })// Platformconst platformQuote = await client.quote.create( { url: customerWalletAddress.resourceServer, accessToken: customerQuoteGrant.access_token.value }, { method: 'ilp', walletAddress: customerWalletAddress.id, receiver: platformIncomingPayment.id })use open_payments::types::{QuoteRequest, QuoteMethod};let merchant_quote_request = QuoteRequest { method: QuoteMethod::Ilp, wallet_address: Some(customer_wallet_address.id.clone()), receiver: Some(merchant_incoming_payment.id.clone()), debit_amount: None, receive_amount: None,};let platform_quote_request = QuoteRequest { method: QuoteMethod::Ilp, wallet_address: Some(customer_wallet_address.id.clone()), receiver: Some(platform_incoming_payment.id.clone()), debit_amount: None, receive_amount: None,};let merchant_quote = client .quotes() .create(&customer_wallet_address.resource_server, &merchant_quote_request, Some(&customer_quote_grant.access_token.value)) .await?;let platform_quote = client .quotes() .create(&customer_wallet_address.resource_server, &platform_quote_request, Some(&customer_quote_grant.access_token.value)) .await?;Each response returns a receiveAmount, a debitAmount, and other required information.
debitAmount- The amount the customer must pay toward the incoming payment resource (receiveAmountplus any applicable fees)receiveAmount- TheincomingAmountvalue from the incoming payment resource
Example response
The following shows an example response from the merchant’s wallet provider. A similar response will be returned from your wallet provider.
{ "id": "https://cloudninebank.example.com/quotes/{...}", // url identifying the quote "walletAddress": "https://cloudninebank.example.com/customer", "receiver": "https://happylifebank.example.com/incoming-payments/{...}", // url of the incoming payment the quote is created for "debitAmount": { "value": "9900", "assetCode": "USD", "assetScale": 2 }, "receiveAmount": { "value": "9900", "assetCode": "USD", "assetScale": 2 }, "method": "ilp", "createdAt": "2025-03-12T23:22:51.50Z"}6. Request an interactive outgoing payment grant
Section titled “6. Request an interactive outgoing payment grant”Use the customer’s authServer information received in Step 1 to call the POST Grant Request API.
This call obtains an access token that allows your platform to request outgoing payment resources be created on the customer’s wallet account.
const combinedQuoteAmount = quote1.debitAmount.value + // '9900' quote2.debitAmount.value // '100'
const pendingCustomerOutgoingPaymentGrant = await client.grant.request( { url: customerWalletAddress.authServer }, { access_token: { access: [ { identifier: customerWalletAddress.id, type: 'outgoing-payment', actions: ['create'], limits: { debitAmount: { assetCode: 'USD', assetScale: 2, value: combinedQuoteAmount, } } } ] }, interact: { start: ['redirect'], finish: { method: 'redirect', uri: 'https://paymentplatform.example/finish/{...}', // where to redirect the customer after they've completed interaction nonce: NONCE } } })use open_payments::types::{AccessTokenRequest, AccessItem, OutgoingPaymentAction, InteractRequest, InteractStart, InteractFinish, InteractFinishMethod, AccessLimits, Amount, GrantRequest};
let merchant_amount = match merchant_quote.debit_amount.as_ref() { Some(a) => &a.value, None => { eprintln!("Missing debit_amount on merchant quote"); return Ok(()); }};let platform_amount = match platform_quote.debit_amount.as_ref() { Some(a) => &a.value, None => { eprintln!("Missing debit_amount on platform quote"); return Ok(()); }};let merchant_value = match merchant_amount.parse::<i64>() { Ok(v) => v, Err(_) => { eprintln!("Invalid merchant debit_amount value"); return Ok(()); }};let platform_value = match platform_amount.parse::<i64>() { Ok(v) => v, Err(_) => { eprintln!("Invalid platform debit_amount value"); return Ok(()); }};let combined_quote_amount = merchant_value + platform_value;let outgoing_access = AccessTokenRequest { access: vec![AccessItem::OutgoingPayment { identifier: Some(customer_wallet_address.id.clone()), actions: vec![OutgoingPaymentAction::Create], limits: Some(AccessLimits { debit_amount: Some(Amount { value: combined_quote_amount.to_string(), asset_code: "USD".into(), asset_scale: 2 }), ..Default::default() }), }],};let interact = InteractRequest { start: Some(vec![InteractStart::Redirect]), finish: Some(InteractFinish { method: InteractFinishMethod::Redirect, uri: Some("https://paymentplatform.example/finish/{...}".into()), nonce: Some("NONCE".into()) }) };let outgoing_grant_request = GrantRequest::new(outgoing_access, Some(interact));let pending_customer_outgoing_payment_grant = client .grant() .request(&customer_wallet_address.auth_server, &outgoing_grant_request) .await?;Example response
{ "interact": { "redirect": "https://auth.interledger-test.dev/{...}", // uri to redirect the customer to, to begin interaction "finish": "..." // unique key to secure the callback }, "continue": { "access_token": { "value": "..." // access token for continuing the outgoing payment grant request }, "uri": "https://auth.interledger-test.dev/continue/{...}", // uri for continuing the outgoing payment grant request "wait": 30 }}7. Start interaction with the customer
Section titled “7. Start interaction with the customer”Once the client receives the authorization server’s response, it must send the user to the interact.redirect URI contained in the response. This starts the interaction flow.
The response also includes a continue object, which is essential for managing the interaction and obtaining explicit user consent for outgoing payment grants. The continue object contains an access token and a URI that the client will use to finalize the grant request after the user has completed their interaction with the identity provider (IdP). This ensures that the client can securely obtain the necessary permissions to proceed with the payment process.
8. Finish interaction with the customer
Section titled “8. Finish interaction with the customer”The user interacts with the authorization server through the server’s interface and approves or denies the grant.
Provided the user approves the grant, the authorization server:
- Sends the user to the
finish.uriprovided in the interactive outgoing payment grant request. The means by which the server sends the user to the URI is out of scope, but common options include redirecting the user from a web page and launching the system browser with the target URI. - Secures the redirect by adding a unique hash, allowing your client to validate the
finishcall, and an interaction reference as query parameters to the URI.
9. Request a grant continuation
Section titled “9. Request a grant continuation”In our example, we’re assuming the IdP the customer interacted with has a user interface. When the interaction completes, the customer returns to your platform. Now your platform can make a continuation request for the outgoing payment grant.
Call the POST Grant Continuation Request API. This call requests an access token that allows your platform to request outgoing payment resources be created on the customer’s wallet account.
Issue the request to the continue.uri provided in the initial outgoing payment grant response (Step 6).
Include the interact_ref returned in the redirect URI’s query parameters.
const customerOutgoingPaymentGrant = await client.grant.continue( { url: pendingCustomerOutgoingPaymentGrant.continue.uri, accessToken: pendingCustomerOutgoingPaymentGrant.continue.access_token.value }, { interact_ref: interactRef })let customer_outgoing_payment_grant = client .grant() .continue_grant( if let Some(continue_field) = &pending_customer_outgoing_payment_grant.continue_field { &continue_field.uri } else { eprintln!("Missing continue field on pending grant"); return Ok(()); }, &interact_ref, if let Some(continue_field) = &pending_customer_outgoing_payment_grant.continue_field { Some(&continue_field.access_token.value) } else { None }, ) .await?;Example response
{ "access_token": { "value": "...", // final access token required before creating outgoing payments "manage": "https://auth.cloudninebank.example.com/token/{...}", // management uri for access token "access": [ { "type": "outgoing-payment", "actions": ["create"], "identifier": "https://cloudninebank.example.com/customer", "limits": { "receiver": "https://happylifebank.example.com/incoming-payments/{...}" // url of the incoming payment that's being paid } } ] }, "continue": { "access_token": { "value": "..." // access token for continuing the request }, "uri": "https://auth.cloudninebank.example.com/continue/{...}" // continuation request uri }}10. Request the creation of outgoing payment resources
Section titled “10. Request the creation of outgoing payment resources”Recall that the Create Quote API responses for the merchant and your platform (Step 5) both included a debitAmount and a receiveAmount. The responses also included an id which is a URL to identify each quote.
Because the quotes contain debit and receive amounts, we won’t specify any other amounts when setting up the outgoing payments. Instead, we will specify a quoteId.
Use the access token returned in Step 5 that’s associated with the merchant to call the POST Create Outgoing Payment API. Include the appropriate quoteId in the request. Now, do the same with the access token associated with your platform.
// Merchantconst customerOutgoingPaymentToMerchant = await client.outgoingPayment.create( { url: customerWalletAddress.resourceServer, accessToken: customerOutgoingPaymentGrant.access_token.value }, { walletAddress: customerWalletAddress.id, quoteId: merchantQuote.id })// Platformconst customerOutgoingPaymentToPlatform = await client.outgoingPayment.create( { url: customerWalletAddress.resourceServer, accessToken: customerOutgoingPaymentGrant.access_token.value }, { walletAddress: customerWalletAddress.id, quoteId: platformQuote.id })use open_payments::types::OutgoingPaymentRequest;let merchant_outgoing_request = OutgoingPaymentRequest { wallet_address: customer_wallet_address.id.clone(), receiver: Some(merchant_incoming_payment.id.clone()), debit_amount: None, receive_amount: None, quote_id: Some(merchant_quote.id.clone()),};let platform_outgoing_request = OutgoingPaymentRequest { wallet_address: customer_wallet_address.id.clone(), receiver: Some(platform_incoming_payment.id.clone()), debit_amount: None, receive_amount: None, quote_id: Some(platform_quote.id.clone()),};let customer_outgoing_payment_to_merchant = client .outgoing_payments() .create(&customer_wallet_address.resource_server, &merchant_outgoing_request, Some(&customer_outgoing_payment_grant.access_token.value)) .await?;let customer_outgoing_payment_to_platform = client .outgoing_payments() .create(&customer_wallet_address.resource_server, &platform_outgoing_request, Some(&customer_outgoing_payment_grant.access_token.value)) .await?;Example response
The following shows an example response when an outgoing payment resource is created on the customer’s account for the merchant. A similar response will be returned when an outgoing payment resource is created for you, as the wallet provider.
{ "id": "https://cloudninebank.example.com/outgoing-payments/{...}", // url identifying the outgoing payment "walletAddress": "https://cloudninebank.example.com/customer", "receiver": "https://happylifebank.example.com/incoming-payments/{...}", // url of the incoming payment being paid "debitAmount": { "value": "9900", "assetCode": "USD", "assetScale": 2 }, "receiveAmount": { "value": "9900", "assetCode": "USD", "assetScale": 2 }, "sentAmount": { "value": "0", "assetCode": "USD", "assetScale": 2 }, "createdAt": "2022-03-12T23:20:54.52Z"}