Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@stripe/stripe-js": "^8.9.0",
"@vercel/blob": "^2.3.1",
"mermaid": "^11.12.2",
"mppx": "https://pkg.pr.new/mppx@235",
"mppx": "https://pkg.pr.new/mppx@231",
"react": "^19",
"react-dom": "^19",
"stripe": "^20.4.1",
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

151 changes: 151 additions & 0 deletions src/pages/guides/split-payments.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
---
imageDescription: "Split a single charge across multiple recipients for marketplaces, referral fees, and revenue sharing"
---

import { Cards } from 'vocs'
import { OneTimePaymentsCard, PayAsYouGoCard, ServerQuickstartCard } from '../../components/cards'

# Accept split payments [Distribute a charge across multiple recipients]

Split a single charge across multiple recipients in one atomic transaction. The primary recipient receives the remainder after all splits are deducted.

Split payments are useful for:

- **Marketplaces** — route a platform fee to yourself and the rest to the seller
- **Referral programs** — pay a bounty to the referrer on every purchase
- **Revenue sharing** — distribute earnings across partners or contributors

## How it works

When you add `splits` to a charge, the SDK constructs multiple on-chain transfers in a single transaction:

1. Each split recipient receives their declared amount
2. The primary `recipient` receives `amount - sum(splits)`
3. The server verifies all transfers atomically

:::info
Split amounts are in human-readable units, the same as the top-level `amount`. The primary recipient's share is always implicit — you only declare the splits.
:::

## Server

Add a `splits` array to any `mppx.charge` call. Each entry specifies a `recipient` and `amount`.

```ts twoslash
import { Mppx, tempo } from 'mppx/server'

const mppx = Mppx.create({ methods: [tempo()] })
// ---cut---
export async function handler(request: Request) {
const result = await mppx.charge({
amount: '1.00',
currency: '0x20c0000000000000000000000000000000000000', // pathUSD
recipient: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // seller
splits: [ // [!code hl]
{ // [!code hl]
amount: '0.10', // [!code hl]
recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // platform fee // [!code hl]
}, // [!code hl]
], // [!code hl]
})(request)

// seller receives $0.90, platform receives $0.10
if (result.status === 402) return result.challenge
return result.withReceipt(Response.json({ data: '...' }))
}
```

### With per-split memos

Each split can carry its own on-chain memo for reconciliation:

```ts twoslash
import { Mppx, tempo } from 'mppx/server'

const mppx = Mppx.create({ methods: [tempo()] })

declare const request: Request
// ---cut---
const result = await mppx.charge({
amount: '1.00',
currency: '0x20c0000000000000000000000000000000000000', // pathUSD
memo: '0x6f726465722d313233', // order-123
recipient: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // seller
splits: [
{
amount: '0.10',
memo: '0x706c6174666f726d2d666565', // platform-fee // [!code hl]
recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // platform
},
],
})(request)
```

### With fee sponsorship

