# Refunds

Refund a card payment in full or in part, how long a refund takes, and why a cash payment is not refunded in cash.

A **refund** gives money back to your customer for a payment that succeeded. Card payments can be
refunded, in full or in part. Cash payments cannot: see [Cash payments](#cash-payments) below.

* Only a `succeeded` payment can be refunded.
* You can make several partial refunds, as long as their total stays within the payment's
  `amount_received`. A refund over what remains is refused with `amount_too_large`.
* A cash payment is refused with `refund_unsupported_payment_method`, in test mode as in live mode.

## Refund a card payment

Create a refund with `POST /v1/refunds`. The `Idempotency-Key` header is required, as on every
request that moves money: a refund sent twice with the same key is made once.

| Parameter        | What it holds                                                                               |
| ---------------- | ------------------------------------------------------------------------------------------- |
| `payment_intent` | The payment to refund (`pi_…`). Or send `charge`, its succeeded attempt (`ch_…`).           |
| `amount`         | Optional. In centimes. Without it, everything not yet refunded.                             |
| `reason`         | Optional. `requested_by_customer`, `duplicate` or `fraudulent`.                             |
| `metadata`       | Optional. Your own references, such as a return number: see [Metadata](https://pay-docs.adpromax.com/en/payments/metadata). |

The answer is the refund, `pending` at first.

```bash
curl https://pay-api.adpromax.com/v1/refunds \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: refund-order-1042-1" \
  -d '{
    "payment_intent": "pi_01k7e7v2x5z8b1d4f7h0k3n6qs",
    "amount": 10000,
    "reason": "requested_by_customer",
    "metadata": { "return_id": "R-311" }
  }'
```

```js title="refund.mjs"
const response = await fetch('https://pay-api.adpromax.com/v1/refunds', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'refund-order-1042-1',
  },
  body: JSON.stringify({
    payment_intent: 'pi_01k7e7v2x5z8b1d4f7h0k3n6qs',
    amount: 10000,
    reason: 'requested_by_customer',
    metadata: { return_id: 'R-311' },
  }),
})
const refund = await response.json()
if (!response.ok)
  throw new Error(refund.error.message)
console.log(refund.id, refund.status)
```

```ts title="refund.ts"
interface Refund {
  id: string
  object: 'refund'
  amount: number
  currency: string
  payment_intent: string
  reason: string | null
  status: string
  metadata: Record<string, string>
}

interface ApiError {
  error: { type: string, code: string, message: string, request_id: string }
}

const response = await fetch('https://pay-api.adpromax.com/v1/refunds', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'refund-order-1042-1',
  },
  body: JSON.stringify({
    payment_intent: 'pi_01k7e7v2x5z8b1d4f7h0k3n6qs',
    amount: 10000,
    reason: 'requested_by_customer',
    metadata: { return_id: 'R-311' },
  }),
})
if (!response.ok) {
  const { error } = (await response.json()) as ApiError
  throw new Error(`${error.code}: ${error.message}`)
}
const refund = (await response.json()) as Refund
console.log(refund.id, refund.status)
```

```python title="refund.py"
import os

import requests

response = requests.post(
    "https://pay-api.adpromax.com/v1/refunds",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    headers={"Idempotency-Key": "refund-order-1042-1"},
    json={
        "payment_intent": "pi_01k7e7v2x5z8b1d4f7h0k3n6qs",
        "amount": 10000,
        "reason": "requested_by_customer",
        "metadata": {"return_id": "R-311"},
    },
)
refund = response.json()
response.raise_for_status()
print(refund["id"], refund["status"])
```

```php title="refund.php"
<?php
$ch = curl_init('https://pay-api.adpromax.com/v1/refunds');
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Idempotency-Key: refund-order-1042-1',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'payment_intent' => 'pi_01k7e7v2x5z8b1d4f7h0k3n6qs',
        'amount' => 10000,
        'reason' => 'requested_by_customer',
        'metadata' => ['return_id' => 'R-311'],
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$refund = json_decode(curl_exec($ch), true);
echo $refund['id'], ' ', $refund['status'], PHP_EOL;
```

```ruby title="refund.rb"
require 'json'
require 'net/http'

uri = URI('https://pay-api.adpromax.com/v1/refunds')
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'refund-order-1042-1'
request.body = JSON.generate(
  payment_intent: 'pi_01k7e7v2x5z8b1d4f7h0k3n6qs',
  amount: 10000,
  reason: 'requested_by_customer',
  metadata: { return_id: 'R-311' }
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
refund = JSON.parse(response.body)
raise refund.dig('error', 'message') unless response.is_a?(Net::HTTPSuccess)

puts "#{refund['id']} #{refund['status']}"
```

```go title="refund.go"
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
)

type Refund struct {
	ID            string `json:"id"`
	Amount        int64  `json:"amount"`
	PaymentIntent string `json:"payment_intent"`
	Status        string `json:"status"`
}

type APIError struct {
	Error struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func main() {
	body, err := json.Marshal(map[string]any{
		"payment_intent": "pi_01k7e7v2x5z8b1d4f7h0k3n6qs",
		"amount":         10000,
		"reason":         "requested_by_customer",
		"metadata":       map[string]string{"return_id": "R-311"},
	})
	if err != nil {
		log.Fatal(err)
	}
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/refunds", bytes.NewReader(body))
	if err != nil {
		log.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", "refund-order-1042-1")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		var apiErr APIError
		json.NewDecoder(res.Body).Decode(&apiErr)
		log.Fatal(apiErr.Error.Message)
	}
	var refund Refund
	if err := json.NewDecoder(res.Body).Decode(&refund); err != nil {
		log.Fatal(err)
	}
	fmt.Println(refund.ID, refund.Status)
}
```

```java title="Refund.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Refund {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "payment_intent": "pi_01k7e7v2x5z8b1d4f7h0k3n6qs",
              "amount": 10000,
              "reason": "requested_by_customer",
              "metadata": { "return_id": "R-311" }
            }
            """;
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/refunds"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", "refund-order-1042-1")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() >= 400) {
            throw new IllegalStateException(response.body());
        }
        // The refund as JSON: read its id and status with a JSON library.
        System.out.println(response.statusCode() + " " + response.body());
    }
}
```

```csharp title="Refund.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

using var request = new HttpRequestMessage(
    HttpMethod.Post, "https://pay-api.adpromax.com/v1/refunds");
request.Headers.Add("Idempotency-Key", "refund-order-1042-1");
request.Content = JsonContent.Create(new
{
    payment_intent = "pi_01k7e7v2x5z8b1d4f7h0k3n6qs",
    amount = 10000,
    reason = "requested_by_customer",
    metadata = new { return_id = "R-311" },
});

using var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
    var failure = await response.Content.ReadFromJsonAsync<ApiError>(json);
    throw new InvalidOperationException(failure?.Error.Message);
}
var refund = await response.Content.ReadFromJsonAsync<Refund>(json);
Console.WriteLine($"{refund!.Id} {refund.Status}");

record Refund(string Id, long Amount, string PaymentIntent, string Status);
record ApiError(ApiErrorBody Error);
record ApiErrorBody(string Code, string Message);
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde = { version = "1", features = ["derive"] }
//             serde_json = "1"
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct Refund {
    id: String,
    status: String,
}

#[derive(Deserialize)]
struct ApiError {
    error: ErrorBody,
}

#[derive(Deserialize)]
struct ErrorBody {
    message: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let response = reqwest::blocking::Client::new()
        .post("https://pay-api.adpromax.com/v1/refunds")
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .header("Idempotency-Key", "refund-order-1042-1")
        .json(&json!({
            "payment_intent": "pi_01k7e7v2x5z8b1d4f7h0k3n6qs",
            "amount": 10000,
            "reason": "requested_by_customer",
            "metadata": { "return_id": "R-311" }
        }))
        .send()?;
    if !response.status().is_success() {
        let failure: ApiError = response.json()?;
        return Err(failure.error.message.into());
    }
    let refund: Refund = response.json()?;
    println!("{} {}", refund.id, refund.status);
    Ok(())
}
```

```json title="200 OK" response
{
  "id": "re_01k7e7w3y6a9c2e5g8j1m4p7rt",
  "object": "refund",
  "amount": 10000,
  "currency": "mad",
  "payment_intent": "pi_01k7e7v2x5z8b1d4f7h0k3n6qs",
  "charge": "ch_01k7e7v3c6e9g2j5m8p1r4t7wb",
  "status": "pending",
  "reason": "requested_by_customer",
  "metadata": { "return_id": "R-311" },
  "created": 1790863800,
  "livemode": false
}
```

## The life of a refund

The statuses of a refund:

- `pending` → `succeeded`: carried out
- `pending` → `failed`: refused
- `pending` → `canceled`: you cancel

A refund is `pending` until it is carried out, then `succeeded`. It ends `failed` if the card network
refuses it, and `canceled` if you cancel it first.

* **In test mode**, a refund succeeds within seconds.
* **In live mode**, a card refund may be **processed manually**: our team carries it out with the
  card network. It stays `pending` until that is done, which can take a few business days. Once it
  is `succeeded`, your customer's bank still takes its own time to show the money on their card.

The statuses are the same either way, so your code does not change between modes.

## Cancel a refund

A `pending` refund that has not been carried out yet can be canceled with
`POST /v1/refunds/{id}/cancel`. It becomes `canceled`, and its amount can be refunded again later.

```bash
curl -X POST https://pay-api.adpromax.com/v1/refunds/re_01k7e7w3y6a9c2e5g8j1m4p7rt/cancel \
  -u "$APX_SECRET_KEY:"
```

```js title="cancel-refund.mjs"
const id = 're_01k7e7w3y6a9c2e5g8j1m4p7rt'
const response = await fetch(`https://pay-api.adpromax.com/v1/refunds/${id}/cancel`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.APX_SECRET_KEY}` },
})
const refund = await response.json()
console.log(refund.status) // canceled
```

```ts title="cancel-refund.ts"
interface Refund {
  id: string
  object: 'refund'
  status: string
}

const id = 're_01k7e7w3y6a9c2e5g8j1m4p7rt'
const response = await fetch(`https://pay-api.adpromax.com/v1/refunds/${id}/cancel`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.APX_SECRET_KEY}` },
})
const refund = (await response.json()) as Refund
console.log(refund.status) // canceled
```

```python title="cancel_refund.py"
import os

import requests

refund_id = "re_01k7e7w3y6a9c2e5g8j1m4p7rt"
response = requests.post(
    f"https://pay-api.adpromax.com/v1/refunds/{refund_id}/cancel",
    auth=(os.environ["APX_SECRET_KEY"], ""),
)
print(response.json()["status"])  # canceled
```

```php title="cancel_refund.php"
<?php
$id = 're_01k7e7w3y6a9c2e5g8j1m4p7rt';
$ch = curl_init("https://pay-api.adpromax.com/v1/refunds/{$id}/cancel");
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
]);
$refund = json_decode(curl_exec($ch), true);
echo $refund['status'], PHP_EOL; // canceled
```

```ruby title="cancel_refund.rb"
require 'json'
require 'net/http'

id = 're_01k7e7w3y6a9c2e5g8j1m4p7rt'
uri = URI("https://pay-api.adpromax.com/v1/refunds/#{id}/cancel")
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
puts JSON.parse(response.body)['status'] # canceled
```

```go title="cancel_refund.go"
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
)

type Refund struct {
	ID     string `json:"id"`
	Status string `json:"status"`
}

func main() {
	id := "re_01k7e7w3y6a9c2e5g8j1m4p7rt"
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/refunds/"+id+"/cancel", nil)
	if err != nil {
		log.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()

	var refund Refund
	if err := json.NewDecoder(res.Body).Decode(&refund); err != nil {
		log.Fatal(err)
	}
	fmt.Println(refund.Status) // canceled
}
```

```java title="CancelRefund.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class CancelRefund {
    public static void main(String[] args) throws Exception {
        String id = "re_01k7e7w3y6a9c2e5g8j1m4p7rt";
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/refunds/" + id + "/cancel"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .POST(HttpRequest.BodyPublishers.noBody())
            .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofString());
        // The refund as JSON: its status is now "canceled".
        System.out.println(response.body());
    }
}
```

```csharp title="CancelRefund.cs"
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));

var id = "re_01k7e7w3y6a9c2e5g8j1m4p7rt";
using var response = await http.PostAsync(
    $"https://pay-api.adpromax.com/v1/refunds/{id}/cancel", null);
var refund = await response.Content.ReadFromJsonAsync<Refund>(json);
Console.WriteLine(refund!.Status); // canceled

record Refund(string Id, string Status);
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde = { version = "1", features = ["derive"] }
use serde::Deserialize;

#[derive(Deserialize)]
struct Refund {
    status: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let id = "re_01k7e7w3y6a9c2e5g8j1m4p7rt";
    let refund: Refund = reqwest::blocking::Client::new()
        .post(format!("https://pay-api.adpromax.com/v1/refunds/{id}/cancel"))
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .send()?
        .json()?;
    println!("{}", refund.status); // canceled
    Ok(())
}
```

## Events and balances

| Event             | When                                                 |
| ----------------- | ---------------------------------------------------- |
| `refund.created`  | A refund was created.                                |
| `refund.updated`  | A refund changed status, for example to `succeeded`. |
| `refund.failed`   | A refund failed.                                     |
| `charge.refunded` | An attempt was refunded, in full or in part.         |

* The payment intent's `amount_refunded` grows when a refund **succeeds**. The payment intent stays
  `succeeded`, even when it is refunded in full.
* A failed or canceled refund frees its amount: you can refund it again.
* A succeeded refund is taken from your balance; it shows as a refund line under **Balances**. Our
  fee on the payment is not given back: a payment refunded in full leaves your balance lower by that
  fee.

## From the dashboard

On a payment's page, **Refund** opens a form:

* the amount, prefilled with what remains to refund, with *Full* and *Partial*;
* a reason: duplicate, fraudulent, requested by customer, or other with a note;
* in live mode, a last confirmation before the refund is sent.

The **Refunds** page lists every refund with its status, its payment and who made it.

Who can refund on the dashboard depends on the role: Owner, Administrator, Finance and Support can.
Support can refund up to a ceiling per refund, 1,000.00 MAD by default, which the form shows before
you type. For the other roles, the button is disabled and says why. See
[Team and roles](https://pay-docs.adpromax.com/en/account/team-and-roles). An API key is not limited by roles: it acts for the whole
organization.

## Cash payments

A succeeded cash payment **cannot be refunded through AdProMaxPay**: neither in cash, nor to a card.

**Why.** Your customer handed the money over in cash at an agent. There is no card and no account to
send it back to.

**What happens.** The payment stays `succeeded` and its amount stays in your balance. On the
dashboard, **Refund** is disabled on a cash payment and says why; the API answers
`refund_unsupported_payment_method`, in test mode as in live mode.

**What you do.** Settle it with your customer directly: a bank transfer, a store credit, or cash at
your shop. Record it in your own books. Tell your customers your policy before they choose cash.

A cash code that has not been paid yet is not refunded but **canceled**: see
[Cancel a code you no longer want](https://pay-docs.adpromax.com/en/payments/cash-payments#cancel-a-code-you-no-longer-want).
