Modern Payment Gateway
for Indonesia

Powerful QRIS payment processing with real-time webhooks, comprehensive API, and beautiful merchant dashboard.

📚 Read Documentation 🚀 Get Started
< 2s
Average Response Time
99.9%
Uptime Guarantee
24/7
Real-time Processing

🚀 Why Choose MSPay?

Lightning Fast

Real-time payment processing with instant webhooks and QRIS generation in milliseconds.

🔐

Secure by Default

HMAC-SHA256 signature verification, API key authentication, and encrypted data transmission.

📊

Beautiful Dashboard

Modern merchant and admin dashboard with real-time statistics and transaction management.

🔔

Smart Webhooks

Automatic retry mechanism, signature verification, and detailed webhook logs.

📱

QRIS Support

Dynamic QRIS generation with unique codes and automatic amount verification.

🛠️

Developer Friendly

RESTful API, comprehensive documentation, and code examples in multiple languages.

📚 Complete Documentation

🚀 Getting Started

Welcome to MSPay! Follow these steps to integrate our payment gateway into your application.

1. Get Your API Credentials

Contact the administrator to create a merchant account. You will receive:

  • API Key (pk_xxx...): Used for making requests to MSPay
  • API Secret (sk_xxx...): Used for verifying webhook signatures
⚠️ Important: Keep your API Secret secure! Never expose it in client-side code or commit it to version control.

2. Base URL

Production: https://pay.masanto.me/api/v1
Development: http://localhost:3000/api/v1

🔐 Authentication

MSPay uses API Key authentication. Include your API Key in the X-API-Key header for all requests.

Request Headers
X-API-Key: pk_fb6dfa91d532c8de3da3194429ddfa9af029207bc136b591d5e022bbbcf8573f
Content-Type: application/json
Header Required Description
X-API-Key ✅ Yes Your merchant API Key
Content-Type ✅ Yes Must be application/json

💳 Create Payment

Create a new payment transaction and generate a QRIS code for your customer.

Endpoint

POST /api/v1/payment/create

Request Body

cURL
curl -X POST http://localhost:3000/api/v1/payment/create \
  -H "Content-Type: application/json" \
  -H "X-API-Key: pk_your_api_key_here" \
  -d '{
    "amount": 100000,
    "customer_name": "John Doe",
    "customer_phone": "081234567890",
    "product_name": "Premium Package",
    "payment_method": "qris"
  }'
JavaScript (Fetch)
const response = await fetch('http://localhost:3000/api/v1/payment/create', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': 'pk_your_api_key_here'
  },
  body: JSON.stringify({
    amount: 100000,
    customer_name: 'John Doe',
    customer_phone: '081234567890',
    product_name: 'Premium Package',
    payment_method: 'qris'
  })
});

const data = await response.json();
console.log(data);
Python (Requests)
import requests

response = requests.post(
    'http://localhost:3000/api/v1/payment/create',
    headers={
        'Content-Type': 'application/json',
        'X-API-Key': 'pk_your_api_key_here'
    },
    json={
        'amount': 100000,
        'customer_name': 'John Doe',
        'customer_phone': '081234567890',
        'product_name': 'Premium Package',
        'payment_method': 'qris'
    }
)

data = response.json()
print(data)
PHP
<?php
$ch = curl_init('http://localhost:3000/api/v1/payment/create');

curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'X-API-Key: pk_your_api_key_here'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'amount' => 100000,
    'customer_name' => 'John Doe',
    'customer_phone' => '081234567890',
    'product_name' => 'Premium Package',
    'payment_method' => 'qris'
]));

$response = curl_exec($ch);
$data = json_decode($response, true);
curl_close($ch);

print_r($data);
?>

Request Parameters

Parameter Type Required Description
amount integer ✅ Yes Amount in IDR (minimum: 1000)
customer_name string ❌ No Customer name
customer_phone string ❌ No Customer phone number
product_name string ❌ No Product/service name
payment_method string ❌ No Payment method (default: "qris")

Response (200 OK)

