NMI Docs

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 NumberTypeStatus
4111111111111111VisaApproved
5555555555554444MastercardApproved
378282246310005American ExpressApproved
4111111111111112VisaDeclined
4111111111111113VisaInvalid

Test Scenarios

  1. **Valid Transaction**
    • Card: 4111111111111111
    • Amount: \$1.00
    • Expiry: 1225
    • CVV: 123
  2. **Declined Transaction**
    • Card: 4111111111111112
    • Amount: \$5.00
    • Expiry: 1225
    • CVV: 123
  3. **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

CodeDescription
100Approved
200Declined
300Error/Duplicate
400Bad Request
999System Error

Transaction Types

TypeDescription
saleImmediate charge
authAuthorization only
captureCapture authorized amount
refundRefund transaction
voidCancel transaction

Support

For additional support:

Version History

VersionDateChanges
1.0.02024-01-15Initial release
1.1.02024-01-16Added duplicate prevention
1.2.02024-01-17Enhanced error handling

Document Version: 1.2.0
Last Updated: January 17, 2024
Author: Joshua Wahyu Novianto
Contact: joshuapplg@gmail.com