OverviewAnchorIcon

Use the server-side shipping callback to dynamically update shipping options, taxes, and order totals when a consumer changes their shipping address or selects a different shipping option during PayPal checkout.

EligibilityAnchorIcon

  • One-time payments, with or without saving the payment method — subscriptions, recurring billing, and saving a payment method without an immediate purchase are not eligible.
  • Pay Now flow only — the Continue flow is not supported.
  • Shipping must be enabled — Server-side shipping callbacks only work when enableShippingAddress: true in the createPayment() configuration. If shipping is disabled (enableShippingAddress: false), the shippingCallbackUrl parameter is ignored and no callbacks are sent, even if a callback URL is configured.

How it worksAnchorIcon

The shipping callback creates a real-time communication loop between PayPal and your server while the consumer is in the PayPal paysheet.

  1. Merchant passes shippingCallbackUrl in createPayment() options
  2. Buyer clicks PayPal button, logs in, and sees the paysheet
  3. Paysheet is immediately pre-populated with the buyer's default wallet address (if one exists on file)
  4. On login, PayPal immediately calls your server with the buyer's default wallet address. Subsequent callbacks fire when the buyer changes their shipping address or selects a different shipping option
  5. Your server responds with shipping options, tax, and updated totals
  6. PayPal updates the paysheet with your response

Consumer statesAnchorIcon

StateWhat the consumer sees
Loading After changing their shipping address or selecting a shipping option, the paysheet shows a loading indicator. Shipping options and totals are not interactive during this period.
Success The paysheet displays all shipping options from your shipping_options[] response. The option with selected: true is pre-selected and highlighted. The order total updates to reflect shipping cost and tax.
Error If your server returns HTTP 422 in PayPal's error format (for example, { "name": "UNPROCESSABLE_ENTITY", "details": [{ "issue": "ADDRESS_ERROR" }] }), PayPal displays an appropriate error message on the paysheet. The consumer can select a different address or option. Always leave at least one valid choice so the consumer is not stuck.
Timeout If your callback endpoint does not respond within the timeout window, the paysheet shows a generic error message. The consumer can retry or cancel.

PrerequisitesAnchorIcon

  1. Braintree JS SDK v3.137.0+ (v3.137.0+ recommended for latest stability)
  2. A Braintree sandbox account and a production account
  3. A publicly routable HTTPS endpoint for the callback URL
  4. Callback domain registered in the Braintree Control Panel
The callback URL must be reachable from PayPal's servers on the public internet. Internal domains, localhost, VPN-only hosts, and URLs that return redirects will not work. PayPal's callback service uses an outbound proxy that does not follow HTTP redirects.

Currency and intent matching requirement: The intent and currency values you pass to loadPayPalSDK()must match the values you pass to createPayment(). Mismatches cause silent failures or unexpected behavior.

Client-side setupAnchorIcon

// 1. Create a Braintree client instance
braintree.client.create(
  { authorization: "CLIENT_TOKEN_FROM_SERVER" },
  function (clientErr, clientInstance) {
    if (clientErr) { console.error("Error creating client:", clientErr); return; }

    // 2. Create a PayPal Checkout component
    braintree.paypalCheckout.create(
      { client: clientInstance },
      function (paypalCheckoutErr, paypalCheckoutInstance) {
        if (paypalCheckoutErr) { console.error("Error:", paypalCheckoutErr); return; }

        // 3. Load the PayPal JS SDK
        // IMPORTANT: currency and intent must match createPayment() below
        paypalCheckoutInstance.loadPayPalSDK(
          { currency: "USD", intent: "capture" },
          function () {
            // 4. Render PayPal buttons
            paypal.Buttons({
              fundingSource: paypal.FUNDING.PAYPAL,

              createOrder: function () {
                return paypalCheckoutInstance.createPayment({
                  flow: "checkout",           // Required: must be 'checkout'
                  amount: "10.00",            // Order subtotal
                  currency: "USD",            // Must match loadPayPalSDK currency
                  intent: "capture",          // Must match loadPayPalSDK intent
                  enableShippingAddress: true,    // Required: displays shipping in paysheet
                  shippingAddressEditable: true,  // Required: allows address changes
                  shippingCallbackUrl: "https://merchant.example.com/shipping-callback",
                });
              },

              onApprove: function (data, actions) {
                return paypalCheckoutInstance.tokenizePayment(data, function (err, payload) {
                  if (err) { console.error("Error tokenizing:", err); return; }
                  // Send payload.nonce to your server to create a transaction
                  console.log("Payment nonce:", payload.nonce);
                });
              },

              onCancel: function (data) { console.log("Payment cancelled", data); },
              onError: function (err) { console.error("PayPal error:", err); },
            }).render("#paypal-button-container");
          }
        );
      }
    );
  }
);

