Saltar al contenido principal

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​

EndpointMethodPurpose
/healthGETSSP 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 routePOSTReceives signed SSP events; verify X-SSP-Signature
Your provider webhook routePOSTReceives your processor's callbacks
Your setup pageGETOptional — see Setup Handoff
The endpoints below are your own design

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 successfully
  • pending - Payment initiated, awaiting confirmation
  • failed - Payment failed
  • cancelled - 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​

EventDescriptionAction
payment.capturedPayment successfulForward to SSP with status: success
payment.failedPayment failedForward to SSP with status: failed
payment.pendingPayment pendingForward to SSP with status: pending
refund.createdRefund processedForward to SSP with refund details
refund.failedRefund failedForward 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​

CodeDescriptionUser Action
INSUFFICIENT_FUNDSNot enough balanceUse different card
CARD_EXPIREDCard has expiredUse different card
CARD_DECLINEDCard declined by bankContact bank
INVALID_CVVIncorrect CVVRe-enter CVV
INVALID_CARDInvalid card numberCheck card number
PAYMENT_TIMEOUTPayment timed outTry again
NETWORK_ERRORNetwork issueTry again
PROVIDER_ERRORProvider system errorTry again later

Complete Example​

See the Razorpay UPI Gateway example for a complete, production-ready implementation.


Next Steps​

Support​