# Returns

Create and manage return requests: approve or deny them, buy the return shipping label, mark the package received, and refund the shopper.

A return is a shopper's request to send back items from one of your orders. Shoppers can open returns themselves in your ShipWave returns portal, and you can create them with this API. Both kinds appear in the same list and follow the same lifecycle.

**Scopes:** `GET` endpoints need `read:returns`. `POST` and `PUT` need `write:returns`. A **Shipping API** key (`read:*`, `write:*`) has both. See [Authentication](authentication.md#scopes).

Return responses can include fields that aren't listed on this page. Rely only on the documented fields; undocumented fields can change or be removed without notice.

Every endpoint sees only the returns and orders on your account. A return ID that belongs to another account returns `404 NOT_FOUND`, the same as an ID that doesn't exist.

These endpoints don't send [webhook events](webhooks.md). Poll [Get Return](#get-return) to follow a return.

## Return Lifecycle

```
pending ──approve──▶ approved ──label──▶ label_created ──delivered or receive──▶ received ──refund──▶ refunded
   │                                                                                     └──────────▶ no_action_needed ($0)
   └──deny──▶ denied
```

| Status | Meaning | How a return gets there |
|--------|---------|-------------------------|
| `pending` | Requested and waiting for your decision | [Create Return](#create-return), or the shopper submits it in your returns portal |
| `approved` | Approved, no label yet | [Approve Return](#approve-return) |
| `denied` | Rejected | [Deny Return](#deny-return) |
| `label_created` | A return label was purchased | [Create Return Label](#create-return-label) |
| `shipped` | The shopper shipped the package | Set automatically for returns synced from a connected marketplace |
| `received` | The package arrived | [Mark Return Received](#mark-return-received), or automatically when the carrier reports a ShipWave return label delivered |
| `refunded` | The refund was issued | [Refund Return](#refund-return) |
| `no_action_needed` | Closed with a $0 refund | [Refund Return](#refund-return) when nothing is owed after fees |
| `expired` | Closed with no refund because the return label went unused | Can be set automatically when the carrier still hasn't picked up the package 14 days after the label was purchased. ShipWave voids the unused label when the carrier allows it. |
| `cancelled` | Closed with no refund | ShipWave dashboard |
| `exchanged` | Resolved with an exchange instead of a refund | ShipWave dashboard |
| `invoice_created`, `invoice_sent` | The shopper was billed for an item that wasn't sent back | ShipWave dashboard, or automatically |

A delivered return label moves the return to `received` from `label_created` or `shipped`. If the label of an `expired` or `cancelled` return is delivered anyway, the return reopens as `received`. A delivery never refunds the shopper automatically. ShipWave emails you, and you refund with [Refund Return](#refund-return).

## Return Shipping and Labels

When you call [Create Return Label](#create-return-label), ShipWave rates the parcel from the shopper's address to your return warehouse, buys the cheapest available rate right away, and returns the label URLs. It doesn't email the label to the shopper. Send `labelUrl` (or the PDF or PNG version) to the shopper yourself. Calling it again for the same return returns the same label; it never buys a second one unless you ask it to replace the first.

The shopper doesn't pay anything when the label is created. If your return policy doesn't offer free returns, ShipWave records a return shipping fee on the return (`shippingFeeCharged`) when the label is purchased, and deducts it from the shopper's refund. International returns always carry a return shipping fee, even when your policy offers free returns. When the return ships free, the fee is `0`.

## Shopper Notifications

Creating, approving, denying, labeling, and receiving a return don't email the shopper. [Refund Return](#refund-return) emails the shopper a refund receipt when it issues a refund greater than $0 (except for Walmart returns, which Walmart refunds).

## The Return Object

[Get Return](#get-return) returns these fields. [List Returns](#list-returns) returns a subset.

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Return ID |
| `orderNumber` | string | Order number the return was opened against |
| `orderDate` | string \| null | ISO 8601 order date (Get Return only) |
| `customerEmail` | string | Shopper's email address |
| `customerName` | string \| null | Shopper's name |
| `status` | string | See [Return Lifecycle](#return-lifecycle) |
| `reason` | string | Return reason. The returns portal uses `wrong_item`, `defective`, `not_as_described`, `changed_mind`, and `other`. |
| `reasonDetail` | string \| null | The shopper's explanation |
| `items` | array | Items being returned, as they were submitted, each with a resolved `price` (below) |
| `refundAmount` | number \| null | Amount refunded. `null` until the return is refunded or closed. |
| `shippingFeeCharged` | number | Return shipping fee deducted (or to be deducted) from the refund |
| `restockingFeeCharged` | number | Restocking fee deducted from the refund. `0` until the refund is processed. |
| `returnTrackingNumber` | string \| null | Tracking number of the return label |
| `returnCarrier` | string \| null | Carrier of the return label |
| `returnLabelUrl` | string \| null | URL of the return label (Get Return only) |
| `adminNotes` | string \| null | Internal notes, kept as a log of timestamped lines (`[YYYY-MM-DD HH:MM:SS] ...`, UTC). Notes you send are added as `Note:` lines, and ShipWave adds its own lines when it refunds or closes a return. Nothing is ever replaced or removed. (Get Return only) |
| `denialReason` | string \| null | Reason given when the return was denied (Get Return only) |
| `requestedAt` | string | ISO 8601 time the return was requested |
| `approvedAt`, `deniedAt`, `shippedAt`, `receivedAt`, `refundedAt` | string \| null | ISO 8601 time of each lifecycle step |
| `labelCreatedAt` | string \| null | ISO 8601 time the return label was purchased (Get Return only) |
| `order` | object \| null | The linked order (Get Return only). `null` if the return isn't linked to an order. |
| `returnShipment` | object \| null | The return label's shipment (Get Return only). `null` before a label exists. |

**Item object:**

| Field | Type | Description |
|-------|------|-------------|
| `lineItemId` | string | Order line item ID, when it was provided |
| `sku` | string | SKU, when it was provided |
| `name` | string | Item name, when it was provided |
| `quantity` | integer | Quantity being returned |
| `price` | number | Unit price. A price stored with the item wins. Otherwise ShipWave uses the price the shopper paid for the matching order line (matched by `lineItemId`, then `sku`, then `name`), or `0` when nothing matches. |

Items created with this API store only these fields and an optional `reason`. Items from your returns portal or a connected marketplace can carry other fields.

---

## List Returns

```
GET /api/v1/returns
```

Returns a page of your returns, most recently requested first, plus a count of all your returns by status.

> **Response shape:** unlike other list endpoints, this one doesn't use the standard `data`/`meta` envelope. The page of returns is in `returns`, the paging info is in `pagination`, and the per-status totals are in `counts`. The `page` and `limit` parameters work as described in [Pagination](pagination.md).

### Query Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | integer | `1` | Page number (minimum 1) |
| `limit` | integer | `50` | Items per page (1–100; larger values are reduced to 100) |
| `status` | string | — | Only returns with this exact status. `all` or no value returns every status. An unknown status returns an empty list. |
| `search` | string | — | Case-insensitive partial match on order number, shopper email, or shopper name |

### Example

```bash
curl "https://shipwave.app/api/v1/returns?status=label_created&limit=25" \
  -H "Authorization: Bearer sw_live_abc123..."
```

```typescript
const res = await fetch(
  "https://shipwave.app/api/v1/returns?status=label_created&limit=25",
  { headers: { Authorization: "Bearer sw_live_abc123..." } }
);
const { returns, pagination, counts } = await res.json();
```

### Response

```json
{
  "returns": [
    {
      "id": "clx7ret1",
      "orderNumber": "#1042",
      "customerEmail": "jane@example.com",
      "customerName": "Jane Doe",
      "status": "label_created",
      "reason": "defective",
      "reasonDetail": "Left side stopped working after two days",
      "items": [
        {
          "lineItemId": "clx1item1",
          "sku": "WIDGET-001",
          "name": "Blue Widget",
          "quantity": 1,
          "price": 49.99
        }
      ],
      "refundAmount": null,
      "shippingFeeCharged": 0,
      "restockingFeeCharged": 0,
      "returnTrackingNumber": "9400111899223100012345",
      "returnCarrier": "USPS",
      "requestedAt": "2026-10-05T16:12:44.000Z",
      "approvedAt": "2026-10-05T18:02:10.000Z",
      "deniedAt": null,
      "shippedAt": null,
      "receivedAt": null,
      "refundedAt": null
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 25,
    "total": 1,
    "totalPages": 1
  },
  "counts": {
    "all": 14,
    "pending": 2,
    "approved": 1,
    "label_created": 3,
    "shipped": 0,
    "received": 1,
    "no_action_needed": 0,
    "expired": 1,
    "invoice_created": 0,
    "invoice_sent": 0,
    "refunded": 5,
    "denied": 1,
    "cancelled": 0,
    "exchanged": 0
  }
}
```

`pagination.total` counts the returns that match your filters. `counts` always covers every return on your account and ignores `status` and `search`. It has a key for every status in [Return Lifecycle](#return-lifecycle), and the per-status counts add up to `all`.

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Create Return

```
POST /api/v1/returns
```

Creates a return in `pending` status. ShipWave looks up the order by `orderNumber`, checks the request against your return window, and saves a copy of your return policy terms on the return. The restocking fee rate ShipWave later deducts comes from that copy, so changing your return policy doesn't re-price returns that are already open.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `orderNumber` | string | yes | The order's number. The leading `#` is optional and matching is case-insensitive, so `1042` and `#1042` both find order `#1042`. The return stores the order's own number. |
| `customerEmail` | string | yes | Shopper's email address (up to 320 characters) |
| `customerName` | string | yes | Shopper's name (up to 200 characters) |
| `reason` | string | yes | Return reason, for example `defective` (up to 500 characters). The returns portal uses `wrong_item`, `defective`, `not_as_described`, `changed_mind`, and `other`. |
| `reasonDetail` | string | no | The shopper's explanation (up to 5,000 characters) |
| `items` | array | no | Items being returned, up to 100 (defaults to an empty list). See **Item fields** below. |
| `itemCondition` | string | no | `unopened` or `opened`. Used only when your return policy charges different restocking fees for unopened and opened items; otherwise it's stored as `null`. |
| `returnWindowOverride` | object | no | Needed only when the order is past its return window and has no active override from [Reopen Return Window](#reopen-return-window). See below. |
| `adminNotes` | string | no | A first internal note (up to 4,000 characters), saved as a timestamped `Note:` line |

**Item fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `lineItemId` | string | one of these three | The order line's `id`. It must be a line on this order. |
| `sku` | string | one of these three | SKU |
| `name` | string | one of these three | Item name |
| `quantity` | integer | no | Whole number from 1 to 10,000 (default `1`) |
| `price` | number | no | Unit price, `0` or more. Leave it out to use the price the shopper paid for the matching order line. |
| `reason` | string | no | Reason for this item (up to 500 characters) |

Other item fields are dropped. Text fields can be up to 500 characters.

**`returnWindowOverride` object:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `confirmed` | boolean | yes | Must be `true` |
| `reason` | string | yes | Why you're accepting a late return (up to 1,000 characters are kept) |
| `reference` | string | no | Your own reference, such as a ticket number (up to 200 characters are kept) |

Things to know:

- **The order must exist on your account.** If no order matches `orderNumber`, the request fails with `404 NOT_FOUND` and no return is created.
- **Later steps depend on the items.** [Create Return Label](#create-return-label) needs each item's `sku` to match a product with a weight on file, and [Refund Return](#refund-return) needs a price above $0 for each item (sent with the item, or resolved from the order line).
- **The return window** is your return policy's window (30 days if you haven't set one). It starts on the order's latest carrier-confirmed delivery date, or the order date when there's no delivery, and counts calendar days: the last day ends at 11:59:59 PM in the `timeZone` shown in the response (currently `America/New_York` for every store). A return requested after that needs either an active override from [Reopen Return Window](#reopen-return-window) or a confirmed `returnWindowOverride`; otherwise the request fails with `409 CONFLICT`. An override is ignored for an order still inside its window.

### Example

```bash
curl -X POST https://shipwave.app/api/v1/returns \
  -H "Authorization: Bearer sw_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "orderNumber": "#1042",
    "customerEmail": "jane@example.com",
    "customerName": "Jane Doe",
    "reason": "defective",
    "reasonDetail": "Left side stopped working after two days",
    "items": [
      { "lineItemId": "clx1item1", "sku": "WIDGET-001", "name": "Blue Widget", "quantity": 1 }
    ],
    "itemCondition": "unopened"
  }'
```

```typescript
const res = await fetch("https://shipwave.app/api/v1/returns", {
  method: "POST",
  headers: {
    Authorization: "Bearer sw_live_abc123...",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    orderNumber: "#1042",
    customerEmail: "jane@example.com",
    customerName: "Jane Doe",
    reason: "defective",
    reasonDetail: "Left side stopped working after two days",
    items: [
      { lineItemId: "clx1item1", sku: "WIDGET-001", name: "Blue Widget", quantity: 1 },
    ],
    itemCondition: "unopened",
  }),
});
const { data } = await res.json(); // 201 Created
```

### Response (201 Created)

```json
{
  "data": {
    "id": "clx7ret1",
    "orderNumber": "#1042",
    "customerEmail": "jane@example.com",
    "customerName": "Jane Doe",
    "status": "pending",
    "reason": "defective",
    "reasonDetail": "Left side stopped working after two days",
    "items": [
      { "lineItemId": "clx1item1", "sku": "WIDGET-001", "name": "Blue Widget", "quantity": 1 }
    ],
    "itemCondition": "unopened",
    "restockingFeePercent": 10,
    "requestedAt": "2026-10-05T16:12:44.000Z",
    "returnWindow": {
      "deliveredAt": "2026-09-28T19:03:00.000Z",
      "returnDeadline": "2026-10-29T03:59:59.999Z",
      "returnDeadlineDate": "2026-10-28",
      "timeZone": "America/New_York",
      "returnWindowDays": 30,
      "daysOutsideWindow": 0,
      "exceptionApplied": false
    },
    "order": {
      "id": "clx1ord1",
      "orderNumber": "#1042",
      "totalValue": 54.43
    }
  }
}
```

| Field | Description |
|-------|-------------|
| `items` | The items as stored: the fields listed above, trimmed, with `quantity` filled in. Prices are resolved in [Get Return](#get-return). |
| `restockingFeePercent` | Restocking fee rate quoted for this return. With a condition-tiered policy, a return created without `itemCondition` is quoted at the opened rate. |
| `returnWindow.deliveredAt` | Delivery date the window started from, or `null` when it started from the order date |
| `returnWindow.returnDeadline` | Last moment a return is on time (ISO 8601) |
| `returnWindow.returnDeadlineDate` | Last on-time calendar day, `YYYY-MM-DD` in `timeZone` |
| `returnWindow.daysOutsideWindow` | Calendar days the request is past the deadline (`0` when on time) |
| `returnWindow.exceptionApplied` | `true` when the order was past its window and was accepted because of your `returnWindowOverride` or an active window override |

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | The body is missing, isn't valid JSON, or isn't a JSON object |
| 400 | `VALIDATION_ERROR` | A required field is missing or empty (for example `reason is required`), a field has the wrong type or is too long, or `customerEmail` isn't an email address |
| 400 | `VALIDATION_ERROR` | An item is invalid. The message names it, for example `items[0].quantity must be a whole number from 1 to 10000`, or a `lineItemId` that isn't on the order. |
| 400 | `VALIDATION_ERROR` | `itemCondition must be "unopened" or "opened"`, or `returnWindowOverride` isn't an object |
| 404 | `NOT_FOUND` | No order with this number on your account (`Order not found`) |
| 409 | `CONFLICT` | `orderNumber` matches more than one of your orders and none exactly. Send it exactly as the [Orders API](orders.md) returns it. `details.orderNumbers` lists the matches. |
| 409 | `CONFLICT` | The order is past its return window, has no active window override, and the body has no confirmed `returnWindowOverride` with a `reason`. `details` describes the window (example below). |

Return window conflict:

```json
{
  "error": {
    "code": "CONFLICT",
    "message": "This order is past the 30-day return window. To approve an exception, confirm the policy override and provide a reason.",
    "details": {
      "deliveredAt": "2026-08-20T17:45:00.000Z",
      "returnDeadline": "2026-09-20T03:59:59.999Z",
      "returnDeadlineDate": "2026-09-19",
      "timeZone": "America/New_York",
      "returnWindowDays": 30,
      "daysOutsideWindow": 16,
      "overrideRequired": true
    }
  }
}
```

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Get Return

```
GET /api/v1/returns/:id
```

Returns one return with its linked order and return shipment.

### Example

```bash
curl https://shipwave.app/api/v1/returns/clx7ret1 \
  -H "Authorization: Bearer sw_live_abc123..."
```

### Response

```json
{
  "data": {
    "id": "clx7ret1",
    "orderNumber": "#1042",
    "orderDate": "2026-09-24T14:31:07.000Z",
    "customerEmail": "jane@example.com",
    "customerName": "Jane Doe",
    "status": "label_created",
    "reason": "defective",
    "reasonDetail": "Left side stopped working after two days",
    "items": [
      {
        "lineItemId": "clx1item1",
        "sku": "WIDGET-001",
        "name": "Blue Widget",
        "quantity": 1,
        "price": 49.99
      }
    ],
    "refundAmount": null,
    "shippingFeeCharged": 0,
    "restockingFeeCharged": 0,
    "returnTrackingNumber": "9400111899223100012345",
    "returnCarrier": "USPS",
    "returnLabelUrl": "https://files.example.com/labels/clx7ret1.png",
    "adminNotes": "[2026-10-05 18:02:10] Note: Approved: defect confirmed from photos",
    "denialReason": null,
    "requestedAt": "2026-10-05T16:12:44.000Z",
    "approvedAt": "2026-10-05T18:02:10.000Z",
    "deniedAt": null,
    "labelCreatedAt": "2026-10-05T18:04:31.000Z",
    "shippedAt": null,
    "receivedAt": null,
    "refundedAt": null,
    "order": {
      "id": "clx1ord1",
      "orderNumber": "#1042",
      "totalValue": 54.43,
      "lineItems": [
        {
          "id": "clx1item1",
          "sku": "WIDGET-001",
          "name": "Blue Widget",
          "quantity": 1,
          "weight": 16,
          "price": 49.99,
          "imageUrl": "https://cdn.shopify.com/s/files/1/widget-blue.jpg"
        }
      ]
    },
    "returnShipment": {
      "id": "clx2ship9",
      "trackingCode": "9400111899223100012345",
      "trackingStatus": "pre_transit",
      "carrier": "USPS",
      "service": "GroundAdvantage"
    }
  }
}
```

`order.lineItems` lists every line on the order, not only the returned items, with the same fields as order lines in the [Orders API](orders.md). `returnShipment.id` is a shipment ID you can use with the [Shipments API](shipments.md).

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 404 | `NOT_FOUND` | No return with this ID on your account (`Return request not found`). A return that belongs to another account also returns 404. |

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Add Return Note

```
PUT /api/v1/returns/:id
```

Adds a note to the return's internal notes, in any status. Notes are a log: your note is appended as a new timestamped `Note:` line, and existing notes, including the entries ShipWave writes when it refunds a return, are never replaced or removed. A note identical to the last line isn't added twice.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `adminNotes` | string | no | The note to add (up to 4,000 characters). An empty string or a missing field adds nothing and returns the current notes. |

Other fields are ignored, except `refundAmount`, which is rejected. Use [Refund Return](#refund-return) to refund. Notes can't be cleared, so `null` is rejected.

### Example

```bash
curl -X PUT https://shipwave.app/api/v1/returns/clx7ret1 \
  -H "Authorization: Bearer sw_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{ "adminNotes": "Shopper sent photos of the defect" }'
```

### Response

```json
{
  "data": {
    "id": "clx7ret1",
    "adminNotes": "[2026-10-05 16:20:03] Note: Shopper sent photos of the defect",
    "refundAmount": null
  }
}
```

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | The body is missing, isn't valid JSON, or isn't a JSON object |
| 400 | `VALIDATION_ERROR` | The body includes `refundAmount` (`refundAmount is read-only. Process refunds through POST /api/v1/returns/:id/refund.`) |
| 400 | `VALIDATION_ERROR` | `adminNotes` isn't a string, or is longer than 4,000 characters |
| 404 | `NOT_FOUND` | No return with this ID on your account (`Return request not found`) |
| 409 | `CONFLICT` | The notes kept changing while your note was being added. Fetch the return and try again. |

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Approve Return

```
POST /api/v1/returns/:id/approve
```

Approves a `pending` return and moves it to `approved`. Approving doesn't buy a label or set the refund amount.

If the return was requested after its return window closed and no exception was recorded when it was created, you need to confirm one now with `returnWindowOverride`, unless the order has an active override from [Reopen Return Window](#reopen-return-window). The window is checked in the timezone recorded when the return was created.

Only a return that is still `pending` is approved. If another request approves or denies it first, you get `409 CONFLICT`.

### Request Body

All fields are optional, and the body can be empty.

| Field | Type | Description |
|-------|------|-------------|
| `adminNotes` | string | A note to add to the return's internal notes (see [Add Return Note](#add-return-note)) |
| `returnWindowOverride` | object | `{ "confirmed": true, "reason": "...", "reference": "..." }`, as in [Create Return](#create-return). Needed only for a late return without a recorded exception or an active window override. |

A body that includes `refundAmount` is rejected.

### Example

```bash
curl -X POST https://shipwave.app/api/v1/returns/clx7ret1/approve \
  -H "Authorization: Bearer sw_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{ "adminNotes": "Approved: defect confirmed from photos" }'
```

### Response

```json
{
  "data": {
    "id": "clx7ret1",
    "status": "approved",
    "approvedAt": "2026-10-05T18:02:10.000Z",
    "refundAmount": null
  }
}
```

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | The body isn't valid JSON or isn't a JSON object |
| 400 | `VALIDATION_ERROR` | The body includes `refundAmount` (`refundAmount is read-only. Approval does not set the eventual refund amount.`) |
| 400 | `VALIDATION_ERROR` | `adminNotes` isn't a string or is too long, or `returnWindowOverride` isn't an object |
| 400 | `VALIDATION_ERROR` | The return isn't `pending` (`Cannot approve return with status: approved`) |
| 404 | `NOT_FOUND` | No return with this ID on your account (`Return request not found`) |
| 409 | `CONFLICT` | The return was requested after its window closed, no exception or active window override was recorded, and the body has no confirmed `returnWindowOverride` with a `reason`. `details` has the same fields as the [Create Return](#create-return) conflict. |
| 409 | `CONFLICT` | Another request changed the return while it was being approved (for example, it was denied). `details.status` is its current status. |

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Deny Return

```
POST /api/v1/returns/:id/deny
```

Denies a `pending` return and moves it to `denied`. The shopper isn't notified. If another request approves or denies the return first, you get `409 CONFLICT`.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `denialReason` | string | yes | Why the return was denied (up to 2,000 characters) |
| `adminNotes` | string | no | A note to add to the return's internal notes (see [Add Return Note](#add-return-note)) |

### Example

```bash
curl -X POST https://shipwave.app/api/v1/returns/clx7ret1/deny \
  -H "Authorization: Bearer sw_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{ "denialReason": "Item was used and is outside the return policy" }'
```

### Response

```json
{
  "data": {
    "id": "clx7ret1",
    "status": "denied",
    "deniedAt": "2026-10-05T18:02:10.000Z",
    "denialReason": "Item was used and is outside the return policy"
  }
}
```

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | The body is missing, isn't valid JSON, or isn't a JSON object |
| 400 | `VALIDATION_ERROR` | `denialReason is required` (missing or empty), or `denialReason` isn't a string or is longer than 2,000 characters. This is checked before the return is looked up. |
| 400 | `VALIDATION_ERROR` | `adminNotes` isn't a string or is too long |
| 400 | `VALIDATION_ERROR` | The return isn't `pending` (`Cannot deny return with status: approved`) |
| 404 | `NOT_FOUND` | No return with this ID on your account (`Return request not found`) |
| 409 | `CONFLICT` | Another request changed the return while it was being denied (for example, it was approved). `details.status` is its current status. |

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Create Return Label

```
POST /api/v1/returns/:id/label
```

Buys a prepaid 4×6 return label from the shopper to your return warehouse and moves the return to `label_created`. The return must be `approved` (or already `label_created`, below) and linked to an order. See [Return Shipping and Labels](#return-shipping-and-labels) for how the label is paid for and delivered.

How ShipWave builds the shipment:

- **From:** `customerAddress` if you send it. Otherwise the ship-to address of the order's first shipment, with the return's `customerName`. If your `customerAddress` has no `phone`, ShipWave uses the phone on the order's shipping address. When the shopper has no phone at all, your return warehouse's phone goes on the label instead, because carriers such as FedEx require one.
- **To:** your default warehouse, if it has a complete address. Otherwise the first warehouse, alphabetically by name, that has one. The label always goes to one of your own warehouses; you can't choose another destination.
- **Parcel:** the weight and dimensions you send. Otherwise the box the shopper described in the returns portal, then the catalog weight and dimensions of the returned items. Every returned item that requires shipping must have a SKU that matches a product with a weight on file, even when you send `parcelWeight`.
- **Rate:** the cheapest available rate. FedEx is skipped when either phone number isn't a valid US or Canada number. International returns include customs paperwork, which needs an HS code, country of origin, weight, and value for every item (see [Products](products.md)).

Calling this endpoint again is safe:

- **The return already has a label** (`label_created`): you get that label back with `idempotent: true`, and nothing is bought. `labelPdfUrl` and `labelPngUrl` are `null` and `allRates` is empty in that response.
- **To replace the label,** send `replaceExistingLabel: true`. ShipWave voids the current label with the carrier first and then buys a new one. If the carrier won't void it (for example, the package is already moving), the request fails with `409 CONFLICT` and nothing is bought.
- **Two requests at once:** only one buys a label. The other gets `409 CONFLICT`.

### Request Body

All fields are optional, and the body can be empty.

| Field | Type | Description |
|-------|------|-------------|
| `customerAddress` | object | Address the shopper ships from (below) |
| `parcelWeight` | number | Parcel weight in ounces |
| `parcelLength` | number | Parcel length in inches |
| `parcelWidth` | number | Parcel width in inches |
| `parcelHeight` | number | Parcel height in inches |
| `replaceExistingLabel` | boolean | `true` to void the return's current label and buy a new one. Ignored for an `approved` return. |

Parcel values must be positive numbers; `0` is ignored.

**`customerAddress` object:**

| Field | Type | Required |
|-------|------|----------|
| `name` | string | no |
| `street1` | string | yes |
| `street2` | string | no |
| `city` | string | yes |
| `state` | string | yes |
| `zip` | string | yes |
| `country` | string | no (defaults to `US`) |
| `phone` | string | no |

When you send `customerAddress`, it must include `street1`, `city`, `state`, and `zip`, and every field must be a string. The carrier validates the address itself when it rates the shipment.

### Example

```bash
curl -X POST https://shipwave.app/api/v1/returns/clx7ret1/label \
  -H "Authorization: Bearer sw_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "customerAddress": {
      "name": "Jane Doe",
      "street1": "742 Evergreen Ter",
      "city": "Springfield",
      "state": "OR",
      "zip": "97477",
      "country": "US",
      "phone": "541-555-0123"
    }
  }'
```

### Response

This example is for a store whose return policy offers free returns, so `shippingFee` is `0`:

```json
{
  "data": {
    "label": {
      "trackingNumber": "9400111899223100012345",
      "carrier": "USPS",
      "service": "GroundAdvantage",
      "labelUrl": "https://files.example.com/labels/clx7ret1.png",
      "labelPdfUrl": null,
      "labelPngUrl": "https://files.example.com/labels/clx7ret1.png",
      "shippingFee": 0,
      "isInternational": false,
      "returnedGoodsTreatment": null,
      "customerPaysReturnShipping": false,
      "originalShippingRefundable": false,
      "customsFormUrl": null,
      "customsFormType": null,
      "customsFormSubmittedElectronically": null
    },
    "allRates": [
      {
        "carrier": "USPS",
        "service": "GroundAdvantage",
        "deliveryDays": 3
      },
      {
        "carrier": "UPS",
        "service": "Ground",
        "deliveryDays": 4
      }
    ],
    "idempotent": false,
    "replacedLabel": null
  }
}
```

| Field | Description |
|-------|-------------|
| `label.labelUrl` | Label to send to the shopper |
| `label.labelPdfUrl` | The label as a PDF, when the carrier provided one. Otherwise `null`. |
| `label.labelPngUrl` | The label as a PNG (the same file as `labelUrl` when it is a PNG). Otherwise `null`. |
| `label.shippingFee` | Return shipping fee saved on the return as `shippingFeeCharged` and deducted from the shopper's refund. `0` when the return ships free. If you already changed the return's shipping fee in the ShipWave dashboard, that amount is kept for a domestic return. |
| `label.customerPaysReturnShipping` | `true` when the shopper bears return shipping: your policy doesn't offer free returns, or the return is international |
| `label.originalShippingRefundable` | Always `false`. Refunds cover the returned items and their tax, not the original shipping charge. |
| `label.isInternational` | `true` when the shopper's country differs from your warehouse's |
| `label.returnedGoodsTreatment` | For an international return of goods originally shipped from the US, the returned-goods customs declaration ShipWave filed. Otherwise `null`. |
| `label.customsFormUrl`, `customsFormType`, `customsFormSubmittedElectronically` | The commercial invoice for an international return, when the carrier produced one. `null` for domestic returns. |
| `allRates` | The carrier services that quoted this return, cheapest first. ShipWave bought the first one it could use (FedEx is skipped when a phone number isn't a valid US or Canada number). |
| `idempotent` | `true` when the return already had a label and this response returns it without buying another |
| `replacedLabel` | With `replaceExistingLabel: true`, the label that was voided: `trackingNumber` and the carrier's `voidStatus`. Otherwise `null`. |

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | The body isn't valid JSON or isn't a JSON object, `customerAddress` is incomplete or not an object, a parcel value isn't a positive number, or `replaceExistingLabel` isn't a boolean |
| 400 | `VALIDATION_ERROR` | The return isn't `approved` or `label_created` (`Cannot create label for return with status: pending`) |
| 400 | `VALIDATION_ERROR` | `No warehouse configured for returns`: none of your warehouses has a complete address |
| 400 | `VALIDATION_ERROR` | `Customer address not found. Please provide address manually.`: no `customerAddress` and no shipping address on the order |
| 400 | `VALIDATION_ERROR` | `Return request is not linked to an order` |
| 400 | `VALIDATION_ERROR` | A returned item has no weight on file, or a product that needs catalog dimensions has none |
| 400 | `VALIDATION_ERROR` | `Cannot generate label. Missing weight data.` |
| 400 | `VALIDATION_ERROR` | `No shipping rates available for this return` |
| 400 | `VALIDATION_ERROR` | FedEx is the only usable rate but a phone number is missing or isn't a valid US or Canada number (the message says which side) |
| 400 | `VALIDATION_ERROR` | The shopper has no phone number and your return warehouse address has none to use instead. Add a phone to either and try again. |
| 400 | `VALIDATION_ERROR` | An international return is missing customs data. The message lists what's missing, for example an item's HS code or country of origin. |
| 400 | `VALIDATION_ERROR` | The carrier rejected the shipment or the purchase, for example an address it can't verify. The message includes the carrier's reason. |
| 404 | `NOT_FOUND` | No return with this ID on your account (`Return request not found`) |
| 409 | `CONFLICT` | The return shares one label with another return. Manage that label in the ShipWave dashboard. |
| 409 | `CONFLICT` | Another label request for this return is in progress, or an earlier purchase didn't finish. Fetch the return before trying again. |
| 409 | `CONFLICT` | `replaceExistingLabel` was sent but the current label couldn't be voided. Nothing was bought. |
| 502 | `INTERNAL_ERROR` | The carrier service couldn't rate or buy the label right now, or a foreign-currency rate couldn't be converted to USD. Nothing was bought; try again shortly. |

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Mark Return Received

```
POST /api/v1/returns/:id/receive
```

Marks a `label_created` or `shipped` return as `received`. Returns that use a ShipWave label are usually marked received automatically when the carrier reports delivery, so you mostly need this for packages that arrive before tracking updates. Receiving doesn't refund the shopper.

### Request Body

The body can be empty.

| Field | Type | Description |
|-------|------|-------------|
| `adminNotes` | string | A note to add to the return's internal notes (see [Add Return Note](#add-return-note)) |

### Example

```bash
curl -X POST https://shipwave.app/api/v1/returns/clx7ret1/receive \
  -H "Authorization: Bearer sw_live_abc123..."
```

### Response

```json
{
  "data": {
    "id": "clx7ret1",
    "status": "received",
    "receivedAt": "2026-10-09T15:40:22.000Z"
  }
}
```

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | The body isn't valid JSON or isn't a JSON object, or `adminNotes` isn't a string or is too long |
| 400 | `VALIDATION_ERROR` | The return isn't `label_created` or `shipped` (`Cannot mark as received with status: approved`) |
| 404 | `NOT_FOUND` | No return with this ID on your account (`Return request not found`) |
| 409 | `CONFLICT` | The return changed while it was being marked received, for example because the carrier's delivery scan received it first. `details.status` is its current status. |

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Refund Return

```
POST /api/v1/returns/:id/refund
```

Refunds a `received` return. ShipWave calculates the amount; you can't send one.

```
refund = returned items + sales tax on those items − restocking fee − return shipping fee
```

- **Returned items** are valued at the price the shopper paid, after discounts.
- **Sales tax** on the returned items is confirmed against the Shopify order.
- **Restocking fee** is a percentage of the items plus tax, at the rate saved on the return when it was created, so a later policy change doesn't re-price it. If your policy charges different rates for unopened and opened items, the item condition picks the saved rate, and a return with no condition recorded uses the opened rate. Only an older return with no saved rate uses your current policy rate. A restocking fee you already changed in the ShipWave dashboard is kept. Before ShipWave deducts a restocking fee, the return needs an inspection recorded in the ShipWave dashboard, or you must confirm skipping it with `inspectionSkip`.
- **Return shipping fee** is the fee recorded on the return (`shippingFeeCharged`), normally set when the return label was purchased.

A refund above $0 is issued on the linked Shopify order to the original payment method, the return moves to `refunded`, and ShipWave emails the shopper a receipt under your store name (your return policy's merchant name). When nothing is owed after fees, no refund is issued, no email is sent, and the return moves to `no_action_needed`.

Returns from a connected Walmart channel are refunded through Walmart for the returned items' amount, with no fees deducted. `feeOverride` and `inspectionSkip` are ignored for them, and the response includes a `walmartRefund` object.

Each return can be refunded once, and retrying is safe:

- **After a refund succeeded,** a retry returns the stored result with `idempotent: true`, and the shopper isn't refunded again.
- **After Shopify rejected a refund** (`422`, `Shopify refund failed: ...`), fix the cause and call again. Before retrying, ShipWave checks the order's refunds in Shopify. If the failed attempt made no refund, it refunds now. If Shopify shows that the attempt did refund the shopper, ShipWave records that refund and closes the return without refunding twice (`idempotent: true`). If Shopify shows a refund ShipWave can't match to the attempt, nothing is refunded and you get `409 CONFLICT`; contact ShipWave support to reconcile the return.
- **While an attempt is still in progress,** other calls return `409 CONFLICT`. Wait and fetch the return before trying again.

### Request Body

All fields are optional, and the body can be empty.

| Field | Type | Description |
|-------|------|-------------|
| `adminNotes` | string | A note to add to the return's internal notes (up to 4,000 characters), before the refund entry ShipWave adds |
| `feeOverride` | object | Changes the restocking fee, the return shipping fee, or both (below) |
| `inspectionSkip` | object | Confirms refunding without a recorded inspection (below). Needed only when a restocking fee applies. |

Bodies that include `finalRefundAmount`, `refundAmount`, or `amount` are rejected.

**`feeOverride` object:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `confirmed` | boolean | yes | Must be `true` |
| `reason` | string | yes | Why you're changing the fees (up to 1,000 characters are kept) |
| `restockingFee` | number | one of these two | Restocking fee to deduct, in dollars (`0` or more) |
| `shippingFee` | number | one of these two | Return shipping fee to deduct, in dollars (`0` or more). For an international return it can't be lower than the cost of the purchased label. |

Fees can't add up to more than the returned items plus tax. Fee overrides aren't allowed on a return-first exchange from the returns portal, where the refund is only the price difference owed to the shopper.

**`inspectionSkip` object:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `confirmed` | boolean | yes | Must be `true` |
| `reason` | string | yes | Why no inspection was recorded (up to 1,000 characters are kept) |

### Example

```bash
curl -X POST https://shipwave.app/api/v1/returns/clx7ret1/refund \
  -H "Authorization: Bearer sw_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "inspectionSkip": {
      "confirmed": true,
      "reason": "Checked on arrival; box unopened"
    }
  }'
```

### Response

```json
{
  "data": {
    "id": "clx7ret1",
    "status": "refunded",
    "refundedAt": "2026-10-09T16:05:12.000Z",
    "itemsSubtotal": 49.99,
    "itemsTax": 4.44,
    "defaultShippingFee": 0,
    "defaultRestockingFee": 5.44,
    "shippingFeeDeducted": 0,
    "restockingFeeDeducted": 5.44,
    "finalRefundAmount": 48.99,
    "feeOverrideApplied": false,
    "inspectionSkipped": true,
    "shopifyRefundId": "1029384756",
    "refundReceiptEmailSent": true,
    "dutyRefund": null,
    "idempotent": false
  }
}
```

| Field | Description |
|-------|-------------|
| `status` | `refunded`, or `no_action_needed` when the refund is $0 |
| `refundedAt` | ISO 8601 refund time. `null` for `no_action_needed`. |
| `itemsSubtotal` | Returned items at the price paid |
| `itemsTax` | Sales tax on the returned items |
| `defaultShippingFee`, `defaultRestockingFee` | Fees ShipWave would deduct without a `feeOverride` |
| `shippingFeeDeducted`, `restockingFeeDeducted` | Fees actually deducted |
| `finalRefundAmount` | Amount refunded to the shopper |
| `feeOverrideApplied` | `true` when your `feeOverride` was used |
| `inspectionSkipped` | `true` when the refund went ahead without a recorded inspection |
| `shopifyRefundId` | ID of the refund on the Shopify order. Omitted when no Shopify refund was made. |
| `refundReceiptEmailSent` | Whether the receipt email reached the mail server |
| `dutyRefund` | For orders where duties and import taxes were collected at checkout, the result of refunding them: an object whose `mode` is `refunded`, `skipped`, or `failed`. `null` otherwise. |
| `idempotent` | `true` when this response replays an earlier successful refund |

A replayed response (`idempotent: true`) has the same fields as the first one. A Walmart refund response doesn't include `itemsTax` or `inspectionSkipped`.

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | The body isn't valid JSON or isn't a JSON object, or includes `finalRefundAmount`, `refundAmount`, or `amount` |
| 400 | `VALIDATION_ERROR` | `adminNotes` isn't a string or is longer than 4,000 characters |
| 400 | `VALIDATION_ERROR` | `feeOverride` or `inspectionSkip` isn't an object, a fee isn't a number of `0` or more, or `feeOverride` changes neither fee |
| 400 | `VALIDATION_ERROR` | The fees add up to more than the returned items plus tax, or an international return's shipping fee is below the label cost |
| 404 | `NOT_FOUND` | No return with this ID on your account (`Return request not found`) |
| 409 | `CONFLICT` | The return isn't `received` (`Cannot refund return with status: label_created. Must be "received" first.`) |
| 409 | `CONFLICT` | `feeOverride` or `inspectionSkip` is missing `confirmed: true` or a `reason` |
| 409 | `CONFLICT` | A restocking fee applies, no inspection is recorded, and there's no `inspectionSkip` |
| 409 | `CONFLICT` | The return already has an exchange or replacement order and can't also be refunded |
| 409 | `CONFLICT` | The calculated refund is more than the order's remaining refundable balance (after earlier refunds and lost chargebacks) |
| 409 | `CONFLICT` | An earlier refund attempt for this return is still in progress, or another request is retrying it |
| 409 | `CONFLICT` | An earlier attempt failed and Shopify shows a refund on the order that ShipWave can't match to it. Nothing was refunded. Contact ShipWave support. |
| 409 | `CONFLICT` | Shopify issued the refund but ShipWave couldn't finish recording it. Don't retry; contact ShipWave support. |
| 409 | `CONFLICT` | A return-first exchange has no price difference owed to the shopper, or a `feeOverride` was sent for one |
| 409 | `CONFLICT` | A Walmart return isn't linked to its channel, or the channel is disconnected |
| 422 | `VALIDATION_ERROR` | The return has no items, or an item has a missing or $0 price (other than a line that was discounted 100%) or a quantity that isn't a positive whole number |
| 422 | `VALIDATION_ERROR` | Sales tax couldn't be confirmed with Shopify, so the refund was held |
| 422 | `VALIDATION_ERROR` | A refund above $0 needs a linked Shopify order, and the return has none (or the store connection is incomplete) |
| 422 | `VALIDATION_ERROR` | A returned item couldn't be matched to a Shopify order line, so nothing was refunded |
| 422 | `VALIDATION_ERROR` | Shopify rejected the refund (`Shopify refund failed: ...`) |
| 502 | `INTERNAL_ERROR` | Walmart rejected the refund, or ShipWave couldn't reach Shopify to check an earlier failed attempt. Nothing was refunded. |
| 500 | `INTERNAL_ERROR` | Unexpected error (`Failed to process refund`) |

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Get Return Window

```
GET /api/v1/returns/window-overrides?orderNumber=:orderNumber
```

Shows one order's return window, any active window override, any open return, and your returns portal link. Use it before [Reopen Return Window](#reopen-return-window).

### Query Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `orderNumber` | string | yes | Order number. The leading `#` is optional and matching is case-insensitive. |

### Example

```bash
curl "https://shipwave.app/api/v1/returns/window-overrides?orderNumber=1007" \
  -H "Authorization: Bearer sw_live_abc123..."
```

### Response

```json
{
  "data": {
    "order": {
      "id": "clx1ord2",
      "orderNumber": "#1007"
    },
    "returnWindow": {
      "returnDeadline": "2026-09-20T03:59:59.999Z",
      "returnDeadlineDate": "2026-09-19",
      "isOutsideWindow": true,
      "daysOutsideWindow": 16
    },
    "activeOverride": null,
    "openReturn": null,
    "portalUrl": "https://shipwave.app/returns/acme"
  }
}
```

| Field | Description |
|-------|-------------|
| `returnWindow` | The order's window as of now, calculated as in [Create Return](#create-return). `null` if the order has no dates to start from. |
| `activeOverride` | The unexpired override with the latest expiry: `id`, `grantedAt`, `expiresAt`, `reason`, `reference`. `null` when there's none. |
| `openReturn` | `{ "id": "...", "status": "..." }` for a return on this order that isn't `denied`, `refunded`, `no_action_needed`, or `cancelled`. `null` when there's none. |
| `portalUrl` | Your returns portal, or `null` if you haven't set one up |

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | `orderNumber is required` |
| 404 | `NOT_FOUND` | No order with this number on your account (`Order not found`) |

For `401`, `403`, and `429` responses, see [Errors](errors.md).

---

## Reopen Return Window

```
POST /api/v1/returns/window-overrides
```

Reopens your returns portal for one order whose return window has closed, so the shopper can submit the return themselves. The override lasts through the end of the 14th calendar day after you grant it. While it's active, [Create Return](#create-return) and [Approve Return](#approve-return) accept the order's late return through the API too, without a `returnWindowOverride`.

- **Already reopened:** if the order has an active override, you get that override back (`200 OK`, `alreadyActive: true`) and no new one is created.
- **Still inside its window:** the shopper can already use the portal, so no override is created and you get `409 CONFLICT` with the window's state in `details`.

### Request Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `orderNumber` | string | yes | Order number. The leading `#` is optional and matching is case-insensitive. |
| `reason` | string | yes | Why you're reopening the window: at least 5 characters (up to 1,000 are kept) |
| `reference` | string | no | Your own reference, such as a ticket number (up to 200 characters are kept) |

### Example

```bash
curl -X POST https://shipwave.app/api/v1/returns/window-overrides \
  -H "Authorization: Bearer sw_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "orderNumber": "#1007",
    "reason": "Defective unit reported after the window closed",
    "reference": "Ticket 4821"
  }'
```

### Response (201 Created)

The same fields as [Get Return Window](#get-return-window), plus `alreadyActive` and `granted`. When the order already had an active override, the status is `200 OK`, `alreadyActive` is `true`, and `granted` describes that existing override.

```json
{
  "data": {
    "order": {
      "id": "clx1ord2",
      "orderNumber": "#1007"
    },
    "returnWindow": {
      "returnDeadline": "2026-09-20T03:59:59.999Z",
      "returnDeadlineDate": "2026-09-19",
      "isOutsideWindow": true,
      "daysOutsideWindow": 16
    },
    "activeOverride": {
      "id": "clx8ovr1",
      "grantedAt": "2026-10-05T16:30:00.000Z",
      "expiresAt": "2026-10-20T03:59:59.999Z",
      "reason": "Defective unit reported after the window closed",
      "reference": "Ticket 4821"
    },
    "openReturn": null,
    "portalUrl": "https://shipwave.app/returns/acme",
    "alreadyActive": false,
    "granted": {
      "id": "clx8ovr1",
      "grantedAt": "2026-10-05T16:30:00.000Z",
      "expiresAt": "2026-10-20T03:59:59.999Z",
      "days": 14
    }
  }
}
```

### Errors

| Status | Code | Cause |
|--------|------|-------|
| 400 | `VALIDATION_ERROR` | The body is missing, isn't valid JSON, or isn't a JSON object |
| 400 | `VALIDATION_ERROR` | `orderNumber is required` |
| 400 | `VALIDATION_ERROR` | `A reason of at least 5 characters is required` |
| 404 | `NOT_FOUND` | No order with this number on your account (`Order not found`) |
| 409 | `CONFLICT` | The order is still inside its return window, so no override is needed. `details` has the same fields as [Get Return Window](#get-return-window). |

For `401`, `403`, and `429` responses, see [Errors](errors.md).
