Skip to Content
Payment Flow

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, passing total_amount (in currency units) and a distribution that defines the slots.
  • Payylo returns the group_payment, its payment_slots, and a shareable checkout_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 then preauthorised once 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.preauthorised for that slot. group_payment.partially_paid is 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, then group_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:

SourceRate
bcvOfficial BCV rate
parallelParallel-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:

  1. 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.07 instead of 50.00) - that unique amount is what later identifies the transfer.
  2. 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.
  3. 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.
  4. 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

  1. Set clear session timeouts to avoid indefinite waiting
  2. Implement real-time updates so users see slot status changes
  3. Handle partial failures gracefully with retry mechanisms
  4. Validate slot amounts match cart totals before creation
  5. 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

Last updated on