Skip to main content

Overview

The Vaniyk Empire API uses Stripe to handle all payment processing. The payment flow follows a secure, asynchronous pattern using Payment Intents and webhooks to ensure reliable payment confirmation and content access.

Payment Architecture

Payment Lifecycle

The complete payment process follows these stages:

Purchase Model

Every payment creates a Purchase record:
const purchaseSchema = new mongoose.Schema({
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  content: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Content',
    required: true
  },
  amount: {
    type: Number,
    required: true
  },
  stripePaymentIntentId: {
    type: String
  },
  status: {
    type: String,
    enum: ['pending', 'completed', 'failed', 'refunded'],
    default: 'pending'
  },
  purchasedAt: {
    type: Date,
    default: Date.now
  }
});

// Compound index prevents duplicate purchases
purchaseSchema.index({ user: 1, content: 1 });
The compound index on (user, content) enables fast duplicate purchase checking and prevents users from purchasing the same content multiple times.

Step 1: Creating a Payment Intent

The payment flow begins when a user initiates a purchase:
POST /api/payments/create-payment-intent
Authorization: Bearer <token>

{
  "contentId": "64f8a..."
}
Stripe requires amounts in the smallest currency unit (cents for USD). Always multiply dollar amounts by 100: Math.round(content.price * 100)

Payment Intent Metadata

The payment intent includes metadata to link payments back to the application:
{
  contentId: "64f8a1b2c3d4e5f6g7h8i9j0",  // MongoDB content ID
  userId: "64f8a1b2c3d4e5f6g7h8i9j1",     // MongoDB user ID
  contentTitle: "Advanced Node.js Patterns" // For reference
}

Step 2: Client-Side Payment Confirmation

After receiving the client secret, the frontend uses Stripe.js to collect payment:
import { loadStripe } from '@stripe/stripe-js';

// 1. Initialize Stripe
const stripe = await loadStripe('pk_test_...');

