Step-by-Step Guide to Worldpay Subscription Integration in Vue.js & Nuxt.js
Step 1: Create a file named Worldpay-subscribe.vue on Vue JS or Nuxt JS Application and paste the below code in it.
In this code, we will create a session payment.
<template>
<div class="px-4 bg-white">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 py-10">
<div class="grid grid-cols-12">
<div class="col-span-12 lg:col-span-6">
<div class="mb-6 text-center md:text-start order-1">
<h1 class="text-2xl md:text-4xl font-semibold">Start Your Subscription</h1>
<p class="text-gray-500 text-xl md:text-4xl mb-6">pay now</p>
</div>
<div class="flex items-center mt-10 mb-6 order-2 md:order-3">
<img src="/images/icons/credit-debit.png" alt="Card or Debit" class="w-[80px] rounded-xl me-3" />
<p class="text-2xl font-bold text-gray-700">Card or Debit</p>
</div>
<section class="container" id="container">
<section class="card">
<section class="checkout" id="card-form">
<div class="label">Card number <span class="type"></span></div>
<section id="card-pan" class="field"></section>
<section class="columns">
<section class="column">
<div class="label">Expiry date</div>
<section id="card-expiry" class="field"></section>
</section>
<section class="column">
<div class="label">CVV</div>
<section id="card-cvv" class="field"></section>
</section>
</section>
<section class="buttons">
<button class="submit" id="submit" type="button">
<span v-if="isSubmitting"> <i class="bx bx-loader bx-spin text-xl"></i> Submitting... </span>
<span v-else>Join</span>
</button>
<button class="clear" id="clear" type="button">
Clear
</button>
</section>
</section>
</section>
</section>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import axios from 'axios';
const isSubmitting = ref(false);
let checkoutInstance = null
const loadScript = () => {
return new Promise((resolve, reject) => {
if (window.Worldpay) return resolve()
const script = document.createElement('script')
script.src = 'https://try.access.worldpay.com/access-checkout/v2/checkout.js'
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
}
const initialiseCheckout = async () => {
const checkoutId = 'YOUR_WORLDPAY_CHECKOUT_ID'
const containerSelector = '#container'
const panParentSelector = '#card-pan'
const cvvParentSelector = '#card-cvv'
const expiryDateParentSelector = '#card-expiry'
const submitButton = document.querySelector('#submit')
const clearButton = document.querySelector('#clear')
window.Worldpay.checkout.init(
{
id: checkoutId,
form: containerSelector,
fields: {
pan: {
selector: panParentSelector,
placeholder: '4444 3333 2222 1111'
},
expiry: {
selector: expiryDateParentSelector,
placeholder: 'MM/YY'
},
cvv: {
selector: cvvParentSelector,
placeholder: '123'
}
},
styles: {
input: {
color: 'black',
fontWeight: 'bold',
fontSize: '20px',
letterSpacing: '3px'
},
'input.is-valid': {
color: 'green'
},
'input.is-invalid': {
color: 'red'
}
},
enablePanFormatting: true,
allowNonLuhnCompliantCards: true
},
(error, checkout) => {
if (error) {
console.error('Init error:', error)
return
}
checkoutInstance = checkout
submitButton?.addEventListener('click', (event) => {
event.preventDefault()
checkout.generateSessionState((error, sessionState) => {
if (error) {
console.error('Session error:', error)
return
}
// console.log('SESSION:', sessionState)
// alert(`Session: ${sessionState}`)
axios.post('http://localhost:8000/api/worldpay/create-session-payment', {
session_state: sessionState
}).then((response) => {
console.log(response.data)
if(response.status === 200){
window.location.href = '/users/my-accounts'
}
});
})
})
clearButton?.addEventListener('click', (event) => {
event.preventDefault()
checkout.clearForm(() => {})
})
}
)
}
onMounted(async () => {
try {
await loadScript()
initialiseCheckout()
} catch (err) {
console.error('Script load failed:', err)
}
})
</script>
<style scoped>
.payment-content{
font-size: 15px;
}
.container {
display: flex;
align-items: center;
flex-direction: column;
}
.columns {
display: flex;
}
.columns .column {
margin-right: 15px;
}
.field {
height: 40px;
border-bottom: 1px solid lightgray;
/* border: 1px solid lightgray;
border-radius: 4px;
padding: 10px; */
}
.field.is-onfocus {
border-color: black;
}
.field.is-empty {
border-color: orange;
}
.field.is-invalid {
border-color: red;
}
.field.is-valid {
border-color: green;
}
#card-pan {
margin-bottom: 30px;
}
.card .checkout .submit {
background: #dc2626;
cursor: pointer;
width: 200px;
margin-top:30px;
color: white;
outline: 0;
font-size: 14px;
border: 0;
border-radius: 4px;
text-transform: uppercase;
font-weight: bold;
padding: 15px 0;
transition: background 0.3s ease;
margin-right:20px;
}
.card .checkout .clear {
background: green;
cursor: pointer;
width: 100px;
margin-top:30px;
color: white;
outline: 0;
font-size: 14px;
border: 0;
border-radius: 4px;
text-transform: uppercase;
font-weight: bold;
padding: 15px 0;
transition: background 0.3s ease;
}
.buttons {
display: flex;
}
</style>
Let's move to Laravel as Backend.
Step 1: Create a route in api.php file as follows:
Route::post('/worldpay/create-session-payment', [PaymentController::class, 'createPaymentFromSession']);
Step 2: Create a Model named Membership.php and adjust the fields as per your requirement and create a migration too:
<?php
namespace App\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Membership extends Model
{
use HasFactory;
protected $table = 'memberships';
protected $fillable = [
'user_id',
'joined_date',
'expiry_date',
'is_free_trail_completed',
'worldpay_token',
'cancelled_at',
'status', // free_trail_active, active, free_trial_completed, cancelled, expired
];
protected $casts = [
'joined_date' => 'date',
'expiry_date' => 'date',
'cancelled_at' => 'datetime',
'worldpay_token' => 'array',
];
public function user()
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
}
Step 3: Create a controller named PaymentController and paste the below code:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Membership;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PaymentController extends Controller
{
public function createPaymentFromSession(Request $request)
{
/**
* Transaction reference format :
* TXN_1_1234 for free trail
* SUB_1_1234 for subscription
*/
$sessionState = $request->session_state;
$authUserId = auth()->id();
$user = User::where('id', $authUserId)->first();
// Check if user has an existing membership, if yes then charge the renewal amount else charge the free trial amount
$existingMembership = Membership::where('user_id', $authUserId)->first();
if ($existingMembership) {
$transactionRef = 'SUB_' . $authUserId . '_' . time();
$amount = (float) config('app.subscription_amount') ?? 2;
}else{
$transactionRef = 'TXN_' . $authUserId . '_' . time();
$amount = 0;
}
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'WP-Api-Version' => '2024-06-01',
'Authorization' => 'Basic ' . base64_encode(env('WORLDPAY_USERNAME') . ':' . env('WORLDPAY_PASSWORD')),
])->post(env('WORLDPAY_MODE_URL').'/api/payments', [
"transactionReference" => $transactionRef,
"merchant" => [
"entity" => env('WORLDPAY_ENTITY'),
],
"instruction" => [
"method" => "card",
"paymentInstrument" => [
"type" => "checkout",
"cardHolderName" => $user->name,
"sessionHref" => $sessionState,
],
"tokenCreation" => [
"type" => "worldpay"
],
"customerAgreement" => [
"type" => "subscription",
"storedCardUsage" => "first"
],
"narrative" => [
"line1" => "Members Discounts Ltd",
],
"value" => [
"currency" => "GBP",
"amount" => $amount // keep 0 for free trial and $request->amount for subscription
]
]
]);
$sessionResponse = $response->json();
// Log::info('Worldpay createPaymentFromSession Response:', $sessionResponse);
if (isset($sessionResponse['errorName'])) {
Log::info('Worldpay Error : ', $sessionResponse);
return response()->json([
'message' => $sessionResponse['message'] ?? 'Worldpay error',
'error' => $sessionResponse['errorName'],
], 400);
}
if ($sessionResponse['outcome'] !== 'authorized') {
Log::info('Worldpay Payment authorization failed : ', $sessionResponse);
$message = isset($sessionResponse['message']) ? $sessionResponse['message'] : 'Payment authorization failed';
return response()->json([
'message' => $message,
'paymentId' => $data['paymentId'] ?? null,
'status' => $sessionResponse['outcome']
], 400);
}
$trailDay = (int) env('TRAIL_DAYS', 7);
$joinedDate = Carbon::now()->toDateString();
$expiryDate = Carbon::now()->addDays($trailDay)->toDateString();
if($existingMembership){
$existingMembership->update([
'expiry_date' => $expiryDate,
'is_free_trail_completed' => true,
'status' => 'active',
'worldpay_token' => $response->json(),
]);
return response()->json([
'message' => 'Payment authorized successfully',
'status' => $sessionResponse['outcome'],
], 200);
}
$newMembershipCreated = Membership::create([
'user_id' => auth()->id() ?? 2,
'joined_date' => $joinedDate,
'expiry_date' => $expiryDate,
'is_free_trail_completed' => false,
'status' => 'free_trail_active',
'worldpay_token' => $response->json(), // save token for later auto-renewal
]);
if(!$newMembershipCreated){
Log::info('Worldpay Payment authorized BUT Membber ship not created : ', $sessionResponse);
return response()->json([
'message' => 'Payment authorized successfully. but Membership not created',
'paymentId' => $data['paymentId'] ?? null,
'status' => $sessionResponse['outcome']
], 500);
}
return response()->json([
'message' => 'Payment authorized successfully',
'status' => $sessionResponse['outcome'],
], 200);
}
}
Your Subscription for 7 days is paid and your card details is stored safely with Worldpay using a session and token.
Now, you have to take the future payments automatically (recurring payments - subscriptions)
Step 4: Open a terminal and run the below command to create a command file:
php artisan make:command ChargeSubscriptions
This will create a file inside: app/Console/Commands/ChargeSubscriptions.php
Step 5: Go to routes/console.php file and paste the below code.
/** This will charge subscriptions after the subscription expires. if it fails, it sets the status to expired */
Schedule::command('subscriptions:charge')->daily();
Step 6: Open the file app/Console/Commands/ChargeSubscriptions.php and paste the below code:
<?php
namespace App\Console\Commands;
use App\Jobs\ChargeMembershipJob;
use App\Models\Membership;
use Illuminate\Console\Command;
class ChargeSubscriptions extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'subscriptions:charge';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
// \Log::info('Scheduler is running '.now()->toDateString());
$memberships = Membership::whereIn('status', ['free_trail_active', 'active'])
->where('expiry_date', '<=', now()->toDateString())
->get();
// \Log::info('Found '.count($memberships).' memberships to charge');
foreach ($memberships as $membership) {
// dispatch(new ChargeMembershipJob($membership));
ChargeMembershipJob::dispatch($membership);
}
}
}
Step 7: Generate the Job Class. open a terminal and run the below command:
php artisan make:job ChargeMembershipJob
This will create a file inside: app\Jobs\ChargeMembershipJob.php
Step 8: Open the file ChargeMembershipJob.php and paste the below code:
<?php
namespace App\Jobs;
use App\Models\Setting;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
class ChargeMembershipJob implements ShouldQueue
{
use Queueable;
protected $membership;
public function __construct($membership)
{
$this->membership = $membership;
}
public function handle()
{
$membership = $this->membership;
// \Log::info('ChargeMembershipJob handle called', [
// 'membership' => $membership,
// ]);
if (!$membership->worldpay_token) {
$membership->update([
'status' => 'expired',
]);
return;
}
$this->chargeToken($membership);
}
public function chargeToken($membership)
{
$subscriptionAmount = (float) config('app.subscription_amount') ?? 4.99;
// \Log::info('Recurring Charge REQUEST called', [
// 'membership' => $membership,
// ]);
/**
* Do not change the transaction reference format. This is used for other worldpay related operations.
* Eg:- $transactionRef = 'SUB_' . $user->id . '_' . time();
* Documentation url : https://docs.worldpay.com/access/products/payments/recurring-first
* Refer the checkout SDK on the documentation
*/
$transactionRef = 'SUB_' . $membership->user_id . '_' . time();
$renewalAmount = $subscriptionAmount;
$tokenData = is_array($membership->worldpay_token) ? $membership->worldpay_token : json_decode($membership->worldpay_token, true);
$tokenHref = $tokenData['token']['href'] ?? ($tokenData['tokenPaymentInstrument']['href'] ?? null);
$schemeReference = $tokenData['schemeReference'] ?? null;
$response = Http::withBasicAuth(env('WORLDPAY_USERNAME'), env('WORLDPAY_PASSWORD'))
->withHeaders([
'Content-Type' => 'application/vnd.worldpay.payments-v7+json',
'Accept' => 'application/vnd.worldpay.payments-v7+json'
])->post(env('WORLDPAY_MODE_URL').'/cardPayments/merchantInitiatedTransactions',[
"transactionReference" => $transactionRef,
"merchant" => [
"entity" => env('WORLDPAY_ENTITY')
],
"instruction" => [
"requestAutoSettlement" => [
"enabled" => true
],
"narrative" => [
"line1" => "Members Discounts",
],
"value" => [
"currency" => "GBP",
"amount" => $renewalAmount,
],
"paymentInstrument" => [
"type" => "card/token",
"href" => $tokenHref,
],
"customerAgreement" => [
"type" => "subscription",
"storedCardUsage" => "subsequent",
"schemeReference" => $schemeReference
],
],
// "channel" => "moto" // merchant-initiated
]);
// \Log::info('Recurring Charge Response ALONE', [
// 'response' => $response->json()
// ]);
// \Log::info('Recurring Charge Response', [
// 'membership' => $membership,
// 'membership_id' => $membership->id,
// 'status' => $response->status(),
// 'body' => $response->body()
// ]);
return $response;
}
}
Now you have to update the Webhook URL on your Worldpay dashboard to know the Payment was done successfully or not on the Backend.
The URL will be like: https://yourdomain.com/api/worldpay/webhook
Step 9: Create a route as given below:
Route::post('/worldpay/webhook', [MembershipController::class, 'webhook']);
Route::delete('/membership-cancel', [MembershipController::class, 'cancelMembership']);
Step 10: Create a controller named MembershipController.php and paste the below code:
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Membership;
use App\Models\Payment;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class MembershipController extends Controller
{
public function cancelMembership(Request $request)
{
$authUserId = auth()->id();
$todayDate = Carbon::now()->toDateString();
$membership = Membership::where('user_id', $authUserId)
->whereIn('status', ['active','free_trail_active'])
->where('expiry_date', '>=', $todayDate)
->first();
if($membership){
// Call the payment gateway to cancel the membership
$tokenData = is_array($membership->worldpay_token) ? $membership->worldpay_token : json_decode($membership->worldpay_token, true);
$tokenHref = $tokenData['tokenPaymentInstrument']['href'] ?? null;
$response = Http::withBasicAuth(env('WORLDPAY_USERNAME'), env('WORLDPAY_PASSWORD'))
->delete($tokenHref);
// \Log::warning('delete token response:');
// if a user cancels the membership before the free trial period is over, we will still update his free trial as completed
$membership->update([
'status' => 'cancelled',
'is_free_trail_completed' => true,
'cancelled_at' => Carbon::now()->toString(),
'worldpay_token' => null,
]);
return response()->json([
'status' => 200,
'message' => 'You have successfully cancelled your membership'
]);
}
return response()->json([
'status' => 500,
'message' => 'You do not have an active membership'
], 404);
}
public function webhook(Request $request)
{
$data = $request->all();
\Log::info('Worldpay Webhook', $data);
// Normalize event type
$eventType = data_get($data, 'eventType')
?? data_get($data, 'eventDetails.type');
// ============================
// PAYMENT AUTHORIZED EVENT
// ============================
if ($eventType === 'authorized') {
$transactionRef = data_get($data, 'eventDetails.transactionReference');
$amount = data_get($data, 'eventDetails.amount.value');
$currency = data_get($data, 'eventDetails.amount.currencyCode');
$userId = $this->getUserFromTxn($transactionRef);
$user = User::where('id',$userId)->first();
if (!$user) {
\Log::error('Authorized: Missing or invalid user', $data);
return response()->json(['status' => 'error'], 200);
}
// Handle recurring vs first payment
if (str_starts_with($transactionRef, 'SUB_')) {
$membershipUserId = explode('_', $transactionRef)[1];
$membership = Membership::where('user_id', $membershipUserId)->first();
if ($membership) {
$membership->update([
'expiry_date' => now()->addYear(),
'is_free_trail_completed' => true,
'status' => 'active'
]);
}
} else {
// First successful payment (if you ever charge upfront)
$membership = Membership::where('user_id', $userId)->latest()->first();
}
// Prevent duplicate payments
if (Payment::where('transaction_number', $transactionRef)->exists()) {
return response()->json(['status' => 'duplicate'], 200);
}
// Create Payment
Payment::create([
'user_id' => $userId,
'membership_id' => $membership->id ?? null,
'amount' => $amount,
'currency' => $currency,
'outcome' => 'authorized',
'transaction_number' => $transactionRef,
'payment_id' => data_get($data, 'eventId'),
'payment_mode' => 'online',
'status' => 'paid',
'raw_response' => json_encode($data),
]);
return response()->json(['status' => 'payment recorded'], 200);
}
if($eventType === 'cancelled' || $eventType === 'error' || $eventType === 'expired' || $eventType === 'refused') {
$transactionRef = data_get($data, 'eventDetails.transactionReference');
$userId = $this->getUserFromTxn($transactionRef);
$membership = Membership::where('user_id', $userId)->first();
if ($membership) {
$membership->update([
'is_free_trail_completed' => true,
'status' => 'expired'
]);
}
}
// ============================
// IGNORE OTHER EVENTS
// ============================
return response()->json(['status' => 'ignored'], 200);
}
private function getUserFromTxn($txn)
{
$parts = explode('_', $txn);
return $parts[1] ?? null;
}
}
That's all!