Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Sepay Laravel Package

sepayvn/laravel-sepay

SePay payment gateway integration for Laravel. Accept bank transfers and VietQR payments, verify webhooks, query transactions, and manage payment flows through a clean, configurable API designed for Vietnamese banking and e-commerce applications.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish

composer require sepayvn/laravel-sepay
php artisan vendor:publish --tag="sepay-config"

Check the tag list in the README before publishing — the package only uses one or two tags (config, and migrations/routes if present). If your app already has a custom webhook route, publish config only and leave the routes untouched.

Set credentials in .env (never commit)

SEPAY_API_TOKEN=          # from my.sepay.vn dashboard, used for outbound API calls
SEPAY_WEBHOOK_API_KEY=    # the key SePay puts in the Authorization header on webhooks

These are two different values. Most first-day bugs come from putting the API token in the webhook slot.

Where to look first

  1. config/sepay.php — environment toggle, route path, middleware, and which key is used for webhook verification.
  2. The package's facade class (SePay) — its resource methods are the public API surface; skim them before writing any HTTP client yourself.
  3. routes/ inside the package (if published) — confirm the exact webhook path and whether auth middleware is pre-applied.
  4. The README's webhook section — v1.2.3 contains a typo fix in the webhook instructions (PR #17), so re-read that block even if you onboarded on an earlier version; the old text may have misled you on the header/key format.

First use case: automatic order reconciliation from bank transfers

The canonical integration is: customer transfers money to your bank account with an order code in the transfer memo → SePay fires a webhook → you mark the order paid. Get that end-to-end path working before touching checkout/gateway features. It is the feature with the clearest payoff and the most failure modes, so it's the one worth testing thoroughly.

Version note (v1.2.3)

This release adds Laravel 13 support (PR #23). The rest is CI dependency bumps (actions/checkout, git-auto-commit-action, laravel-pint-action, dependabot/fetch-metadata) and one docs typo fix — no code-breaking changes. If you're still on Laravel 11/12, upgrading is low-risk; if you're moving to Laravel 13, verify your other packages support it before you bump.

Implementation Patterns

Webhook receiver: thin controller, queued work

Register the route outside the web middleware group (or exclude it from CSRF). The handler should do only four things: verify, persist the raw payload, dispatch a job, return 200.

Route::post('/sepay/webhook', function (Request $request) {
    // Package handler does verification; wrap it if you want logging first.
    Log::channel('sepay')->info('webhook', $request->all());

    // Idempotency guard — unique index on sepay_id or reference_code.
    $tx = SePayTransaction::firstOrCreate(
        ['external_id' => $request->input('id')],
        $request->only(['gateway', 'transaction_date', 'account_number',
                        'transfer_type', 'amount_in', 'amount_out',
                        'accumulated', 'code', 'content', 'reference_code']),
    );

    if ($tx->wasRecentlyCreated) {
        ProcessSePayPayment::dispatch($tx->id);
    }

    return response()->json(['success' => true]);
});

Returning {"success": true} matters — SePay treats non-2xx as a delivery failure and retries, which is how you get duplicate fulfilment if your idempotency guard is missing.

Match the transfer to an order by an embedded code, not by free text

The content / code field is typed by a human in a banking app. It gets diacritics stripped, prefixes added by the bank, truncated, and sometimes mistyped. Practical rules:

  • Issue short, uppercase, alphanumeric order codes (DH8F2K) and render them into the QR/memo yourself.
  • Match with a case-insensitive regex for that pattern, not str_contains($content, $order->code) on the full string.
  • Keep the code short enough to survive truncation (many banks cut the memo around 25–40 chars once their own prefix is added).
  • Store both the raw content and the extracted code so you can re-run matching later.

Handle amount mismatches explicitly

Transfers can be partial, overpaid, or split across two transfers for one order. Model this instead of assuming equality:

$paid = $order->payments()->sum('amount_in');
if ($paid >= $order->total) { /* fulfilled */ }
elseif ($paid > 0) { /* partially paid, notify ops */ }

Store VND as integers (bigint / decimal(15,0)), never floats.

Use the API for reconciliation, not for realtime

Run a scheduled command nightly that pulls transactions for the last N days and reconciles against your ledger. This catches webhooks you missed during a deploy, a queue outage, or a tunnel drop. Keep the lookback window tight and paginate — don't pull full history on every run.

Schedule::command('sepay:reconcile')->dailyAt('02:00');

Treat the API record as the source of truth and the webhook as the fast path. If they disagree, the API wins.

Testing pattern

Http::fake() the outbound calls with recorded JSON fixtures, and post recorded webhook payloads at your own route in feature tests:

$this->withHeader('Authorization', 'Apikey '.config('sepay.webhook_api_key'))
     ->postJson('/sepay/webhook', $fixture)
     ->assertOk();

$this->assertDatabaseHas('orders', ['id' => 1, 'status' => 'paid']);

Store at least five real payload fixtures: incoming transfer, outgoing transfer, partial payment, duplicate delivery (same id twice), and one with a mangled memo. The duplicate one is the regression test you'll actually need.

Checkout / gateway flow, if you use it

Create the order server-side, redirect to the hosted page, and treat the return URL as cosmetic only — confirm payment via webhook or API before showing a success page. Users can and will hit the return URL without completing payment.

Gotchas and Tips

The Authorization header is not a bearer token. SePay sends Authorization: Apikey <key>. If you compare the raw header against your key, it never matches. Strip the Apikey prefix and compare with hash_equals() to avoid timing leaks. Check the shipped handler's behaviour before writing your own — v1.2.3's doc typo fix may change what the README tells you the header looks like.

Webhook key ≠ API token. Covered above, but it is the single most common setup failure. Symptom: every webhook returns 401/403 and SePay's dashboard shows repeated delivery failures.

Route registration collisions. If the package auto-registers a webhook route and you also define one, you get a confusing "works in one environment, 404 in another" situation. Decide once: package route + your listener, or publish and own the route. Then php artisan route:list | grep -i sepay to confirm.

CSRF. The webhook route must live in routes/api.php or be explicitly excluded from VerifyCsrfToken (Laravel 11+ bootstrap validateCsrfTokens exceptions). Otherwise SePay's POST returns 419 and retries forever.

Config cache. After changing .env, run php artisan config:clear. Never call env() outside config/ — it returns null once config is cached, which manifests as "auth works locally, fails in production."

Timezone. SePay timestamps are Vietnam time (UTC+7). Cast transaction_date through Carbon rather than storing the raw string, and don't assume config('app.timezone') matches. A naive whereDate('transaction_date', today()) will be off by a day near midnight.

Currency precision. VND has no minor units. Any float column will eventually produce a 1-đồng mismatch that breaks an exact-equality reconciliation check.

Pagination and rate limits. The transactions endpoint is paginated; a reconcile job that loops without a page cap will hang when the API throttles. Set a hard max page count and log when you hit it.

Sandbox vs production. Keep separate credentials per environment and assert in a boot check that production isn't pointing at a sandbox token. Consider a config guard in AppServiceProvider that throws on boot when app()->isProduction() and the token matches the sandbox prefix.

Logging. Log the raw payload — you will need it. But redact account_number and accumulated before it hits a shared log channel; Vietnamese bank account numbers are sensitive and the accumulated balance is more than you want in a third-party log aggregator.

Extension points worth knowing. Override the webhook handler class or point the config at your own route path rather than forking the package; listen for the package's event (if it dispatches one) instead of editing vendor code, so composer update stays painless. If you find yourself patching vendor/sepayvn/, stop and write an adapter around the facade instead.

On the release itself. v1.2.3 is a maintenance release: CI action bumps, a docs typo fix, and Laravel 13 support. No deprecations, no signature changes. The only actionable item is the webhook documentation correction — if you integrated before this release, verify your header handling against the updated instructions rather than trusting old copy-pasted code.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin