Skip to main content

Handling Refunds

Optional Integration

Automating refunds via the API is entirely optional. If your business workflows do not require programmatic refunds, you can completely skip this tutorial, and process refunds manually via the Simpler merchant dashboard instead.

Refunding an order has two independent parts: requesting the refund via POST /refunds, and learning the outcome via the REFUND_COMPLETED webhook.

The synchronous response is not always the final word — some refunds resolve minutes or days later (insufficient merchant balance, manual bank transfers), and refunds can also be initiated outside your call entirely (the Simpler merchant dashboard, or the payment provider directly). The webhook is the only channel that reliably covers every case.

Canonical references

Step 1: Requesting a refund

Call POST https://merchant.simpler.so/api/v1/refunds with basic authentication (your merchant API key and secret).

  • order_id (required) — the order's ID in your own system (e.g. your PrestaShop, Magento, WooCommerce, or Shopify order ID), not Simpler's internal order ID. Simpler resolves the order by looking up this ID against your merchant account.
  • amount (optional) — the amount in cents to return to the shopper. Omit it for a full refund; any other value triggers a partial refund. Multiple partial refunds are allowed as long as their sum doesn't exceed the order total.

Send a stable Idempotency-Key header on every attempt. If your infrastructure retries the same logical refund (e.g. after a timeout), Simpler returns the original request instead of issuing a second one.

src/Service/Simpler/RefundService.php
class SimplerRefundService {

public function refundOrder(string $orderId, ?int $amountCents = null): array {
$client = new \GuzzleHttp\Client();

$body = ['order_id' => $orderId];
if ($amountCents !== null) {
$body['amount'] = $amountCents;
}

$response = $client->post('https://merchant.simpler.so/api/v1/refunds', [
'auth' => ['YOUR_API_KEY', 'YOUR_API_SECRET'],
'headers' => [
// Stable per refund attempt — reuse it if you retry.
'Idempotency-Key' => $this->idempotencyKeyFor($orderId, $amountCents),
],
'json' => $body,
'http_errors' => false,
]);

return [
'status' => $response->getStatusCode(),
'body' => (string) $response->getBody(),
];
}
}

Step 2: Reading the synchronous response

StatusMeaningNext step
200Refund succeeded immediately.Done — no further action needed, though REFUND_COMPLETED still fires for consistency.
202Accepted but not yet completed — queued for retry (e.g. insufficient merchant balance) or requires manual processing. No response body.Wait for REFUND_COMPLETED — see Step 3.
400Malformed request (e.g. missing order_id).Fix the request; not retryable as-is.
401 / 403Invalid or mismatched API credentials.Check your merchant API key/secret.
404Unknown merchant.Check your API key.
422The request was well-formed but couldn't be processed for a business reason — order not found, an active refund already exists on the order, the amount exceeds the refundable balance, or a terminal payment-provider rejection.Inspect the error field; not retryable without changing the request.
500Unexpected server error.Safe to retry with the same Idempotency-Key.
202 has no body

Unlike the error responses above, 202 Accepted is returned with an empty body — don't try to parse a JSON error out of it. Treat it purely as "accepted, wait for the webhook."

Step 3: Handling the REFUND_COMPLETED webhook

Extend the webhook receiver you built in the Handling Webhooks tutorial with a case for REFUND_COMPLETED. It fires once, when the refund reaches a terminal state — SUCCEEDED or FAILED — from any of the ways a refund can happen: your POST /refunds call, the Simpler merchant dashboard, or the payment provider directly.

src/Controller/Simpler/WebhookController.php
class SimplerWebhookController extends AbstractController {

public function receiveWebhook(Request $request): Response {
// 1. Verify the signature (see Handling Webhooks tutorial)
$signature = $request->headers->get('X-Simpler-CRC');
$payload = $request->getContent();
$mySecret = 'YOUR_APP_SECRET';

if (hash_hmac('sha1', $payload, $mySecret) !== $signature) {
return new Response('Invalid Signature', 401);
}

$event = json_decode($payload, true);

// 2. Handle the REFUND_COMPLETED event
if ($event['type'] === 'REFUND_COMPLETED') {
$data = $event['data'];

// Correlate using your own order ID — the webhook does not echo
// back any identifier from the original POST /refunds call.
$order = CheckoutService::getOrder($data['order_id']);

if ($data['outcome'] === 'SUCCEEDED') {
$order->markRefunded($data['amount_cents'], $data['currency']);
} elseif ($data['outcome'] === 'FAILED') {
// $data['reason'] is a free-text explanation, not a stable enum —
// log it, but don't branch your logic on its exact value.
$order->flagRefundFailed($data['reason']);
}
}

// 3. Acknowledge receipt
return new Response('Webhook Received', 200);
}
}
Match by your own order ID

Use order_id (your platform's own ID) to look up the order, the same way you already do for ORDER_UPDATED — not simpler_order_id, which is Simpler's internal identifier.

No request correlation today

The webhook doesn't currently echo back any identifier from your original POST /refunds call (no idempotency key, no request reference). If you issue multiple partial refunds on the same order, disambiguate using order_id together with amount_cents.

See also