-
Notifications
You must be signed in to change notification settings - Fork 0
How to implement refunds
Full or partial refunds, issued from your back office. Unlike payments, there is no shopper-facing flow and no JavaScript — one call, one response.
Prerequisite: How to wire the library.
use PayplugUnifiedCore\Exceptions\ApiException;
use PayplugUnifiedCore\Exceptions\InvalidRefundRequestException;
use PayplugUnifiedCore\Exceptions\PaymentNotFoundException;
use PayplugUnifiedCore\Exceptions\RefundAmountException;
use PayplugUnifiedCore\Utilities\Helpers\AmountHelper;
try {
$response = $service->createRefund(
$operationId, // the payment's own id
$accountId,
(string) $order->getId(), // required
'Refund for order ' . $order->getId(), // required
$submerchantExternalId, // optional — must mirror the payment
AmountHelper::toCents($amount), // optional — null refunds everything remaining
'EUR' // optional
);
} catch (InvalidRefundRequestException $e) {
// empty orderId or description — nothing was sent
} catch (RefundAmountException $e) {
// amount was zero or negative — nothing was sent
} catch (PaymentNotFoundException $e) {
// 404 — unknown operation id
} catch (ApiException $e) {
// any other non-2xx; $e->getCode() is the HTTP status
}
$response['status']; // 200
$response['body']; // raw JSON stringThe first argument is the id of the payment being refunded, not a refund id and not your order
id. It is the same value this library's webhook vocabulary calls operationId — which is why the
parameter carries that name rather than inventing a second one for the same thing.
You will typically have it from the payment's webhook, stored via IPaymentRepository:
$operation = $paymentRepository->getByOrderId((string) $order->getId());
$operationId = $operation->operationId;Omit $amount, or pass null, to refund the full remaining amount. Pass an integer in minor
units for a partial refund.
- Zero or negative throws
RefundAmountExceptionbefore any network call. - An amount exceeding what was captured is not checked locally — the Unified API rejects it
itself, and UPC deliberately doesn't duplicate that check. Expect an
ApiException. - UPC does not track how much has already been refunded. Multiple partial refunds that together exceed the payment are your side's problem to prevent; sum what you've issued before calling.
Both are checked locally and throw InvalidRefundRequestException if empty. The local check exists
because ApiException carries only the HTTP status, not the API's own message naming the missing
field — so a remote rejection would tell you far less than this does.
Both were confirmed required by probing each field's absence individually against the real staging API (2026-08-27), not merely by reading the documentation.
It must mirror the payment being refunded. It belongs to the MID configuration for the payment's currency, and both directions fail:
- Refunding a EUR payment whose configuration owns a submerchant, without it →
400 The parameter "subMerchantExternalId" is missing. - Refunding a non-EUR payment with it →
400 Invalid parameter., succeeding once omitted
(Staging, 2026-09-04. Note the API validates the lower-case submerchantExternalId key despite its
own error text capitalising it.)
Practically: store what you used at payment time and pass exactly that back. Don't re-derive it from current settings — a merchant who changed configuration since the payment would refund into a mismatch.
Sent only when non-null and non-empty.
Only available on this endpoint since 2026-09-04. Before that an amount travelled bare and the platform inferred what the minor units meant — harmless while every payment was EUR, ambiguous for a multi-currency merchant.
Passing null or '' falls back to that inference mode rather than putting "" on the wire. Pass
it explicitly if you take more than one currency.
Caveat on the evidence: the staging run that first succeeded changed both
submerchantExternalIdandcurrencyat once, so which of the two the earlierInvalid parameter.referred to was never isolated.
A 2xx here means the refund was accepted. The outcome arrives as its own webhook, carrying its own operation id — which is exactly why idempotency is keyed on operation id rather than order id. A refund on an order that already has a payment would otherwise look like a duplicate of it and be silently dropped.
Expect PaymentOutcome::REFUNDED in IOrderStateMutator::apply().
-
$operationIdis the payment's id, fromIPaymentRepository -
orderIdanddescriptionalways non-empty - Amounts converted with
AmountHelper::toCents(), never* 100 -
submerchantExternalIdmirrors what the payment used — stored, not re-derived -
currencypassed explicitly if the merchant takes more than one - Cumulative partial refunds tracked on your side
-
IOrderStateMutatorhandlesREFUNDED - Refund webhooks not treated as duplicates of the payment
| Symptom | Cause |
|---|---|
InvalidRefundRequestException |
Empty orderId or description — nothing was sent |
RefundAmountException |
Amount zero or negative — nothing was sent |
PaymentNotFoundException |
Unknown operation id; likely passed an order id or a refund id |
400 The parameter "subMerchantExternalId" is missing. |
Payment's configuration owns one; pass it |
400 Invalid parameter. |
Passed a submerchant id for a currency whose configuration owns none |
ApiException on an apparently valid amount |
Exceeds what remains captured — not checked locally |
| Refund succeeds but the order never updates | Refund webhook dropped as a duplicate of the payment |
PaymentNotFoundExceptionis a sibling ofApiException, not a subclass — catchingApiExceptionalone will not catch it. See Error handling.
Core foundations shared library for Payplug e-commerce plugins
Guides
How-to guides
- Overview
- How to wire the library
- How to implement Hosted Fields
- How to implement alias payments
- How to handle webhooks
- How to implement refunds