# Payments API v1 → v2 integration upgrade guide (/api/rest/integration/payments-api/v1-v2-migration)



Upgrade your existing PayPal Payments API v1 integration to v2.

In v1, the Payments API was a single server-side API for creating a payment and then capturing or authorizing it. Payments v2 splits that lifecycle into two purpose-built APIs, one for checkout and one for post-approval processing. Don't assume an operation is equivalent just because it shares a name with its v1 counterpart.

The Payments v1 API is deprecated and will be removed. If you currently use v1, upgrading to v2 provides full fee transparency on every capture, support for orders with multiple payees, webhook notifications in place of IPN, and a saved payment method option through Vault. Follow this migration guide to update your endpoint URLs and request and response field shapes, and adapt to new operation semantics, status values, and notification mechanisms for v2.

**Audience:** This guide is for developers and integration engineers migrating an existing Payments v1 integration to Orders v2 and Payments v2. It assumes familiarity with the Payments API, OAuth 2.0, and your own codebase's current v1 implementation.

## Migration snapshot [#migration-snapshot]

* **Effort:** Tier 1, structural refactor, plus 3 gaps to resolve first.
* **Auth changes:** None for standard integrations. Partner-facilitated captures and refunds require a new `PayPal-Auth-Assertion` header.
* **Platform enrollment:** None required for most integrations. `payment_instruction.platform_fees` requires PayPal Complete Payments Platform approval.
* **Endpoints migrated:** 19 operations.

> **Note:** Payments v1 was a single API covering the entire payment lifecycle. Version 2 splits that lifecycle across two purpose-built APIs:
>
> * **Orders v2** (`/v2/checkout/orders`) handles the checkout lifecycle: create, update, approve, and the initial capture or authorize.
> * **Payments v2** (`/v2/payments`) handles post-approval operations: query, capture, void, reauthorize, and refund.

## Prerequisites [#prerequisites]

* An existing Payments v1 integration to audit and migrate.
* OAuth 2.0 client credentials for both sandbox and live. Sandbox credentials do not work against live, and the two need to be configured separately.
* Sandbox buyer and seller accounts, created at developer.paypal.com.
* Use `api-m.sandbox.paypal.com` as the base URL for all testing before go-live.
* PayPal Complete Payments Platform enrollment, only if your integration uses `payment_instruction.platform_fees` or `payment_instruction.disbursement_mode`. Most integrations do not need this.

## Resolve three gaps before you begin [#resolve-three-gaps-before-you-begin]

1. **List Payments has no v2 equivalent.** `GET /v1/payments/payment` does not exist in Orders v2 or Payments v2. Migrate to the Transaction Search API. This is the highest-effort gap.
2. **`intent: order` (AS2 flow) is limited release.** The three-party delayed auth pattern requires Account Manager enablement in v2. The standard migration path does not apply.
3. **Write operations return minimal responses by default.** V2 `capture`, `authorize`, `void`, and `refund` calls return only `id`, `status`, and HATEOAS links unless you send `Prefer: return=representation`. See **Step 8** for the full list of affected operations and their default responses.

## Step 1: Review your current integration [#step-1-review-your-current-integration]

### Search strings [#search-strings]

Search your codebase:

```text
/v1/payments/payment
/v1/payments/sale/
/v1/payments/authorization/
/v1/payments/orders/
/v1/payments/capture/
/v1/payments/refund/
intent.*sale
intent.*authorize
intent.*order
payer_info
parent_payment
is_final_capture
invoice_number
experience_profile_id
amount.total
amount.currency
valid_until
PAY-
```

### What carries forward [#what-carries-forward]

| Item                                   | Carries forward? | Notes                                                                            |
| -------------------------------------- | ---------------- | -------------------------------------------------------------------------------- |
| OAuth 2.0 client credentials token     | Yes              | Same `POST /v1/oauth2/token` endpoint; same Bearer token pattern                 |
| `PayPal-Partner-Attribution-Id` header | Yes              | Still supported in v2                                                            |
| Authorization, capture, refund IDs     | Partial          | Sub-resource IDs remain usable; PAY- order IDs do not map                        |
| `intent: authorize` value              | Yes              | Same value in Orders v2 (uppercased: `AUTHORIZE`)                                |
| `intent: sale`                         | No               | Renamed to `CAPTURE` in Orders v2                                                |
| `intent: order` (AS2)                  | Limited          | Replaced by `processing_instruction` (limited release, requires Account Manager) |
| `POST /v1/payments/payment`            | No               | Replaced by `POST /v2/checkout/orders` with restructured body                    |
| `POST .../execute`                     | No               | Replaced by `POST .../capture` or `.../authorize`                                |
| `GET /v1/payments/payment` (List)      | No               | **No replacement in v2:** migrate to Transaction Search API                      |
| `payer.payer_info` object              | No               | Moved to `payment_source.[method].*`                                             |
| `parent_payment` field                 | No               | Replaced by `links[rel=up]` HATEOAS navigation                                   |
| `state` (resource status)              | No               | Renamed to `status` throughout v2                                                |
| `amount.total` / `amount.currency`     | No               | Renamed to `amount.value` / `amount.currency_code`                               |
| `is_final_capture`                     | No               | Renamed to `final_capture`; **default behavior inverted**                        |
| `valid_until` on authorizations        | No               | Renamed to `expiration_time`                                                     |
| `transaction_fee` on captures          | No               | Replaced by `seller_receivable_breakdown.paypal_fee`                             |
| `notify_url` (IPN)                     | No               | Replaced by webhooks                                                             |
| `PayPal-Request-Id` (30-day window)    | Partial          | Header same; retention window reduced to **6 hours**                             |

## Step 2: Full API surface mapping [#step-2-full-api-surface-mapping]

The endpoint mapping and field-level mapping tables in this section map every v1 endpoint and field to its v2 equivalent, across both Orders v2 and Payments v2.

### Endpoint mapping [#endpoint-mapping]