createPayment optionsAnchorIcon

OptionRequiredDescription
flowYesMust be "checkout".
amountYesOrder subtotal as a string (for example, '10.00').
currencyYes ISO 4217 currency code (for example, 'USD'). Must match loadPayPalSDK config.
intentYesMust be "capture". Must match loadPayPalSDK config.
enableShippingAddressYesSet to true to display shipping address in the paysheet.
shippingAddressEditableYes Set to true to allow the consumer to change their address, which triggers callbacks.
shippingCallbackUrlYes Full HTTPS URL of your server-side callback endpoint. The domain must be registered in the Braintree Control Panel.

Callback requestAnchorIcon

When the consumer changes their shipping address or selects a different shipping option, PayPal sends an HTTP POST to your shippingCallbackUrl with the following JSON body.

Request fieldsAnchorIcon

FieldTypeRequiredDescription
idstringYesThe PayPal order ID.
amountobjectYes Current order total. Contains value (string) and currency_code (string, ISO 4217).
item_totalstringNoSum of all line item amounts.
shippingstringNoCurrent shipping cost.
tax_totalstringNoCurrent tax amount.
handlingstringNoHandling fee included in the order total.
insurancestringNoInsurance cost included in the order total.
shipping_discountstringNoDiscount applied to shipping (subtracted from the total).
discountstringNoOrder-level discount (subtracted from the total).
shipping_addressobjectYes Consumer's selected shipping address (partial — no street address). Contains country_code, admin_area_1, admin_area_2, postal_code.
shipping_optionobjectNo The shipping option the consumer selected. Not present on the first callback.
line_itemsarrayNoArray of order line items (max 249).

Example: Initial address callback (no shipping option)AnchorIcon

{
  "id": "2SM98888S4900545P",
  "amount": { "value": "10.00", "currency_code": "USD" },
  "item_total": "10.00",
  "tax_total": "0.00",
  "shipping": "0.00",
  "shipping_address": {
    "admin_area_2": "San Jose",
    "admin_area_1": "CA",
    "postal_code": "95131",
    "country_code": "US"
  }
}

Example: Shipping option selection callbackAnchorIcon

{
  "id": "2SM98888S4900545P",
  "amount": { "value": "15.00", "currency_code": "USD" },
  "item_total": "10.00",
  "tax_total": "2.00",
  "shipping": "3.00",
  "shipping_address": {
    "admin_area_2": "San Jose",
    "admin_area_1": "CA",
    "postal_code": "95131",
    "country_code": "US"
  },
  "shipping_option": {
    "id": "standard",
    "description": "Standard Shipping",
    "type": "SHIPPING",
    "amount": { "value": "3.00", "currency_code": "USD" }
  }
}

Callback responseAnchorIcon

Your server must respond with HTTP 200 OK and a JSON body containing the updated order details and shipping options.

Response fieldsAnchorIcon

FieldTypeRequiredDescription
idstringYesThe PayPal order ID (same as the request id).
amountobjectYes Updated order total. The value must equal item_total + shipping + handling + tax_total + insuranceshipping_discountdiscount.
item_totalstringNoSum of all line item amounts.
shippingstringNoShipping cost for the selected option.
tax_totalstringNoCalculated tax for the shipping destination.
shipping_optionsarrayYesArray of 1–10 shipping options. Exactly one must have selected: true.

Shipping option fields (response)AnchorIcon

FieldTypeRequiredDescription
idstringYesUnique identifier for this option (must be unique within the array).
descriptionstringYesDisplay name shown to the consumer in the paysheet (max 127 characters).
selectedbooleanYes Whether this option is pre-selected. Exactly one option in the array must be true.
typestringYesSHIPPING, PICKUP_IN_STORE, or PICKUP_FROM_PERSON. Note: PICKUP is deprecated.
amountobjectYes Cost of this option. Contains value (string) and currency_code (string).

Merchant Success Response FieldsAnchorIcon

FieldTypeRequiredNotes
idstringyesOrder ID (e.g. 2SM98888S4900545P)
amount.valuestringyesTotal, decimal
amount.currency_codestringyese.g. USD
item_totalstringnoItem subtotal, decimal
shippingstringnoSelected shipping cost, decimal
handlingstringnoHandling fee, decimal
tax_totalstringnoTotal tax, decimal
insurancestringnoInsurance fee, decimal
shipping_discountstringnoShipping discount (subtracted), decimal
discountstringnoOrder discount (subtracted), decimal
shipping_optionsarrayyes1–10 items
shipping_options[].idstringyesOption identifier, max 127 chars
shipping_options[].descriptionstringyesDisplay text, max 127 chars (NOT label)
shipping_options[].selectedbooleannoOnly one true allowed
shipping_options[].typestringnoSHIPPING | PICKUP_IN_STORE | PICKUP_FROM_PERSON
shipping_options[].amount.valuestringnoOption price, decimal
shipping_options[].amount.currency_codestringnoe.g. USD