Split payments work with [fee sponsorship](/payment-methods/tempo#fee-sponsorship). The server co-signs the multi-transfer transaction so the client doesn't need gas tokens.

```ts twoslash
import { Mppx, tempo } from 'mppx/server'

const mppx = Mppx.create({ methods: [tempo()] })

declare const request: Request
// ---cut---
const result = await mppx.charge({
amount: '1.00',
currency: '0x20c0000000000000000000000000000000000000', // pathUSD
feePayer: true, // [!code hl]
recipient: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // seller
splits: [
{ amount: '0.05', recipient: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' }, // referrer
{ amount: '0.10', recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' }, // platform
],
})(request)
```

## Client

The client SDK handles split payments automatically — no client-side configuration is needed. When the server includes `splits` in the Challenge, the client constructs the matching multi-transfer transaction.

### Validating split recipients

Use `expectedRecipients` to restrict which split recipients the client will sign for. This prevents a compromised server from redirecting funds to unexpected addresses.

```ts twoslash
import { Mppx, tempo } from 'mppx/client'
import { privateKeyToAccount } from 'viem/accounts'

const account = privateKeyToAccount('0xabc…123')
// ---cut---
Mppx.create({
methods: [
tempo.charge({
account,
expectedRecipients: [ // [!code hl]
'0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // platform // [!code hl]
'0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', // referrer // [!code hl]
], // [!code hl]
}),
],
})
```

If the server sends a Challenge with a split recipient not in the allowlist, the client throws an error instead of signing.

## Constraints

| Rule | Limit |
|------|-------|
| Splits per charge | 1–10 |
| Each split amount | Must be > 0 |
| Sum of all splits | Must be strictly less than `amount` |
| Split memo | Optional, 32-byte hex hash |

## Next steps

<Cards>
<OneTimePaymentsCard />
<PayAsYouGoCard />
<ServerQuickstartCard />
</Cards>
26 changes: 26 additions & 0 deletions src/pages/payment-methods/tempo/charge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,32 @@ const result = await mppx.charge({

When `feePayer` is `true`, the server adds a fee payer signature (domain `0x78`) before broadcasting. The client doesn't need gas tokens. See [fee sponsorship](/payment-methods/tempo#fee-sponsorship) for details.

### With split payments

Split a charge across multiple recipients in a single transaction. The primary `recipient` receives `amount` minus the sum of all splits.

```ts twoslash
import { Mppx, tempo } from "mppx/server";

const mppx = Mppx.create({ methods: [tempo()] });

declare const request: Request;
// ---cut---
const result = await mppx.charge({
amount: "1.00",
currency: "0x20c0000000000000000000000000000000000000", // pathUSD
recipient: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", // seller
splits: [ // [!code hl]
{ // [!code hl]
amount: "0.10", // [!code hl]
recipient: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", // platform fee // [!code hl]
}, // [!code hl]
], // [!code hl]
})(request);
```

Up to 10 splits per charge. Each split must have a positive amount, and the sum of all splits must be less than the total `amount`. See the [split payments guide](/guides/split-payments) for more details.

### With Stripe

```ts twoslash
Expand Down
18 changes: 18 additions & 0 deletions src/pages/sdk/typescript/client/Method.tempo.charge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,24 @@ Client identifier used to derive the client fingerprint in attribution memos.

Function that returns a viem client for the given chain ID.

### expectedRecipients (optional)

- **Type:** `readonly string[]`

Allowlist of addresses the client will accept as split recipients. When set, the client rejects any Challenge whose split recipients are not in this list, preventing a compromised server from redirecting funds.

```ts twoslash
import { tempo } from 'mppx/client'
import { privateKeyToAccount } from 'viem/accounts'

const method = tempo.charge({
account: privateKeyToAccount('0xabc…123'),
expectedRecipients: [ // [!code focus]
'0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // [!code focus]
], // [!code focus]
})
```

### mode (optional)

- **Type:** `'push' | 'pull'`
Expand Down
32 changes: 32 additions & 0 deletions src/pages/sdk/typescript/server/Method.tempo.charge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,35 @@ Server-defined correlation data, serialized as `opaque` on the Challenge.
- **Type:** `string`

Address to receive the payment.

### splits (optional)

- **Type:** `Array<{ amount: string; memo?: string; recipient: string }>`

Split the charge across additional recipients. Each entry specifies an `amount` (in human-readable units) and a `recipient` address. The primary `recipient` receives `amount` minus the sum of all split amounts.

| Constraint | Value |
|---|---|
| Array length | 1–10 |
| Each split amount | Must be > 0 |
| Sum of splits | Must be strictly less than `amount` |
| Split memo | Optional, 32-byte hex hash |

```ts twoslash
import { Mppx, tempo } from 'mppx/server'

const mppx = Mppx.create({ methods: [tempo.charge()] })
// ---cut---
export async function handler(request: Request) {
const response = await mppx.charge({
amount: '1.00',
currency: '0x20c0000000000000000000000000000000000000', // pathUSD
recipient: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // seller
splits: [ // [!code focus]
{ amount: '0.10', recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' }, // platform fee // [!code focus]
], // [!code focus]
})(request)

if (response.status === 402) return response.challenge
return response.withReceipt(Response.json({ data: '...' }))
}
4 changes: 4 additions & 0 deletions vocs.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ export default defineConfig({
text: "Accept streamed payments",
link: "/guides/streamed-payments",
},
{
text: "Accept split payments",
link: "/guides/split-payments",
},
{
text: "Accept multiple payment methods",
link: "/guides/multiple-payment-methods",
Expand Down
Loading