| v1 endpoint                                            | v2 replacement                                          | API         | Category            |
| ------------------------------------------------------ | ------------------------------------------------------- | ----------- | ------------------- |
| `POST /v1/payments/payment` (`intent: sale`)           | `POST /v2/checkout/orders` (`intent: CAPTURE`)          | Orders v2   | Restructured        |
| `POST /v1/payments/payment` (`intent: authorize`)      | `POST /v2/checkout/orders` (`intent: AUTHORIZE`)        | Orders v2   | Restructured        |
| `POST /v1/payments/payment` (`intent: order`)          | `POST /v2/checkout/orders` and `processing_instruction` | Orders v2   | Limited release     |
| `GET /v1/payments/payment` (List)                      | No equivalent                                           | none        | No replacement      |
| `GET /v1/payments/payment/{id}`                        | `GET /v2/checkout/orders/{id}`                          | Orders v2   | Partial             |
| `PATCH /v1/payments/payment/{id}`                      | `PATCH /v2/checkout/orders/{id}`                        | Orders v2   | Restructured        |
| `POST /v1/payments/payment/{id}/execute` (`sale`)      | `POST /v2/checkout/orders/{id}/capture`                 | Orders v2   | Split               |
| `POST /v1/payments/payment/{id}/execute` (`authorize`) | `POST /v2/checkout/orders/{id}/authorize`               | Orders v2   | Split               |
| `GET /v1/payments/sale/{id}`                           | `GET /v2/payments/captures/{id}`                        | Payments v2 | Restructured        |
| `POST /v1/payments/sale/{id}/refund`                   | `POST /v2/payments/captures/{id}/refund`                | Payments v2 | Restructured        |
| `GET /v1/payments/authorization/{id}`                  | `GET /v2/payments/authorizations/{id}`                  | Payments v2 | Restructured        |
| `POST /v1/payments/authorization/{id}/capture`         | `POST /v2/payments/authorizations/{id}/capture`         | Payments v2 | Restructured        |
| `POST /v1/payments/authorization/{id}/void`            | `POST /v2/payments/authorizations/{id}/void`            | Payments v2 | Restructured        |
| `POST /v1/payments/authorization/{id}/reauthorize`     | `POST /v2/payments/authorizations/{id}/reauthorize`     | Payments v2 | Restructured        |
| `POST /v1/payments/orders/{id}/capture`                | No direct equivalent                                    | none        | Behavioral gap      |
| `POST /v1/payments/orders/{id}/do-void`                | `POST /v2/checkout/orders/{id}/void`                    | Orders v2   | AS2 limited release |
| `GET /v1/payments/capture/{id}`                        | `GET /v2/payments/captures/{id}`                        | Payments v2 | Restructured        |
| `POST /v1/payments/capture/{id}/refund`                | `POST /v2/payments/captures/{id}/refund`                | Payments v2 | Restructured        |
| `GET /v1/payments/refund/{id}`                         | `GET /v2/payments/refunds/{id}`                         | Payments v2 | Restructured        |

### Field-level mapping: create payment to create order [#field-level-mapping-create-payment-to-create-order]

| v1 field                                          | v2 field                                                         | Category           | Notes                                                    |
| ------------------------------------------------- | ---------------------------------------------------------------- | ------------------ | -------------------------------------------------------- |
| `intent: "sale"`                                  | `intent: "CAPTURE"`                                              | Behavioral change  | Value renamed and changed to uppercase                   |
| `intent: "authorize"`                             | `intent: "AUTHORIZE"`                                            | Behavioral change  | Value changed to uppercase                               |
| `intent: "order"`                                 | `processing_instruction: "ORDER_SAVED_ON_AUTHORIZE"`             | Restructured       | Limited release: different field entirely                |
| `payer.payment_method: "paypal"`                  | `payment_source.paypal: {}`                                      | Restructured       | Payment method moved to `payment_source`                 |
| `payer.payer_info.email`                          | `payment_source.paypal.email_address`                            | Restructured       | Restructured to payment-source-scoped object             |
| `payer.payer_info.payer_id`                       | `payment_source.paypal.account_id`                               | Restructured       | Field renamed within new location                        |
| `payer.payer_info.first_name`                     | `payment_source.paypal.name.given_name`                          | Restructured       | none                                                     |
| `payer.payer_info.last_name`                      | `payment_source.paypal.name.surname`                             | Restructured       | none                                                     |
| `redirect_urls.return_url`                        | `payment_source.[method].experience_context.return_url`          | Restructured       | Scoped per payment source                                |
| `redirect_urls.cancel_url`                        | `payment_source.[method].experience_context.cancel_url`          | Restructured       | Scoped per payment source                                |
| `application_context.brand_name`                  | `payment_source.[method].experience_context.brand_name`          | Restructured       | Moved from `application_context`                         |
| `application_context.shipping_preference`         | `payment_source.[method].experience_context.shipping_preference` | Restructured       | Moved from `application_context`                         |
| `experience_profile_id`                           | Removed                                                          | No replacement     | Use `payment_source.[method].experience_context` instead |
| `transactions[].amount.total`                     | `purchase_units[].amount.value`                                  | Restructured       | Array and field both renamed                             |
| `transactions[].amount.currency`                  | `purchase_units[].amount.currency_code`                          | Restructured       | none                                                     |
| `transactions[].amount.details.subtotal`          | `purchase_units[].amount.breakdown.item_total.value`             | Restructured       | Nested path changed; now a Money object                  |
| `transactions[].amount.details.tax`               | `purchase_units[].amount.breakdown.tax_total.value`              | Restructured       | none                                                     |
| `transactions[].amount.details.shipping`          | `purchase_units[].amount.breakdown.shipping.value`               | Restructured       | none                                                     |
| `transactions[].amount.details.handling_fee`      | `purchase_units[].amount.breakdown.handling.value`               | Restructured       | none                                                     |
| `transactions[].amount.details.shipping_discount` | `purchase_units[].amount.breakdown.shipping_discount.value`      | Restructured       | none                                                     |
| `transactions[].amount.details.insurance`         | `purchase_units[].amount.breakdown.insurance.value`              | Restructured       | none                                                     |
| `transactions[].custom`                           | `purchase_units[].custom_id`                                     | Direct replacement | none                                                     |
| `transactions[].invoice_number`                   | `purchase_units[].invoice_id`                                    | Direct replacement | none                                                     |
| `transactions[].soft_descriptor`                  | `purchase_units[].soft_descriptor`                               | Direct replacement | none                                                     |
| `transactions[].payee.email`                      | `purchase_units[].payee.email_address`                           | Direct replacement | none                                                     |
| `transactions[].items[].price`                    | `purchase_units[].items[].unit_amount.value`                     | Restructured       | none                                                     |
| `transactions[].items[].currency`                 | `purchase_units[].items[].unit_amount.currency_code`             | Restructured       | none                                                     |
| `transactions[].notify_url` (IPN)                 | Removed                                                          | No replacement     | Replaced by webhooks                                     |

### Field-level mapping: execute payment to capture or authorize [#field-level-mapping-execute-payment-to-capture-or-authorize]

| v1 field (on execute)                         | v2 equivalent | Category       | Notes                                                                |
| --------------------------------------------- | ------------- | -------------- | -------------------------------------------------------------------- |
| `payer_id`                                    | Removed       | No replacement | No longer accepted on capture/authorize calls in v2                  |
| `transactions[].amount` (override at execute) | Removed       | No replacement | Amount locked at create time; use `PATCH` before execution to change |

### Field-level mapping: authorization operations [#field-level-mapping-authorization-operations]

