Payment Flow
This page shows Payylo’s group-payment flow: how a single purchase is split into slots and paid by multiple participants, each covering their own slot.
Overview
Payylo coordinates the split between your backend, the checkout, the card provider, and each participant. The diagrams below walk through the end-to-end process from setup to capture.
The Group Payment Lifecycle
One purchase is split into slots; each participant pays one slot. Instead of repeating the same steps for every participant, the diagram shows the three phases - setup, one participant paying (looped), and completion:
The Three Phases in Detail
Multiple participants collaboratively pay for a single purchase, one slot each:
1. Setup (server-side)
- Your backend creates a group payment with
POST /v1/group-payments, passingtotal_amount(in currency units) and adistributionthat defines the slots. - Payylo returns the
group_payment, itspayment_slots, and a shareablecheckout_url. - You share that URL with participants - or embed the flow (see Embedded Checkout).
2. Each participant pays a slot
- A participant opens the checkout, picks an available slot, and enters their card.
- Payylo pre-authorises (holds) the funds with the card provider - it does not capture yet.
- The group moves to
partially_preauthorised(some slots held) and thenpreauthorisedonce every slot is held. No money has been captured at this point - if the group expires here, the holds are simply released. - Webhooks:
payment_intent.preauthorisedfor that slot.group_payment.partially_paidis not sent for holds - it always means captured money.
3. Auto-capture & completion
- Once every slot is authorised, Payylo automatically captures all of them.
- Webhooks:
payment_intent.captured, thengroup_payment.paid- your cue to fulfil the order / confirm the booking.
Group Payment States
With card providers, no money is captured while collecting - only held.
partially_paid always means real captured money, never just holds: it
appears in the capture phase while captures confirm one by one, or earlier when
a slot is paid on an instant-settle rail (Zelle, Mercantil, BNC, Banesco),
which captures immediately with no preauthorisation step.
If the group expires before completing, a daily sweep closes it: card holds
are released (nobody is charged), captured money on API-refundable rails
(cards, Mercantil) is refunded automatically back to the payer, and captures on
Zelle/BNC/Banesco are flagged for your manual reconciliation. The sweep emits a
group_payment.expired event - see
Group expiry and automatic refunds.
USD → VES conversion
Merchants price in USD, but Venezuela’s gateways (Mercantil, BNC, Banesco) only process bolívares - so USD charges on those rails are converted at the merchant-selected rate source, configured in the dashboard:
| Source | Rate |
|---|---|
bcv | Official BCV rate |
parallel | Parallel-market average |
Every applied conversion is snapshotted on the payment intent as
metadata.exchange_rate - the rate of the day is evidence for reconciliation,
receipts and disputes:
{
"exchange_rate": {
"source": "bcv",
"rate": 161.35,
"provider": "dolarapi",
"base_currency": "USD",
"quote_currency": "VES",
"base_amount": 10000,
"quoted_at": "2026-06-20T16:57:23.000Z"
}
}The snapshot is surfaced on payment_intent.captured webhooks, payer receipts
and the transactions CSV export. If no acceptably fresh rate is available
(provider outage), the VES-only gateways are skipped in the provider
cascade instead of charging at a bad rate.
IGTF surcharge (special taxpayers)
Merchants flagged as SENIAT special taxpayers add the 3% IGTF (Impuesto a las Grandes Transacciones Financieras) as a surcharge on charges in foreign currency - the charged amount becomes base + 3%. Payments in bolívares never carry it.
Every applied surcharge is snapshotted on the intent as metadata.taxes:
{
"taxes": {
"base_amount": 10000,
"igtf_rate": 0.03,
"igtf_amount": 300,
"currency": "USD"
}
}Like the exchange-rate snapshot, it is surfaced on payment_intent.captured
webhooks, receipts and the CSV export. IVA is deliberately not computed -
whether your price includes IVA is your pricing policy.
Zelle: payer-reported transfers
Zelle has no merchant API, so it works differently from the other rails - the payment is reported by the payer and verified against the merchant’s bank feed:
- The payer picks Zelle at checkout and is shown the merchant’s Zelle email
plus an exact, cent-unique amount to send (e.g.
50.07instead of50.00) - that unique amount is what later identifies the transfer. - The payer sends the transfer from their own US bank app, then reports it
in the checkout (sender name + confirmation code). The payment stays pending
- reporting alone never marks it paid.
- Payylo matches the incoming credit in the merchant’s bank feed (linked once from the dashboard via Plaid) against the cent-unique amount. A first verification runs immediately; late-posting transfers are settled by a periodic reconcile sweep.
- On match the payment is captured and the normal webhooks fire
(
payment_intent.captured,group_payment.*).
Zelle is USD-only and settles instantly (no preauthorisation), so a group with
a Zelle slot can be partially_paid while still collecting. If a slot ends up
paid twice (e.g. a reported Zelle transfer verifies after the slot was paid
another way), the duplicate is flagged for manual refund instead of moving the
slot again.
Error Handling for Group Payments
Payylo provides comprehensive error handling for group payment scenarios:
Group Payment Creation Errors
- Invalid total amount: Cart total doesn’t match sum of slot amounts
- Slot configuration: Invalid item assignments or duplicate amounts
- API validation: Missing required fields or invalid data
Individual Payment Errors
- Payment declined: Individual user’s payment method declined
- Insufficient funds: User doesn’t have enough balance
- Slot already paid: User trying to pay for an already completed slot
- Payment intent failures: PSP communication issues during payment processing
Group Completion Errors
- Session expiry: Group payment session timed out before completion
- Partial payments: Some slots paid but others remain incomplete
- Webhook delivery: Issues sending completion notifications
Best Practices for Group Payments
- Set clear session timeouts to avoid indefinite waiting
- Implement real-time updates so users see slot status changes
- Handle partial failures gracefully with retry mechanisms
- Validate slot amounts match cart totals before creation
- Test group scenarios with multiple concurrent users
Integration Examples
Creating a group payment
Amounts are in currency units (e.g. 300.00), not cents. Slots come from the
distribution - equally across N slots, or custom amounts that sum to the
total.
curl https://api.payylo.com/v1/group-payments \
-H "Authorization: Bearer $PAYYLO_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"group_name": "Office gear",
"total_amount": 300.00,
"currency": "EUR",
"expires_at": "2026-06-22T18:00:00Z",
"distribution": {
"type": "custom",
"slots": [
{ "amount": 250.00, "metadata": { "item": "Laptop" } },
{ "amount": 30.00, "metadata": { "item": "Wireless Mouse" } },
{ "amount": 20.00, "metadata": { "item": "Mechanical Keyboard" } }
]
}
}'Webhook handler for group payments
Verify the signature first (see Webhooks), then switch on the real event types:
app.post(
'/webhooks/payylo',
express.raw({ type: 'application/json' }),
(req, res) => {
// Verify req.header('Payylo-Signature') against the raw body - see /webhooks.
const event = JSON.parse(req.body.toString('utf8'));
switch (event.type) {
case 'group_payment.partially_paid':
// Some money has actually been CAPTURED (not just held) - e.g. while
// captures confirm one by one, or after a partial capture failure.
console.log('Partially paid:', event.data.group_payment_id);
break;
case 'group_payment.paid':
// All slots paid - fulfil the order / confirm the booking here.
console.log('Group payment paid:', event.data.group_payment_id);
break;
case 'group_payment.refunded':
case 'group_payment.partially_refunded':
console.log('Refund event:', event.type, event.data.group_payment_id);
break;
case 'payment_intent.failed':
console.log('A payment intent failed:', event.data);
break;
}
res.json({ received: true });
}
);Next Steps
- Authentication - Set up secure API access
- Quick Start - Create your first group payment
- Webhooks - Handle real-time notifications
- API Reference - Interactive Swagger UI with every endpoint (try requests live)