Merchant Success ResponseAnchorIcon

  1. json
{
  "id": "2SM98888S4900545P",
  "amount": {
    "currency_code": "USD",
    "value": "20.00"
  },
  "item_total": "20.00",
  "shipping": "0.00",
  "handling": "0.00",
  "tax_total": "0.00",
  "insurance": "0.00",
  "shipping_discount": "0.00",
  "discount": "0.00",
  "shipping_options": [
    {
      "id": "1",
      "description": "Free Shipping",
      "type": "SHIPPING",
      "selected": true,
      "amount": {
        "currency_code": "USD",
        "value": "0.00"
      }
    },
    {
      "id": "2",
      "description": "USPS Priority Shipping",
      "type": "SHIPPING",
      "selected": false,
      "amount": {
        "currency_code": "USD",
        "value": "7.00"
      }
    },
    {
      "id": "3",
      "description": "1-Day Shipping",
      "type": "SHIPPING",
      "selected": false,
      "amount": {
        "currency_code": "USD",
        "value": "10.00"
      }
    }
  ]
}

Server-side handlerAnchorIcon

const express = require("express");
const app = express();
app.use(express.json());

const SHIPPING_RATES = {
  US: [
    { id: "free",     description: "Free Shipping (5-7 business days)",     amount: "0.00",  type: "SHIPPING" },
    { id: "standard", description: "Standard Shipping (3-5 business days)", amount: "3.00",  type: "SHIPPING" },
    { id: "express",  description: "Express Shipping (1-2 business days)",  amount: "10.00", type: "SHIPPING" },
  ],
  CA: [
    { id: "standard_ca", description: "Standard to Canada (7-10 business days)", amount: "8.00",  type: "SHIPPING" },
    { id: "express_ca",  description: "Express to Canada (3-5 business days)",   amount: "18.00", type: "SHIPPING" },
  ],
};

const BLOCKED_COUNTRIES = ["CU", "IR", "KP", "SY"];

function getTaxRate(state) {
  return { CA: 0.0725, NY: 0.08, TX: 0.0625 }[state] || 0.05;
}

app.post("/shipping-callback", (req, res) => {
  const { id, amount, item_total, shipping_address, shipping_option } = req.body;

  const country = shipping_address.country_code;

  if (BLOCKED_COUNTRIES.includes(country)) {
    return res.status(422).json({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "COUNTRY_ERROR" }] });
  }

  const options = SHIPPING_RATES[country];
  if (!options) {
    return res.status(422).json({ name: "UNPROCESSABLE_ENTITY", details: [{ issue: "ADDRESS_ERROR" }] });
  }

  // On the first callback, shipping_option is absent — default to first option
  const selectedId = shipping_option?.id || options[0].id;
  const selectedOption = options.find((o) => o.id === selectedId) || options[0];
  const shippingCost = parseFloat(selectedOption.amount);

  const itemTotal = parseFloat(item_total || amount.value);
  const taxTotal = (itemTotal * getTaxRate(shipping_address.admin_area_1)).toFixed(2);
  const total = (itemTotal + shippingCost + parseFloat(taxTotal)).toFixed(2);

  return res.status(200).json({
    id,
    amount: { value: total, currency_code: amount.currency_code },
    item_total: itemTotal.toFixed(2),
    shipping: shippingCost.toFixed(2),
    tax_total: taxTotal,
    shipping_options: options.map((opt) => ({
      id: opt.id,
      description: opt.description,
      selected: opt.id === selectedOption.id,
      type: opt.type,
      amount: { value: opt.amount, currency_code: amount.currency_code },
    })),
  });
});

app.listen(3000);

Error handlingAnchorIcon

If you cannot fulfill shipping for the given address or option, respond with HTTP 422 using PayPal's error format: a name of UNPROCESSABLE_ENTITY and a details array whose issue is the error code.

{
  "name": "UNPROCESSABLE_ENTITY",
  "details": [{ "issue": "ADDRESS_ERROR" }]
}

Error codesAnchorIcon

