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
LoadingAfter 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.
SuccessThe 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.
ErrorIf your server returns HTTP 422 with an error code (for example, SHIPPING_ADDRESS_INVALID), PayPal displays an appropriate error message on the paysheet. The consumer can select a different address or cancel.
TimeoutIf 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').
currencyYesISO 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.
shippingAddressEditableYesSet to true to allow the consumer to change their address, which triggers callbacks.
shippingCallbackUrlYesFull 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.
amountobjectYesCurrent 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.
shipping_addressobjectYesConsumer's selected shipping address (partial — no street address). Contains country_code, admin_area_1, admin_area_2, postal_code.
shipping_optionobjectNoThe 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).
amountobjectYesUpdated 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).
selectedbooleanYesWhether 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.
amountobjectYesCost 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({ error: "SHIPPING_COUNTRY_UNSUPPORTED" });
  }

  const options = SHIPPING_RATES[country];
  if (!options) {
    return res.status(422).json({ error: "SHIPPING_ADDRESS_INVALID" });
  }

  // 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 options, respond with HTTP 422 and a JSON body containing an error code.

{ "error": "SHIPPING_ADDRESS_INVALID" }

Error codesAnchorIcon

Error codeWhen to useConsumer experience
SHIPPING_ADDRESS_INVALIDThe address is malformed, incomplete, or otherwise invalid.PayPal shows an error message. Consumer can try a different address.
SHIPPING_COUNTRY_UNSUPPORTEDYou do not ship to the selected country.PayPal shows that shipping is not available to that country. Consumer can select a different address.
SHIPPING_STATE_UNSUPPORTEDYou do not ship to the selected state or province.PayPal shows that shipping is not available to that region.
SHIPPING_ZIP_UNSUPPORTEDYou do not ship to the selected postal code area.PayPal shows that shipping is not available to that area.
SHIPPING_OPTION_NOT_AVAILABLEA previously available shipping option is no longer available for the current address.PayPal removes the unavailable option and prompts the consumer to select a different one.
PICKUP_NOT_AVAILABLEIn-store or person pickup is not available for the selected address.PayPal shows that pickup is not available. Consumer can select a delivery shipping option.

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.

Registration stepsAnchorIcon

Sandbox:

  1. Log in to the Braintree sandbox Control Panel.
  2. Navigate to Settings > Processing.
  3. Under PayPal, click Options.
  4. In the Shipping Callback Domains section, add your domain.
  5. Click Save.

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.

TroubleshootingAnchorIcon

ProblemCauseSolution
Callbacks not firingDomain not registeredRegister your callback domain in the Braintree Control Panel (sandbox and production separately).
Callbacks not firingshippingCallbackUrl not setPass shippingCallbackUrl in the createPayment() options object.
Callbacks not firingMissing companion optionsEnsure enableShippingAddress: true and shippingAddressEditable: true are set in createPayment().
Consumer sees generic errorServer returned non-422 statusUse 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 mismatchThe currency and intent in loadPayPalSDK() must match createPayment().