| v1 field                                   | v2 field               | Category           | Notes                                            |
| ------------------------------------------ | ---------------------- | ------------------ | ------------------------------------------------ |
| `state`                                    | `status`               | Direct replacement | Applies to all resource types                    |
| `valid_until`                              | `expiration_time`      | Direct replacement | Authorization expiry timestamp renamed           |
| `parent_payment`                           | `links[rel=up]`        | Restructured       | Navigation to parent via HATEOAS link, not field |
| `amount.total`                             | `amount.value`         | Direct replacement | none                                             |
| `amount.currency`                          | `amount.currency_code` | Direct replacement | none                                             |
| `is_final_capture` (on capture body)       | `final_capture`        | Behavioral change  | Renamed; **default behavior inverted**           |
| `invoice_number` (on capture body)         | `invoice_id`           | Direct replacement | none                                             |
| `fmf_details` (on reauthorize body)        | Removed                | No replacement     | Not accepted in v2 reauthorize                   |
| `processor_response` (on reauthorize body) | Removed                | No replacement     | Not accepted in v2 reauthorize                   |

### Field-level mapping: capture and refund responses [#field-level-mapping-capture-and-refund-responses]

| v1 response field                   | v2 response field                        | Category           | Notes                                                     |
| ----------------------------------- | ---------------------------------------- | ------------------ | --------------------------------------------------------- |
| `transaction_fee` (on sale/capture) | `seller_receivable_breakdown.paypal_fee` | Restructured       | Now a full breakdown object with gross, fee, and net      |
| `sale_id` (on refund response)      | `links[rel=up]` (to capture)             | Restructured       | Back-reference replaced by HATEOAS                        |
| `description` (on refund request)   | `note_to_payer`                          | Behavioral change  | Field renamed; purpose is buyer-facing communication      |
| `reason` (on refund request)        | Removed                                  | No replacement     | No equivalent in v2; encode internally or use `custom_id` |
| `custom` (on refund)                | `custom_id`                              | Direct replacement | none                                                      |

### Universal field rename reference [#universal-field-rename-reference]

These renames apply consistently across all v2 operations and are frequently missed:

| v1 field                         | v2 field                                                   | Applies to                  |
| -------------------------------- | ---------------------------------------------------------- | --------------------------- |
| `state`                          | `status`                                                   | All resources               |
| `amount.total`                   | `amount.value`                                             | All resources               |
| `amount.currency`                | `amount.currency_code`                                     | All resources               |
| `invoice_number`                 | `invoice_id`                                               | Payments, captures, refunds |
| `custom`                         | `custom_id`                                                | Captures, refunds           |
| `is_final_capture`               | `final_capture`                                            | Authorization capture       |
| `valid_until`                    | `expiration_time`                                          | Authorizations              |
| `transaction_fee`                | `seller_receivable_breakdown.paypal_fee`                   | Capture responses           |
| `parent_payment`                 | `links[rel=up]`                                            | All sub-resources           |
| `payer_id`                       | `payment_source.paypal.account_id`                         | Payer identity              |
| `payer_info.email`               | `payment_source.paypal.email_address`                      | Payer identity              |
| `payer_info.first_name`          | `payment_source.paypal.name.given_name`                    | Payer identity              |
| `payer_info.last_name`           | `payment_source.paypal.name.surname`                       | Payer identity              |
| `payer_info.phone`               | `payment_source.paypal.phone.phone_number.national_number` | Payer identity              |
| `payer_info.country_code`        | `payment_source.paypal.address.country_code`               | Payer identity              |
| `payer.status`                   | `payment_source.paypal.account_status`                     | Payer verification status   |
| `transactions[].soft_descriptor` | `purchase_units[].soft_descriptor`                         | Statement descriptor        |
| `line1`                          | `address_line_1`                                           | Addresses                   |
| `line2`                          | `address_line_2`                                           | Addresses                   |
| `city`                           | `admin_area_2`                                             | Addresses                   |
| `state` (address field)          | `admin_area_1`                                             | Addresses                   |
| `sale_id` (on refund response)   | `links[rel=up]` to capture                                 | Refund responses            |
| `description` (refund request)   | `note_to_payer`                                            | Refund requests             |

> **Note:** `state` appears in two distinct contexts with different v2 replacements: as a resource status field (becomes `status`) and as an address sub-field for a US state or province (becomes `admin_area_1`). If your code uses `state` in both contexts, handle each rename separately.

## Step 3: Update payment creation [#step-3-update-payment-creation]

The `POST /v1/payments/payment` endpoint maps to `POST /v2/checkout/orders`. The `transactions[]` array becomes `purchase_units[]`, `intent` values are uppercase, and payer and redirect information is now passed in the `payment_source` object.

### Intent rename [#intent-rename]

v2 requires the `intent` value in uppercase, rejecting the lowercase `sale` value v1 used.

v1, rejected by v2:

```json
{ "intent": "sale" }
```

v2, required value:

```json
{ "intent": "CAPTURE" }
```

**Most common migration failure:** Sending `intent: "sale"` to the v2 endpoint returns a validation error. The field name is identical in both APIs; the value change is easy to miss.

### Payer and redirect URLs move to `payment_source` [#payer-and-redirect-urls-move-to-payment_source]

v1 sends the payment method, redirect URLs, and application context as separate top-level objects. v2 consolidates all of them under `payment_source.[method].experience_context`.

v1:

```json
{
  "payer": {
    "payment_method": "paypal"
  },
  "redirect_urls": {
    "return_url": "https://example.com/return",
    "cancel_url": "https://example.com/cancel"
  },
  "application_context": {
    "brand_name": "My Store",
    "shipping_preference": "SET_PROVIDED_ADDRESS"
  }
}
```

v2:

```json
{
  "payment_source": {
    "paypal": {
      "experience_context": {
        "return_url": "https://example.com/return",
        "cancel_url": "https://example.com/cancel",
        "brand_name": "My Store",
        "shipping_preference": "SET_PROVIDED_ADDRESS"
      }
    }
  }
}
```

**The `experience_context` pattern:** The top-level `application_context` and `redirect_urls` objects are deprecated. All payer experience settings move into `payment_source.[method].experience_context`. Replace `"paypal"` with `"card"`, `"google_pay"`, or `"apple_pay"` as appropriate.

**The `user_action` value rename:** The value `"commit"` (which shows a "Pay Now" button) is renamed to `"PAY_NOW"` in v2's `experience_context`. The field name is the same; only the value string changes. If you send `"commit"` to v2, the API either rejects the request or returns unexpected checkout button behavior.

### Amount structure [#amount-structure]

v2 renames `amount.total` and `amount.currency` to `value` and `currency_code`, and moves the breakdown fields into Money objects nested under `breakdown`.

v1:

```json
{
  "amount": {
    "currency": "USD",
    "total": "107.47",
    "details": {
      "subtotal": "99.99",
      "tax": "5.48",
      "shipping": "2.00"
    }
  }
}
```