JSON Response
{
  "success": true,
  "data": {
    "transaction_id": "TRX1736789012345ABC",
    "payment_url": "http://localhost:3000/payment/?id=TRX1736789012345ABC",
    "qris_string": "00020101021126...",
    "qr_image": "data:image/png;base64,iVBORw0KGgoAAAANS...",
    "amount": 100000,
    "unique_code": 456,
    "total_amount": 100456,
    "payment_method": "qris",
    "expires_at": "2026-01-07T12:30:00.000Z",
    "status": "pending"
  }
}
💡 Tip: The unique_code is automatically added to help identify payments. Your customer must pay the exact total_amount.

🔍 Check Payment Status

Check the current status of a payment transaction.

Endpoint

GET /api/v1/payment/{transaction_id}

Example Request

cURL
curl -X GET http://localhost:3000/api/v1/payment/TRX1736789012345ABC \
  -H "X-API-Key: pk_your_api_key_here"

Response (200 OK)

JSON Response
{
  "success": true,
  "data": {
    "transaction_id": "TRX1736789012345ABC",
    "status": "paid",
    "amount": 100000,
    "total_amount": 100456,
    "unique_code": 456,
    "customer_name": "John Doe",
    "customer_phone": "081234567890",
    "product_name": "Premium Package",
    "payment_method": "qris",
    "paid_at": "2026-01-07T12:15:30.000Z",
    "created_at": "2026-01-07T12:00:00.000Z",
    "expires_at": "2026-01-07T12:30:00.000Z"
  }
}

Payment Statuses

Status Description
pending Payment is waiting for customer to pay
paid Payment has been successfully completed
expired Payment link has expired (default: 30 minutes)
cancelled Payment was cancelled

🔔 Webhooks

MSPay automatically sends webhooks to your server when a payment status changes. This is the recommended way to handle payment notifications.

Setup Webhook URL

  1. Login to your Merchant Dashboard
  2. Go to Settings
  3. Enter your webhook URL (must be publicly accessible)
  4. Save settings
💡 Example Webhook URLs:
https://yourstore.com/api/payment/callback
https://abc123.ngrok.io/webhook/mspay (for testing)

Webhook Payload

When a payment is completed, MSPay will send a POST request to your webhook URL:

Webhook Request
POST https://yourstore.com/api/payment/callback
Headers:
  X-Signature: a8f5f167f44f4964e6c998dee827110c284831e5f54e47e5b7b7c20f3eef4d4e
  Content-Type: application/json

Body:
{
  "transaction_id": "TRX1736789012345ABC",
  "merchant_id": "MERCH12345",
  "status": "paid",
  "amount": 100000,
  "unique_code": 456,
  "total_amount": 100456,
  "customer_name": "John Doe",
  "customer_phone": "081234567890",
  "product_name": "Premium Package",
  "paid_at": "2026-01-07T12:15:30.000Z",
  "payment_method": "qris"
}

Webhook Retry Policy

  • MSPay will retry failed webhooks up to 3 times
  • Retry intervals: 2s, 4s, 6s (exponential backoff)
  • Your endpoint must respond with HTTP 200 within 10 seconds
  • All webhook attempts are logged in the admin dashboard

🔒 Signature Verification

CRITICAL: Always verify webhook signatures to prevent fraud and ensure requests are genuinely from MSPay.

⚠️ Security Warning: Never process webhooks without signature verification! This could allow attackers to fake payment notifications.

How It Works

  1. MSPay creates a signature using HMAC-SHA256 with your API Secret
  2. The signature is sent in the X-Signature header
  3. You verify the signature using your API Secret
  4. Only process the webhook if the signature is valid

Implementation Examples

Node.js (Express)
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const API_SECRET = process.env.MSPAY_API_SECRET; // sk_xxx...