Error code (details[].issue)When to useBehavior on the address callbackBehavior on the option-selection callback
ADDRESS_ERRORThe buyer's address cannot be served at all. Address is rejected; the buyer is prompted to choose or add another address (generic message — no per-code text). Not this context — the buyer sees the generic "shipping method unavailable" message. Return an option code here instead.
COUNTRY_ERRORYou do not ship to the selected country.Same generic address rejection as ADDRESS_ERROR.Generic "shipping method unavailable".
STATE_ERRORYou do not ship to the selected state or province.Same generic address rejection.Generic "shipping method unavailable".
ZIP_ERRORYou do not ship to the selected postal code.Same generic address rejection.Generic "shipping method unavailable".
METHOD_UNAVAILABLEThe selected shipping method cannot fulfill this order.Not this context — use an address code on the address callback. "The shipping method you chose is unavailable. To continue, choose another way to get your order."
STORE_UNAVAILABLEA store or pickup location cannot fulfill part of the order.Not this context."Part of your order isn't available at this store."

Domain registrationAnchorIcon

You must register your callback domain in the Braintree Control Panel before PayPal will send callbacks to your endpoint. Register separately in both sandbox and production.

Domain rulesAnchorIcon

  • Length: 4–255 characters.
  • No URL scheme: Do not include https://. Register the domain only (for example, merchant.example.com).
  • No wildcards: Patterns like *.example.com are not allowed.
  • Subdomain-specific: Registering example.com does not cover api.example.com. Register each subdomain separately.
  • Characters: Alphanumeric and hyphens only. Hyphens must be between segments.
  • Top-level domain: 2–63 letters.
  • Second-level domain and subdomains: 1–63 alphanumeric characters and hyphens; cannot start or end with a hyphen.
  • Trailing dot: A domain may end with a trailing dot.

Registration stepsAnchorIcon

Sandbox:

  1. Log in to the Braintree sandbox Control Panel.
  2. Select the gear icon in the top right corner.
  3. Select Account Settings from the drop-down menu.
  4. Navigate to the Payment Methods section.
  5. Next to PayPal, select Options link.
  6. Select the View Domain Names button.
  7. Enter the domain of your return page in the Specify Your Domain Names section.
  8. Select Add Domain Names button.

Production: Repeat the same steps in the Braintree production Control Panel.

Best practicesAnchorIcon

  1. Handle the first callback with no shipping option. Your server must handle the first callback where shipping_option is absent. Default to a reasonable shipping method (for example, the cheapest or most common option).
  2. Validate addresses early. Check country and region support before calculating shipping rates. Return a specific error code rather than a generic error.
  3. Respond quickly. The consumer sees a loading state in the paysheet while waiting for your response. Slow responses degrade the checkout experience. Cache shipping rates where possible.
  4. Keep amount totals consistent. The amount.value in your response must equal the sum of all breakdown fields. Inconsistencies may cause errors.
  5. Pre-select the best default. Set selected: true on the option that gives the best consumer experience (for example, free shipping or the most popular option).
  6. Match currency and intent. Ensure currency_code in your response matches the currency in loadPayPalSDK() and createPayment(). Use intent: 'capture'.
  7. Test in sandbox first. Register your domain in the sandbox Control Panel and test all callback scenarios: address change, option selection, error cases, and edge cases such as international addresses and pickup options.
  8. Handle all address variations. Not all addresses include admin_area_1, admin_area_2, or postal_code. Gracefully handle missing address components.
  9. Always leave a valid path. If your callback rejects every address or every shipping option, the buyer is trapped in a reject → retry loop with no way to complete checkout. Ensure at least one address and one option can succeed.

TroubleshootingAnchorIcon

ProblemCauseSolution
Buyer can't enter an address, or paysheet shows "PayPal isn't available at this time" Your callback returned HTTP 422 on the first (address) callback, and/or used the legacy { "error": "CODE" } error shape. Return shipping options successfully on the first callback; surface errors only on later callbacks (after the buyer selects an option). Use PayPal's error format: { "name": "UNPROCESSABLE_ENTITY", "details": [{ "issue": "CODE" }] }.
Callbacks not firingDomain not registered Register your callback domain in the Braintree Control Panel (sandbox and production separately).
Callbacks not firingshippingCallbackUrl not set Pass shippingCallbackUrl in the createPayment() options object.
Callbacks not firingMissing companion options Ensure enableShippingAddress: true and shippingAddressEditable: true are set in createPayment().
Consumer sees generic errorServer returned non-422 status Use HTTP 422 for expected errors. A 500 or timeout triggers a generic error in the paysheet.
Consumer sees generic errorServer timed outOptimize your callback endpoint for speed. Cache shipping rates.
Shipping options not displayingNo selected: true optionExactly one shipping option must have selected: true.
Shipping options not displayingMore than 10 options returnedThe shipping_options array must contain 1–10 items.
Wrong total shown to consumerAmount math mismatchEnsure amount.value equals the sum of all breakdown fields.
Silent failure, no callbacksCurrency or intent mismatch The currency and intent in loadPayPalSDK() must match createPayment().