PayMongo Saved Cards
How Batchmates vaults cards with PayMongo and charges them later. Card data is tokenized directly in the browser — Batchmates servers never see raw card numbers.
PayMongo has no zero-amount setup intent, so a card can only be vaulted through a real payment. Vaulting therefore runs a small one-time verification charge. PayMongo also supports on-session reuse only, so the donor must re-enter the card CVC for every saved-card charge. Recurring donations are currently disabled platform-wide pending gateway support.
Source files: app/Services/PayMongoTransactionService.php · resources/js/components/payment-methods/AddPayMongoCardModal.tsx
Card Vaulting Flow
Step 1 — Tokenize in the browser
The frontend POSTs card details directly to PayMongo's payment methods endpoint using the public key (Basic Auth). This produces a payment method id (pm_...).
POST https://api.paymongo.com/v1/payment_methods
Authorization: Basic base64(PAYMONGO_PUBLIC_KEY:)
Request
{
"data": {
"attributes": {
"type": "card",
"details": {
"card_number": "4343434343434345",
"exp_month": 12,
"exp_year": 2028,
"cvc": "123"
},
"billing": { "name": "Juan Dela Cruz" }
}
}
}
PayMongo returns a pm_... id. No raw card data ever reaches Batchmates.
Step 2 — Vault via backend
Authentication: Required
Send the pm_... id plus optional display metadata. The backend creates (or reuses) a PayMongo customer and runs a ₱25 verification charge with setup_future_usage to vault the card.
- Name
payment_method_id- Type
- string
- Description
The
pm_...id from PayMongo's client-side API. Must start withpm_.
- Name
card_meta- Type
- object
- Description
Optional card display info. If omitted, the backend fetches it from PayMongo.
- Name
card_meta.last4- Type
- string
- Description
Last 4 digits of the card
- Name
card_meta.brand- Type
- string
- Description
Card brand (e.g.
"visa","mastercard")
- Name
card_meta.exp_month- Type
- integer
- Description
Expiration month (1–12)
- Name
card_meta.exp_year- Type
- integer
- Description
Expiration year (4 digits)
Request
{
"payment_method_id": "pm_abc123def456",
"card_meta": {
"last4": "4345",
"brand": "visa",
"exp_month": 12,
"exp_year": 2028
}
}
Response (vaulted)
{
"success": true,
"data": {
"id": 12,
"payment_gateway": "paymongo",
"card_last_four": "4345",
"card_brand": "visa",
"is_default": true
},
"message": "Card linked successfully"
}
Response (3DS required)
{
"success": false,
"requires_action": true,
"action_url": "https://checkout.paymongo.com/authenticate/pi_...",
"message": "Card requires authentication"
}
A one-time ₱25.00 card verification charge (VAULT_AMOUNT_CENTAVOS = 2500) is required because PayMongo cannot vault a card without a real payment. This is disclosed to the user in the add-card modal. On a 201, the card is vaulted; on a 200 with requires_action, the user must complete 3DS first.
Internal Vaulting Sequence
// app/Services/PayMongoTransactionService.php
public function linkCard(int $userId, string $paymentMethodId, array $cardMeta): array
{
// Reuse an existing PayMongo customer for this user, or create one
$customerId = $existingCustomerId ?? $this->paymongo->createCustomer([...])['id'];
// PaymentMethod is created is_active = false until the charge succeeds
$record = PaymentMethod::create([
'payment_gateway' => 'paymongo',
'gateway_token' => $paymentMethodId, // pm_...
'gateway_customer_id' => $customerId, // cus_...
'is_active' => false,
]);
// Verification charge with setup_future_usage vaults the card
$intent = $this->paymongo->createPaymentIntent([
'amount' => self::VAULT_AMOUNT_CENTAVOS, // 2500 centavos = ₱25.00
'currency' => 'PHP',
'payment_method_allowed' => ['card'],
'capture_type' => 'automatic',
'setup_future_usage' => ['session_type' => 'on_session', 'customer_id' => $customerId],
'metadata' => ['payment_method_record_id' => (string) $record->id],
]);
$intent = $this->paymongo->attachPaymentIntent($intent['id'], [
'payment_method' => $paymentMethodId,
'return_url' => config('app.url').'/api/v1/payments/paymongo/vault-return?pm='.$record->id.'&intent='.$intent['id'],
]);
// status 'succeeded' → is_active = true, return success
// status 'awaiting_next_action' → return action_url for 3DS
// otherwise → delete the record and fail
}
One PayMongo customer (cus_...) is created per user and reused across all their cards. If the verification charge is not successful and 3DS isn't pending, the just-created inactive PaymentMethod record is deleted so no dangling card is left behind.
3DS on Vaulting — vault-return
When the verification charge requires 3DS, the backend returns requires_action with an action_url. The frontend redirects the browser there. After the challenge, PayMongo returns to:
GET /api/v1/payments/paymongo/vault-return?pm={record_id}&intent={pi_id}
confirmVault() re-fetches the payment intent from PayMongo's API — never trusting the redirect alone — checks the intent belongs to this record (matching customer_id or metadata.payment_method_record_id), and activates the PaymentMethod on succeeded. It then redirects to {FRONTEND_URL}/payment-methods?paymongo_vault=success (or failed).
Charging a Saved PayMongo Card
Charges a vaulted PayMongo card directly to create a donation.
Authentication: Required. Regular users may only charge their own cards; system_admin and institution_admin may charge any user's card.
The card must have payment_gateway = "paymongo", be is_active, and be vaulted (have a gateway_customer_id).
Request Body
- Name
campaign_id- Type
- integer
- Description
ID of the campaign to donate to
- Name
payment_method_id- Type
- integer
- Description
ID of the saved PayMongo payment method
- Name
amount- Type
- number
- Description
Donation amount in PHP (minimum: 1)
- Name
cvc- Type
- string
- Description
Card CVC (3–4 digits) — PayMongo requires it on every saved-card charge
- Name
is_anonymous- Type
- boolean
- Description
Default:
false
- Name
message- Type
- string
- Description
Optional message (max 500 characters)
Request
{
"campaign_id": 1,
"payment_method_id": 12,
"amount": 500,
"cvc": "123"
}
Response (completed)
{
"success": true,
"donation": {
"id": 88,
"amount": "500.00",
"status": "completed",
"payment_gateway": "paymongo",
"paid_at": "2026-07-14T08:00:00.000000Z"
},
"message": "Donation completed successfully"
}
Response (3DS required)
{
"success": false,
"requires_action": true,
"action_url": "https://checkout.paymongo.com/authenticate/pi_...",
"message": "Payment requires authentication"
}
Because PayMongo only allows on-session reuse, the donor must re-enter the card CVC each time. The review step in Donate.tsx shows a CVC input whenever a saved PayMongo card is selected, and the charge request will not be sent without it.
Charge Sequence
// app/Services/PayMongoTransactionService.php
public function chargeWithSavedCard(array $data, PaymentMethod $paymentMethod, string $cvc): array
{
// 1. Re-supply the CVC on the vaulted payment method (required for reuse)
$this->paymongo->updatePaymentMethod($paymentMethod->gateway_token, [
'details' => ['cvc' => $cvc],
]);
// 2. Create the payment intent for the donation (amount in centavos)
$intent = $this->paymongo->createPaymentIntent([
'amount' => (int) round($donation->total_amount * 100),
'currency' => 'PHP',
'payment_method_allowed' => ['card'],
'capture_type' => 'automatic',
'customer_id' => $paymentMethod->gateway_customer_id,
'metadata' => ['donation_reference' => $donation->reference_number],
]);
$donation->update(['transaction_id' => $intent['id']]); // pi_...
// 3. Attach the vaulted card, with a 3DS return_url
$intent = $this->paymongo->attachPaymentIntent($intent['id'], [
'payment_method' => $paymentMethod->gateway_token,
'return_url' => config('app.url').'/api/v1/payments/paymongo/intent-return?id='.$donation->reference_number,
]);
// status 'succeeded' → completeDonation(), success
// status 'awaiting_next_action' → return action_url for 3DS
// otherwise → donation → failed
}
3DS on Charge — intent-return
If the charge needs 3DS, the frontend redirects to the returned action_url. After the challenge PayMongo returns to:
GET /api/v1/payments/paymongo/intent-return?id={reference_number}
The handler calls verifyAndComplete() (which re-fetches the payment intent from PayMongo's API), then redirects to {FRONTEND_URL}/donations/success if the donation is now completed, or /donations/failed otherwise.
What Cannot Be Vaulted
Only cards can be vaulted. GCash, Maya, GrabPay, and QR Ph are redirect-based and single-use — for those, use PayMongo Checkout.
No Recurring via PayMongo
PayMongo supports on-session reuse only (donor-present CVC plus possible per-charge 3DS), so it cannot back unattended recurring charges. Recurring/subscription billing is currently disabled platform-wide pending gateway support.
Mobile / Native Clients
The vaulting and charge endpoints are platform-neutral, but the card-capture step shown here is written for the web. On native clients:
- Tokenization has no browser dependency. Call PayMongo's
POST https://api.paymongo.com/v1/payment_methodsdirectly from the app (or use PayMongo's mobile SDK) with the public key to obtain thepm_...id, then send that id tolink-card. Raw card data still never touches Batchmates servers — the same security guarantee holds on mobile. - 3DS is a browser round-trip. When
link-cardorcharge-saved/paymongoreturnsrequires_actionwith anaction_url, open it in an in-app browser (ASWebAuthenticationSession/ Custom Tabs), not an embeddedWebView. - The
vault-returnandintent-returnURLs resolve to the webFRONTEND_URL— register them as deep links / universal links so the app regains control after the 3DS challenge, then re-fetch the payment method or donation to confirm the final state. - The CVC-per-charge requirement is unchanged: collect the CVC in the app UI and pass it on every
charge-saved/paymongorequest.