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
  5. A callback endpoint that validates incoming requests from a known PayPal server

The callback URL must be reachable from PayPal's servers on the public internet. PayPal's callback service uses an outbound proxy that does not follow HTTP redirects, so internal domains, localhost, VPN-only hosts, and URLs that return redirects won't work.

You must allowlist PayPal's published server IP ranges for shipping callback traffic. This safeguards the integration by confirming incoming requests came from PayPal, especially if your integration doesn't verify callback signatures.

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."

TimeoutsAnchorIcon

PayPal's backend enforces a 1000ms connection timeout and 5000ms read timeout on the callback request to your server. If the response takes longer than 5000ms, the request fails with CALLBACK_TIMED_OUT_ERROR, which surfaces to the buyer as a shipping or tax calculation error.

Verify callback signatures (optional)AnchorIcon

In addition to TLS and IP allowlisting, you can verify that PayPal sent each shipping callback and that the request was not modified in transit. PayPal signs every callback request and includes signature metadata in the request headers. You verify the signature locally using PayPal's public certificate.

Signature verification is optional. If your integration relies on HTTPS and IP allowlisting, you don't need to verify signatures.

How signature verification worksAnchorIcon

When PayPal sends a shipping callback to your callback_url, it includes five signature headers. Use these headers, together with PayPal's public certificate, to confirm three things:

  1. The request came from PayPal (authenticity).
  2. The request body and signed headers were not modified (integrity).
  3. The request is not a stale or replayed message (freshness).

Perform verification on your server. Verification does not require a call back to PayPal.

Callback signature headersAnchorIcon

Every callback includes the following headers.

HeaderRequiredDescription
paypal-transmission-idYes A globally unique identifier (UUID) for this callback transmission. Part of the signing string. Use to trace and detect replayed requests.
paypal-transmission-timeYes The ISO 8601 timestamp when PayPal generated the callback. Part of the signing string. Use to detect replayed requests outside an acceptable time window.
paypal-transmission-sigYesThe Base64-encoded RSA-SHA256 signature that PayPal generates over the signing string.
paypal-cert-urlYes The HTTPS URL where you retrieve the PayPal public certificate for the key to sign this request.
paypal-transmission-algNoThe signature algorithm. Currently SHA256withRSA.

Example headersAnchorIcon

paypal-transmission-id: db49fb10-1343-11ef-ac58-e32457403f67
paypal-transmission-time: 2024-05-16T05:19:23Z
paypal-cert-url: https://api-m.paypal.com/v2/checkout/callback/certs/abcd1234567890ab
paypal-transmission-alg: SHA256withRSA
paypal-transmission-sig: ab2tJk1VCFm4EqdSuKqezr38rTdY3JeRQlw94V1xYAIQVgLScAFn2XLRAhtyo/1jp8RZPO80hLpWfbdvvwyxK1u6L6NX/bnr0vA1wpfkwbqDT/Z01YP2VvJiYQdhpf/yXosoIKwP+37pYdAhwCsIu2D5fJAaDXpM8A06SpZJkR4f5+k566PDVPP7RCED9Xny4Yo0WyVaFORkiDJkBLGhUUIoO7Jv5JP5RQmFqVxhwSAaf4YYy/2r21r5HMQgAIgtS7oHYRea43Ze03Zz0EWqJu/hvKBzKHqhcvUPF64hVg8Nk2yqjnW7ZHgBWoHil2SJ1xQzaq2vDCXv5gWDn5+TZA==

Signing mechanismAnchorIcon

PayPal computes the signature as follows.

  1. Build the signing string by concatenating the following values, separated by the pipe character (|), with no spaces:

    {paypal-transmission-id}|{paypal-transmission-time}|{CRC32(request_body)}
    • CRC32(request_body) is the CRC32 checksum of the UTF-8-encoded request body, expressed as an unsigned 32-bit decimal integer.
    • Example: 0c9e007e-4a4b-11f1-8264-0932b276ec42|2026-05-07T19:29:21Z|1438240336
  2. Sign the signing string with PayPal's private key using RSA with SHA-256 (SHA256withRSA).

  3. Base64-encode the signature and place it in the paypal-transmission-sig header.

Verify a callback signatureAnchorIcon

Follow these steps each time you receive a shipping callback. If any step fails, reject the request with HTTP 401. See Verification error responses.

1. Validate the source IPAnchorIcon

Before processing the callback, confirm that the request originates from a known PayPal server. Allowlist the source IP against PayPal's published range for shipping-callback traffic.

2. Validate the certificate URLAnchorIcon

Before fetching anything from paypal-cert-url, confirm that the URL is safe to call:

  • The scheme must be https.
  • The host must be a trusted PayPal domain.

Accept only the following hosts:

EnvironmentCertificate host
Sandboxapi-m.sandbox.paypal.com
Liveapi-m.paypal.com

If the URL fails either check, do not fetch it. Reject the callback with CERTIFICATE_VALIDATION_FAILED.

3. Retrieve the PayPal certificateAnchorIcon

Issue an HTTPS GET request to paypal-cert-url to retrieve the X.509 certificate. The certificate contains the public key you use to verify the signature.

The certificate endpoint is public and does not require authentication.

4. Cache and refresh certificatesAnchorIcon

To avoid added latency on every callback, cache certificates locally. Use the certificate ID embedded in the URL (the trailing path segment, for example, caf7c102a476fd6c) as the cache key.