// 2. Request payment intent
const response = await fetch('/api/payments/create-payment-intent', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`
  },
  body: JSON.stringify({ contentId: '64f8a...' })
});

const { clientSecret } = await response.json();

// 3. Confirm payment with Stripe
const { error, paymentIntent } = await stripe.confirmCardPayment(
  clientSecret,
  {
    payment_method: {
      card: cardElement,
      billing_details: { name: 'John Doe' }
    }
  }
);

if (error) {
  console.error('Payment failed:', error.message);
} else if (paymentIntent.status === 'succeeded') {
  console.log('Payment successful!');
  // Wait for webhook to update database
}
Don’t rely on the client-side payment status to grant access. Always wait for webhook confirmation to ensure security.

Step 3: Webhook Processing

Stripe sends webhooks to notify the server of payment events. This is the only reliable way to confirm payments.

Webhook Route Configuration

// CRITICAL: Webhook route MUST use raw body
router.post(
  '/webhook',
  express.raw({ type: 'application/json' }),  // Raw body for signature verification
  paymentController.handleWebhook
);
The webhook route MUST be registered BEFORE express.json() middleware in server.js to receive raw request bodies for signature verification.

Webhook Handler

The webhook handler verifies signatures and processes events:
exports.handleWebhook = async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    // 1. Verify webhook signature
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // 2. Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(event.data.object);
      break;
    
    case 'payment_intent.payment_failed':
      await handlePaymentFailed(event.data.object);
      break;

    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  // 3. Acknowledge receipt
  res.json({ received: true });
};

Payment Success Handler

const handlePaymentSuccess = async (paymentIntent) => {
  try {
    // Find purchase by Stripe Payment Intent ID
    const purchase = await Purchase.findOne({
      stripePaymentIntentId: paymentIntent.id
    });

    if (purchase) {
      // Update status to completed
      purchase.status = 'completed';
      await purchase.save();
      
      console.log(`Payment completed for purchase ${purchase._id}`);
      
      // Here you could also:
      // - Send confirmation email
      // - Update analytics
      // - Trigger notifications
    }
  } catch (error) {
    console.error('Error handling payment success:', error);
  }
};

Payment Failure Handler

const handlePaymentFailed = async (paymentIntent) => {
  try {
    const purchase = await Purchase.findOne({
      stripePaymentIntentId: paymentIntent.id
    });

    if (purchase) {
      purchase.status = 'failed';
      await purchase.save();
      
      console.log(`Payment failed for purchase ${purchase._id}`);
      
      // Optionally notify user of failure
    }
  } catch (error) {
    console.error('Error handling payment failure:', error);
  }
};

Step 4: Accessing Purchased Content

Once a payment is completed, users can access the content:
GET /api/content/:contentId/access
Authorization: Bearer <token>

exports.accessContent = async (req, res) => {
  try {
    const { contentId } = req.params;
    const userId = req.mongoUser._id;

    // 1. Verify purchase exists and is completed
    const purchase = await Purchase.findOne({
      user: userId,
      content: contentId,
      status: 'completed'  // Only completed purchases grant access
    });

    if (!purchase) {
      return res.status(403).json({ 
        error: 'You need to purchase this content to access it' 
      });
    }

    // 2. Retrieve full content including file URL
    const content = await Content.findOne({ 
      _id: contentId, 
      status: 'published' 
    })
    .populate('createdBy', 'name');
    
    if (!content) {
      return res.status(404).json({ error: 'Content not found' });
    }

    // 3. Return complete content with file URL
    res.json({ content });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};
Only purchases with status: 'completed' grant content access. Pending, failed, or refunded purchases are denied.

Payment Status Checking

Clients can check payment status after submission:
GET /api/payments/status/:paymentIntentId
Authorization: Bearer <token>

exports.getPaymentStatus = async (req, res) => {
  try {
    const { paymentIntentId } = req.params;
    const userId = req.mongoUser._id;

    const purchase = await Purchase.findOne({
      stripePaymentIntentId: paymentIntentId,
      user: userId
    }).populate('content', 'title description type thumbnailUrl');

    if (!purchase) {
      return res.status(404).json({ error: 'Payment not found' });
    }

    res.json({ purchase });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};

Admin Payment Management

View All Payments

Admins can view all payments across the platform:
GET /api/payments/admin/all?page=1&limit=20&status=completed
Authorization: Bearer <admin-token>

exports.getAllPayments = async (req, res) => {
  try {
    const { page = 1, limit = 20, status } = req.query;
    
    const query = {};
    if (status) query.status = status;

    const purchases = await Purchase.find(query)
      .populate('user', 'name email')
      .populate('content', 'title type')
      .limit(limit * 1)
      .skip((page - 1) * limit)
      .sort({ purchasedAt: -1 });

    const count = await Purchase.countDocuments(query);

    res.json({
      purchases,
      totalPages: Math.ceil(count / limit),
      currentPage: Number(page),
      totalPurchases: count
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};

Process Refunds

Admins can issue refunds for completed purchases:
POST /api/payments/admin/refund/:purchaseId
Authorization: Bearer <admin-token>

exports.refundPayment = async (req, res) => {
  try {
    const { purchaseId } = req.params;

    // 1. Find purchase
    const purchase = await Purchase.findById(purchaseId);

    if (!purchase) {
      return res.status(404).json({ error: 'Purchase not found' });
    }

    // 2. Validate refund eligibility
    if (purchase.status !== 'completed') {
      return res.status(400).json({ 
        error: 'Only completed purchases can be refunded' 
      });
    }

    // 3. Create refund in Stripe
    const refund = await stripe.refunds.create({
      payment_intent: purchase.stripePaymentIntentId
    });

    // 4. Update purchase status
    if (refund.status === 'succeeded') {
      purchase.status = 'refunded';
      await purchase.save();

      res.json({ 
        message: 'Refund successful',
        purchase 
      });
    } else {
      res.status(400).json({ error: 'Refund failed' });
    }
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};
Refunding a purchase updates the status to refunded, which automatically revokes content access.

Payment States

Pending Purchase
  • Payment Intent created
  • Purchase record saved in database
  • Awaiting payment confirmation
  • No content access granted
Triggers:
  • User initiates purchase
  • Payment Intent created successfully
Next States:
  • completed (payment succeeds)
  • failed (payment fails)

Webhook Security

Webhook signature verification is critical for security:
1

Stripe Signs Webhook

Stripe generates a signature using your webhook secret and includes it in the stripe-signature header
2

Server Receives Request

The raw request body is preserved by using express.raw() middleware
3

Signature Verification

stripe.webhooks.constructEvent() verifies the signature matches the body
4

Process or Reject

If verification succeeds, process the event. If it fails, return 400 to reject the webhook
try {
  event = stripe.webhooks.constructEvent(
    req.body,                            // Raw body (Buffer)
    req.headers['stripe-signature'],     // Signature header
    process.env.STRIPE_WEBHOOK_SECRET    // Your webhook secret
  );
} catch (err) {
  // Invalid signature - reject webhook
  return res.status(400).send(`Webhook Error: ${err.message}`);
}

Complete Payment Sequence

Error Handling

// Status: 404
{ "error": "Content not found" }
Causes:
  • Content ID doesn’t exist
  • Content is not published
  • Content was deleted
// Status: 400
{ "error": "You have already purchased this content" }
Causes:
  • User has completed purchase for this content
  • Prevents duplicate charges
Solution:
  • Direct user to access content instead
// Webhook event: payment_intent.payment_failed
Causes:
  • Card declined
  • Insufficient funds
  • Invalid card details
Handling:
  • Purchase status set to ‘failed’
  • User can retry with different payment method
// Status: 403
{ "error": "You need to purchase this content to access it" }
Causes:
  • No completed purchase found
  • Purchase was refunded
  • Payment still pending

Best Practices

Always Use Webhooks

Never trust client-side payment confirmation. Always wait for webhook events to update purchase status.

Verify Signatures

Always verify webhook signatures to prevent fraudulent requests from unauthorized sources.

Idempotent Handlers

Design webhook handlers to be idempotent since Stripe may send the same event multiple times.

Log Everything

Log all payment events, webhook processing, and errors for debugging and audit trails.

Testing Webhooks

Use Stripe CLI to test webhooks locally:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login to Stripe
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/payments/webhook

# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
The Stripe CLI provides a webhook signing secret for local testing. Use this in your .env file during development.

Next Steps

Architecture Overview

Review the complete system architecture

Payment API Reference

Explore the complete Payment API documentation

Build docs developers (and LLMs) love