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 valuesAttributes
payer_id(string) — Conditionally required. The payer ID from the PayPal approval. Likepaypal_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 sendpayer_idalone. 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, mirroringpayment_method -
billing_info(object) — Billing address on the order. For PayPal this comes from the PayPal account and can differ fromshipping_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 ResponsesshowPaymentError(order.description);}
import requestsresponse = 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"}
Related Endpoints
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." }