# Orders API v1 → v2 integration upgrade guide (/api/rest/integration/orders-api/v1-v2-migration)



Upgrade your existing PayPal Orders API v1 integration to v2.

The Orders API is a server-side API for creating a checkout order and capturing or authorizing payment after the buyer approves it. Orders v2 introduces a unified `payment_source` model for PayPal, cards, Google Pay, and Apple Pay.

If you currently use v1, upgrading to v2 provides safer retries with idempotency keys, webhooks in place of IPN, and support for order updates and shipment tracking after checkout. 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 Orders v1 integration to v2. It assumes familiarity with the Orders API, OAuth 2.0, and your own codebase's current v1 implementation.

## Migration snapshot [#migration-snapshot]

* **Effort:** Tier 1, structural refactor, plus 2 gaps to resolve first.
* **Auth changes:** None. Same OAuth 2.0.
* **Platform enrollment:** None required for most integrations. `payment_instruction` fields (`platform_fees`, `disbursement_mode`) require PayPal Complete Payments Platform approval.
* **Endpoints migrated:** 8 operations.

## Prerequisites [#prerequisites]

* An existing Orders v1 integration to audit and migrate.
* OAuth 2.0 client credentials for both sandbox and live. Sandbox credentials do not work in the live environment.
* 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 `partner_fee_details` or `disbursement_mode`. Most integrations do not need this.

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

1. **Cancel order has no v2 equivalent:** `DELETE /v1/checkout/orders/{id}` does not exist in v2. If your integration cancels orders, redesign that flow before migrating.
2. **`POST .../pay` has been split:** The single v1 `"pay"` call becomes either `POST .../capture` or `POST .../authorize` depending on your order intent. This is a flow change, not a rename.

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

### Search strings [#search-strings]

Find in your project:

* `/v1/checkout/orders`
* `POST /v1/checkout/orders`
* `DELETE /v1/checkout/orders/`
* `/v1/checkout/orders/{id}/pay`
* `intent.*SALE`
* `disbursement_mode`
* `notify_url`
* `partner_fee_details`
* `payment_linked_group`
* `gross_total_amount`
* `supplementary_data`
* `risk_correlation_id`
* `redirect_urls`
* `approval_url`
* `payment_summary`
* `payer_info`

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

| Item                                                              | Status  | Notes                                                                                     |
| ----------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------- |
| OAuth 2.0 Bearer token                                            | Yes     | Same token endpoint, same pattern                                                         |
| `PayPal-Partner-Attribution-Id`                                   | Yes     | Still supported                                                                           |
| `application_context.brand_name`, `locale`, `shipping_preference` | No      | Moved to `payment_source.{source}.experience_context`                                     |
| `application_context.user_action`                                 | No      | Moved to `experience_context.user_action`; value "commit" becomes "PAY\_NOW"              |
| `purchase_units[]` array structure                                | Partial | Array retained; field names and nesting changed                                           |
| `intent: "AUTHORIZE"`                                             | Yes     | Unchanged                                                                                 |
| `intent: "SALE"`                                                  | No      | Renamed to `"CAPTURE"`                                                                    |
| `POST .../pay` endpoint                                           | No      | Split into `capture` and `authorize`                                                      |
| `DELETE .../orders/{id}`                                          | No      | No `cancel` endpoint in v2                                                                |
| `amount.total` / `amount.currency`                                | No      | Renamed to `value` / `currency_code`                                                      |
| `amount.details` sub-fields                                       | No      | Replaced by `amount.breakdown` Money objects                                              |
| `partner_fee_details`                                             | No      | Replaced by `payment_instruction.platform_fees[]`, PayPal Complete Payments Platform only |
| `disbursement_mode` on pay body                                   | No      | Moved to create-time `payment_instruction`, PayPal Complete Payments Platform only        |
| `notify_url` IPN                                                  | No      | Replaced by webhooks                                                                      |
| `gross_total_amount`                                              | No      | No v2 equivalent                                                                          |
| `payment_linked_group`                                            | No      | No v2 equivalent                                                                          |
| Address fields (`line1`, `city`, `state`)                         | No      | Renamed throughout                                                                        |

## 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, request field, and response field to its v2 equivalent.

### Endpoint mapping [#endpoint-mapping]

