Docs
Firmly Agentic Commerce
Set theme to dark (⇧+D)

Complete PayPal Express Order

POST https://api.firmly.work/api/v1/domains/{domain}/express/paypal/complete-order

​​ Overview

Places the order against the merchant using the PayPal authorization recorded by Authorize. This is the call that charges the buyer. On success the cart becomes an order: cart_status flips to "submitted" and the response carries the merchant’s order number.

Availability and the host this endpoint runs on are covered in the PayPal Express Checkout overview.

​​ Prerequisites

  • Start must have been called on this cart, and the buyer must have approved the payment in PayPal.
  • Authorize is optional — see below.
  • Shipping info must be set. Without it this endpoint returns 412 MissingShippingInfo.

​​ Authentication

  • x-firmly-authorization (string, required) — Device access token from Browser Session

​​ Path Parameters

  • domain (string, required) — The merchant domain (e.g., “staging.luma.gift”)

​​ Request Body

  • attributes (object, required) — The PayPal approval values

    Attributes
    • payer_id (string) — Conditionally required. The payer ID from the PayPal approval. Like paypal_token, it is read from the cart first, so it may be omitted when a preceding Authorize call stored it on the same device session. Send it when you cannot rely on that.
    • paypal_token (string) — The EC token from Start. Optional when the cart already holds it: Firmly back-fills the token from the session created by Start, so a caller that has kept the same device session can send payer_id alone. Send it explicitly if you are not sure the session still holds it.

  • tags (array, optional) — Order tags, applied to the placed order

​​ Response

Returns the submitted order. The shape is the cart, plus order fields — note there is no order_id; the merchant’s identifier is platform_order_number.

  • platform_order_number (string) — The merchant’s own order number. This is the identifier to show the buyer and to use in support.

  • cart_status (string) — "submitted" once the order is placed

  • submitted_at (string) — ISO 8601 timestamp of placement

  • payment_summary (object) — How the order was paid, mirroring payment_method

  • billing_info (object) — Billing address on the order. For PayPal this comes from the PayPal account and can differ from shipping_info.

  • shipping_info (object) — Delivery address

  • line_items (array) — Items on the order

  • urls (object) — Merchant URLs for the placed order, e.g. thank_you_page

  • total / sub_total / tax / shipping_total / cart_discount (object) — Order totals

​​ Code Example


const response = await fetch(
'https://api.firmly.work/api/v1/domains/staging.luma.gift/express/paypal/complete-order',
{
method: 'POST',
headers: {
'x-firmly-authorization': authToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
attributes: {
paypal_token: paypalToken,
payer_id: payerID
}
})
}
);
const order = await response.json();
if (response.ok) {
showConfirmation(order.platform_order_number);
} else {
// PayPal rejections arrive here as 422 — see Error Responses
showPaymentError(order.description);
}

import requests
response = requests.post(
'https://api.firmly.work/api/v1/domains/staging.luma.gift/express/paypal/complete-order',
headers={
'x-firmly-authorization': auth_token,
'Content-Type': 'application/json'
},
json={
'attributes': {
'paypal_token': paypal_token,
'payer_id': payer_id
}
}
)
order = response.json()
if response.ok:
print(order['platform_order_number'], order['cart_status'])

curl -X POST https://api.firmly.work/api/v1/domains/staging.luma.gift/express/paypal/complete-order \
-H "x-firmly-authorization: YOUR_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"attributes": {
"paypal_token": "EC-XXXXXXXXXXXXXXXXX",
"payer_id": "XXXXXXXXXXXXX"
}
}'

​​ Response Example


{
"cart_id": "e49f0dea-b65f-4f38-92c2-fa983d20f679",
"platform_order_number": "62590",
"cart_status": "submitted",
"submitted_at": "2026-09-15T07:56:51.968Z",
"line_items": [
{
"line_item_id": "aeaefb09-abee-4b35-9215-63584cc02c21",
"sku": "24-MB05",
"base_sku": "24-MB05",
"quantity": 1,
"requires_shipping": true,
"description": "Wayfarer Messenger Bag",
"price": { "currency": "USD", "value": 45, "number": 4500, "symbol": "$" },
"line_price": { "currency": "USD", "value": 45, "number": 4500, "symbol": "$" },
"msrp": { "currency": "USD", "value": 45, "number": 4500, "symbol": "$" },
"image": {
"url": "https://staging.luma.gift/wayfarer-messenger-bag.jpg",
"alt": "Wayfarer Messenger Bag",
"type": "default"
}
}
],
"payment_summary": {
"payment_type": "PayPal",
"attributes": {
"paypal_token": "EC-XXXXXXXXXXXXXXXXX",
"payer_id": "XXXXXXXXXXXXX",
"sandbox": true
}
},
"payment_method": {
"payment_type": "PayPal",
"attributes": {
"paypal_token": "EC-XXXXXXXXXXXXXXXXX",
"payer_id": "XXXXXXXXXXXXX",
"sandbox": true
}
},
"shipping_info": {
"first_name": "John",
"last_name": "Smith",
"email": "john@example.com",
"phone": "2065551212",
"address1": "123 Main St",
"city": "Seattle",
"state_or_province": "WA",
"state_name": "Washington",
"country": "US",
"postal_code": "98101"
},
"billing_info": {
"first_name": "John",
"last_name": "Smith",
"email": "john@example.com",
"phone": "2065551212",
"address1": "123 Main St",
"city": "Seattle",
"state_or_province": "WA",
"country": "US",
"postal_code": "98101"
},
"shipping_method": {
"sku": "freeshipping_freeshipping",
"description": "Standard",
"price": { "currency": "USD", "value": 0, "number": 0, "symbol": "$" }
},
"sub_total": { "currency": "USD", "value": 45, "number": 4500, "symbol": "$" },
"shipping_total": { "currency": "USD", "value": 0, "number": 0, "symbol": "$" },
"tax": { "currency": "USD", "value": 0, "number": 0, "symbol": "$" },
"total": { "currency": "USD", "value": 45, "number": 4500, "symbol": "$" },
"display_name": "Luma (Staging)",
"shop_id": "staging.luma.gift"
}

  • Start — Open the PayPal session
  • Authorize — Record the buyer’s approval