v2:

```json
{
  "amount": {
    "currency_code": "USD",
    "value": "107.47",
    "breakdown": {
      "item_total": {
        "currency_code": "USD",
        "value": "99.99"
      },
      "tax_total": {
        "currency_code": "USD",
        "value": "5.48"
      },
      "shipping": {
        "currency_code": "USD",
        "value": "2.00"
      }
    }
  }
}
```

`currency` becomes `currency_code`, `total` becomes `value`, and `details` becomes `breakdown`. All breakdown sub-fields are now Money objects, not bare strings. V2 validates that `value` equals the sum of breakdown components; a mismatch returns `422 Unprocessable Entity`.

Complete `details` to `breakdown` field mapping:

| v1 `details` field  | v2 `breakdown` field               |
| ------------------- | ---------------------------------- |
| `subtotal`          | `item_total` (Money object)        |
| `tax`               | `tax_total` (Money object)         |
| `shipping`          | `shipping` (Money object)          |
| `handling_fee`      | `handling` (Money object)          |
| `shipping_discount` | `shipping_discount` (Money object) |
| `insurance`         | `insurance` (Money object)         |

### Full before and after: create payment to create order [#full-before-and-after-create-payment-to-create-order]

The following requests apply every field-level change from this step together, from a complete v1 create payment call to its v2 create order equivalent.

v1, create payment request:

```http
POST /v1/payments/payment

{
  "intent": "sale",
  "payer": { "payment_method": "paypal" },
  "redirect_urls": {
    "return_url": "https://example.com/return",
    "cancel_url": "https://example.com/cancel"
  },
  "transactions": [
    {
      "amount": {
        "currency": "USD",
        "total": "107.47",
        "details": {
          "subtotal": "99.99",
          "tax": "5.48",
          "shipping": "2.00"
        }
      },
      "description": "My Store order-1234",
      "custom": "my-internal-ref",
      "invoice_number": "INV-001",
      "items": [
        {
          "name": "NeoPhone",
          "sku": "sku03",
          "price": "99.99",
          "currency": "USD",
          "quantity": "1"
        }
      ],
      "shipping_address": {
        "recipient_name": "John Doe",
        "line1": "2211 N First Street",
        "city": "San Jose",
        "state": "CA",
        "postal_code": "95131",
        "country_code": "US"
      }
    }
  ]
}
```

v2, create order request:

```http
POST /v2/checkout/orders

{
  "intent": "CAPTURE",
  "payment_source": {
    "paypal": {
      "experience_context": {
        "return_url": "https://example.com/return",
        "cancel_url": "https://example.com/cancel"
      }
    }
  },
  "purchase_units": [
    {
      "description": "My Store order-1234",
      "custom_id": "my-internal-ref",
      "invoice_id": "INV-001",
      "amount": {
        "currency_code": "USD",
        "value": "107.47",
        "breakdown": {
          "item_total": { "currency_code": "USD", "value": "99.99" },
          "tax_total": { "currency_code": "USD", "value": "5.48" },
          "shipping": { "currency_code": "USD", "value": "2.00" }
        }
      },
      "items": [
        {
          "name": "NeoPhone",
          "sku": "sku03",
          "unit_amount": { "currency_code": "USD", "value": "99.99" },
          "quantity": "1",
          "category": "PHYSICAL_GOODS"
        }
      ],
      "shipping": {
        "name": { "full_name": "John Doe" },
        "address": {
          "address_line_1": "2211 N First Street",
          "admin_area_2": "San Jose",
          "admin_area_1": "CA",
          "postal_code": "95131",
          "country_code": "US"
        }
      }
    }
  ]
}
```

## Step 4: Update payment execution [#step-4-update-payment-execution]

In v1, a single execute endpoint finalized payment regardless of intent. In v2, execution splits into two dedicated endpoints, capture or authorize, and `payer_id` is no longer passed at execution time.

### The split [#the-split]

| v1 intent   | v2 execution endpoint                     |
| ----------- | ----------------------------------------- |
| `sale`      | `POST /v2/checkout/orders/{id}/capture`   |
| `authorize` | `POST /v2/checkout/orders/{id}/authorize` |

### Capture, replaces sale to execute [#capture-replaces-sale-to-execute]

v1 finalizes a `sale`-intent payment with a single `execute` call. v2 replaces it with a dedicated `capture` endpoint on the order.

v1:

```http
POST /v1/payments/payment/{id}/execute

{
  "payer_id": "HPQAUP5WC3J8U"
}
```

v2:

```http
POST /v2/checkout/orders/{id}/capture
Prefer: return=representation

{}
```

**`payer_id` and the `Prefer` header:** `payer_id` is no longer sent at execution time. Add `Prefer: return=representation` if you need financial details, such as capture ID and fee amounts, in the response. See **Step 8** for the full default-response behavior.

### Authorize, replaces authorize to execute [#authorize-replaces-authorize-to-execute]

v1 finalizes an `authorize`-intent payment with the same `execute` call. v2 replaces it with a dedicated `authorize` endpoint, followed by a separate call to capture the authorization.

v1:

```http
POST /v1/payments/payment/{id}/execute
{ "payer_id": "HPQAUP5WC3J8U" }
```

v2:

```http
POST /v2/checkout/orders/{id}/authorize
{}

// Then capture via Payments v2:
POST /v2/payments/authorizations/{auth_id}/capture
```

### Amount override at execute time is removed [#amount-override-at-execute-time-is-removed]

V1 execute optionally accepted a `transactions[].amount` override to adjust the payment amount at execution. This does not exist in v2. Amounts are fixed at order creation. Use `PATCH /v2/checkout/orders/{id}` before capture to adjust amounts.

## Step 5: Update Payments v2 operations [#step-5-update-payments-v2-operations]

### Authorization operations [#authorization-operations]

v2 renames the same fields here that it renames elsewhere: `currency` and `total` become `currency_code` and `value`, and `is_final_capture` becomes `final_capture`.

v1, capture authorization:

```http
POST /v1/payments/authorization/{id}/capture

{
  "amount": {
    "currency": "USD",
    "total": "50.00"
  },
  "is_final_capture": true,
  "invoice_number": "INV-001"
}
```

v2, capture authorization:

```http
POST /v2/payments/authorizations/{id}/capture
Prefer: return=representation

{
  "amount": {
    "currency_code": "USD",
    "value": "50.00"
  },
  "final_capture": true,
  "invoice_id": "INV-001"
}
```

**Void returns 204 No Content:** `POST /v2/payments/authorizations/{id}/void` returns `204 No Content` by default. Remove all body-parsing code from void response handlers. Add `Prefer: return=representation` only if you need the voided resource returned.

**`fmf_details` removed from reauthorize:** v2's reauthorize endpoint accepts only `amount`. Including `fmf_details` or `processor_response` in the reauthorize request body will cause a validation error. Remove these fields from all reauthorize calls.