| v1 endpoint                             | v2 replacement                            | Category       |
| --------------------------------------- | ----------------------------------------- | -------------- |
| `POST /v1/checkout/orders`              | `POST /v2/checkout/orders`                | Restructured   |
| `GET /v1/checkout/orders/{order_id}`    | `GET /v2/checkout/orders/{id}`            | Direct         |
| `DELETE /v1/checkout/orders/{order_id}` | No equivalent                             | No replacement |
| `POST .../pay` (`SALE` intent)          | `POST /v2/checkout/orders/{id}/capture`   | Split          |
| `POST .../pay` (`AUTHORIZE` intent)     | `POST /v2/checkout/orders/{id}/authorize` | Split          |
| No equivalent                           | `PATCH /v2/checkout/orders/{id}`          | New in v2      |
| No equivalent                           | `POST .../confirm-payment-source`         | New in v2      |
| No equivalent                           | `POST .../track`                          | New in v2      |

### Request field mapping (create order) [#request-field-mapping-create-order]

| v1 field                                         | v2 field                                                                                    | Category          |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------- | ----------------- |
| `intent: "SALE"`                                 | `intent: "CAPTURE"`                                                                         | Behavioral change |
| `redirect_urls.return_url`                       | `payment_source.{source}.experience_context.return_url`                                     | Restructured      |
| `redirect_urls.cancel_url`                       | `payment_source.{source}.experience_context.cancel_url`                                     | Restructured      |
| `amount.currency`                                | `amount.currency_code`                                                                      | Restructured      |
| `amount.total`                                   | `amount.value`                                                                              | Restructured      |
| `amount.details.subtotal`                        | `amount.breakdown.item_total.value`                                                         | Restructured      |
| `amount.details.tax`                             | `amount.breakdown.tax_total.value`                                                          | Restructured      |
| `amount.details.shipping`                        | `amount.breakdown.shipping.value`                                                           | Restructured      |
| `amount.details.handling_fee`                    | `amount.breakdown.handling.value`                                                           | Restructured      |
| `purchase_units[].custom`                        | `purchase_units[].custom_id`                                                                | Restructured      |
| `items[].price` and `items[].currency`           | `items[].unit_amount.value` and `items[].unit_amount.currency_code`                         | Restructured      |
| `shipping_address.line1`                         | `shipping.address.address_line_1`                                                           | Restructured      |
| `shipping_address.city`                          | `shipping.address.admin_area_2`                                                             | Restructured      |
| `shipping_address.state`                         | `shipping.address.admin_area_1`                                                             | Restructured      |
| `shipping_address.recipient_name`                | `shipping.name.full_name`                                                                   | Restructured      |
| `partner_fee_details`                            | `payment_instruction.platform_fees[]` (PayPal Complete Payments Platform only)              | Restructured      |
| `disbursement_mode` (on pay body)                | `payment_instruction.disbursement_mode`, on create (PayPal Complete Payments Platform only) | Restructured      |
| `notify_url`                                     | Webhooks                                                                                    | Different product |
| `gross_total_amount`                             | No equivalent                                                                               | No replacement    |
| `payment_linked_group`                           | No equivalent                                                                               | No replacement    |
| `supplementary_data[name="risk_correlation_id"]` | `PayPal-Client-Metadata-Id` request header                                                  | Restructured      |
| `supplementary_data[name="buyer_ipaddress"]`     | No equivalent                                                                               | No replacement    |
| `supplementary_data[name="external_channel"]`    | No equivalent                                                                               | No replacement    |
| `application_context.brand_name`                 | `payment_source.{source}.experience_context.brand_name`                                     | Restructured      |
| `application_context.locale`                     | `payment_source.{source}.experience_context.locale`                                         | Restructured      |
| `application_context.shipping_preference`        | `payment_source.{source}.experience_context.shipping_preference`                            | Restructured      |
| `application_context.user_action: "commit"`      | `payment_source.{source}.experience_context.user_action: "PAY_NOW"`                         | Behavioral change |

### Response field mapping [#response-field-mapping]

| v1 response field                             | v2 response field                                                                       | Category          |
| --------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------- |
| `purchase_units[].payment_summary.captures[]` | `purchase_units[].payments.captures[]`                                                  | Restructured      |
| `purchase_units[].payment_summary.sales[]`    | No equivalent                                                                           | No replacement    |
| `capture.transaction_fee`                     | `capture.seller_receivable_breakdown.paypal_fee`                                        | Restructured      |
| HATEOAS `rel: "approval_url"`                 | `rel: "approve"` (no `payment_source`) or `rel: "payer-action"` (with `payment_source`) | Behavioral change |
| `payer_info.email`                            | `payer.email_address`                                                                   | Restructured      |
| `payer_info.first_name`                       | `payer.name.given_name`                                                                 | Restructured      |
| `payer_info.last_name`                        | `payer.name.surname`                                                                    | Restructured      |
| `order.status: "FAILED"`                      | `order.status: "VOIDED"`                                                                | Behavioral change |

## Step 3: Update create order [#step-3-update-create-order]

Update your create order request field by field, starting with the `intent` value.

### Intent: `SALE` to `CAPTURE` [#intent-sale-to-capture]

**Most common migration failure:** Sending `"intent": "SALE"` to the v2 endpoint returns a `400 Bad Request` validation error. This is the highest-frequency mistake in this migration.

v1:

```json
{ "intent": "SALE" }
```

v2:

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

### Redirect URLs, moved into `payment_source.{source}.experience_context` [#redirect-urls-moved-into-payment_sourcesourceexperience_context]

v1 sends `return_url` and `cancel_url` at the top level of the request. v2 nests them inside `experience_context` under the specific payment source.

v1, top-level (required):

```json
{
  "redirect_urls": {
    "return_url": "https://.../return",
    "cancel_url": "https://.../cancel"
  }
}
```

v2, per payment source (examples):

```text
// PayPal Wallet
"payment_source": {
  "paypal": {
    "experience_context": {
      "return_url": "https://.../return",
      "cancel_url": "https://.../cancel"
    }
  }
}

// Google Pay
"payment_source": {
  "google_pay": {
    "experience_context": {
      "return_url": "https://.../return",
      "cancel_url": "https://.../cancel"
    }
  }
}

// Apple Pay
"payment_source": {
  "apple_pay": {
    "experience_context": {
      "return_url": "https://.../return",
      "cancel_url": "https://.../cancel"
    }
  }
}

// Card
"payment_source": {
  "card": {
    "experience_context": {
      "return_url": "https://.../return",
      "cancel_url": "https://.../cancel"
    }
  }
}
```

> **Note:** `experience_context` is a per-source pattern. `application_context` is deprecated. In v2, `return_url`, `cancel_url`, `brand_name`, `locale`, `shipping_preference`, and `user_action` all move inside an `experience_context` object nested under the specific payment source being used. The top-level `application_context` object is deprecated. Do not use it for new integrations.

### Amount structure: v1 compared to v2 [#amount-structure-v1-compared-to-v2]

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"
      }
    }
  }
}
```

> **Note:** Breakdown validation is enforced. v2 validates that `value` equals the sum of all breakdown components. A mismatch returns a `422 Unprocessable Entity`. Validate this sum server-side before calling the API.

### Item fields: v1 compared to v2 [#item-fields-v1-compared-to-v2]

v2 restructures item pricing into a `unit_amount` object with `currency_code`, replacing the flat price and currency strings from v1.

v1:

```json
{
  "name": "NeoPhone",
  "sku": "sku03",
  "price": "99.99",
  "currency": "USD",
  "quantity": "1",
  "tax": "5.00"
}
```

v2:

```json
{
  "name": "NeoPhone",
  "sku": "sku03",
  "unit_amount": {
    "currency_code": "USD",
    "value": "99.99"
  },
  "tax": {
    "currency_code": "USD",
    "value": "5.00"
  },
  "quantity": "1",
  "category": "PHYSICAL_GOODS"
}
```

### Shipping address: v1 compared to v2 [#shipping-address-v1-compared-to-v2]

v2 renames `shipping_address` to `shipping.address` and separates `recipient_name` into its own `shipping.name` object.

v1:

```json
{
  "shipping_address": {
    "recipient_name": "John Doe",
    "line1": "2211 N First Street",
    "line2": "Building 17",
    "city": "San Jose",
    "state": "CA",
    "postal_code": "95131",
    "country_code": "US"
  }
}
```

v2:

```json
{
  "shipping": {
    "name": {
      "full_name": "John Doe"
    },
    "address": {
      "address_line_1": "2211 N First Street",
      "address_line_2": "Building 17",
      "admin_area_2": "San Jose",
      "admin_area_1": "CA",
      "postal_code": "95131",
      "country_code": "US"
    }
  }
}
```

### Partner fee [#partner-fee]

The following requests show how `partner_fee_details` in v1 maps to the `payment_instruction.platform_fees[]` array in v2.

> **Note:** **PayPal Complete Payments Platform only:** The `payment_instruction.platform_fees[]` object is available exclusively to approved PayPal Complete Payments Platform accounts. Including this object without the required enrollment returns a permissions error. See [Get Started with Multiparty](https://developer.paypal.com/platforms/get-started) to apply for access before implementing this feature.

v1:

```json
{
  "partner_fee_details": {
    "receiver": {
      "email": "partner@example.com"
    },
    "amount": {
      "currency": "USD",
      "value": "1.00"
    }
  }
}
```

v2:

```json
{
  "payment_instruction": {
    "platform_fees": [
      {
        "amount": {
          "currency_code": "USD",
          "value": "1.00"
        },
        "payee": {
          "email_address": "partner@example.com"
        }
      }
    ]
  }
}
```

### Disbursement mode, moved to create time [#disbursement-mode-moved-to-create-time]

**Timing change:** In v1, you sent `disbursement_mode` in the body of a `POST .../pay` request at execute time. In v2, you need to set `disbursement_mode` in the `purchase_units[].payment_instruction` object when you create the order. If you pass `disbursement_mode` in the body of a v2 `capture` request, the API accepts the value but doesn't apply it.

> **Note:** **PayPal Complete Payments Platform only:** The `payment_instruction` object and `disbursement_mode` field are available exclusively to approved PayPal Complete Payments Platform accounts. See [Get Started with Multiparty](https://developer.paypal.com/platforms/get-started) to apply for access before implementing this feature.

v2, set on create order:

```json
{
  "purchase_units": [
    {
      "payment_instruction": {
        "disbursement_mode": "DELAYED"
      }
    }
  ]
}
```

### `supplementary_data` (`risk_correlation_id`), moved to request header [#supplementary_data-risk_correlation_id-moved-to-request-header]

v1, request body:

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

v2, request header:

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

> **Note:** Body to header. The `risk_correlation_id` value moves out of the request body entirely. Pass it as the `PayPal-Client-Metadata-Id` HTTP request header, a GUID from Fraudnet or Dyson used by PayPal's risk systems to correlate calls and reduce decline rates.

> **Important:** `buyer_ipaddress` and `external_channel` have no v2 equivalent. These two `supplementary_data` entries are not replicated anywhere in the v2 API. Remove them from your integration.

### Create order request: complete v1 and v2 comparison [#create-order-request-complete-v1-and-v2-comparison]

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

v1, create order:

```http
POST /v1/checkout/orders

