Payments, anti-fraud & saved cards
Pagar.me anti-fraud integration, saved card vault, and the payment page redesign
Overview
Credit card payments in a C2C marketplace carry chargeback risk that falls on the platform. This documents the anti-fraud integration, saved card system, and payment flow redesign.
Anti-fraud
All credit card payments include antifraud: { enabled: true } in the Pagar.me order payload. This triggers Pagar.me's built-in anti-fraud analysis before authorizing the charge.
The analyzing order status
When anti-fraud is enabled, Pagar.me may hold the transaction for review instead of immediately approving it. The payment returns status: "pending" while the fraud check runs.
We map this to a new analyzing order status in our state machine:
pending → analyzing → paid (anti-fraud approved)
pending → analyzing → cancelled (anti-fraud rejected)
pending → paid (instant approval, or PIX)The analyzing status is system-only — no user or seller can trigger it. Webhook handlers accept transitions from both pending and analyzing to paid or cancelled.
Real customer data
Previously, customer data sent to Pagar.me was hardcoded with dummy values (test CPF, fake phone). Now the checkout API builds the customer object from the buyer's profile in the database:
- Name: from
user_profiles.full_name - CPF: from
user_profiles.cpf - Phone: from
user_profiles.phone(parsed into area code + number) - Address: from
shipping_addresses(selected or default)
This is critical for anti-fraud accuracy — Pagar.me's fraud engine uses this data to score transactions.
Saved cards
Architecture
Pagar.me handles PCI compliance via their card vault. We store only non-sensitive metadata:
| Column | Example | Purpose |
|---|---|---|
pagarme_card_id | card_abc123 | Vault token for reuse |
last4 | 8990 | Display in UI |
brand | visa | Brand icon |
holder_name | João Silva | Display |
exp_month / exp_year | 12 / 30 | Expiry display |
The user_saved_cards table has:
- A unique constraint on
(user_id, pagarme_card_id)to prevent duplicates - RLS policies so users can only access their own cards
- Cascade delete when user is removed
Save flow
After a successful credit card payment (status paid or analyzing), the API extracts card metadata from charges[0].last_transaction.card and inserts it with onConflictDoNothing().
Reuse flow
When a saved card is selected at checkout:
- Client sends
savedCardId(our UUID) instead ofcardToken - Server verifies the card belongs to the authenticated user
- Server resolves
pagarme_card_idand sends it ascard_id(notcard_token) to Pagar.me
No CVV is needed for saved card transactions — Pagar.me handles this with their vault.
Payment page redesign
The payment step uses a multi-phase state machine within the component:
method-select → (PIX/balance → review)
→ (saved card → installments → review)
→ (new card → card form → installments → review)Phase 1: Method selection
An Enjoei-inspired list showing all payment options as tappable rows:
- Account balance (with current amount, disabled if insufficient)
- PIX (instant payment)
- Each saved card (brand icon + last4 + installment info)
- "New credit card" option
Phase 2: Card form (new cards only)
Standard card fields plus a CPF field for the cardholder. Test defaults (card number, CVV, etc.) are gated behind NEXT_PUBLIC_PAGARME_ENV=TEST — production shows empty fields.
Phase 3: Installments picker
Shows 1x through 12x options:
- First N installments are interest-free (configurable, default 3)
- Beyond that, simple interest is applied at a configurable monthly rate
- Each row shows the per-installment amount and total
Installment calculation
// Interest-free: simple division
installmentAmount = Math.floor(totalCents / count)
// With interest: total * (1 + rate * count)
totalWithInterest = Math.round(totalCents * (1 + monthlyRate * count))The same calculateInstallments() utility is used client-side for the picker and can be reused server-side for validation.
Webhook idempotency
A processed_webhooks table stores webhook event IDs. Each handler checks this table before processing:
const existing = await db.query.processedWebhooks.findFirst({
where: { id: webhookEventId },
});
if (existing) return { success: true, message: "Already processed" };This complements the existing status-based checks (order.status !== "pending") — belt and suspenders against Pagar.me delivering the same webhook twice in quick succession.
Withdrawal balance verification
Before processing a seller withdrawal, we verify the internal balance against Pagar.me's real recipient balance API:
GET /core/v5/balance/{sellerId}If Pagar.me reports less available than our internal tracking, the withdrawal is blocked and an error is logged. This acts as a safety net against balance drift (missed webhooks, race conditions).
Key files
| File | Purpose |
|---|---|
packages/db/schema/enums.ts | analyzing order status |
packages/db/schema/user-saved-cards.ts | Saved cards table |
packages/db/schema/processed-webhooks.ts | Webhook idempotency |
packages/features/checkout/utils/pagarme.ts | Pagar.me order builder + anti-fraud |
packages/features/checkout/utils/calculate-installments.ts | Installment math |
packages/features/checkout/views/2-payment/ | Payment step UI |
packages/features/checkout/hooks/use-place-order.ts | Order placement hook |
packages/features/checkout/queries/get-user-saved-cards.ts | Saved cards query |
packages/features/pagarme/actions/webhook-handlers.action.ts | Webhook handlers |
packages/features/payments/queries/fetch-recipient-balance.ts | Balance API helper |
apps/web/app/api/v1/checkout/route.ts | Checkout API |