Payment Gateway Integration
Build payment integrations that accept money through your preferred processor.
Choose your model first
There are two ways to integrate payments with SSP, and they have completely different contracts.
A — Marketplace payment plugin (most integrations)
You own the payment flow. SSP tells you an order exists; you collect the money through your provider; you record the capture back into SSP's ledger.
You implement: GET /health, a webhook receiver, and optionally a
setup page. Nothing else — SSP does not call your payment
endpoints.
You need: the payments:capture capability on your listing.
This is the model the rest of this page covers.
B — External gateway provider
SSP calls your service to move money, treating it as a built-in gateway.
This is a separate registration from the plugin marketplace and requires you
to implement a fixed contract (POST /charge, POST /refund,
GET /transactions/{id}, POST /payment-intent and its confirm/cancel,
POST /customers), authenticated with X-API-Key, Bearer, or Basic
depending on what you register. Requests time out after 30 seconds and a
per-provider maximum transaction amount is enforced.
If that's what you need, contact partnerships@ssppos.com — it isn't set up by publishing a marketplace plugin. Summary: Plugin Types.
Quick start
1. Clone a template
git clone https://github.com/ssppos/examples.git
cd examples/razorpay-upi-gateway
npm install
Or start minimal:
cd examples/minimal-template/nodejs
npm install
2. What your service exposes
| Endpoint | Method | Purpose |
|---|---|---|
/health | GET | SSP health-checks this hourly with a 5s timeout. It requires an HTTP 200 exactly, and records a response slower than 3s as degraded. Keep it cheap and dependency-free |
| Your webhook route | POST | Receives signed SSP events; verify X-SSP-Signature |
| Your provider webhook route | POST | Receives your processor's callbacks |
| Your setup page | GET | Optional — see Setup Handoff |
The /charge, /refund, /capabilities and /payment-intent shapes shown
later on this page are the model B contract, and a useful internal structure
for model A. In model A, SSP never calls them — you do.
3. Implement Core Logic
const express = require('express');
const app = express();
// Example: Razorpay Integration
const Razorpay = require('razorpay');
app.post('/charge', authenticate, async (req, res) => {
try {
const { amount, currency, customer, provider_config } = req.body;
// Initialize provider with customer's credentials
const razorpay = new Razorpay({
key_id: provider_config.razorpay_key_id,
key_secret: provider_config.razorpay_key_secret
});
// Create order
const order = await razorpay.orders.create({
amount: Math.round(amount * 100), // Convert to smallest unit
currency: currency,
receipt: `order_${Date.now()}`,
notes: {
customer_email: customer.email,
customer_phone: customer.phone
}
});
// Return success response
res.json({
success: true,
transaction_id: order.id,
status: 'created',
amount: amount,
currency: currency,
metadata: {
provider: 'razorpay',
order_status: order.status
}
});
} catch (error) {
console.error('Charge failed:', error);
res.status(400).json({
error: true,
message: error.message,
code: 'PAYMENT_FAILED'
});
}
});
Endpoint Specifications
GET /health
Health check endpoint - must respond quickly.
Response:
{
"status": "ok",
"version": "1.0.0",
"timestamp": "2025-11-02T10:30:00Z"
}
GET /capabilities
Returns supported features and configurations.
Response:
{
"supported_methods": ["card", "upi", "wallet", "netbanking"],
"supported_currencies": ["INR", "USD", "CAD"],
"features": ["charge", "refund", "payment_intent", "webhooks"],
"supported_regions": ["IN", "US", "CA"],
"requires_redirect": false,
"async_payment_support": true
}
POST /charge
Process an immediate payment.
Request:
{
"amount": 1000.00,
"currency": "INR",
"description": "Order #12345",
"customer": {
"email": "customer@example.com",
"phone": "+919876543210",
"name": "John Doe"
},
"payment_method": "upi",
"provider_config": {
"razorpay_key_id": "rzp_live_xxx",
"razorpay_key_secret": "xxx"
},
"metadata": {
"order_id": "order_12345",
"location_id": "loc_123"
},
"sandbox": false,
"request_id": "req_abc123"
}
Success Response:
{
"success": true,
"transaction_id": "pay_abc123xyz",
"status": "success",
"amount": 1000.00,
"currency": "INR",
"payment_method": "upi",
"metadata": {
"last4": "4242",
"brand": "visa",
"provider_transaction_id": "razorpay_abc123"
},
"created_at": "2025-11-02T10:30:00Z"
}
Error Response:
{
"error": true,
"message": "Insufficient funds",
"code": "INSUFFICIENT_FUNDS",
"details": {
"available_balance": 500.00,
"required_amount": 1000.00
}
}
Status Values:
success- Payment completed successfullypending- Payment initiated, awaiting confirmationfailed- Payment failedcancelled- Payment cancelled by user
POST /refund
Refund a previous transaction.
Request:
{
"transaction_id": "pay_abc123xyz",
"amount": 500.00,
"reason": "Customer request",
"notes": "Partial refund for item return",
"provider_config": {
"razorpay_key_id": "rzp_live_xxx",
"razorpay_key_secret": "xxx"
},
"request_id": "req_refund_789"
}
Response:
{
"success": true,
"refund_id": "rfnd_xyz789",
"transaction_id": "pay_abc123xyz",
"status": "processed",
"amount": 500.00,
"currency": "INR",
"refunded_at": "2025-11-02T11:00:00Z"
}
Implementation Example:
app.post('/refund', authenticate, async (req, res) => {
try {
const { transaction_id, amount, provider_config } = req.body;
const razorpay = new Razorpay({
key_id: provider_config.razorpay_key_id,
key_secret: provider_config.razorpay_key_secret
});
// Create refund
const refund = await razorpay.payments.refund(transaction_id, {
amount: Math.round(amount * 100)
});
res.json({
success: true,
refund_id: refund.id,
transaction_id: transaction_id,
status: 'processed',
amount: amount,
currency: refund.currency
});
} catch (error) {
res.status(400).json({
error: true,
message: error.message,
code: 'REFUND_FAILED'
});
}
});
GET /transactions/:id
Get details of a specific transaction.
Request (sent by SSP to your plugin):
GET /transactions/pay_abc123xyz
X-SSP-Signature: <hmac-sha256-hex>
X-SSP-Event: payment.status_check
Response:
{
"success": true,
"transaction_id": "pay_abc123xyz",
"status": "captured",
"amount": 1000.00,
"currency": "INR",
"payment_method": "upi",
"customer": {
"email": "customer@example.com"
},
"metadata": {
"order_id": "order_12345"
},
"created_at": "2025-11-02T10:30:00Z",
"captured_at": "2025-11-02T10:30:15Z"
}
Implementation:
app.get('/transactions/:id', authenticate, async (req, res) => {
try {
const razorpay = new Razorpay({
key_id: req.query.key_id,
key_secret: req.query.key_secret
});
const payment = await razorpay.payments.fetch(req.params.id);
res.json({
success: true,
transaction_id: payment.id,
status: payment.status,
amount: payment.amount / 100,
currency: payment.currency,
payment_method: payment.method,
created_at: new Date(payment.created_at * 1000).toISOString()
});
} catch (error) {
res.status(404).json({
error: true,
message: 'Transaction not found',
code: 'TRANSACTION_NOT_FOUND'
});
}
});
POST /payment-intent
Create a payment intent for async payments (UPI, QR codes, redirect flows).
Request:
{
"amount": 1000.00,
"currency": "INR",
"customer": {
"email": "customer@example.com",
"phone": "+919876543210"
},
"callback_url": "https://api.ssppos.com/payment/callback/razorpay",
"payment_method": "upi",
"provider_config": {
"razorpay_key_id": "rzp_live_xxx",
"razorpay_key_secret": "xxx"
}
}
Response:
{
"success": true,
"intent_id": "order_abc123",
"redirect_url": "https://razorpay.com/pay/order_abc123",
"qr_code_url": "https://cdn.razorpay.com/qr/order_abc123.png",
"upi_link": "upi://pay?pa=merchant@upi&pn=Merchant&am=1000&cu=INR&tr=order_abc123",
"expires_at": "2025-11-02T11:00:00Z"
}
Implementation:
app.post('/payment-intent', authenticate, async (req, res) => {
try {
const { amount, currency, callback_url, provider_config } = req.body;
const razorpay = new Razorpay({
key_id: provider_config.razorpay_key_id,
key_secret: provider_config.razorpay_key_secret
});
const order = await razorpay.orders.create({
amount: Math.round(amount * 100),
currency: currency,
receipt: `intent_${Date.now()}`,
notes: {
callback_url: callback_url
}
});
res.json({
success: true,
intent_id: order.id,
redirect_url: `https://razorpay.com/checkout/${order.id}`,
expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString()
});
} catch (error) {
res.status(400).json({
error: true,
message: error.message,
code: 'INTENT_CREATION_FAILED'
});
}
});
Webhooks
Receiving from Payment Provider
Handle webhooks from your payment provider:
// NOTE: mount express.raw({ type: 'application/json' }) on this route so
// req.body is a Buffer — HMAC must hash the exact bytes SSP will verify.
app.post('/webhooks/razorpay', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify the incoming Razorpay signature (Razorpay's protocol)
const razorpaySignature = req.headers['x-razorpay-signature'];
const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET;
const expectedRazorpaySignature = crypto
.createHmac('sha256', webhookSecret)
.update(req.body) // raw Buffer
.digest('hex');
if (razorpaySignature !== expectedRazorpaySignature) {
console.warn('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
// 2. Process webhook event
const event = JSON.parse(req.body.toString('utf8'));
console.log('Webhook received:', event.event);
// 3. Forward to SSP (POST /api/plugin/v1/webhooks/external)
// Auth: X-Plugin-Api-Key (pik_) + X-SSP-Signature (HMAC of raw body with your plugin webhook_secret)
try {
const sspBody = JSON.stringify({
provider: 'razorpay-upi',
event_type: event.event,
event_id: event.payload.payment.entity.id,
payload: {
transaction_id: event.payload.payment.entity.id,
status: mapStatus(event.event),
amount: event.payload.payment.entity.amount / 100,
currency: event.payload.payment.entity.currency,
metadata: event.payload.payment.entity.notes
},
timestamp: new Date().toISOString()
});
const sspSignature = crypto
.createHmac('sha256', process.env.SSP_WEBHOOK_SECRET)
.update(sspBody)
.digest('hex');
await axios.post(
process.env.SSP_WEBHOOK_URL || 'https://api.ssppos.com/api/plugin/v1/webhooks/external',
sspBody,
{
headers: {
'X-Plugin-Api-Key': process.env.SSP_PLUGIN_API_KEY, // pik_...
'X-SSP-Signature': sspSignature,
'Content-Type': 'application/json'
}
}
);
} catch (error) {
console.error('Failed to forward webhook:', error);
}
// 4. Respond immediately
res.json({ status: 'ok' });
});
function mapStatus(event) {
const statusMap = {
'payment.captured': 'success',
'payment.failed': 'failed',
'payment.pending': 'pending',
'refund.created': 'refunded'
};
return statusMap[event] || 'unknown';
}
Event Types to Handle
| Event | Description | Action |
|---|---|---|
payment.captured | Payment successful | Forward to SSP with status: success |
payment.failed | Payment failed | Forward to SSP with status: failed |
payment.pending | Payment pending | Forward to SSP with status: pending |
refund.created | Refund processed | Forward to SSP with refund details |
refund.failed | Refund failed | Forward to SSP with error |
Recording Payment Captures in SSP
After your plugin processes a payment externally (via Razorpay, Stripe, UPI, etc.), record the capture in SSP so it appears in the merchant's financial reports:
curl -X POST "https://api.ssppos.com/api/plugin/v1/orders/42/payments" \
-H "X-Plugin-Api-Key: pik_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 42.50,
"currency": "USD",
"payment_method": "upi",
"provider": "razorpay",
"provider_transaction_id": "pay_K9A2X",
"captured_at": "2026-04-15T23:14:07+05:30",
"payer_reference": "customer@paytm"
}'
Why use this endpoint? SSP's financial reports, GST/tax filing, and settlement reconciliation all read from the SSPSaleTransaction table. If your plugin only uses PUT /orders/{id} with order_status: "paid", the payment won't appear in the merchant's reports. The /payments endpoint writes the authoritative transaction row.
Supported payment methods: upi, qr_code, card, cash, wallet, bank_transfer, other
Partial payments: Send amount < remaining_balance — the order transitions to partially_paid and the balance decrements. When the final capture brings the balance to zero, the order transitions to paid.
Currency: Must match the order's location currency (configured per-location in SSP). Multi-location chains across borders are supported — each location can have its own currency.
Idempotency: Same provider_transaction_id returns 200 with the original transaction. This is the standard Stripe/Razorpay convention — replay is expected behavior, not an error.
See the full Payment Capture reference for field details.
Testing
Test with Sandbox
Most payment providers offer test/sandbox environments:
const razorpay = new Razorpay({
key_id: process.env.RAZORPAY_TEST_KEY_ID,
key_secret: process.env.RAZORPAY_TEST_KEY_SECRET
});
Test Cards (Razorpay)
Card Number: 4111 1111 1111 1111
CVV: 123
Expiry: Any future date
Name: Any name
Test UPI (Razorpay)
UPI ID: success@razorpay (for success)
UPI ID: failure@razorpay (for failure)
Local Testing with ngrok
# Start your server
npm start
# Expose to internet
ngrok http 3000
# Use ngrok URL as webhook endpoint
# Example: https://abc123.ngrok.io/webhooks/razorpay
Best Practices
1. Handle Provider-Specific Credentials
Each restaurant may have different credentials:
app.post('/charge', authenticate, async (req, res) => {
// ALWAYS use credentials from provider_config
const { provider_config } = req.body;
const razorpay = new Razorpay({
key_id: provider_config.razorpay_key_id,
key_secret: provider_config.razorpay_key_secret
});
// Process payment...
});
2. Sandbox Mode Support
Support both sandbox and production:
function getRazorpayInstance(provider_config, sandbox = false) {
const keyId = sandbox
? provider_config.test_key_id
: provider_config.live_key_id;
const keySecret = sandbox
? provider_config.test_key_secret
: provider_config.live_key_secret;
return new Razorpay({
key_id: keyId,
key_secret: keySecret
});
}
3. Idempotency
Handle duplicate requests gracefully:
const processedRequests = new Map();
app.post('/charge', authenticate, async (req, res) => {
const requestId = req.body.request_id;
// Check if already processed
if (processedRequests.has(requestId)) {
return res.json(processedRequests.get(requestId));
}
// Process payment
const result = await processPayment(req.body);
// Cache result
processedRequests.set(requestId, result);
res.json(result);
});
4. Error Handling
Return consistent error responses:
app.post('/charge', authenticate, async (req, res) => {
try {
const result = await processPayment(req.body);
res.json(result);
} catch (error) {
// Log full error server-side
console.error('Payment failed:', {
error: error.message,
stack: error.stack,
request_id: req.body.request_id
});
// Return user-friendly error
const errorCode = mapErrorCode(error);
res.status(400).json({
error: true,
message: getUserFriendlyMessage(errorCode),
code: errorCode,
request_id: req.body.request_id
});
}
});
function mapErrorCode(error) {
if (error.message.includes('insufficient')) return 'INSUFFICIENT_FUNDS';
if (error.message.includes('expired')) return 'CARD_EXPIRED';
if (error.message.includes('declined')) return 'CARD_DECLINED';
return 'PAYMENT_FAILED';
}
5. Logging
Log all transactions:
async function logTransaction(type, data, result, error = null) {
await db.transaction_logs.create({
type: type, // 'charge', 'refund', etc.
request_id: data.request_id,
amount: data.amount,
currency: data.currency,
status: result ? 'success' : 'failed',
transaction_id: result?.transaction_id,
error_message: error?.message,
created_at: new Date()
});
}
Common Error Codes
| Code | Description | User Action |
|---|---|---|
INSUFFICIENT_FUNDS | Not enough balance | Use different card |
CARD_EXPIRED | Card has expired | Use different card |
CARD_DECLINED | Card declined by bank | Contact bank |
INVALID_CVV | Incorrect CVV | Re-enter CVV |
INVALID_CARD | Invalid card number | Check card number |
PAYMENT_TIMEOUT | Payment timed out | Try again |
NETWORK_ERROR | Network issue | Try again |
PROVIDER_ERROR | Provider system error | Try again later |
Complete Example
See the Razorpay UPI Gateway example for a complete, production-ready implementation.
Next Steps
- Webhooks Guide - Handle real-time events
- Security Best Practices - Secure your plugin
Support
- GitHub: github.com/ssppos/examples
- Email: developers@ssppos.com
- Examples: github.com/ssppos/examples