{
  "intent": "SALE",
  "redirect_urls": {
    "return_url": "https://example.com/return",
    "cancel_url": "https://example.com/cancel"
  },
  "purchase_units": [
    {
      "reference_id": "order_1234",
      "custom": "my-internal-ref",
      "invoice_number": "INV-001",
      "amount": {
        "currency": "USD",
        "total": "107.47",
        "details": {
          "subtotal": "99.99",
          "tax": "5.48",
          "shipping": "2.00"
        }
      },
      "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:

Replace `"paypal"` with `"google_pay"`, `"apple_pay"`, or `"card"` as needed. The `experience_context` object nests the same way for each.

```http
POST /v2/checkout/orders

{
  "intent": "CAPTURE",
  "payment_source": {
    "paypal": {
      "experience_context": {
        "return_url": "https://example.com/return",
        "cancel_url": "https://example.com/cancel"
      }
    },
    // Replace "paypal" with "google_pay", "apple_pay", or "card"
    // as needed. experience_context nests the same way for each.
  },
  "purchase_units": [
    {
      "reference_id": "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 order execution [#step-4-update-order-execution]

The single v1 pay call splits into two dedicated v2 endpoints, `capture` or `authorize`, depending on your order's `intent`.

### The split [#the-split]

| v1 intent              | v1 endpoint    | v2 endpoint                               |
| ---------------------- | -------------- | ----------------------------------------- |
| `SALE` (now `CAPTURE`) | `POST .../pay` | `POST /v2/checkout/orders/{id}/capture`   |
| `AUTHORIZE`            | `POST .../pay` | `POST /v2/checkout/orders/{id}/authorize` |

### Capture, replaces `SALE` to pay [#capture-replaces-sale-to-pay]

v1 finalizes a `SALE`-intent order with a `POST .../pay` call. v2 replaces it with a dedicated `capture` endpoint that takes an empty body.

v1:

```http
POST /v1/checkout/orders/{id}/pay

{
  "disbursement_mode": "INSTANT"
}
```

v2:

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

{}
```

The `capture` body is empty, or contains only `payment_source` for advanced flows. Set the `disbursement_mode` at order creation time instead.

### Authorize, replaces `AUTHORIZE` to pay [#authorize-replaces-authorize-to-pay]

v1 finalizes an `AUTHORIZE`-intent order with the same `POST .../pay` call. v2 replaces it with a dedicated `authorize` endpoint, followed by a separate call to capture the authorization.

v1:

```http
POST /v1/checkout/orders/{id}/pay

{
  "disbursement_mode": "INSTANT"
}
```

v2:

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

{}

# Then capture the authorization:
POST /v2/payments/authorizations/{auth_id}/capture
```

> **Note:** Finding the authorization ID. After authorizing, the authorization ID is at `purchase_units[].payments.authorizations[0].id`. Use it to call `POST /v2/payments/authorizations/{auth_id}/capture` using the Payments v2 API.

## Step 5: Handle the cancel order gap [#step-5-handle-the-cancel-order-gap]

> **Important:** `DELETE /v1/checkout/orders/{id}` has no v2 equivalent. v2 does not expose a cancel or delete order endpoint. Calling `DELETE /v2/checkout/orders/{id}` returns `404 Not Found`.

Available strategies:

* **Do nothing for abandonment:** Orders that are never approved or captured expire automatically. For example, when you cancel an order as a defensive cleanup in response to user abandonment, the order expires automatically.
* **Cancel in your system only:** Record the cancellation server-side and decline to call `capture` or `authorize`. The order expires without any further API calls.
* **Update `CANCELED` status references:** This status does not exist in v2. Update any business logic or database state machines that rely on a `CANCELED` order status.

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

> **Important:** If you include `notify_url` in a v2 create order request, the API accepts the field but never fires an IPN notification for it.

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

```http
POST /v1/notifications/webhooks

{
  "url": "https://your-server.com/paypal-webhook",
  "event_types": [
    { "name": "CHECKOUT.ORDER.APPROVED" },
    { "name": "PAYMENT.CAPTURE.COMPLETED" },
    { "name": "PAYMENT.CAPTURE.PENDING" },
    { "name": "PAYMENT.CAPTURE.DENIED" },
    { "name": "PAYMENT.CAPTURE.REFUNDED" },
    { "name": "PAYMENT.AUTHORIZATION.CREATED" },
    { "name": "PAYMENT.AUTHORIZATION.VOIDED" }
  ]
}
```

### Event mapping [#event-mapping]

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

### Signature verification [#signature-verification]

Always verify webhook payloads before processing. Do not reuse IPN validation logic.

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

{
  "auth_algo": "SHA256withRSA",
  "cert_url": "...",
  "transmission_id": "...",
  "transmission_sig": "...",
  "transmission_time": "...",
  "webhook_id": "your-webhook-id",
  "webhook_event": { /* full event payload */ }
}
```

## Step 7: Response structure changes [#step-7-response-structure-changes]

Several response fields change shape or value between v1 and v2, starting with how the API returns payer information.

### Payer information: v1 compared to v2 [#payer-information-v1-compared-to-v2]

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

v1, payer\_info:

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

v2, payer:

```json
{
  "payer": {
    "email_address": "buyer@example.com",
    "name": {
      "given_name": "John",
      "surname": "Doe"
    },
    "payer_id": "HPQAUP5WC3J8U"
  }
}
```

### HATEOAS approval link [#hateoas-approval-link]

The v1 `rel: "approval_url"` link is replaced in v2, but the exact replacement `rel` depends on how the order was created:

| Create order request     | v2 HATEOAS `rel` returned | Meaning                                                                                  |
| ------------------------ | ------------------------- | ---------------------------------------------------------------------------------------- |
| Without `payment_source` | `"approve"`               | Standard redirect. PayPal redirects the buyer to approve.                                |
| With `payment_source`    | `"payer-action"`          | Buyer may need to complete an additional step, such as 3D Secure or wallet confirmation. |

The following responses show the exact `rel` value returned in each scenario from preceding table.

v1:

```json
{ "rel": "approval_url" }
```

v2, without `payment_source`:

```json
{ "rel": "approve" }
```

v2, with `payment_source`:

```json
{ "rel": "payer-action" }
```

> **Important:** Don't hardcode a specific `rel` value. Because the `rel` returned by v2 depends on whether `payment_source` was included at create time, hardcoding a filter for `"approval_url"`, `"approve"`, or `"payer-action"` alone will break for at least one integration pattern. Navigate HATEOAS links dynamically. Check for whichever `rel` is present in the response and redirect the buyer to that URL.

### Order status values [#order-status-values]

| v1 status             | v2 status               | Notes                                                |
| --------------------- | ----------------------- | ---------------------------------------------------- |
| `CREATED`             | `CREATED`               | Same                                                 |
| `APPROVED`            | `APPROVED`              | Same                                                 |
| `COMPLETED`           | `COMPLETED`             | Same                                                 |
| `FAILED`              | `VOIDED`                | Terminal failure renamed                             |
| `IN_PROGRESS`         | No equivalent           | Not surfaced in v2                                   |
| `PARTIALLY_COMPLETED` | No equivalent           | Not surfaced in v2                                   |
| `CANCELED`            | No equivalent           | No cancel endpoint, so no `CANCELED` state           |
| No equivalent         | `PAYER_ACTION_REQUIRED` | New v2 state (3D Secure or additional action needed) |

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

| Feature                           | Endpoint or mechanism            | Why it matters                                                                                   |
| --------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------ |
| Update order                      | `PATCH /v2/checkout/orders/{id}` | Modify amount, items, or shipping post-create, pre-capture. Supports dynamic pricing.            |
| Idempotency                       | `PayPal-Request-Id` header       | Safely retry create, capture, and authorize calls without duplicate charges.                     |
| Minimal compared to full response | `Prefer: return=representation`  | Returns the full order resource in one call, avoiding a follow-up `GET` request.                 |
| Shipment tracking                 | `POST .../track`                 | Attach carrier tracking to captured orders. Improves seller protection eligibility.              |
| Order update callback             | Callback URL on create           | Real-time shipping address and option updates during the buyer checkout flow.                    |
| `seller_receivable_breakdown`     | In `capture` response            | Full fee transparency: gross amount, PayPal fee, net. Replaces the bare `transaction_fee` field. |

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

**Sandbox setup:** Use `api-m.sandbox.paypal.com`. Create sandbox buyer and seller accounts at developer.paypal.com. Sandbox OAuth credentials do not work in the live environment.

| Scenario                               | What to verify                                                                                                                                                                 |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Create with CAPTURE intent             | `201 Created`; no intent validation error                                                                                                                                      |
| Create with AUTHORIZE intent           | `201 Created`; HATEOAS authorize link present                                                                                                                                  |
| Buyer approval via HATEOAS link        | Order status moves to APPROVED. Confirm `rel: "approve"` is returned when there's no `payment_source`, and `rel: "payer-action"` is returned when `payment_source` is present. |
| Capture approved order                 | `201`; `purchase_units[].payments.captures[0].id` present; status `COMPLETED`                                                                                                  |
| Authorize approved order               | Authorization ID present in `purchase_units[].payments.authorizations[0].id`                                                                                                   |
| Amount breakdown mismatch              | `422` returned when value does not equal the sum of breakdown                                                                                                                  |
| Address with v1 field names            | Validation error; confirm `admin_area_1` and `admin_area_2` are accepted                                                                                                       |
| `PATCH` order amount                   | `204 No Content`; `GET` reflects the updated amount                                                                                                                            |
| Webhook delivery                       | `PAYMENT.CAPTURE.COMPLETED` fires; signature verification passes                                                                                                               |
| `notify_url` absent                    | No IPN delivered; confirm webhook is used instead                                                                                                                              |
| `disbursement_mode: DELAYED` on create | Capture succeeds; funds held per disbursement model. PayPal Complete Payments Platform only.                                                                                   |
| `custom_id` round-trip                 | Value present in create, `GET`, and capture response                                                                                                                           |
| HATEOAS `rel` conditional behavior     | Confirm `rel: "approve"` when there's no `payment_source`, `rel: "payer-action"` when `payment_source` is included, and that redirect logic handles both                       |

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

* Switch base URL to `api-m.paypal.com`.
* Replace sandbox OAuth credentials with live client ID and secret.
* All `intent: "SALE"` replaced with `intent: "CAPTURE"`.
* All `POST .../pay` calls replaced with `capture` or `authorize`.
* `disbursement_mode` set at create time, not capture time. Skip this item if it doesn't apply to your integration. PayPal Complete Payments Platform only.
* IPN endpoints decommissioned. Live webhook listeners active and verified.
* `notify_url` removed from all create order requests.
* Cancel order logic updated or removed.
* Log `debug_id` from all non-2xx responses for PayPal support escalation.

## Migration checklist [#migration-checklist]

* Update base URL from `/v1/checkout/orders` to `/v2/checkout/orders`.
* Replace `intent: "SALE"` with `intent: "CAPTURE"`.
* Move `redirect_urls.return_url` and `cancel_url` into `payment_source.{source}.experience_context` where `{source}` is `paypal`, `google_pay`, `apple_pay`, or `card`.
* Move `application_context.brand_name` to `payment_source.{source}.experience_context.brand_name`.
* Move `application_context.locale` to `payment_source.{source}.experience_context.locale`.
* Move `application_context.shipping_preference` to `payment_source.{source}.experience_context.shipping_preference`.
* Move `application_context.user_action` to `payment_source.{source}.experience_context.user_action`. Rename the value `"commit"` to `"PAY_NOW"`.
* Remove `application_context` from create order requests entirely. Use `experience_context` per payment source instead.
* Replace `supplementary_data[name="risk_correlation_id"]` body entry with the `PayPal-Client-Metadata-Id` request header.
* Remove `supplementary_data` entries for `buyer_ipaddress` and `external_channel`. No v2 equivalent exists.
* Rename `amount.currency` to `amount.currency_code`.
* Rename `amount.total` to `amount.value`.
* Rename `amount.details` to `amount.breakdown`. Convert all sub-fields to Money objects.
* Convert item `price` and `currency` to `unit_amount.value` and `unit_amount.currency_code`.
* Convert item `tax` string to a Money object with `currency_code`.
* Rename `shipping_address` to `shipping.address`. Rename all address sub-fields to match.
* Move `recipient_name` to `shipping.name.full_name`.
* Rename `custom` to `custom_id` in all purchase units.
* Replace `POST .../pay` with `POST .../capture` for `CAPTURE` intent.
* Replace `POST .../pay` with `POST .../authorize` for `AUTHORIZE` intent.
* Remove `disbursement_mode` from the capture body. Set it on create in `payment_instruction` instead. This requires PayPal Complete Payments Platform enrollment. See [Get Started with Multiparty](https://developer.paypal.com/platforms/get-started).
* Replace `partner_fee_details` with `payment_instruction.platform_fees[]`. This requires PayPal Complete Payments Platform enrollment. See [Get Started with Multiparty](https://developer.paypal.com/platforms/get-started).
* Update response parsing: change `payment_summary` to `payments` and `payer_info` to `payer`.
* Update HATEOAS `rel` navigation: change `approval_url` to `approve` when there's no `payment_source`, or to `payer-action` when `payment_source` is present. Navigate dynamically, and never hardcode either value.
* Update order status handling for renamed states (`FAILED` to `VOIDED`).
* Remove all `notify_url` fields. Register and verify webhooks.
* Update or remove cancel order logic. v2 has no cancel endpoint.
* Add the `PayPal-Request-Id` header on create, capture, and authorize calls.
* Run all test scenarios 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 v2 [#1-sending-intent-sale-to-v2]

**Mistake:** Passing `"intent": "SALE"` unchanged from v1 to the v2 create order endpoint.

**Why it happens:** The field name is identical; only the value changed.

**Resolution:** v2 returns a `400 Bad Request` validation error. Replace with `"CAPTURE"`. This is the most frequent migration failure in live cutover.

### 2. Still calling `POST .../pay` [#2-still-calling-post-pay]

**Mistake:** Updating the base URL to v2 but keeping the `/pay` path.

**Why it happens:** Developers update the host but not the operation-specific path.

**Resolution:** The pay endpoint does not exist in v2. Route to `/capture` or `/authorize` based on the order's `intent`. v2 returns `404` for the old path.

### 3. Setting `disbursement_mode` on capture [#3-setting-disbursement_mode-on-capture]

**Mistake:** Sending `disbursement_mode` in the capture request body as in v1.

**Resolution:** Set `disbursement_mode` in `purchase_units[].payment_instruction.disbursement_mode` on create order or using `PATCH` before capture. If you send `disbursement_mode` on the capture call instead, the API either accepts the value without applying it or returns a validation error, depending on the request. The `payment_instruction` and `disbursement_mode` objects are available to PayPal Complete Payments Platform accounts only. If PayPal hasn't approved your account, this field is not available regardless of where you send it. See [Get Started with Multiparty](https://developer.paypal.com/platforms/get-started).

### 4. Hardcoding a specific `rel` value to extract the approval URL [#4-hardcoding-a-specific-rel-value-to-extract-the-approval-url]

**Mistake:** Filtering HATEOAS links for a hardcoded `rel` string, whether `"approval_url"` (v1), `"approve"`, or `"payer-action"`.

**Why it happens:** v1 always returned `"approval_url"`. In v2, the `rel` is conditional: `"approve"` is returned when the order is created without a `payment_source`, and `"payer-action"` is returned when a `payment_source` is included. Developers who only test one path miss the other.

**Resolution:** Navigate HATEOAS links dynamically. If your integration creates orders both with and without a `payment_source`, your redirect logic needs to handle both `"approve"` and `"payer-action"`. If your redirect logic filters for only one `rel` value, it fails to find a match for the other integration pattern, and no error surfaces to indicate why.

### 5. Calling `DELETE` to cancel an order [#5-calling-delete-to-cancel-an-order]

**Mistake:** Calling `DELETE /v2/checkout/orders/{id}` expecting a `204`.

**Resolution:** v2 returns `404`. Remove or reroute cancel logic. Orders expire naturally; explicit cancellation is not required and not supported.

### 6. Parsing `payment_summary` instead of payments [#6-parsing-payment_summary-instead-of-payments]

**Mistake:** Reading capture IDs from `purchase_units[].payment_summary.captures[0].id`.

**Resolution:** In v2, captures are at `purchase_units[].payments.captures[0].id`. Orders v2 renames the `payment_summary` object to `payments`. When you pass the wrong path, the API returns `undefined` without throwing an error, making this easy to miss.

### 7. Amount breakdown validation failures [#7-amount-breakdown-validation-failures]

**Mistake:** Setting `amount.value` to a number that doesn't equal the sum of breakdown components.

**Why it happens:** v1's `amount.details` was informational and not validated server-side. v2 enforces this sum.

**Resolution:** v2 returns `422 Unprocessable Entity` on mismatch. Validate `item_total + tax_total + shipping + handling + insurance - shipping_discount - discount = value` before calling the API.

### 8. Expecting IPN delivery via `notify_url` [#8-expecting-ipn-delivery-via-notify_url]

**Mistake:** Including `notify_url` in v2 create order requests and waiting for IPN callbacks.

**Resolution:** If you include `notify_url` in a v2 request, the API accepts the field but never sends an IPN. Register webhooks and validate that `PAYMENT.CAPTURE.COMPLETED` events are received before removing v1 IPN listeners from live traffic.

### 9. Reading `payer_info` in v2 responses [#9-reading-payer_info-in-v2-responses]

**Mistake:** Reading payer details from `response.payer_info.first_name` in v2.

**Resolution:** v2 uses `payer.email_address`, `payer.name.given_name`, and `payer.name.surname`. The `payer_info` object does not exist in v2 responses.

### 10. Sending v1 address field names to v2 [#10-sending-v1-address-field-names-to-v2]

**Mistake:** Sending `shipping_address.city`, `shipping_address.state`, and `shipping_address.line1` to v2.

**Resolution:** v2 address fields are `shipping.address.address_line_1`, `admin_area_2` (city), and `admin_area_1` (state or province). If you send v1 field names instead, the API either discards the unrecognized fields without applying them or returns a validation error for any field it requires.
