# PayPal payment gateway

> Learn how to integrate PayPal as a payment service provider.

PayPal is a redirect gateway. Merx creates an order at PayPal, sends the customer there to approve it, and captures the money when they come back. None of that happens on your site, so unlike [Stripe Elements](https://merx2.wagnerwagner.de/guide/payment-gateways/stripe-elements.md) there’s no JavaScript to load and no payment form to build.

Set up the client ID and secret as described in [Stripe/PayPal secrets](https://merx2.wagnerwagner.de/guide/configuration/stripe-paypal-secrets.md). Merx uses the sandbox keys while [`production`](https://merx2.wagnerwagner.de/reference/options/config-options/production.md) is `false` and the live ones afterwards.

## Send the order to PayPal

If you followed the [validate checkout guide](https://merx2.wagnerwagner.de/guide/getting-started/validate-checkout.md), the only thing to change is the hidden payment gateway field. That’s it.

```php title="/site/templates/checkout.php"
<form id="checkout-form" action="<?= url('api/shop/checkout') ?>" method="post">
  <!-- other fields -->
  <input type="hidden" name="payment_gateway" value="paypal">
  <button>Submit order</button>
</form>
```

Merx creates the order at PayPal, redirects the customer there to approve it, and captures the payment when PayPal sends them back to [`/api/shop/success`](https://merx2.wagnerwagner.de/reference/api/routes/shop-success-get.md). Then the [order page](https://merx2.wagnerwagner.de/reference/classes/order-page.md) is written, the cart is emptied and the customer lands on their order.

## Test with a sandbox account

Create a REST app in the [PayPal Developer Dashboard](https://developer.paypal.com/dashboard/applications/sandbox) for the sandbox keys, and pay with one of the sandbox personal accounts under **Testing Tools → Sandbox Accounts**.

> [!CAUTION]
> A sandbox account only works against the sandbox API, and a live account only against the live one. If a payment fails with `error.merx.paypalError` right after you go live, check that the `paypal.live.*` keys are really live keys.

## When the payment does not complete

**The customer cancels at PayPal.** Merx raises [`error.merx.paymentCanceled`](https://merx2.wagnerwagner.de/reference/options/translations/error-merx-paymentcanceled.md) and sends them back to your checkout page with the cart still filled.

**PayPal declines the capture.** Merx raises [`error.merx.paypalError`](https://merx2.wagnerwagner.de/reference/options/translations/error-merx-paypalerror.md) and no order page is written.

**The capture stays pending.** With some funding sources PayPal reports the order as `COMPLETED` but leaves the capture in `PENDING`. Merx writes the order page but does **not** mark it as paid.

> [!CAUTION]
> There is no PayPal webhook. A pending capture that settles later is not reported back to your shop the way a delayed [Stripe payment](https://merx2.wagnerwagner.de/guide/configuration/stripe-webhooks.md) is, so those orders need a look in the PayPal dashboard and a manual change of the order.

Merx also refuses a PayPal order that has been captured once already, so a customer who reloads the return URL cannot pay twice or complete somebody else’s order.

## Change what the customer sees at PayPal

Merx sends your site title as the brand name, `PAY_NOW` so the button says *Pay Now*, and `NO_SHIPPING`, because the address was already collected in your checkout. Override any of them with [`paypal.applicationContext`](https://merx2.wagnerwagner.de/reference/options/config-options/paypal-application-context.md), which is merged over those defaults.

```php title="/site/config/config.php"
<?php

return [
  'wagnerwagner.merx.paypal.applicationContext' => [
    'brand_name' => 'Nice New Shoes',
    'locale' => 'de-DE',
  ],
];
```

Leave `return_url` and `cancel_url` alone — Merx sets both to the return URL that carries the session token, and a payment that comes back anywhere else cannot be completed.

## Show the cart at PayPal

By default PayPal shows one line with your site title and the cart total. [`$cart->payPalPurchaseUnits()`](https://merx2.wagnerwagner.de/reference/classes/cart/pay-pal-purchase-units.md) builds the same order with every item listed instead, which is what the [`paypal.purchaseUnits`](https://merx2.wagnerwagner.de/reference/options/config-options/paypal-purchase-units.md) option expects.

```php title="/site/config/config.php"
<?php

return [
  'wagnerwagner.merx.paypal.purchaseUnits' => fn (): array => site()->cart()->payPalPurchaseUnits(),
];
```

What the option returns replaces Merx’ own purchase unit rather than adding to it — PayPal charges the sum of all units, so returning a second complete one would have the customer approve the total twice. Merx only adds the `custom_id` that binds the order to the payment.

> [!CAUTION]
> PayPal adds the items up itself and rejects an order whose numbers do not add up. If you build the units by hand, the item total plus the discount has to match the amount exactly.
