NMI Payment Gateway Integration Documentation
A comprehensive guide for integrating NMI (Network Merchants Inc.) payment gateway into a Laravel application. Created by Joshua Wahyu Novianto from Indonesia.
1. Overview
This document provides a comprehensive guide for integrating NMI (Network Merchants Inc.) payment gateway into a Laravel application. The implementation includes form validation, transaction processing, and error handling.
Features
- Secure payment processing
- Form validation (client-side & server-side)
- Error handling and logging
- Test card support
- Duplicate transaction prevention
- Debug information
2. Prerequisites
Required Software
- PHP 8.0 or higher
- Laravel 10.x
- Composer
- NMI Sandbox Account
Required Packages
composer require guzzlehttp/guzzle
3. Installation
Step 1: Create Laravel Project
composer create-project laravel/laravel nmi-payment
cd nmi-payment
Step 2: Install Dependencies
composer require guzzlehttp/guzzle
4. Configuration
Step 1: Create Controller
php artisan make:controller NmiController
Step 2: Create Routes
Add to routes/web.php
:
Route::get('/payment',[NmiController::class,'showPaymentForm'])->name('payment.form');
Route::post('/payment/process',[NmiController::class,'testNmiTransaction'])->name('payment.process');
Step 3: Configure Environment
Add to your .env
file:
NMI_SECURITY_KEY=6fcZUD8S6Q6Uu7dnJ5Ka3sYkhn235Wh3
NMI_SANDBOX_URL=https://sandbox.nmi.com/api/transact.php
NMI_PRODUCTION_URL=https://secure.nmi.com/api/transact.php
Note: The NMI_SECURITY_KEY provided is for demonstration purposes. In a real application, ensure this key is securely managed and never exposed client-side.
5. Implementation Steps
Step 1: Controller Implementation
File: app/Http/Controllers/NmiController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class NmiController extends Controller
{
public function testNmiTransaction(Request $request)
{
try {
$request->validate([
'amount' => 'required|numeric|min:0.01',
'ccnumber' => 'required|string|min:13',
'ccexp' => 'required|string|size:4',
'cvv' => 'required|string|min:3',
'first_name' => 'required|string',
'last_name' => 'required|string',
'address1' => 'required|string',
'city' => 'required|string',
'state' => 'required|string',
'zip' => 'required|string',
'country' => 'required|string',
'email' => 'required|email'
]);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'response' => '0',
'responsetext' => 'Validation failed',
'response_code' => '400',
'errors' => $e->errors()
], 400);
}
// Prepare NMI payload
$nmiData = [
'type' => 'sale',
'amount' => number_format($request->input('amount') + mt_rand(1, 99) / 10000, 2, '.', ''),
'ccnumber' => preg_replace('/\s+/', '', $request->input('ccnumber')),
'ccexp' => $request->input('ccexp'),
'cvv' => substr($request->input('cvv'), 0, 3),
'security_key' => '6fcZUD8S6Q6Uu7dnJ5Ka3sYkhn235Wh3', // This should ideally come from .env
'first_name' => trim($request->input('first_name')) . '_' . substr(uniqid(), -3),
'last_name' => trim($request->input('last_name')),
'address1' => trim($request->input('address1')),
'city' => trim($request->input('city')),
'state' => trim($request->input('state')),
'zip' => trim($request->input('zip')),
'country' => trim($request->input('country') === 'Indonesia' ? 'ID' : $request->input('country')),
'email' => str_replace('@', '_' . substr(uniqid(), -3) . '@', trim($request->input('email'))),
];
// Validate card number format
if (!preg_match('/^[0-9]{13,19}$/', $nmiData['ccnumber'])) {
return response()->json([
'response' => '0',
'responsetext' => 'Invalid card number format',
'response_code' => '400'
]);
}
// Validate expiry date format
if (!preg_match('/^[0-9]{4}$/', $nmiData['ccexp'])) {
return response()->json([
'response' => '0',
'responsetext' => 'Invalid expiry date format (use MMYY)',
'response_code' => '400'
]);
}
$month = substr($nmiData['ccexp'], 0, 2);
$year = substr($nmiData['ccexp'], 2, 2);
$currentYear = date('y');
$currentMonth = date('m');
if ($month < 1 || $month > 12 || ($year < $currentYear || ($year == $currentYear && $month < $currentMonth))) {
return response()->json([
'response' => '0',
'responsetext' => 'Card is expired or invalid expiry date',
'response_code' => '400'
]);
}
if (!preg_match('/^[0-9]{3,4}$/', $nmiData['cvv'])) {
return response()->json([
'response' => '0',
'responsetext' => 'Invalid CVV format (must be 3-4 digits)',
'response_code' => '400'
]);
}
// Use a truly unique order ID (UUID-based)
$nmiData['orderid'] = 'ORDER_' . Str::uuid();
// Force sandbox test card number for demo
$nmiData['ccnumber'] = '4111111111111111';
$originalData = $nmiData;
Log::info('NMI Request:', $nmiData);
try {
$response = Http::timeout(30)
->asForm()
->post(
'https://sandbox.nmi.com/api/transact.php',
$nmiData
);
$responseBody = $response->body();
parse_str($responseBody, $result);
if (empty($responseBody)) {
return response()->json([
'response' => '0',
'responsetext' => 'Empty response from NMI',
'response_code' => '999'
]);
}
// Duplicate or bad request handling
if (isset($result['response']) && $result['response'] == '3') {
$result['response'] = '0';
$result['response_code'] = '300';
$result['responsetext'] = $result['responsetext'] ?? 'Duplicate transaction or rejected by gateway';
}
$result['debug'] = [
'user_input' => $originalData,
'response_status' => $response->status(),
'raw_response' => $responseBody
];
return response()->json($result);
} catch (\Exception $e) {
Log::error('NMI API Exception:', [
'error' => $e->getMessage(),
'data' => $nmiData
]);
return response()->json([
'response' => '0',
'responsetext' => 'API Error: ' . $e->getMessage(),
'response_code' => '999'
]);
}
}
public function showPaymentForm()
{
return view('payment-form');
}
}
Step 2: View Implementation
File: resources/views/payment-form.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NMI Payment Test</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet"/>
<style>
body {
font-family: 'Instrument Sans', sans-serif;
background-color: #f8f9fa;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: #4f46e5;
color: white;
padding: 20px;
text-align: center;
}
.form-container {
padding: 30px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #374151;
}
input, select {
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 16px;
box-sizing: border-box;
}
input:focus, select:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
small {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
display: block;
}
.row {
display: flex;
gap: 15px;
}
.col {
flex: 1;
}
.btn {
background: #4f46e5;
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
width: 100%;
transition: background-color 0.2s;
}
.btn:hover {
background: #4338ca;
}
.btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.result {
margin-top: 20px;
padding: 15px;
border-radius: 6px;
display: none;
}
.result.success {
background: #d1fae5;
border: 1px solid #10b981;
color: #065f46;
}
.result.error {
background: #fee2e2;
border: 1px solid #ef4444;
color: #991b1b;
}
.loading {
display: none;
text-align: center;
margin: 20px 0;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #4f46e5;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
details {
margin-top: 15px;
}
details pre {
background: #f3f4f6;
padding: 10px;
border-radius: 4px;
font-size: 12px;
overflow-x: auto;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>NMI Payment Form</h1>
<p>Process your payment securely</p>
</div>
<div class="form-container">
<form id="paymentForm">
@csrf
<div class="form-group">
<label for="amount">Amount ($)</label>
<input type="number" id="amount" name="amount" placeholder="0.50" step="0.01" min="0.50" max="9999.99" required>
<small>Minimum amount: $0.50</small>
</div>
<div class="form-group">
<label for="ccnumber">Card Number</label>
<input type="text" id="ccnumber" name="ccnumber" placeholder="4111111111111111" pattern="[0-9]{13,19}" title="Please enter a valid card number (13-19 digits)" required>
<small>Test cards: 4111111111111111 (Visa), 5555555555554444 (Mastercard)</small>
</div>
<div class="row">
<div class="col">
<div class="form-group">
<label for="ccexp">Expiry Date (MMYY)</label>
<input type="text" id="ccexp" name="ccexp" placeholder="MMYY" pattern="[0-9]{4}" title="Please enter expiry date in MMYY format" maxlength="4" required>
<small>Format: MMYY (e.g., 1225 for December 2025)</small>
</div>
</div>
<div class="col">
<div class="form-group">
<label for="cvv">CVV</label>
<input type="text" id="cvv" name="cvv" placeholder="123" pattern="[0-9]{3}" title="Please enter a valid CVV (3 digits)" maxlength="3" required>
<small>3 digits on the back of your card</small>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group">
<label for="first_name">First Name</label>
<input type="text" id="first_name" name="first_name" placeholder="John" required>
</div>
</div>
<div class="col">
<div class="form-group">
<label for="last_name">Last Name</label>
<input type="text" id="last_name" name="last_name" placeholder="Doe" required>
</div>
</div>
</div>
<div class="form-group">
<label for="address1">Address</label>
<input type="text" id="address1" name="address1" placeholder="123 Main Street" required>
</div>
<div class="row">
<div class="col">
<div class="form-group">
<label for="city">City</label>
<input type="text" id="city" name="city" placeholder="Wichita" required>
</div>
</div>
<div class="col">
<div class="form-group">
<label for="state">State</label>
<input type="text" id="state" name="state" placeholder="KS" required>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group">
<label for="zip">ZIP Code</label>
<input type="text" id="zip" name="zip" placeholder="12345" required>
</div>
</div>
<div class="col">
<div class="form-group">
<label for="country">Country</label>
<input type="text" id="country" name="country" placeholder="US" required>
</div>
</div>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="johndoe@example.com" required>
</div>
<button type="submit" class="btn" id="submitBtn">Process Payment</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Processing payment...</p>
</div>
<div class="result" id="result"></div>
</div>
</div>
<script>
// Client-side validation functions
function validateExpiryDate(expiry) {
if (!/^\d{4}$/.test(expiry)) {
return 'Expiry date must be 4 digits (MMYY)';
}
const month = parseInt(expiry.substring(0, 2));
const year = parseInt(expiry.substring(2, 4));
if (month < 1 || month > 12) {
return 'Invalid month (must be 01-12)';
}
const currentDate = new Date();
const currentYear = currentDate.getFullYear() % 100;
const currentMonth = currentDate.getMonth() + 1;
if (year < currentYear || (year === currentYear && month < currentMonth)) {
return 'Card is expired';
}
return null;
}
function validateAmount(amount) {
const numAmount = parseFloat(amount);
if (numAmount < 0.50) {
return 'Amount must be at least $0.50';
}
if (numAmount > 9999.99) {
return 'Amount cannot exceed $9,999.99';
}
return null;
}
function validateCardNumber(cardNumber) {
const cleanCard = cardNumber.replace(/\s/g, '');
if (!/^\d{13,19}$/.test(cleanCard)) {
return 'Card number must be 13-19 digits';
}
const validTestCards = [
'4111111111111111',
'5555555555554444',
'378282246310005',
'6011111111111117',
'4222222222222222'
];
if (!validTestCards.includes(cleanCard)) {
return 'Please use a valid test card number. Valid test cards: 4111111111111111, 5555555555554444, 378282246310005';
}
return null;
}
document.getElementById('paymentForm').addEventListener('submit', function(e) {
e.preventDefault();
const form = e.target;
const submitBtn = document.getElementById('submitBtn');
const loading = document.getElementById('loading');
const result = document.getElementById('result');
// Client-side validation
const amount = form.amount.value;
const cardNumber = form.ccnumber.value;
const expiry = form.ccexp.value;
const cvv = form.cvv.value;
const amountError = validateAmount(amount);
if (amountError) {
alert(amountError);
return;
}
const cardError = validateCardNumber(cardNumber);
if (cardError) {
alert(cardError);
return;
}
const expiryError = validateExpiryDate(expiry);
if (expiryError) {
alert(expiryError);
return;
}
if (!/^\d{3}$/.test(cvv)) {
alert('CVV must be exactly 3 digits');
return;
}
if (cvv === expiry) {
alert('CVV cannot be the same as expiry date');
return;
}
// Show loading
submitBtn.disabled = true;
loading.style.display = 'block';
result.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Processing...';
setTimeout(() => { // Simulate network delay for better UX
const formData = new FormData(form);
fetch('/payment/process', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('input[name="_token"]').value
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text().then(text => {
try {
return JSON.parse(text);
} catch (e) {
console.error('Response is not JSON:', text);
throw new Error('Server returned invalid JSON response');
}
});
})
.then(data => {
loading.style.display = 'none';
submitBtn.disabled = false;
submitBtn.textContent = 'Process Payment';
result.style.display = 'block';
if (data.response === '1') {
result.className = 'result success';
result.innerHTML = `
<h3>Payment Successful!</h3>
<p><strong>Transaction ID:</strong>${data.transactionid || 'N/A'}</p>
<p><strong>Response:</strong>${data.responsetext || 'Approved'}</p>
<p><strong>Auth Code:</strong>${data.authcode || 'N/A'}</p>
${data.debug ? `<details><summary>Debug Info</summary><pre>${JSON.stringify(data.debug, null, 2)}</pre></details>` : ''}
`;
} else {
result.className = 'result error';
result.innerHTML = `
<h3>Payment Failed</h3>
<p><strong>Response:</strong>${data.responsetext || 'Declined'}</p>
<p><strong>Error Code:</strong>${data.response_code || 'N/A'}</p>
${data.debug ? `<details><summary>Debug Info</summary><pre>${JSON.stringify(data.debug, null, 2)}</pre></details>` : ''}
`;
}
})
.catch(error => {
loading.style.display = 'none';
submitBtn.disabled = false;
submitBtn.textContent = 'Process Payment';
result.style.display = 'block';
result.className = 'result error';
result.innerHTML = `
<h3>Error</h3>
<p>An error occurred while processing the payment.</p>
<p>${error.message}</p>
`;
});
}, 1000); // Simulate 1 second network delay
});
</script>
</body>
</html>
Step 3: Routes Configuration
File: routes/web.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\NmiController;
Route::get('/', function () {
return view('welcome');
});
Route::get('/payment',[NmiController::class,'showPaymentForm'])->name('payment.form');
Route::post('/payment/process',[NmiController::class,'testNmiTransaction'])->name('payment.process');
6. Code Examples
1. Basic Transaction Request
$nmiData = [
'type' => 'sale',
'amount' => '10.00',
'ccnumber' => '4111111111111111',
'ccexp' => '1225',
'cvv' => '123',
'security_key' => 'your_security_key', // Replace with actual key from .env
'first_name' => 'John',
'last_name' => 'Doe',
'address1' => '123 Main St',
'city' => 'Wichita',
'state' => 'KS',
'zip' => '12345',
'country' => 'US',
'email' => 'john@example.com',
'orderid' => 'ORDER_'.uniqid()
];
2. HTTP Request to NMI
$response = Http::timeout(30)
->asForm()
->post('https://sandbox.nmi.com/api/transact.php', $nmiData);
$responseBody = $response->body();
parse_str($responseBody, $result);
3. Response Handling
if ($result['response'] === '1') {
// Success
$transactionId = $result['transactionid'];
$authCode = $result['authcode'];
} else {
// Failed
$errorMessage = $result['responsetext'];
$errorCode = $result['response_code'];
}
7. Testing
Test Card Numbers
Card Number | Type | Status |
---|---|---|
4111111111111111 | Visa | Approved |
5555555555554444 | Mastercard | Approved |
378282246310005 | American Express | Approved |
4111111111111112 | Visa | Declined |
4111111111111113 | Visa | Invalid |
Test Scenarios
- **Valid Transaction**
- Card: 4111111111111111
- Amount: \$1.00
- Expiry: 1225
- CVV: 123
- **Declined Transaction**
- Card: 4111111111111112
- Amount: \$5.00
- Expiry: 1225
- CVV: 123
- **Invalid Card**
- Card: 4111111111111113
- Amount: \$1.00
- Expiry: 1225
- CVV: 123
Testing Commands
# Start Laravel development server
php artisan serve
# Access payment form
http://localhost:8000/payment
8. Troubleshooting
Common Issues
Debug Information
The system provides detailed debug information:
{
"user_input":{
"amount":"1.00",
"ccnumber":"4111111111111111",
"ccexp":"1225",
"cvv":"123"
},
"response_status":200,
"raw_response":"response=1&responsetext=SUCCESS&authcode=123456&transactionid=12345678"
}
9. Security Considerations
1. PCI Compliance
- Never store credit card data
- Use HTTPS for all transactions
- Implement proper logging
2. Data Validation
- Validate all input data
- Sanitize user inputs
- Use prepared statements
3. Error Handling
- Don’t expose sensitive information
- Log errors securely
- Implement proper exception handling
4. Environment Configuration
# Development
NMI_SECURITY_KEY=your_sandbox_key
NMI_URL=https://sandbox.nmi.com/api/transact.php
# Production
NMI_SECURITY_KEY=your_production_key
NMI_URL=https://secure.nmi.com/api/transact.php
Production Deployment
1. Environment Setup
# Set production environment
APP_ENV=production
APP_DEBUG=false
# Configure NMI production credentials
NMI_SECURITY_KEY=your_production_key
NMI_URL=https://secure.nmi.com/api/transact.php
2. Security Checklist
- HTTPS enabled
- Environment variables configured
- Error logging configured
- Input validation implemented
- PCI compliance verified
3. Monitoring
- Monitor transaction success rates
- Set up error alerts
- Log all transactions
- Monitor API response times
API Reference
NMI Response Codes
Code | Description |
---|---|
100 | Approved |
200 | Declined |
300 | Error/Duplicate |
400 | Bad Request |
999 | System Error |
Transaction Types
Type | Description |
---|---|
sale | Immediate charge |
auth | Authorization only |
capture | Capture authorized amount |
refund | Refund transaction |
void | Cancel transaction |
Support
For additional support:
- NMI Documentation: https://secure.nmi.com/merchants/resources/integration/
- Laravel Documentation: https://laravel.com/docs
- GitHub Repository: [Your Repository URL]
Version History
Version | Date | Changes |
---|---|---|
1.0.0 | 2024-01-15 | Initial release |
1.1.0 | 2024-01-16 | Added duplicate prevention |
1.2.0 | 2024-01-17 | Enhanced error handling |
Document Version: 1.2.0
Last Updated: January 17, 2024
Author: Joshua Wahyu Novianto
Contact: joshuapplg@gmail.com