​​ Error Responses

Errors return a JSON body with code, error, and description. Program against the error value — descriptions are human-readable and may change.

422 — CreditCardDeclined (PayPal declined)

The merchant could not take the payment with PayPal. Despite the error name, this is the standard PayPal decline on this endpoint. Common causes: the buyer’s PayPal account is unverified, the merchant’s PayPal configuration is incomplete, or the cart total changed after the EC token was created.


{
"code": 422,
"error": "CreditCardDeclined",
"description": "Unfortunately, we were unable to process your payment with PayPal. Please use a different form of payment to continue with your order"
}
400 — BadRequest (missing EC token)

No paypal_token was supplied and the cart holds none from a preceding Start or Authorize call.


{ "code": 400, "error": "BadRequest", "description": "Missing PayPal EC Token" }
400 — BadRequest (missing payer ID)

No payer_id was supplied and the cart holds none from a preceding Authorize call.


{ "code": 400, "error": "BadRequest", "description": "Missing Payer ID" }
400 — InvalidInputBody

The attributes object is missing or not an object.


{ "code": 400, "error": "InvalidInputBody", "description": "At path: attributes -- Expected an object, but received: undefined" }
409 — PaymentMethodNotAvailable

PayPal is no longer selectable on this cart.


{ "code": 409, "error": "PaymentMethodNotAvailable", "description": "The payment method is not available. Please, choose a diferent one or try again later." }
400 — MissingRequiredCookie

A cookie the merchant session depends on is absent, so the session transfer could not be completed. Redo the merchant session transfer, then retry.


{ "code": 400, "error": "MissingRequiredCookie", "description": "Required cookie is missing to perform session transfer." }
412 — MissingShippingInfo

Shipping info has not been set on the cart.


{ "code": 412, "error": "MissingShippingInfo", "description": "Shipping info needs to be set." }
412 — MissingShippingMethod

A shipping method has not been selected, on a merchant that does not default one.


{ "code": 412, "error": "MissingShippingMethod", "description": "Shipping method needs to be set." }
412 — MissingTaxSync

Taxes have not finished syncing. Re-read the cart and retry.


{ "code": 412, "error": "MissingTaxSync", "description": "The taxes are not synced yet. Please refresh the cart and try again." }
412 — CheckoutError

The merchant rejected the checkout payload.


{ "code": 412, "error": "CheckoutError", "description": "Payment info or billing address invalid." }
400 — InvalidToken

The x-firmly-authorization header is missing, empty, or not a well-formed JWT. Device-auth can also surface as MissingAuthHeader or PartnerNotFound; see Authentication errors for the full set.


{ "code": 400, "error": "InvalidToken", "description": "Jwt token is invalid." }
401 — InvalidJWTToken

The device JWT is well-formed but its signature does not verify, or required claims are missing.


{ "code": 401, "error": "InvalidJWTToken", "description": "Jwt token is invalid." }
404 — CartNotFound

No active cart exists for this device on this domain.


{ "code": 404, "error": "CartNotFound", "description": "Cart was not found." }
404 — DomainNotFound

The {domain} path parameter does not match any merchant configured with Firmly, or the merchant has been disabled.


{ "code": 404, "error": "DomainNotFound", "description": "This domain was not found in firmly servers." }
404 — GatewayNotFound

PayPal is not enabled as a payment gateway for this merchant. Check payment_method_options before offering it.


{ "code": 404, "error": "GatewayNotFound", "description": "Payment gateway 'paypal' not found" }
412 — OperationNotSupported

The merchant adapter does not implement PayPal express checkout.


{ "code": 412, "error": "OperationNotSupported", "description": "This operation is not supported for this store." }
503 — StoreUnavailable

The merchant’s own API was unavailable or timed out. Retry with backoff.


{ "code": 503, "error": "StoreUnavailable", "description": "Store temporarily unavailable. Please try again later." }
429 — RateLimited

Too many requests from this device. Back off and retry after the Retry-After window — see Rate Limits.


{ "code": 429, "error": "RateLimited", "description": "Too many requests. Please try again later." }