### Sale becomes capture [#sale-becomes-capture]

The concept of a "sale" is renamed to "capture" throughout v2.

Path changes (all require `Prefer: return=representation` for body data):

```text
# Show sale becomes show capture
GET /v1/payments/sale/{id}
GET /v2/payments/captures/{id} ("sale" becomes "captures", plural)

# Show capture, same, path pluralized
GET /v1/payments/capture/{id}
GET /v2/payments/captures/{id} ("/capture/" becomes "/captures/")

# Show refund, same, path pluralized
GET /v1/payments/refund/{id}
GET /v2/payments/refunds/{id} ("/refund/" becomes "/refunds/")
```

### Refund field changes [#refund-field-changes]

v2 renames `description` to `note_to_payer` and drops the `reason` field entirely, with no replacement.

v1, refund sale or capture:

```http
POST /v1/payments/sale/{id}/refund

{
  "amount": {
    "currency": "USD",
    "total": "25.00"
  },
  "description": "Returned item",
  "reason": "MERCHANDISE_NOT_AS_DESCRIBED"
}
```

v2, refund capture:

```http
POST /v2/payments/captures/{id}/refund
Prefer: return=representation

{
  "amount": {
    "currency_code": "USD",
    "value": "25.00"
  },
  "note_to_payer": "Returned item"
}
```

**The `reason` field is removed from refunds:** The `reason` field does not exist in v2. The `description` field is renamed to `note_to_payer` (buyer-facing). If your integration stores internal refund reason codes, encode them in `custom_id` or track them outside the API.

**Full refund body difference:** In v1, you issue a full refund by omitting `amount` from the request body entirely. In v2, a full refund requires sending an explicit empty body `{}`. HTTP clients that omit the body completely on a v2 refund will receive a validation error.

**`PayPal-Auth-Assertion` header (v2 only):** Third-party and partner API callers acting on behalf of a seller need to include a JWT `PayPal-Auth-Assertion` header on refund and capture calls. This header has no v1 equivalent. Required for partner-facilitated operations in v2.

## Step 6: Handle the AS2 order-intent gap [#step-6-handle-the-as2-order-intent-gap]

**Limited release: confirm eligibility first.** The v1 `intent: order` flow has no standard, generally available equivalent in Orders v2. The AS2 feature requires explicit Account Manager enablement. Do not attempt to migrate this flow without confirming eligibility.

### What changed [#what-changed]

In v2, the AS2 flow has two variants controlled by `processing_instruction`:

| `processing_instruction`   | Behavior                                                                                                                                                       |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ORDER_SAVED_ON_AUTHORIZE` | After buyer approves, a single authorize call creates the authorization and moves the order to `SAVED` status in one step.                                     |
| `ORDER_SAVED_EXPLICITLY`   | After buyer approves, call `POST /v2/checkout/orders/{id}/save` first to move to `SAVED`, then authorize separately. Use when you want to defer authorization. |

v1:

```json
{ "intent": "order" }
```

v2, AS2 one-step:

```json
{
  "intent": "AUTHORIZE",
  "processing_instruction": "ORDER_SAVED_ON_AUTHORIZE"
}
```

**AS2 order status after authorization:** The order status after an AS2 authorization is `SAVED`, not `APPROVED` or `COMPLETED`. The order only reaches `COMPLETED` after a capture with `final_capture: true` is applied.

### AS2 constraints not present in v1 [#as2-constraints-not-present-in-v1]

* **The buyer completes checkout through PayPal only.** Billing agreements, cards, and vaulted payment methods are not supported for this flow.
* **Multi-Seller Payments (MSP) is not supported.** Orders with multiple `purchase_units` targeting different sellers cannot use AS2.
* **Connected Path, Managed Path, and Embedded Path partner types are not supported.** Attempting AS2 with these configurations returns a `SAVE_ORDER_NOT_SUPPORTED` error.

### Direct order capture is removed [#direct-order-capture-is-removed]

V1 supported `POST /v1/payments/orders/{id}/capture` to capture an order directly. In v2 AS2, this is not supported. The required flow adds an extra step:

v2 AS2, two-step capture:

```text
// Step 1: authorize the order
POST /v2/checkout/orders/{id}/authorize
returns authorization_id