app.post('/api/payment/callback', (req, res) => {
  // 1. Get signature from header
  const signature = req.headers['x-signature'];
  
  if (!signature) {
    return res.status(401).json({ error: 'Missing signature' });
  }
  
  // 2. Get payload
  const payload = req.body;
  
  // 3. Generate expected signature
  const expectedSignature = crypto
    .createHmac('sha256', API_SECRET)
    .update(JSON.stringify(payload))
    .digest('hex');
  
  // 4. Verify signature (timing-safe comparison)
  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
  
  if (!isValid) {
    console.error('Invalid signature!');
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  // 5. ✅ Signature valid - process payment
  console.log('✅ Valid webhook received:', payload);
  
  // Update order in your database
  const { transaction_id, status, total_amount } = payload;
  
  if (status === 'paid') {
    // Mark order as paid
    // Send confirmation email
    // Update inventory
    // etc.
  }
  
  // 6. Return success response
  res.json({ success: true });
});

app.listen(5000, () => {
  console.log('Webhook server running on port 5000');
});
Python (Flask)
from flask import Flask, request, jsonify
import hmac
import hashlib
import json
import os

app = Flask(__name__)

API_SECRET = os.getenv('MSPAY_API_SECRET')  # sk_xxx...

@app.route('/api/payment/callback', methods=['POST'])
def mspay_callback():
    # 1. Get signature from header
    signature = request.headers.get('X-Signature')
    
    if not signature:
        return jsonify({'error': 'Missing signature'}), 401
    
    # 2. Get payload
    payload = request.get_json()
    
    # 3. Generate expected signature
    payload_string = json.dumps(payload, separators=(',', ':'))
    expected_signature = hmac.new(
        API_SECRET.encode('utf-8'),
        payload_string.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    # 4. Verify signature (timing-safe comparison)
    is_valid = hmac.compare_digest(signature, expected_signature)
    
    if not is_valid:
        print('❌ Invalid signature!')
        return jsonify({'error': 'Invalid signature'}), 401
    
    # 5. ✅ Signature valid - process payment
    print('✅ Valid webhook received:', payload)
    
    transaction_id = payload.get('transaction_id')
    status = payload.get('status')
    total_amount = payload.get('total_amount')
    
    if status == 'paid':
        # Mark order as paid
        # Send confirmation email
        # Update inventory
        pass
    
    # 6. Return success response
    return jsonify({'success': True}), 200

if __name__ == '__main__':
    app.run(port=5000)
PHP
<?php
$apiSecret = getenv('MSPAY_API_SECRET'); // sk_xxx...

// 1. Get signature from header
$headers = getallheaders();
$signature = $headers['X-Signature'] ?? '';

if (empty($signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Missing signature']);
    exit;
}

// 2. Get payload
$payload = json_decode(file_get_contents('php://input'), true);

// 3. Generate expected signature
$payloadString = json_encode($payload, JSON_UNESCAPED_SLASHES);
$expectedSignature = hash_hmac('sha256', $payloadString, $apiSecret);

// 4. Verify signature (timing-safe comparison)
$isValid = hash_equals($signature, $expectedSignature);

if (!$isValid) {
    error_log('❌ Invalid signature!');
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// 5. ✅ Signature valid - process payment
error_log('✅ Valid webhook received: ' . json_encode($payload));

$transactionId = $payload['transaction_id'];
$status = $payload['status'];
$totalAmount = $payload['total_amount'];

if ($status === 'paid') {
    // Mark order as paid
    // Send confirmation email
    // Update inventory
}

// 6. Return success response
http_response_code(200);
echo json_encode(['success' => true]);
?>
🔑 Security Best Practices:
• Store API Secret in environment variables, never hardcode
• Use timing-safe comparison functions
• Log invalid signature attempts
• Implement rate limiting on webhook endpoint
• Use HTTPS for your webhook URL

⚠️ Error Handling

MSPay uses standard HTTP status codes and returns detailed error messages in JSON format.

Error Response Format

JSON Error Response
{
  "success": false,
  "message": "Invalid API Key"
}

HTTP Status Codes

Status Code Meaning Description
200 OK Request successful
201 Created Resource created successfully
400 Bad Request Invalid request parameters
401 Unauthorized Invalid or missing API Key
404 Not Found Transaction not found
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Server error, please contact support

Common Errors

Error Message Solution
Invalid API Key Check your API Key in merchant dashboard
Minimum amount is Rp 1.000 Amount must be at least 1000
Transaction not found Verify the transaction ID is correct
Invalid signature Check your API Secret and payload format
Merchant is suspended Contact admin to activate your account

💻 Complete Integration Examples

E-Commerce Checkout Flow

Here's a complete example of integrating MSPay into an e-commerce checkout:

Node.js Complete Example
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const MSPAY_API_URL = 'http://localhost:3000/api/v1';
const MSPAY_API_KEY = process.env.MSPAY_API_KEY;
const MSPAY_API_SECRET = process.env.MSPAY_API_SECRET;

// 1. Create Order & Payment
app.post('/checkout', async (req, res) => {
  try {
    const { cart, customer } = req.body;
    
    // Calculate total
    const amount = cart.reduce((sum, item) => sum + item.price, 0);
    
    // Create payment via MSPay
    const response = await axios.post(
      `${MSPAY_API_URL}/payment/create`,
      {
        amount: amount,
        customer_name: customer.name,
        customer_phone: customer.phone,
        product_name: cart.map(i => i.name).join(', '),
        payment_method: 'qris'
      },
      {
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': MSPAY_API_KEY
        }
      }
    );
    
    const payment = response.data.data;
    
    // Save order to database
    const order = await db.orders.create({
      transaction_id: payment.transaction_id,
      customer_id: customer.id,
      amount: payment.total_amount,
      status: 'pending',
      items: cart
    });
    
    // Return payment info to frontend
    res.json({
      success: true,
      order_id: order.id,
      payment_url: payment.payment_url,
      qr_image: payment.qr_image,
      amount: payment.total_amount,
      expires_at: payment.expires_at
    });
    
  } catch (error) {
    console.error('Checkout error:', error);
    res.status(500).json({ error: 'Checkout failed' });
  }
});

// 2. Receive Webhook from MSPay
app.post('/webhook/mspay', async (req, res) => {
  try {
    // Verify signature
    const signature = req.headers['x-signature'];
    const payload = req.body;
    
    const expectedSignature = crypto
      .createHmac('sha256', MSPAY_API_SECRET)
      .update(JSON.stringify(payload))
      .digest('hex');
    
    if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    
    // Find order
    const order = await db.orders.findOne({
      transaction_id: payload.transaction_id
    });
    
    if (!order) {
      return res.status(404).json({ error: 'Order not found' });
    }
    
    // Update order status
    if (payload.status === 'paid' && order.status === 'pending') {
      await db.orders.update(order.id, {
        status: 'paid',
        paid_at: payload.paid_at
      });
      
      // Send confirmation email
      await sendEmail({
        to: order.customer.email,
        subject: 'Payment Confirmed',
        template: 'payment-success',
        data: { order, payment: payload }
      });
      
      // Update inventory
      for (const item of order.items) {
        await db.products.decrementStock(item.id, item.quantity);
      }
      
      console.log(`✅ Order ${order.id} marked as paid`);
    }
    
    res.json({ success: true });
    
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).json({ error: 'Internal error' });
  }
});

// 3. Check Payment Status (polling)
app.get('/order/:orderId/status', async (req, res) => {
  try {
    const order = await db.orders.findById(req.params.orderId);
    
    if (!order) {
      return res.status(404).json({ error: 'Order not found' });
    }
    
    // Check with MSPay if still pending
    if (order.status === 'pending') {
      const response = await axios.get(
        `${MSPAY_API_URL}/payment/${order.transaction_id}`,
        {
          headers: { 'X-API-Key': MSPAY_API_KEY }
        }
      );
      
      const payment = response.data.data;
      
      if (payment.status !== order.status) {
        await db.orders.update(order.id, { status: payment.status });
        order.status = payment.status;
      }
    }
    
    res.json({
      order_id: order.id,
      status: order.status,
      amount: order.amount
    });
    
  } catch (error) {
    console.error('Status check error:', error);
    res.status(500).json({ error: 'Failed to check status' });
  }
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});
✅ Best Practices Implemented:
• Signature verification on webhooks
• Idempotent webhook handling (check status before update)
• Error handling and logging
• Database transactions for consistency
• Email notifications
• Inventory management

Testing Your Integration

  1. Use ngrok for local testing:
    ngrok http 3000
  2. Set webhook URL in merchant dashboard:
    https://abc123.ngrok.io/webhook/mspay
  3. Create test payment
  4. Mark as paid via admin dashboard
  5. Verify webhook received and order updated

Need Help?

  • 📧 Email: support@masanto.me
  • 💬 Join our community Discord
  • 📖 Check webhook logs in admin dashboard
  • 🐛 Report issues on GitHub