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.
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.
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.
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.150000.00 from a float column silently produces bad requests or off-by-one charges. Cast and validate at the boundary.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.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.GuzzleHttp\Client into the underlying ApiClient in your service provider.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.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.refund.succeeded or poll the refund.How can I help you explore Laravel packages today?