// Step 2: capture the authorization
POST /v2/payments/authorizations/{authorization_id}/capture
```

## Step 7: Migrate from IPN to webhooks [#step-7-migrate-from-ipn-to-webhooks]

**`notify_url` is removed in v2:** If you include `notify_url` in a v2 request, the API accepts the field but never sends an IPN. Do not remove IPN listeners until webhooks are live and verified.

### Register a webhook [#register-a-webhook]

```http
POST /v1/notifications/webhooks
```

### Event mapping [#event-mapping]

| v1 IPN transaction type  | v2 webhook event                |
| ------------------------ | ------------------------------- |
| Payment completed (sale) | `PAYMENT.CAPTURE.COMPLETED`     |
| Payment pending          | `PAYMENT.CAPTURE.PENDING`       |
| Payment denied           | `PAYMENT.CAPTURE.DENIED`        |
| Authorization created    | `PAYMENT.AUTHORIZATION.CREATED` |
| Authorization voided     | `PAYMENT.AUTHORIZATION.VOIDED`  |
| Refund completed         | `PAYMENT.CAPTURE.REFUNDED`      |
| Order approved           | `CHECKOUT.ORDER.APPROVED`       |
| Order created            | `CHECKOUT.ORDER.CREATED`        |

### Signature verification [#signature-verification]

Always verify webhook payloads before processing. Do not reuse IPN validation logic. The validation models are different.

Verification endpoint:

```http
POST /v1/notifications/verify-webhook-signature
```

## Step 8: Understand the architectural changes [#step-8-understand-the-architectural-changes]

### State becomes status: value mapping [#state-becomes-status-value-mapping]

The `state` field on all v1 resources is renamed to `status` in v2. Beyond the rename, the actual string values changed: v2 uses uppercase strings throughout, and several values were renamed or removed.

| v1 `state` value | v2 `status` value | Notes                                                          |
| ---------------- | ----------------- | -------------------------------------------------------------- |
| `created`        | `CREATED`         | Uppercased                                                     |
| `approved`       | `APPROVED`        | Uppercased                                                     |
| `completed`      | `COMPLETED`       | Uppercased                                                     |
| `failed`         | `VOIDED`          | **Renamed:** v2 uses `VOIDED` for failed/voided terminal state |
| `pending`        | `PENDING`         | Uppercased                                                     |
| `denied`         | `DECLINED`        | **Renamed** on capture/sale resources                          |
| `expired`        | Not surfaced      | Not present on the order resource in v2                        |

**String comparison breakage:** Update any code that compares `state` values as strings, such as switch statements, equality checks, and database enums, for both the field rename and the value case change. `"completed"` does not equal `"COMPLETED"`.

### The unified payment resource is gone [#the-unified-payment-resource-is-gone]

v1 organizes every payment as one connected resource graph. v2 splits that graph across two separate APIs.

v1, single payment graph:

```text
Payment (PAY-...)
└── transactions[]
└── related_resources[]
├── sale
├── authorization
├── order
└── capture
```

v2, split across two APIs:

```text
Order (/v2/checkout/orders/{id})
└── purchase_units[]
└── payments
├── authorizations[]
│ → /v2/payments/authorizations/{id}
└── captures[]
→ /v2/payments/captures/{id}
└── refunds
→ /v2/payments/refunds/{id}
```

Any system that stored PAY- IDs as the primary transaction key and traversed the graph for full state now needs up to three separate calls. Back-navigation uses `links[rel=up]`; the `parent_payment` field is removed.

### Payer identity model [#payer-identity-model]

v2 renames the `payer_info` object to `payment_source.paypal` and nests the buyer's name under a `name` object instead of separate `first_name` and `last_name` fields.

v1, payer\_info:

```json
{
  "payer": {
    "payer_info": {
      "email": "buyer@example.com",
      "first_name": "John",
      "last_name": "Doe",
      "payer_id": "HPQAUP5WC3J8U"
    }
  }
}
```

v2, payment\_source:

```json
{
  "payment_source": {
    "paypal": {
      "email_address": "buyer@example.com",
      "account_id": "HPQAUP5WC3J8U",
      "name": {
        "given_name": "John",
        "surname": "Doe"
      }
    }
  }
}
```

**High-risk field migration:** Update any downstream system that reads `payer.payer_info.*`, such as fraud detection, customer matching, and identity resolution. The `payer_id` parameter is now `account_id`, nested inside `payment_source.paypal`. This is not just a rename: it is a structural path change.

### `Prefer: return=representation` is required for response data [#prefer-returnrepresentation-is-required-for-response-data]

V1 write operations always returned full resource bodies. V2 returns minimal responses by default.

Add to all write operations that consume response data:

```text
Prefer: return=representation
```

| Operation                     | Default v2 response               | With Prefer header                                       |
| ----------------------------- | --------------------------------- | -------------------------------------------------------- |
| Capture order / authorization | Minimal (`id`, `status`, `links`) | Full capture resource with `seller_receivable_breakdown` |
| Authorize order               | Minimal                           | Full authorization resource                              |
| Void authorization            | `204 No Content`                  | Voided authorization resource                            |
| Refund capture                | Minimal                           | Full refund resource with `seller_payable_breakdown`     |
| `PATCH` order                 | `204 No Content`                  | Not applicable                                           |

### Final capture default inversion [#final-capture-default-inversion]

**Silent failure: always set `final_capture: true`.** V1's default capture behavior voided the authorization after capture. V2's `final_capture` defaults to `false`. Without `true`, every v2 capture leaves the authorization open, causing unexpected balance holds on buyers' accounts. Payments appear successful while PayPal still holds the buyer's money. Always set `final_capture: true` explicitly on every final capture call.

Required on every final Payments v2 capture:

```json
{
  "final_capture": true
}
```

### `PATCH` path syntax change [#patch-path-syntax-change]

Both v1 and v2 use RFC 6902 JSON Patch, but the path syntax for `purchase_units` fields changed from array indexes to `reference_id` selectors:

v1, array index:

```json
[
  {
    "op": "replace",
    "path": "/transactions/0/amount/total",
    "value": "150.00"
  }
]
```

v2, `reference_id` selector:

```json
[
  {
    "op": "replace",
    "path": "/purchase_units/@reference_id=='default'/amount/value",
    "value": "150.00"
  }
]
```

The `reference_id` defaults to `"default"` if none was set at order creation. Use the custom `reference_id` value if you set one. Update any code that constructs `PATCH` paths with array indexes to this selector format.

**After `PATCH`:** Issue a `GET /v2/checkout/orders/{id}` after the `PATCH` to read the updated state. The `204 No Content` response has no body to parse.

### New v2-only headers [#new-v2-only-headers]

**`PayPal-Client-Metadata-Id`**

In v1, risk correlation data was passed as a name and value pair inside the request body:

v1, risk\_correlation\_id in body:

```json
{
  "application_context": {
    "supplementary_data": [
      { "name": "risk_correlation_id", "value": "9N8554567F903282T" }
    ]
  }
}
```

In v2, this moves out of the body entirely and becomes a dedicated HTTP request header:

v2, dedicated request header:

```text
PayPal-Client-Metadata-Id: 9N8554567F903282T
```

The header accepts a GUID from Fraudnet or Dyson. PayPal's risk systems use this GUID to correlate calls and reduce decline rates. If your integration passed `risk_correlation_id` through `supplementary_data`, move it to this header. If your integration did not use `supplementary_data`, add this header as part of migration. Omitting it on high-value transactions may increase decline rates.

**No v2 equivalent for other `supplementary_data` fields:** There is no equivalent for `supplementary_data[name="buyer_ipaddress"]` or `supplementary_data[name="external_channel"]` in v2. Remove these entries.

**`PayPal-Auth-Assertion`**

JWT assertion for third-party and partner callers acting on behalf of a seller. Required for partner-facilitated refunds and captures in v2. No v1 equivalent.

### Idempotency key window reduction [#idempotency-key-window-reduction]

| Version       | `PayPal-Request-Id` retention window          |
| ------------- | --------------------------------------------- |
| v1            | 30 days                                       |
| v2 (default)  | **6 hours**                                   |
| v2 (extended) | 72 hours (requires Account Manager agreement) |

If your retry window exceeds 6 hours, the idempotency key expires and PayPal treats the retry as a new transaction. No error indicates that the key expired, so this risks duplicate charges. Audit all retry logic and key generation to generate fresh keys within the v2 window.

### HTTP status code changes [#http-status-code-changes]

| Operation                  | v1 status            | v2 status                                     |
| -------------------------- | -------------------- | --------------------------------------------- |
| `PATCH` payment / order    | `200 OK` with body   | `204 No Content`                              |
| Void authorization         | `200 OK` with body   | `204 No Content` (default)                    |
| Refund (idempotent retry)  | `201 Created` always | `200 OK` (idempotent) / `201 Created` (first) |
| Capture (idempotent retry) | `200 OK` always      | `200 OK` (idempotent) / `201 Created` (first) |

## Step 9: Migrate List Payments to Transaction Search [#step-9-migrate-list-payments-to-transaction-search]

**`GET /v1/payments/payment` has no v2 equivalent:** There is no list-all-payments endpoint in Orders v2 or Payments v2. This is the highest-effort gap in the migration. Plan and sequence this migration independently from the core API migration.

The v1 List Payments endpoint supported querying completed payments by time range, payee, and sort order with cursor-based pagination. The replacement is the Transaction Search API:

Migration target, Transaction Search API:

```http
GET /v1/reporting/transactions
?start_date=2025-01-01T00:00:00Z
&end_date=2025-01-31T23:59:59Z
&transaction_type=T0006
&page_size=100
&page=1
```

| v1 List Payments field                   | Transaction Search equivalent                                        |
| ---------------------------------------- | -------------------------------------------------------------------- |
| `payments[].id`                          | `transaction_details[].transaction_info.transaction_id`              |
| `payments[].state`                       | `transaction_details[].transaction_info.transaction_status`          |
| `payments[].transactions[].amount.total` | `transaction_details[].transaction_info.transaction_amount.value`    |
| `payments[].create_time`                 | `transaction_details[].transaction_info.transaction_initiation_date` |
| `payments[].payer.payer_info.email`      | `transaction_details[].payer_info.email_address`                     |

Transaction Search is a different API with a different response schema. Review its full reference documentation before building your replacement query. It covers a broader set of transaction types than List Payments did.

## Step 10: New v2 capabilities to adopt [#step-10-new-v2-capabilities-to-adopt]

| Feature                         | Endpoint or mechanism                      | Why it matters                                                                        |
| ------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------- |
| `seller_receivable_breakdown`   | Capture/refund responses                   | Full fee visibility (gross, PayPal fee, net); eliminates secondary calls for fee data |
| `custom_id` on captures/refunds | Request/response field                     | First-class reconciliation identifier surfaced in settlement reports                  |
| Update order                    | `PATCH /v2/checkout/orders/{id}`           | Modify amount, items, or shipping after creation and before capture                   |
| Multi-purchase-unit             | `purchase_units[]` (up to 10)              | Single order with multiple payees/amounts; not possible in Payments v1                |
| Shipment tracking               | `POST /v2/checkout/orders/{id}/track`      | Attach carrier tracking to captured orders for seller protection                      |
| `seller_protection`             | Auth and capture responses                 | Structured eligibility replacing v1's comma-separated string                          |
| Confirm payment source          | `POST .../confirm-payment-source`          | Server-side method confirmation without buyer redirect                                |
| Vault integration               | `payment_source.[method].attributes.vault` | Save payment method during purchase; not available in Payments v1                     |
| Platform fees and disbursement  | `payment_instruction.platform_fees`        | Marketplace fee collection, PayPal Complete Payments Platform only                    |

## Step 11: Testing [#step-11-testing]

**Sandbox setup:** Use `api-m.sandbox.paypal.com` as the base URL. Create sandbox buyer and seller accounts at developer.paypal.com. OAuth credentials are environment-specific; sandbox credentials do not work against live.

### Test scenario checklist [#test-scenario-checklist]

| Scenario                                     | What to verify                                                                                                                                   |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Create order with `CAPTURE` intent           | `201 Created`; no validation error on intent value                                                                                               |
| Create order with `AUTHORIZE` intent         | `201 Created`; HATEOAS authorize link present                                                                                                    |
| Capture approved order                       | `purchase_units[].payments.captures[0].id` present                                                                                               |
| Capture with `Prefer: return=representation` | Full capture with `seller_receivable_breakdown` in response                                                                                      |
| Capture without `Prefer` header              | Only `id`, `status`, `links` returned; confirm your code handles the missing fields explicitly, rather than reading `undefined` without noticing |
| Authorize approved order                     | Authorization ID in `purchase_units[].payments.authorizations[0].id`                                                                             |
| Capture authorization (Payments v2)          | `POST /v2/payments/authorizations/{id}/capture` with `final_capture: true` succeeds                                                              |
| `final_capture: false` capture               | Subsequent `GET` shows authorization still `CREATED` (not voided)                                                                                |
| Void authorization                           | `204 No Content` returned; no body parse attempted                                                                                               |
| Reauthorize                                  | New authorization created; `fmf_details` removed from request                                                                                    |
| Refund capture                               | `201 Created`; `note_to_payer` persists; no `reason` field sent                                                                                  |
| Show capture                                 | `seller_receivable_breakdown` present; `status` (not `state`)                                                                                    |
| Show authorization                           | `expiration_time` present; `status` present                                                                                                      |
| `PATCH` order                                | `204 No Content`; subsequent `GET` reflects updated amount                                                                                       |
| Amount breakdown validation                  | Mismatched breakdown returns `422`                                                                                                               |
| Idempotency key retry within 6h              | Returns same result; no duplicate transaction                                                                                                    |
| Webhook receipt and verification             | `PAYMENT.CAPTURE.COMPLETED` fires; signature verification passes                                                                                 |
| Transaction Search                           | `GET /v1/reporting/transactions` returns expected records; replaces List Payments                                                                |

## Step 12: Go live [#step-12-go-live]

1. Switch base URL from `api-m.sandbox.paypal.com` to `api-m.paypal.com`.
2. Replace sandbox OAuth credentials with live client ID and secret.
3. Verify all `intent: "sale"` references updated to `intent: "CAPTURE"`.
4. Verify all `POST .../execute` calls replaced with `.../capture` or `.../authorize`.
5. Confirm `Prefer: return=representation` set in your HTTP client for all write operations that consume response data.
6. Confirm `final_capture: true` set explicitly on all authorization capture calls.
7. Verify idempotency key generation creates fresh keys. Do not reuse keys beyond 6 hours.
8. Confirm IPN `notify_url` fields are removed. Confirm webhook listeners are live and verified.
9. Confirm List Payments callers migrated to Transaction Search API.
10. Confirm all `payer.payer_info.*` readers updated to `payment_source.[method].*`.
11. Remove all `parent_payment` references. Replace them with `links[rel=up]` navigation.
12. Log `debug_id` from all non-2xx responses for PayPal support escalation.

## Migration checklist [#migration-checklist]

* Inventory all callers of `/v1/payments/payment`. Route them to Orders v2 or Transaction Search.
* Replace `intent: "sale"` with `intent: "CAPTURE"` in all create requests.
* Replace `intent: "authorize"` with `intent: "AUTHORIZE"`.
* If using `intent: "order"` (AS2), confirm Account Manager eligibility before proceeding.
* Update the base URL: change `/v1/payments/payment` to `/v2/checkout/orders`.
* Rename `transactions[]` to `purchase_units[]`.
* Move `redirect_urls` and `application_context` into `payment_source.[method].experience_context`.
* Remove the `payer.payment_method` field from create requests.
* Replace `amount.currency` with `amount.currency_code` throughout.
* Replace `amount.total` with `amount.value` throughout.
* Replace `amount.details` with `amount.breakdown`. Convert sub-fields to Money objects.
* Replace `items[].price` and `currency` with `unit_amount.value` and `currency_code`.
* Rename `shipping_address` to `shipping.address`. Rename all address sub-fields to match.
* Rename `custom` to `custom_id`. Rename `invoice_number` to `invoice_id`.
* Replace `POST .../execute` with `.../capture` or `.../authorize` based on intent.
* Remove `payer_id` from all execute, capture, and authorize request bodies.
* Remove the amount override from execute requests. Move it to `PATCH` before execution.
* Add `Prefer: return=representation` to all write operations that read the response.
* Replace `GET /v1/payments/sale/{id}` with `GET /v2/payments/captures/{id}`.
* Replace `POST /v1/payments/sale/{id}/refund` with `POST /v2/payments/captures/{id}/refund`.
* Rename `description` to `note_to_payer` on refunds. Remove the `reason` field.
* Replace authorization, void, and reauthorize endpoints with Payments v2 paths.
* Rename `is_final_capture` to `final_capture`. Explicitly set it to `true` on every capture.
* Remove `fmf_details` and `processor_response` from all reauthorize requests.
* Update `PATCH` responses: do not parse the body (`204 No Content`).
* Pluralize capture and refund paths: change `/capture/` to `/captures/` and `/refund/` to `/refunds/`.
* Update all response readers: change `state` to `status` and `valid_until` to `expiration_time`.
* Update payer field readers: change `payer.payer_info.*` to `payment_source.[method].*`.
* Replace `parent_payment` traversal with `links[rel=up]` HATEOAS navigation.
* Replace `transaction_fee` with `seller_receivable_breakdown.paypal_fee`.
* Update idempotency key logic: use fresh keys, and do not reuse them beyond 6 hours.
* Remove all `notify_url` fields. Register and verify webhooks.
* Migrate List Payments callers to Transaction Search API.
* Run the full test scenario checklist in sandbox.
* Switch base URL and credentials to live.

## Avoid these common migration pitfalls [#avoid-these-common-migration-pitfalls]

These are the mistakes developers make most often during this migration. Check your integration against each one.

### 1. Sending `intent: "sale"` to Orders v2 [#1-sending-intent-sale-to-orders-v2]

**Mistake:** Passing `"intent": "sale"` unchanged from v1.

**Why it happens:** The field name is identical in both APIs; the value change is easy to miss.

**Resolution:** Orders v2 rejects `"sale"` with a validation error. Replace with `"CAPTURE"` in all create order requests.

### 2. Still passing `payer_id` on capture [#2-still-passing-payer_id-on-capture]

**Mistake:** Including `"payer_id"` in the capture or authorize request body as in v1's execute call.

**Why it happens:** v1 required `payer_id` on execute. Developers carry the field forward when updating the endpoint URL.

**Resolution:** `payer_id` is not accepted in v2 capture or authorize request bodies. Remove it entirely. The approved payer is already associated with the order.

### 3. Missing `Prefer: return=representation` [#3-missing-prefer-returnrepresentation]

**Mistake:** Reading financial fields from the response body of capture, authorize, void, or refund calls without the `Prefer` header.

**Why it happens:** v1 always returned full resource representations from write operations. V2's minimal-response default is not obvious.

**Resolution:** Add `Prefer: return=representation` as a default header in your HTTP client configuration. See **Step 8** for the full list of affected operations and their default responses.

### 4. Not setting `final_capture: true` [#4-not-setting-final_capture-true]

**Mistake:** Calling `POST /v2/payments/authorizations/{id}/capture` without `"final_capture": true`.

**Why it happens:** v1's `is_final_capture` defaulted to true-equivalent behavior; developers assume v2 does the same.

**Resolution:** v2 defaults `final_capture` to `false`. Without `true`, the authorization remains open after capture, causing unexpected balance holds. Payments appear successful while PayPal still holds the buyer's money. Always set `final_capture: true` explicitly.

### 5. Expecting a body from `PATCH` or void responses [#5-expecting-a-body-from-patch-or-void-responses]

**Mistake:** Trying to parse a JSON body from `PATCH` order or void authorization responses.

**Why it happens:** v1 returned `200 OK` with the full resource body from both operations.

**Resolution:** v2 returns `204 No Content` from `PATCH` and void by default. Remove all body-parsing code from these response handlers.

### 6. Using `parent_payment` for navigation [#6-using-parent_payment-for-navigation]

**Mistake:** Reading `response.parent_payment` to navigate from a capture or authorization back to its parent payment.

**Why it happens:** v1 included `parent_payment` on all sub-resources as a direct reference to the PAY- ID.

**Resolution:** The `parent_payment` field does not exist in v2. Navigate upward using the `links` array. Find the link where `rel === "up"` and follow its `href`.

### 7. Expecting `GET /v1/payments/payment` to have a v2 equivalent [#7-expecting-get-v1paymentspayment-to-have-a-v2-equivalent]

**Mistake:** Searching for a list-all-payments endpoint in Orders v2 or Payments v2.

**Why it happens:** Every other operation has a v2 replacement. Developers assume List Payments does too.

**Resolution:** There is no list-all-payments endpoint in v2. Migrate to the Transaction Search API (`GET /v1/reporting/transactions`). This is a separate API with a different schema and query model. Plan this migration independently.

### 8. Reusing idempotency keys beyond 6 hours [#8-reusing-idempotency-keys-beyond-6-hours]

**Mistake:** Retrying a failed payment operation using a `PayPal-Request-Id` key generated more than 6 hours earlier.

**Why it happens:** v1's 30-day window made long-window retries safe. Teams assume the same in v2.

**Resolution:** v2 keys expire after 6 hours. A retry after expiry creates a new transaction rather than returning the original. Generate fresh keys per attempt, or negotiate the 72-hour extended window with your Account Manager.

### 9. Reading `payer.payer_info` instead of `payment_source.[method]` [#9-reading-payerpayer_info-instead-of-payment_sourcemethod]

**Mistake:** Reading `response.payer.payer_info.email` or `response.payer.payer_info.payer_id` from v2 responses.

**Why it happens:** v1's `payer_info` was a consistent top-level object on every payment response.

**Resolution:** v2 returns buyer identity inside `payment_source.[method].*`. For PayPal payments: `payment_source.paypal.email_address`, `payment_source.paypal.account_id` (not `payer_id`), `payment_source.paypal.name.given_name`, `payment_source.paypal.name.surname`. The `payer.payer_info` object does not exist in v2.

### 10. Sending `fmf_details` on reauthorize [#10-sending-fmf_details-on-reauthorize]

**Mistake:** Including `fmf_details` or `processor_response` in the reauthorize request body as in v1.

**Why it happens:** v1's reauthorize endpoint accepted these fields for FMF-aware re-authorization.

**Resolution:** v2's reauthorize endpoint accepts only `amount`. Including `fmf_details` causes a validation error. Remove these fields. Handle FMF logic outside the API call in v2.
