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

Xendit Php Laravel Package

xendit/xendit-php

Official Xendit PHP SDK for integrating payments, invoices, disbursements, virtual accounts, e-wallets, and other Xendit APIs into PHP applications. Includes simple client setup, API wrappers, and examples for common payment flows.

Deep Wiki
Context7

Getting Started

Install and wire it up. The package is framework-agnostic — there is no Laravel service provider, so you own the bootstrap:

composer require xendit/xendit-php
// config/services.php
'xendit' => [
    'secret_key'     => env('XENDIT_SECRET_KEY'),
    'callback_token' => env('XENDIT_CALLBACK_TOKEN'),
    'base_url'       => env('XENDIT_BASE_URL', 'https://api.xendit.co'),
],

Configure the client once in a service provider, not per-call:

// AppServiceProvider::register()
use Xendit\Configuration;
use Xendit\Invoice\InvoiceApi;
use Xendit\VirtualAccounts\VirtualAccountsApi;

Configuration::getDefaultConfiguration()
    ->setApiKey(config('services.xendit.secret_key'));

$this->app->singleton(InvoiceApi::class, fn () => new InvoiceApi());
$this->app->singleton(VirtualAccountsApi::class, fn () => new VirtualAccountsApi());

Where to look first. Read the README's quickstart block first, then the API reference at docs.xendit.co — it is the real source of truth and is far better maintained than the examples in the repo. Then open the src/ tree and confirm which style your installed major version uses: recent releases are OpenAPI-generated (Xendit\Configuration + Xendit\Invoice\InvoiceApi + request model classes), while older 2.x/3.x code you'll find on Stack Overflow uses a static facade (Xendit\Xendit::setApiKey() + Xendit\Invoice::create()). Every tutorial you find will be one or the other; check composer.lock before copying anything.

First use case: hosted Invoice. It is the shortest path to money moving, and it keeps card data entirely off your servers:

use Xendit\Invoice\CreateInvoiceRequest;

$invoice = app(InvoiceApi::class)->createInvoice(new CreateInvoiceRequest([
    'external_id'          => $order->reference,   // your idempotency anchor
    'amount'               => $order->total,       // integer IDR, no decimals
    'payer_email'          => $order->user->email,
    'description'          => "Order {$order->reference}",
    'invoice_duration'     => 86400,
    'success_redirect_url' => route('checkout.return', $order),
    'failure_redirect_url' => route('checkout.return', $order),
]));

return redirect($invoice['invoice_url']);

Treat the redirect as UX only. The authoritative "paid" signal is the invoice.paid webhook — build that controller on day one, before you build the happy path.

Implementation Patterns

Webhook controller is the core of the integration. One endpoint, verify, persist raw payload, dispatch a job, return 200 immediately:

public function __invoke(Request $request)
{
    abort_unless(
        hash_equals((string) config('services.xendit.callback_token'), (string) $request->header('x-callback-token')),
        401
    );

    $event = XenditEvent::create([
        'external_id' => $request->input('external_id'),
        'type'        => $request->input('event'),   // 'invoice.paid', 'fva_status', ...
        'payload'     => $request->all(),
        'status'      => $request->input('status'),
    ]);

    ProcessXenditEvent::dispatch($event->id)->onQueue('payments');

    return response()->noContent();
}

Deduplicate on (type, external_id, status) with a unique index — callbacks are at-least-once and occasionally out of order. Your job should be a no-op if the order is already in the target state.

Invoices for top-ups and one-off checkout; Virtual Accounts for reconciliation-heavy flows. A closed VA is single-use and locks the amount, which makes bank-transfer matching trivial:

$va = app(VirtualAccountsApi::class)->createFixedVirtualAccount(new CreateVirtualAccountRequest([
    'external_id'     => $order->reference,
    'bank_code'       => 'BCA',
    'name'            => $user->name,
    'expected_amount' => $order->total,
    'is_closed'       => true,     // single-use, exact amount
    'is_single_use'   => true,
]));
return $va['account_number'];

EWallets need their actions interpreted per channel. The charge response includes an array of actions — mobile_deeplink_checkout_url for native apps, qr_checkout_string for QR-based wallets, web_checkout_url for desktop. Pick by channel_code and by whether the request came from a mobile user agent; don't hardcode one key.

Cards: tokenize, never transmit PAN. Use xendit.js on the front end to get a token, then charge server-side. AUTHENTICATION_REQUIRED is a normal outcome, not an error — persist the charge, redirect the user to actions[].url, and wait for the cards callback. Multi-use tokens + Xendit\Recurring are what you want for subscriptions; single-use tokens expire.

Disbursements and Payouts are the risky direction. Always send X-IDEMPOTENCY-KEY equal to your own transfer ID, always write the ledger row before calling, and always run them from a queued job with retries bounded. Check balance first (Balance endpoint, account_type=CASH) so you fail loudly in your own logs rather than in a support ticket.

xenPlatform / split payments are driven by request headers, not body fields: for-user-id (sub-account) and with-split-rule. The generated SDK methods accept these as trailing optional arguments; set them per-call rather than globally if you serve multiple sub-accounts.

Testing. Sandbox keys route to the sandbox host automatically. There are no fake/simulation helpers in the SDK, so either hit sandbox from a manual test suite or wrap each API class behind a small interface (PaymentGateway) that you bind to a fake in feature tests. Mocking Guzzle's handler stack directly works but couples your tests to the SDK's internals — the interface is cheaper long-term.

Gotchas and Tips

  • The v4/v5 restructure is a hard break. Xendit\Invoice::create() no longer exists in current releases; it's Xendit\Invoice\InvoiceApi::createInvoice(). Most blog posts and LLM-generated snippets are for the old facade. Pin the major version in composer.json and read the changelog before composer update.
  • Amounts are integers. IDR has no minor unit, and the SDK does no rounding or validation. Passing 150000.00 from a float column silently produces bad requests or off-by-one charges. Cast and validate at the boundary.
  • No webhook signature helper ships with this package. Unlike Stripe, you compare the x-callback-token header yourself. Use hash_equals, fail closed if the config value is empty, and remember the token is shared across all callback types in the project.
  • Idempotency is opt-in on every write. The SDK will happily create two invoices or two disbursements. Key creates on your own domain ID; for retries, retry only on connection errors and 5xx, and only when you sent an idempotency key.
  • Errors are one class. Generated versions throw Xendit\ApiException wrapping getCode(), getResponseBody(), and getResponseHeaders() — the body is where the useful error_code lives (e.g. INSUFFICIENT_BALANCE, CALLBACK_TOKEN_INVALID). Catch it globally, log the body, and map error_code to user-facing messages instead of surfacing raw JSON.
  • Set Guzzle timeouts explicitly. The default client has no meaningful timeout; one hung request blocks a queue worker. Pass a configured GuzzleHttp\Client into the underlying ApiClient in your service provider.
  • Base URL selection. Older versions infer sandbox vs production from the xnd_development_ prefix on the key; newer ones let you set the host. If you see production traffic in sandbox (or 401s after rotating keys), check the host config before the credentials.
  • Statuses are plain strings, not enums. Don't compare against class constants that don't exist; guard with a match and a default branch that alerts rather than silently ignoring an unknown state. Xendit adds statuses over time.
  • invoice_url expires at invoice_duration, and VA numbers are only payer-unique when is_closed is true. Store the Xendit IDs (id, external_id) on your own models — you'll need them for lookup, refunds, and support escalations.
  • Refunds are asynchronous for most channels. Don't flip your order to "refunded" on a 200; wait for refund.succeeded or poll the refund.
  • Log the request ID. Xendit support will ask for it, and it's the only fast way to trace a disputed call. Add it to your HTTP client's middleware so it's captured on both success and failure.
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.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource