Docs / Payments setup — and the one step that silently loses money

Payments setup — and the one step that silently loses money

Payment credentials are server-side only: they live in .env on your machine and never in the database, never in a form, never in this admin. That is deliberate. What follows is where each value goes — and the one step that is easy to miss and expensive to miss.

The step that costs money if you skip it

Most Indonesian payment methods are asynchronous. A buyer picks a virtual account, an e-wallet, or pays at a retail outlet, and then closes the tab. They may finish paying an hour later, from their banking app, on a different device. Nothing about that ever returns to your website.

So the purchase is completed by the provider calling your server — a webhook — and by nothing else.

If that call never arrives:

  • the buyer's money reaches you,
  • their licence is never issued,
  • the order sits at "pending",
  • and nothing anywhere says why.

This is not a rare edge case. On the Indonesian methods it is the normal path.

Where each provider gets its URL

Provider Webhook URL Who sets it
Midtrans https://yourdomain/webhooks/midtrans automatic — sent with every transaction
Xendit https://yourdomain/webhooks/xendit you, by hand, once
PayPal (none) not needed — it completes on the return

Midtrans is handled for you: each transaction carries its own notification URL, so there is nothing to configure. PayPal completes when the buyer comes back, so it needs no webhook at all.

Xendit is the one that needs you. Its "invoice paid" callback is account-wide, not per-invoice, so it is set once in Xendit's own dashboard.

Xendit, click by click

  1. Sign in at dashboard.xendit.co.
  2. Settings → Developers → Webhooks.
  3. Under Invoices paid, paste the URL your admin page shows for Xendit (https://yourdomain/webhooks/xendit).
  4. Save, and copy the webhook verification token shown on the same page.
  5. Put that token in your server's .env as XENDIT_CALLBACK_TOKEN, alongside XENDIT_SECRET_KEY.
  6. Restart the site so the new values are read.

Both halves are required. The URL without the token means deliveries arrive and are refused; the token without the URL means nothing arrives at all.

Proving it actually works

Do not trust "I pasted it". Open Admin → Settings → Payment webhooks. It reports what has genuinely reached your server:

What it says What it means What to do
Never received Nothing has ever arrived from this provider The URL is missing or wrong in the provider's dashboard
Not matching Deliveries arrive but name no order of yours The URL points at you from a different provider account than the store uses
Working At least one delivery matched a real order Nothing — it is wired up

The counters only move for calls that carry the provider's own credentials, so a random probe from the internet can never make an unconfigured gateway look healthy.

Test it end to end before you take real money. Xendit's dashboard has a "test webhook" button on the same page; use it, then reload the admin card. If it still says Never received, the URL is wrong — check for a typo, a missing https://, or a trailing slash.

The credentials themselves

All of these go in .env on the server, then restart:

# Midtrans
MIDTRANS_ENV=sandbox          # or: production
MIDTRANS_SERVER_KEY=...
MIDTRANS_CLIENT_KEY=...

# Xendit
XENDIT_SECRET_KEY=...
XENDIT_CALLBACK_TOKEN=...     # from Settings → Developers → Webhooks

# PayPal
PAYPAL_CLIENT_ID=...
PAYPAL_SECRET=...

A gateway with no key is simply switched off — there is nothing else to enable. Admin → Settings → Server-side secrets shows which are configured, without ever showing a value.

If your server is behind a proxy or firewall

The webhook path is a public, server-to-server URL. It has no login, by necessity — the provider cannot log in as you. It is protected instead by the provider's own signature or token, an authoritative re-read of the payment status directly from the provider, and a rate limit.

That means POST /webhooks/* must be reachable from the public internet. If you block it, or put the whole site behind an IP allow-list, asynchronous payments stop being fulfilled — with exactly the same silent symptom as never having configured the URL.