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 with pm_.

  • 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"
}

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
}

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).


POST/api/v1/donations/charge-saved/paymongo

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"
}

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

No Recurring via PayMongo


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:

Was this page helpful?