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 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. |
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
- 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 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. |
handling | string | No | Handling fee included in the order total. |
insurance | string | No | Insurance cost included in the order total. |
shipping_discount | string | No | Discount applied to shipping (subtracted from the total). |
discount | string | No | Order-level discount (subtracted from the total). |
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({ 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 handling
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 codes
Error code (details[].issue) | When to use | Behavior on the address callback | Behavior on the option-selection callback |
|---|---|---|---|
ADDRESS_ERROR | The 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_ERROR | You do not ship to the selected country. | Same generic address rejection as ADDRESS_ERROR. | Generic "shipping method unavailable". |
STATE_ERROR | You do not ship to the selected state or province. | Same generic address rejection. | Generic "shipping method unavailable". |
ZIP_ERROR | You do not ship to the selected postal code. | Same generic address rejection. | Generic "shipping method unavailable". |
METHOD_UNAVAILABLE | The 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_UNAVAILABLE | A store or pickup location cannot fulfill part of the order. | Not this context. | "Part of your order isn't available at this store." |
Timeouts
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)
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 works
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:
- The request came from PayPal (authenticity).
- The request body and signed headers were not modified (integrity).
- 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 headers
Every callback includes the following headers.
| Header | Required | Description |
|---|---|---|
paypal-transmission-id | Yes | A globally unique identifier (UUID) for this callback transmission. Part of the signing string. Use to trace and detect replayed requests. |
paypal-transmission-time | Yes | 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-sig | Yes | The Base64-encoded RSA-SHA256 signature that PayPal generates over the signing string. |
paypal-cert-url | Yes | The HTTPS URL where you retrieve the PayPal public certificate for the key to sign this request. |
paypal-transmission-alg | No | The signature algorithm. Currently SHA256withRSA. |
Example headers
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 mechanism
PayPal computes the signature as follows.
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
Sign the signing string with PayPal's private key using RSA with SHA-256 (
SHA256withRSA).Base64-encode the signature and place it in the
paypal-transmission-sigheader.
Verify a callback signature
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 IP
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 URL
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:
| Environment | Certificate host |
|---|---|
| Sandbox | api-m.sandbox.paypal.com |
| Live | api-m.paypal.com |
If the URL fails either check, do not fetch it. Reject the callback with
CERTIFICATE_VALIDATION_FAILED.
3. Retrieve the PayPal certificate
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 certificates
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-urlmatches. -
Refresh the certificate when the cert ID in
paypal-cert-urlchanges, 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 signature
With the certificate retrieved, or pulled from cache, complete the following steps.
Reconstruct the signing string from the request:
{paypal-transmission-id}|{paypal-transmission-time}|{CRC32(UTF-8-encoded request body)}NoteImportant: Use the UTF-8-encoded request body bytes for the CRC32 calculation. Re-serializing the JSON changes the bytes and breaks verification.Base64-decode
paypal-transmission-sigto get the raw signature bytes.Verify the signature against the signing string using the public key from the certificate, with
SHA256withRSA.Optional: Validate that
paypal-transmission-timefalls 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 failures
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 example
rawBody represents the original request body bytes received over HTTP.
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 responses
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.
| Issue | When to return it |
|---|---|
INVALID_SIGNATURE | Signature verification failed because the signed and received payloads do not match. |
CERTIFICATE_VALIDATION_FAILED | Certificate validation failed because the certificate used for verification is not valid or could not be retrieved. |
SIGNATURE_VERIFICATION_FAILED | Signature verification failed for a reason not covered by the other codes. |
Sample error response
{
"name": "UNAUTHORIZED",
"message": "Authentication failed due to invalid authentication credentials.",
"details": [
{
"issue": "INVALID_SIGNATURE"
}
]
}Authentication best practices
- 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_FAILEDresponses can indicate certificate rotation.
Certificate configuration
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 authorities
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 authorities
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 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.
- 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 steps
Sandbox:
- Log in to the Braintree sandbox Control Panel.
- Select the gear icon in the top right corner.
- Select Account Settings from the drop-down menu.
- Navigate to the Payment Methods section.
- Next to PayPal, select Options link.
- Select the View Domain Names button.
- Enter the domain of your return page in the Specify Your Domain Names section.
- Select Add Domain Names button.
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. - 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.
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.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| 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 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().
|