# Refunds

Process full and partial refunds for card and ACH transactions.

---

## Overview

The Refunds API allows you to return funds to customers for completed transactions. You can:

- 💰 **Full refunds** - Return the entire transaction amount
- 📊 **Partial refunds** - Return a portion of the transaction amount  
- 🔄 **Multiple refunds** - Issue multiple partial refunds up to the original amount
- ⚡ **Instant processing** - Refunds are sent to the gateway immediately
- 🔁 **Decline queue** - Refunds declined by the gateway are queued in the Refund Decline Queue for retry

Each refund is recorded as a new transaction of `type: "refund"` with a `REF-…` ID and `originalTransactionId` pointing at the sale. The original sale's `status` becomes `refunded` once its full amount has been refunded; after a partial refund it keeps its status (`approved` or `settled`).

---

## API Route Options

| Auth Type | Endpoint | Use Case |
|-----------|----------|----------|
| **API Key** (`x-api-key`) | `POST /api/v1/public/refund` | ERP / server integration |
| **JWT Bearer** | `POST /api/v1/transactions/:id/refund` | Dashboard / admin (requires the `transactions.refund` permission) |
| **JWT Bearer** | `POST /api/v1/transactions/:id/refund/partial` | Partial refund (`amount` required) |
| **SDK / API key** | `POST /api/v1/auxvault/refund` | Official v2.0 SDK namespace; server-side `x-api-key`, SDK public keys get `403` |

For ERP and server-to-server integrations, see Refund a Transaction — Public API in the [Transactions API](./transactions.md).

---

## Process a Refund (Admin API)

### Endpoint

```http
POST /api/v1/transactions/:transactionId/refund
```

`:transactionId` is the transaction UUID or its `TXN-…` / `ACH-…` / `CASH-…` ID.

### Full Refund

Refund the entire remaining amount:

```bash
curl -X POST https://uat.auxcore.net/api/v1/transactions/7c9e6679-7425-40de-944b-e07fc1f90ae7/refund \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Response (201):**
```json
{
  "success": true,
  "data": {
    "id": "c2a8e1f4-3b7d-4e9a-8f12-6d5c4b3a2e10",
    "transactionId": "REF-1769614200000-9F8E7D6C",
    "type": "refund",
    "status": "refunded",
    "amount": "150.75",
    "currency": "USD",
    "originalTransactionId": "TXN-1769605200000-A1B2C3D4",
    "responseCode": "100",
    "responseText": "Approved",
    "createdAt": "2026-01-28T15:30:00.000Z"
  }
}
```

### Partial Refund

Send an `amount` to refund part of the transaction. Either endpoint works:

```http
POST /api/v1/transactions/:transactionId/refund/partial
```

```bash
curl -X POST https://uat.auxcore.net/api/v1/transactions/7c9e6679-7425-40de-944b-e07fc1f90ae7/refund/partial \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50.00
  }'
```

The response has the same shape as a full refund, with `amount` set to the refunded value.

### Request Parameters

| Field | Type | Description | Required |
|-------|------|-------------|----------|
| `amount` | decimal | Refund amount (omit on `/refund` for a full refund; required and > 0 on `/refund/partial`) | No |

A `reason` or `metadata` sent in the body is accepted but not stored.

### Check the refund status

The HTTP status is `201` and `success` is `true` whenever a refund row was created — **including when the gateway declined it**. Always read `data.status`:

| `data.status` | Meaning |
|---------------|---------|
| `refunded` | Gateway accepted the refund |
| `declined` | Gateway refused it, or no gateway credentials were available — `data.responseText` says why. Only a gateway decline is added to the Refund Decline Queue |

TSYS Sierra exception: a full refund of a sale that has not settled yet is processed as a void, so `data` is the original sale with `status: "voided"` rather than a new `REF-…` row.

On `POST /api/v1/public/refund` only a lowercase `amount` makes the refund partial — a PascalCase `Amount` is ignored there and the full amount is refunded.

---

## Multiple Partial Refunds

You can issue multiple partial refunds until you reach the original transaction amount:

```bash
# First partial refund
curl -X POST https://uat.auxcore.net/api/v1/public/refund \
  -H "x-api-key: YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"transactionId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "amount": 30.00}'

# Second partial refund
curl -X POST https://uat.auxcore.net/api/v1/public/refund \
  -H "x-api-key: YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"transactionId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "amount": 20.00}'
```

The system locks the original transaction, totals earlier `refunded` and `pending` refunds, and rejects any refund that would exceed the remaining amount.

### Gateway-specific rules

| Case | Behavior |
|------|----------|
| Cash | Full refund only, recorded immediately as `refunded` (no gateway) |
| TSYS Sierra, not yet settled | A full refund is processed as a **void**; a partial refund returns `400` until the sale settles |
| Payliance ACH | Full refund only. A debit already sent to the bank (`processing`) can be refunded |
| Pacaso | Refund is requested from Pacaso by email and recorded as `refunded` with `responseCode: "REQUESTED"` |

---

## Look Up Refunds

There is no separate refunds resource — refunds are transactions.

### Get a Refund

```http
GET /api/v1/transactions/:id
```

```bash
curl https://uat.auxcore.net/api/v1/transactions/REF-1769614200000-9F8E7D6C \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
```

With an API key, use `GET /api/v1/public/transaction/:id`.

### List Refunds

```http
GET /api/v1/transactions
```

Send `type=refund` to return refunds only, e.g. `GET /api/v1/transactions?type=refund&page=1`.

### Query Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `type` | string | `refund` |
| `page` | integer | Page number (default: 1) |
| `limit` | integer | Items per page (default: 50) |
| `dateFrom` | date | Filter by start date |
| `dateTo` | date | Filter by end date |
| `status` | string | Filter by status (`refunded`, `declined`, `pending`) |

```bash
curl "https://uat.auxcore.net/api/v1/transactions?type=refund&dateFrom=2026-01-01&status=refunded" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
```

With an API key, use `GET /api/v1/public/transaction-list?type=refund`. The refunds for a given sale are the rows whose `originalTransactionId` equals the sale's `transactionId`; `GET /api/v1/transactions/:id` on the sale also returns its `refundedAmount`.

---

## Refund Statuses

| Status | Description |
|--------|-------------|
| `refunded` | Refund processed successfully |
| `declined` | Refund declined by the processor, or no gateway credentials were available |
| `pending` | Refund in progress (only while the request is being processed) |

---

## Declined Refunds (PRA)

When the gateway declines a refund, it is added to the **Refund Decline Queue**. Declines flagged by the gateway as Protective Refund Amount (PRA) declines are also handed to the PRA handling flow.

**Declined Response (201):**
```json
{
  "success": true,
  "data": {
    "transactionId": "REF-1769614200000-9F8E7D6C",
    "type": "refund",
    "status": "declined",
    "responseCode": "200",
    "responseText": "Transaction was declined by processor",
    "originalTransactionId": "TXN-1769605200000-A1B2C3D4"
  }
}
```

Each queue entry allows up to 3 retries, with exponential backoff between attempts. Retries run when the queue is processed (`POST /api/v1/refund-decline-queue/process-retries`, or `POST /api/v1/refund-decline-queue/:id/retry` for one entry) — no scheduler in AuxCORE triggers them on its own. A refund declined because no gateway credentials were available is not queued.

[Learn more about Refund Decline Queue →](../advanced/refund-queue.md)

---

## Refund via AuxCORE SDK

```http
POST /api/v1/auxvault/refund
```

```bash
curl -X POST https://uat.auxcore.net/api/v1/auxvault/refund \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"TransactionId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "Amount": 50.00}'
```

Omit `Amount` for a full refund. Returns `{ "success": true, "Status": 1, "TransactionId": "…", "Amount": 50, "message": "Refund processed" }`. `success` reflects only that the request was accepted — a gateway decline still returns `Status: 1`, so confirm the outcome by querying the transaction.

---

## Refund Timeline

| Event | Timeframe |
|-------|-----------|
| Refund initiated | Immediate |
| Customer sees refund | 3-5 business days (card) / 3-5 business days (ACH) |
| Settlement | Next settlement batch |

⏰ **Note:** While refunds are processed immediately by AuxCORE, it takes 3-5 business days for funds to appear in the customer's account.

---

## Webhooks

Refund events carry the refund transaction in `data` (`type: "refund"`, `originalTransactionId` set):

### transaction.refunded
```json
{
  "id": "0b6f3c2e-8a41-4d7e-9c55-2f1e7a9d3b64",
  "type": "transaction.refunded",
  "created": "2026-01-28T15:30:00.000Z",
  "data": {
    "transactionId": "REF-1769614200000-9F8E7D6C",
    "type": "refund",
    "status": "refunded",
    "amount": "150.75",
    "originalTransactionId": "TXN-1769605200000-A1B2C3D4",
    "merchantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  },
  "metadata": { "tenantId": "your-tenant-id", "merchantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }
}
```

### transaction.refund_failed
```json
{
  "id": "5d2a9e71-3c84-4f0b-a6e2-9b7c1d4e8f20",
  "type": "transaction.refund_failed",
  "created": "2026-01-28T15:30:00.000Z",
  "data": {
    "transactionId": "REF-1769614200000-9F8E7D6C",
    "type": "refund",
    "status": "declined",
    "amount": "150.75",
    "responseText": "Transaction was declined by processor",
    "originalTransactionId": "TXN-1769605200000-A1B2C3D4"
  },
  "metadata": { "tenantId": "your-tenant-id", "merchantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }
}
```

---

## Best Practices

### ✅ DO:
- **Verify transaction ID** before refunding
- **Check refundable amount** before partial refunds (`refundedAmount` on the sale)
- **Check `data.status`** on every refund response
- **Handle declined refunds** via the `transaction.refund_failed` webhook
- **Notify customers** when refund is processed
- **Track refund IDs** (`REF-…`) for reconciliation

### ❌ DON'T:
- **Don't refund more than original amount**
- **Don't refund voided transactions** (they were never charged)
- **Don't assume instant customer receipt**
- **Don't re-send a declined refund as a new refund** — retry it through the Refund Decline Queue instead

---

## Error Handling

Refund errors are returned as HTTP `400` with `error.code: "VALIDATION_ERROR"`, or `404` with `NOT_FOUND`.

### Transaction Not Refundable
```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Only approved or settled transactions can be refunded"
  }
}
```

### Refund Amount Exceeds Available
```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Refund amount ($50.00) exceeds remaining refundable amount ($20.00). Original: $100.00, previously refunded: $80.00."
  }
}
```

### Transaction Not Found
```json
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Transaction with id TXN-0000000000000-INVALID0 not found"
  }
}
```

Other messages you may see: `Valid refund amount is required` (partial refund without a positive `amount`), `Cash refunds support full refund only`, `Partial refunds on TSYS are only supported after settlement. …`, and `This transaction has already been lost to a chargeback and cannot be refunded. …`.

---

## Code Examples

### JavaScript
```javascript
async function processRefund(transactionId, amount = null) {
  const response = await fetch('https://uat.auxcore.net/api/v1/public/refund', {
    method: 'POST',
    headers: {
      'x-api-key': apiKey,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      transactionId,
      ...(amount && { amount })
    })
  });

  const result = await response.json();

  if (result.success && result.data.status === 'refunded') {
    console.log('Refund processed:', result.data.transactionId);
  } else if (result.success) {
    console.warn('Refund declined:', result.data.responseText);
  } else {
    console.error('Refund failed:', result.error);
  }

  return result;
}
```

### Python
```python
def process_refund(transaction_id, amount=None):
    payload = {'transactionId': transaction_id}
    if amount:
        payload['amount'] = amount

    response = requests.post(
        'https://uat.auxcore.net/api/v1/public/refund',
        headers={
            'x-api-key': api_key,
            'Content-Type': 'application/json'
        },
        json=payload
    )

    result = response.json()

    if result.get('success') and result['data']['status'] == 'refunded':
        print(f"Refund processed: {result['data']['transactionId']}")
    elif result.get('success'):
        print(f"Refund declined: {result['data'].get('responseText')}")
    else:
        print(f"Refund failed: {result['error']}")

    return result
```

---

## Next Steps

- [Handle declined refunds →](../advanced/refund-queue.md)
- [Set up refund webhooks →](../getting-started/webhooks.md)
- [View transactions →](./transactions.md)

---

**Need help?** Contact support@auxcore.net
