Saved Cards
How Batchmates vaults card details with Magpie and charges them later — without redirecting the user to a hosted page.
Why Customers Exist
Magpie issues two kinds of objects when you tokenize a card:
| Object | ID prefix | Lifetime |
|---|---|---|
| Source | src_xxx | Can expire on its own |
| Customer | cus_xxx | Permanent |
A bare src_xxx source can expire before you charge it. Attaching it to a cus_xxx customer makes the card permanently vaulted — the source lives as long as the customer does.
One Magpie customer record is created per user (keyed by email). When a user adds their second card, the existing customer ID is reused — Magpie rejects duplicate customer emails with a 409 error.
Saving a Card — Two-Step Tokenization
Card data never touches the backend directly. The flow is split into two requests so that raw card numbers and CVCs only transit the Magpie tokenization endpoint and are never stored or logged by Batchmates.
Step 1 — Tokenize the card
Endpoint: POST /api/v1/payments/magpie/create-source
Send the raw card fields to this endpoint. The backend forwards them to Magpie and returns the resulting source_id plus the card metadata that Magpie echoes back (last4, brand, exp).
Request Body
- Name
number- Type
- string
- Description
Full card number (16 digits, no spaces)
- Name
exp_month- Type
- integer
- Description
Expiration month (1–12)
- Name
exp_year- Type
- integer
- Description
Expiration year (4 digits, e.g.
2028)
- Name
cvc- Type
- string
- Description
Card security code (3–4 digits)
- Name
name- Type
- string
- Description
Cardholder name as it appears on the card
Request
{
"number": "4242424242424242",
"exp_month": 12,
"exp_year": 2028,
"cvc": "123",
"name": "Juan Dela Cruz"
}
Response
{
"success": true,
"data": {
"id": "src_abc123xyz",
"type": "card",
"card": {
"last4": "4242",
"brand": "visa",
"exp_month": 12,
"exp_year": 2028
}
}
}
Step 2 — Vault the source
Endpoint: POST /api/v1/payment-methods
Send only the source_id returned in Step 1, plus the card metadata from the source response. No raw card data is accepted here.
Request Body
- Name
payment_gateway- Type
- string
- Description
Must be
"magpie"
- Name
source_id- Type
- string
- Description
The
idreturned fromcreate-source(e.g.src_abc123xyz)
- Name
card_last_four- Type
- string
- Description
Last 4 digits from the source response (exactly 4 characters)
- Name
card_brand- Type
- string
- Description
Card brand from the source response (e.g.
"visa","mastercard")
- Name
card_exp_month- Type
- integer
- Description
Expiration month from the source response
- Name
card_exp_year- Type
- integer
- Description
Expiration year from the source response
- Name
set_as_default- Type
- boolean
- Description
Set as default payment method. Default:
false
Request
{
"payment_gateway": "magpie",
"source_id": "src_abc123xyz",
"card_last_four": "4242",
"card_brand": "visa",
"card_exp_month": 12,
"card_exp_year": 2028
}
Response
{
"success": true,
"data": {
"id": 3,
"payment_gateway": "magpie",
"card_last_four": "4242",
"card_brand": "visa",
"card_exp_month": 12,
"card_exp_year": 2028,
"is_default": false
},
"message": "Payment method added successfully"
}
Vaulting Sequence (Internal)
When POST /api/v1/payment-methods is called with a source_id, MagpieTransactionService::savePaymentMethodFromSource() runs this sequence:
// app/Services/MagpieTransactionService.php
public function savePaymentMethodFromSource(int $userId, string $sourceId, array $cardMeta): PaymentMethod
{
$user = User::findOrFail($userId);
// Step 1 — get or create a Magpie customer → cus_xxx
$existingCustomerId = PaymentMethod::where('user_id', $userId)
->whereNotNull('gateway_customer_id')
->value('gateway_customer_id');
$customerId = $existingCustomerId ?? $this->magpieService->createCustomer([
'email' => $user->email,
'description' => $user->name,
])['id'];
// Step 2 — attach source to customer (vaults the card)
$this->magpieService->attachSourceToCustomer($customerId, $sourceId);
// Step 3 — persist the PaymentMethod record (no raw card data stored)
return PaymentMethod::create([
'user_id' => $user->id,
'payment_gateway' => 'magpie',
'gateway_token' => $sourceId, // src_xxx
'gateway_customer_id' => $customerId, // cus_xxx
'card_last_four' => $cardMeta['last4'],
'card_brand' => $cardMeta['brand'],
'card_exp_month' => $cardMeta['exp_month'],
'card_exp_year' => $cardMeta['exp_year'],
]);
}
Charging a Saved Card
Endpoint: POST /api/v1/donations/charge-saved
No browser redirect is needed. The charge is synchronous from the backend.
Request Body
- Name
campaign_id- Type
- integer
- Description
Campaign to donate to
- Name
payment_method_id- Type
- integer
- Description
ID of the saved card. Must belong to the authenticated user — returns 403 otherwise.
- Name
amount- Type
- number
- Description
Donation amount in PHP (minimum: 1)
- 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": 3,
"amount": 500
}
Response
{
"success": true,
"data": {
"id": 91,
"amount": "500.00",
"status": "completed",
"payment_gateway": "magpie",
"paid_at": "2026-03-01T08:00:00.000000Z"
},
"message": "Donation charged successfully"
}
Error (Wrong Owner — 403)
{
"success": false,
"message": "Payment method does not belong to you"
}
Internal Charge Call
// app/Services/MagpieTransactionService.php
public function chargeWithSavedPaymentMethod(Donation $donation, PaymentMethod $pm): void
{
$fees = FeeCalculator::calculate('magpie', $donation->amount);
$charge = $this->magpieService->createCharge([
'customer' => $pm->gateway_customer_id, // cus_xxx
'source' => $pm->gateway_token, // src_xxx
'amount' => (int) round($fees['total_amount'] * 100),
'currency' => 'php',
'metadata' => [
'reference_number' => $donation->reference_number,
'donation_id' => $donation->id,
],
]);
// If charge immediately succeeds, donation is marked completed synchronously.
// If the charge is pending (e.g. requires 3DS), the charge.succeeded webhook finalises it.
if ($charge['status'] === 'succeeded') {
$donation->update(['status' => 'completed', 'paid_at' => now()]);
$campaign->increment('raised_amount', $donation->amount);
$campaign->increment('available_amount', $donation->amount);
$campaign->increment('supporter_count');
}
}
If the charge returns status: 'succeeded' synchronously, the donation is marked completed immediately — no webhook wait needed. If the charge requires 3DS authentication, an action.url is returned and the charge.succeeded webhook finalises the donation after the user completes authentication.
Vaulting Status
Card vaulting via attachSourceToCustomer is currently returning "Cardholder authentication is unavailable" from Magpie. This is believed to be an account-level feature gate pending enablement by Magpie support. The two-step tokenization flow (create-source → vault) is correctly implemented on the Batchmates side.
The POST /api/v1/payment-methods endpoint and savePaymentMethodFromSource service method are fully implemented. Once Magpie enables the vaulting feature on the account, no code changes are needed.
What Cannot Be Vaulted
GCash and Maya payments cannot be saved as reusable payment methods. Each payment requires a fresh authorization. Use Magpie Checkout for these payment types.
GCash, Maya, and QR Ph sources are redirect-based and single-use. They cannot be attached to a customer and reused. Only credit/debit card sources (type: 'card') support vaulting.