Overview
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.
Eligibility
- 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: truein thecreatePayment()configuration. If shipping is disabled (enableShippingAddress: false), theshippingCallbackUrlparameter is ignored and no callbacks are sent, even if a callback URL is configured.
How it works
The shipping callback creates a real-time communication loop between PayPal and your server while the consumer is in the PayPal paysheet.
- Merchant passes
shippingCallbackUrlincreatePayment()options - Buyer clicks PayPal button, logs in, and sees the paysheet
- Paysheet is immediately pre-populated with the buyer's default wallet address (if one exists on file)
- 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
- Your server responds with shipping options, tax, and updated totals
- PayPal updates the paysheet with your response
Consumer states
| State | What 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 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. |
| 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. |
Prerequisites
- Braintree JS SDK v3.137.0+ (v3.137.0+ recommended for latest stability)
- A Braintree sandbox account and a production account
- A publicly routable HTTPS endpoint for the callback URL
- Callback domain registered in the Braintree Control Panel
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 setup
// 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 options
| Option | Required | Description |
|---|---|---|
flow | Yes | Must be "checkout". |
amount | Yes | Order subtotal as a string (for example, '10.00'). |
currency | Yes | ISO 4217 currency code (for example, 'USD'). Must match loadPayPalSDK config. |
intent | Yes | Must be "capture". Must match loadPayPalSDK config. |
enableShippingAddress | Yes | Set to true to display shipping address in the paysheet. |
shippingAddressEditable | Yes | Set to true to allow the consumer to change their address, which triggers callbacks. |
shippingCallbackUrl | Yes | Full HTTPS URL of your server-side callback endpoint. The domain must be registered in the Braintree Control Panel. |
Callback request
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 fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The PayPal order ID. |
amount | object | Yes | Current order total. Contains value (string) and currency_code (string, ISO 4217). |
item_total | string | No | Sum of all line item amounts. |
shipping | string | No | Current shipping cost. |
tax_total | string | No | Current tax amount. |
shipping_address | object | Yes | Consumer's selected shipping address (partial — no street address). Contains country_code, admin_area_1, admin_area_2, postal_code. |
shipping_option | object | No | The shipping option the consumer selected. Not present on the first callback. |
line_items | array | No | Array of order line items (max 249). |
shipping_option field. Your server must handle this case and return available options. Subsequent callbacks (when the consumer selects an option) will include shipping_option.Example: Initial address callback (no shipping option)
{
"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 callback
{
"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 response
Your server must respond with HTTP 200 OK and a JSON body containing the updated order details and shipping options.
Response fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The PayPal order ID (same as the request id). |
amount | object | Yes | Updated order total. The value must equal item_total + shipping + handling + tax_total + insurance − shipping_discount − discount. |
item_total | string | No | Sum of all line item amounts. |
shipping | string | No | Shipping cost for the selected option. |
tax_total | string | No | Calculated tax for the shipping destination. |
shipping_options | array | Yes | Array of 1–10 shipping options. Exactly one must have selected: true. |
Shipping option fields (response)
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier for this option (must be unique within the array). |
description | string | Yes | Display name shown to the consumer in the paysheet (max 127 characters). |
selected | boolean | Yes | Whether this option is pre-selected. Exactly one option in the array must be true. |
type | string | Yes | SHIPPING, PICKUP_IN_STORE, or PICKUP_FROM_PERSON. Note: PICKUP is deprecated. |
amount | object | Yes | Cost of this option. Contains value (string) and currency_code (string). |
Merchant Success Response Fields
| Field | Type | Required | Notes |
|---|---|---|---|
| id | string | yes | Order ID (e.g. 2SM98888S4900545P) |
| amount.value | string | yes | Total, decimal |
| amount.currency_code | string | yes | e.g. USD |
| item_total | string | no | Item subtotal, decimal |
| shipping | string | no | Selected shipping cost, decimal |
| handling | string | no | Handling fee, decimal |
| tax_total | string | no | Total tax, decimal |
| insurance | string | no | Insurance fee, decimal |
| shipping_discount | string | no | Shipping discount (subtracted), decimal |
| discount | string | no | Order discount (subtracted), decimal |
| shipping_options | array | yes | 1–10 items |
| shipping_options[].id | string | yes | Option identifier, max 127 chars |
| shipping_options[].description | string | yes | Display text, max 127 chars (NOT label) |
| shipping_options[].selected | boolean | no | Only one true allowed |
| shipping_options[].type | string | no | SHIPPING | PICKUP_IN_STORE | PICKUP_FROM_PERSON |
| shipping_options[].amount.value | string | no | Option price, decimal |
| shipping_options[].amount.currency_code | string | no | e.g. USD |
Merchant Success Response
- 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 handler
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 handling
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 codes
| Error code | When to use | Consumer experience |
|---|---|---|
SHIPPING_ADDRESS_INVALID | The address is malformed, incomplete, or otherwise invalid. | PayPal shows an error message. Consumer can try a different address. |
SHIPPING_COUNTRY_UNSUPPORTED | You 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_UNSUPPORTED | You do not ship to the selected state or province. | PayPal shows that shipping is not available to that region. |
SHIPPING_ZIP_UNSUPPORTED | You do not ship to the selected postal code area. | PayPal shows that shipping is not available to that area. |
SHIPPING_OPTION_NOT_AVAILABLE | A 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_AVAILABLE | In-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. |
422 for error responses. Do not use 400 or 500 for expected shipping restriction errors. A 500 or timeout causes PayPal to show a generic error to the consumer.Domain registration
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 rules
- 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.comare not allowed. - Subdomain-specific: Registering
example.comdoes not coverapi.example.com. Register each subdomain separately. - Characters: Alphanumeric and hyphens only. Hyphens must be between segments.
Registration steps
Sandbox:
- Log in to the Braintree sandbox Control Panel.
- Navigate to Settings > Processing.
- Under PayPal, click Options.
- In the Shipping Callback Domains section, add your domain.
- Click Save.
Production: Repeat the same steps in the Braintree production Control Panel.
Best practices
- Handle the first callback with no shipping option. Your server must handle the first callback where
shipping_optionis absent. Default to a reasonable shipping method (for example, the cheapest or most common option). - Validate addresses early. Check country and region support before calculating shipping rates. Return a specific error code rather than a generic error.
- 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.
- Keep amount totals consistent. The
amount.valuein your response must equal the sum of all breakdown fields. Inconsistencies may cause errors. - Pre-select the best default. Set
selected: trueon the option that gives the best consumer experience (for example, free shipping or the most popular option). - Match currency and intent. Ensure
currency_codein your response matches the currency inloadPayPalSDK()andcreatePayment(). Useintent: 'capture'. - 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.
- Handle all address variations. Not all addresses include
admin_area_1,admin_area_2, orpostal_code. Gracefully handle missing address components.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Callbacks not firing | Domain not registered | Register your callback domain in the Braintree Control Panel (sandbox and production separately). |
| Callbacks not firing | shippingCallbackUrl not set | Pass shippingCallbackUrl in the createPayment() options object. |
| Callbacks not firing | Missing companion options | Ensure enableShippingAddress: true and shippingAddressEditable: true are set in createPayment(). |
| Consumer sees generic error | Server returned non-422 status | Use HTTP 422 for expected errors. A 500 or timeout triggers a generic error in the paysheet. |
| Consumer sees generic error | Server timed out | Optimize your callback endpoint for speed. Cache shipping rates. |
| Shipping options not displaying | No selected: true option | Exactly one shipping option must have selected: true. |
| Shipping options not displaying | More than 10 options returned | The shipping_options array must contain 1–10 items. |
| Wrong total shown to consumer | Amount math mismatch | Ensure amount.value equals the sum of all breakdown fields. |
| Silent failure, no callbacks | Currency or intent mismatch | The currency and intent in loadPayPalSDK() must match createPayment(). |