Bonaventure OgetoBy Bonaventure Ogeto|

Verify Paystack Payments in Laravel

To verify a Paystack payment in Laravel, use Http::withToken() to GET the verify endpoint. Check data.data.status is "success", the amount matches your Order model, and the currency is correct. Use Order::where("reference", $ref)->where("paid", false)->update() for idempotent fulfillment.

PaystackService Class

// app/Services/PaystackService.php
namespace App\Services;

use App\Models\Order;
use Illuminate\Support\Facades\Http;

class PaystackService
{
    private string $secretKey;

    public function __construct()
    {
        $this->secretKey = config('services.paystack.secret_key');
    }

    public function verify(string $reference): array
    {
        $response = Http::withToken($this->secretKey)
            ->get('https://api.paystack.co/transaction/verify/' . $reference);

        return $response->json();
    }

    public function verifyAndFulfill(string $reference): array
    {
        $order = Order::where('reference', $reference)->first();

        if (!$order) {
            return ['success' => false, 'error' => 'Order not found'];
        }

        if ($order->paid) {
            return ['success' => true, 'already_fulfilled' => true];
        }

        $result = $this->verify($reference);

        if (!($result['status'] ?? false) || $result['data']['status'] !== 'success') {
            return ['success' => false, 'error' => 'Payment not successful'];
        }

        if ($result['data']['amount'] !== $order->amount) {
            return ['success' => false, 'error' => 'Amount mismatch'];
        }

        if ($result['data']['currency'] !== $order->currency) {
            return ['success' => false, 'error' => 'Currency mismatch'];
        }

        $updated = Order::where('reference', $reference)
            ->where('paid', false)
            ->update(['paid' => true, 'paid_at' => now()]);

        return [
            'success' => true,
            'already_fulfilled' => $updated === 0,
            'amount' => $order->amount / 100,
            'currency' => $order->currency,
        ];
    }
}

Using the Service in Controllers

// In PaymentController
public function callback(Request $request, PaystackService $paystack)
{
    $reference = $request->query('reference') ?? $request->query('trxref');

    if (!$reference) {
        return redirect('/')->with('error', 'Missing reference');
    }

    $result = $paystack->verifyAndFulfill($reference);

    if ($result['success']) {
        return view('payment.success', ['reference' => $reference]);
    }

    return view('payment.failed', ['error' => $result['error']]);
}

public function verify(Request $request, PaystackService $paystack)
{
    $reference = $request->query('reference');

    if (!$reference) {
        return response()->json(['error' => 'Missing reference'], 400);
    }

    $result = $paystack->verifyAndFulfill($reference);

    return response()->json($result, $result['success'] ? 200 : 400);
}

Laravel automatically injects the PaystackService through constructor or method injection.

Retry Logic

public function verifyWithRetry(string $reference, int $maxRetries = 3): ?array
{
    for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
        try {
            $response = Http::withToken($this->secretKey)
                ->timeout(10)
                ->get('https://api.paystack.co/transaction/verify/' . $reference);

            if ($response->successful()) {
                return $response->json();
            }
        } catch (\Exception $e) {
            // Log and retry
        }

        if ($attempt < $maxRetries - 1) {
            sleep(pow(2, $attempt));
        }
    }

    return null;
}

Common Mistakes

  • Using $order->save() instead of ::where()->update(). The save() method is not atomic. Two concurrent requests could both read paid=false and both save paid=true.
  • Trusting request input for amounts. Always compare against the Eloquent model.
  • Mixing test and live keys. Both must be in the same mode.
  • Not checking currency. Always verify the currency matches.

Ship Payments Faster

Key Takeaways

  • Laravel HTTP client makes verification calls clean: Http::withToken($key)->get($url).
  • Always check status, amount, and currency against your Eloquent model.
  • Use where("paid", false)->update() for atomic idempotent fulfillment.
  • Create a PaystackService class for reusable verification logic across controllers.
  • Both callback and webhook handlers should call the same verification method.
  • If the verify call fails, retry. The Paystack transaction state does not change.

Frequently Asked Questions

Can I verify a transaction multiple times?
Yes. The Paystack verify endpoint is idempotent. It returns the same result every time for the same reference.
Should I register PaystackService as a singleton?
It depends on your needs. As a singleton, the same instance is reused. As a regular binding, a new instance is created per request. Both work fine for Paystack API calls.
How do I handle verification in queued jobs?
Your webhook handler can dispatch a job that calls verifyAndFulfill. The idempotent logic ensures safety even if the job runs multiple times.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon