# Refunds

A **refund** is the return of crypto that was deposited for an off-ramp exchange but cannot be (or should not be) converted. Refunds are a partner-driven action: Spike emits signals about when a refund is needed, and the partner triggers the actual refund transaction by calling `POST /v1/exchanges/refund`.

This page describes when refunds are required, how to trigger one, the lifecycle the partner observes, and the address-resolution rules.

## When refunds are required

Spike emits `refund.required` when an off-ramp exchange that received funds enters a terminal state where conversion will not happen. Common triggers:

- The trade execution failed and no acceptable retry remains.
- The deposit was rejected after receipt (wrong network, below configured minimum).
- The exchange entered `FAILED` after a downstream phase failure (e.g. fiat-withdrawal failure carrying `failureReason: WITHDRAWAL_FAILED`).
- The exchange was `CANCELLED` after a deposit had already arrived.

`refund.required` is a **signal** to act. Spike does not auto-execute the refund — the partner must call `POST /v1/exchanges/refund` to initiate it.

The aggregate `offramp.failed` event that precedes `refund.required` carries `failureContext.failureReason` (see [Events and Webhooks — Failure context](events-and-webhooks.md#failure-context)). Branch on `failureReason` to decide policy: dust thresholds may be skipped, `DEPOSIT_REFUNDED` may already be in progress server-side, etc.

## Triggering a refund

`POST /v1/exchanges/refund` accepts an exchange identifier (`exchangeId` or `externalId`), the `amount`, and the **required** `refundAddress` (with `address` and `network`). There is no fallback to a previously-configured refund address — every refund request must carry a `refundAddress` explicitly. An optional `reason` can be supplied for partner-side bookkeeping.

The exchange must be in one of the **refundable states**:

- `FAILED`
- `CANCELLED`

A refund request against an exchange in any other state (e.g. `DEPOSIT_PENDING`, `PROCESSING`, `COMPLETED`, `AWAITING_AUTHORIZATION`) returns `409` with `code: REFUND_BLOCKED_STATE_CONFLICT`. A refund request against a `COMPLETED` exchange is not supported — the trade is final and reversing the conversion is a separate off-ramp in the opposite direction, not a refund.

Refunds are accepted **only for off-ramp exchanges**. Calling `POST /v1/exchanges/refund` against an on-ramp `exchangeId` returns `400` with a message indicating the operation is not supported. On-ramp failures do not produce a Spike refund — Spike never holds the partner-side fiat, so reversal is the partner's responsibility (see [On-ramp vs off-ramp](#on-ramp-vs-off-ramp)).

## Refund lifecycle

Each refund is an independent sub-resource on the exchange (`cryptoRefunds[]`) with its own status:

| Status | Meaning |
|---|---|
| `PENDING` | The refund record exists but has not yet been submitted to the network. |
| `INITIATED` | The refund withdrawal was submitted to the network. |
| `COMPLETED` | The refund was confirmed on-chain. `transactionHash`, `networkFee`, and `netAmount` are populated. |
| `FAILED` | The refund withdrawal failed. Resolution typically requires Spike support. |

`networkFee` and `netAmount` are null until the refund is broadcast. `netAmount` equals `amount − networkFee` and is the amount actually delivered to the refund address.

### Refund flow

```mermaid
flowchart TD
    A[off-ramp enters FAILED or CANCELLED with funds received] --> B[offramp.failed - failureContext]
    B --> C[refund.required]
    C --> D[Partner calls POST /v1/exchanges/refund]
    D --> E[refund.initiated]
    E --> F{Withdrawal confirmed?}
    F -- yes --> G[refund.completed]
    F -- no --> H[refund.failed]
    H --> I[Contact Spike support]
```

## Multiple refunds per exchange

A single exchange can produce more than one refund. Use this for:

1. **Partial refunds** — refund part of a deposited amount in installments.
2. **Re-attempt after failure** — a `FAILED` refund can be retried by issuing a fresh refund request once the underlying issue is resolved.

The available refundable amount is `total deposited − total in-flight or completed refunds`. A refund request that exceeds the remaining amount is rejected.

Multi-refund support is driven by the **`Idempotency-Key`** on the request:

- Same `Idempotency-Key` replay → returns the existing refund (`200/202`).
- Different `Idempotency-Key`, same exchange/amount/address → creates a new refund.

There is no automatic deduplication on `(amount, refundAddress)`. To dedupe a transport-level retry, send the same key. To create a deliberate second refund, use a fresh key. See [Idempotency](idempotency.md).

## Refund address

The refund address is **mandatory in every refund request body**. Spike validates it on receipt:

- `address` must be a valid format and checksum for `network`.
- `network` must match the deposit network of the original off-ramp deposit. A refund cannot be sent to a different network from the deposited asset.

A network mismatch between the request's `refundAddress.network` and the original deposit's network is rejected with `code: REFUND_NETWORK_MISMATCH`.

Storing a default refund address on the deposit address (via `POST /v1/deposit-addresses` or `PATCH /v1/deposit-addresses/{depositAddressId}`) is supported for operational ergonomics, but the `POST /v1/exchanges/refund` API still requires an explicit `refundAddress` in the request body. Treat the deposit-address-level refund address as a partner-side default to copy into the request — not as a server-side fallback.

## On-ramp vs off-ramp

The refund API supports off-ramp only.

- **Off-ramp**: `POST /v1/exchanges/refund` is the partner's mechanism to return crypto deposited against an off-ramp that cannot complete.
- **On-ramp**: there is no equivalent endpoint. When an on-ramp enters `FAILED` (trade failure, withdrawal failure) or the partner rejects authorization, Spike has not held partner-side fiat — the partner reverses the user-side fiat on their own rails. No `refund.*` event fires for an on-ramp failure.

## Common questions

**Can I issue a refund on an exchange that is still `PROCESSING`?**
No. Refundable states are `FAILED` and `CANCELLED` only. A request against any other state returns `409 REFUND_BLOCKED_STATE_CONFLICT`. To stop an in-flight off-ramp, cancel it first via `POST /v1/exchanges/cancel`; once it reaches `CANCELLED`, the refund request becomes valid.

**What happens to dust deposits?**
Deposits below configured minimums may be unrecoverable because the network fee to refund would exceed the deposit value. Spike still emits the failure signal; the partner can choose to skip the refund. Suppress these from customer-facing activity feeds. See [Failure handling](../operations/failure-handling.md).

**Are refund webhooks guaranteed in order?**
No. Treat each `refund.*` event as a hint and confirm state with `GET /v1/exchanges/by-id` or by inspecting `cryptoRefunds[]`. See [Events and Webhooks](events-and-webhooks.md).

**How long does a refund take?**
Refund timing is dominated by network confirmation time. Spike submits the withdrawal as soon as the refund is initiated; on-chain delivery follows the network's normal confirmation cadence.