Recommended caching behavior:

  • Cache the certificate the first time you fetch it.
  • Reuse the cached certificate as long as the cert ID in paypal-cert-url matches.
  • Refresh the certificate when the cert ID in paypal-cert-url changes, or when verifying fails with the cached certificate. PayPal rotates certificates periodically. A cert ID change indicates a new key is in use.
  • Do not cache certificates indefinitely or pin to a single cert ID. Indefinite caching fails verification when the certificate rotates.

5. Verify the signatureAnchorIcon

With the certificate retrieved, or pulled from cache, complete the following steps.

  1. Reconstruct the signing string from the request:

    {paypal-transmission-id}|{paypal-transmission-time}|{CRC32(UTF-8-encoded request body)}
  2. Base64-decode paypal-transmission-sig to get the raw signature bytes.

  3. Verify the signature against the signing string using the public key from the certificate, with SHA256withRSA.

  4. Optional: Validate that paypal-transmission-time falls within an acceptable time range to help detect stale or replayed requests.

If verification succeeds, continue processing the callback and return your standard shipping callback response as described in Callback response.

If verification fails, reject the callback. See the following step.

6. Handle verification failuresAnchorIcon

If any verification step fails, return HTTP 401 Unauthorized with one of the error codes described in Authentication error responses. Do not process the callback payload.

Node.js verification exampleAnchorIcon

const crypto = require('crypto');
const https = require('https');
const { URL } = require('url');

const TRUSTED_PAYPAL_HOSTS = new Set([
  'api-m.paypal.com',
  'api-m.sandbox.paypal.com',
]);

const certCache = new Map(); // Maps a cert ID to its PEM string

function crc32(buffer) {
  // Use a CRC32 implementation, for example, the buffer-crc32 npm package.
  // The input must be the raw request body bytes, exactly as received.
  return require('buffer-crc32').unsigned(buffer);
}

async function verifyPayPalCallback(headers, rawBody) {
  const transmissionId = headers['paypal-transmission-id'];
  const transmissionTime = headers['paypal-transmission-time'];
  const transmissionSig = headers['paypal-transmission-sig'];
  const certUrl = headers['paypal-cert-url'];

  // Step 2: Validate the certificate URL
  const parsed = new URL(certUrl);
  if (parsed.protocol !== 'https:' || !TRUSTED_PAYPAL_HOSTS.has(parsed.host)) {
    throw new Error('CERTIFICATE_VALIDATION_FAILED');
  }

  // Step 3 and 4: Retrieve or reuse the cached certificate
  const certId = parsed.pathname.split('/').pop();
  let certPem = certCache.get(certId);
  if (!certPem) {
    certPem = await fetchCert(certUrl);
    certCache.set(certId, certPem);
  }

  // Step 5: Verify the signature
  const signingString = `${transmissionId}|${transmissionTime}|${crc32(rawBody)}`;
  const verifier = crypto.createVerify('RSA-SHA256');
  verifier.update(signingString);
  verifier.end();

  const ok = verifier.verify(certPem, transmissionSig, 'base64');
  if (!ok) {
    throw new Error('INVALID_SIGNATURE');
  }

  // Optional: Replay window check
  const skewMs = Math.abs(Date.now() - Date.parse(transmissionTime));
  if (skewMs > 5 * 60 * 1000) {
    throw new Error('SIGNATURE_VERIFICATION_FAILED');
  }
}

function fetchCert(url) {
  return new Promise((resolve, reject) => {
    https.get(url, (res) => {
      let data = '';
      res.on('data', (chunk) => (data += chunk));
      res.on('end', () => resolve(data));
    }).on('error', reject);
  });
}

Verification error responsesAnchorIcon

If signature verification fails, return HTTP 401 Unauthorized with one of the following issue codes. PayPal uses this signal to identify verification failures during integration and ramp.

IssueWhen to return it
INVALID_SIGNATURESignature verification failed because the signed and received payloads do not match.
CERTIFICATE_VALIDATION_FAILEDCertificate validation failed because the certificate used for verification is not valid or could not be retrieved.
SIGNATURE_VERIFICATION_FAILEDSignature verification failed for a reason not covered by the other codes.

Sample error responseAnchorIcon

{
  "name": "UNAUTHORIZED",
  "message": "Authentication failed due to invalid authentication credentials.",
  "details": [
    {
      "issue": "INVALID_SIGNATURE"
    }
  ]
}

Authentication best practicesAnchorIcon

  • Cache certificates by certificate ID. Refresh them when the ID changes or signature verification fails.
  • Compute the CRC32 checksum from the original request body bytes before any parsing or transformation.
  • Monitor verification failures. A sudden spike in CERTIFICATE_VALIDATION_FAILED responses can indicate certificate rotation.

Certificate configurationAnchorIcon

Before PayPal can send shipping callbacks to your endpoint, PayPal must validate the certificate your server presents during the TLS handshake. If PayPal doesn’t trust the certificate authority (CA) that issued your certificate, the TLS connection fails, and PayPal can’t send shipping or tax callbacks.

Supported certificate authoritiesAnchorIcon

PayPal recommends using a certificate issued by a trusted public CA. These CAs are included in PayPal's trusted certificate store and require no additional configuration.

Unsupported certificate authoritiesAnchorIcon

Contact PayPal before going live if:

  • your certificate is issued by a CA that isn't included in PayPal's trusted certificate store.
  • you use a network proxy or SSL inspection tool that presents its own certificate.

PayPal can verify compatibility and add the CA to its trusted certificate store before you go live. Notify PayPal before you make future certificate or network changes. Unannounced changes can prevent PayPal from establishing a TLS connection and stop callback requests.

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().