This is the reference for E-commerce checkout with PayPal: every asset with its fields, the downloads, how to adapt the shop to another payment provider, and what to check when something does not work.
Downloads
- [shop-bundle.json](/casestudies/ecommerce/shop-bundle.json): six records, three Logic Blocks and two automations, for Import Metadata in the console.
- [webhook-landing.json](/casestudies/ecommerce/webhook-landing.json): how PayPal's three events land. Apply it under Org Settings → Connectors → PayPal → Customize → Webhook, or with the
set_connector_webhook_landingtool. - [checkout-cart.glyph.py](/casestudies/ecommerce/checkout-cart.glyph.py): the checkout Glyph's source. A Glyph is created in the console (Glyphs → New Glyph), not through Import Metadata.
Records and fields
Every record also has the platform's id and name fields. Types are the platform's: string, number, boolean.
| Record | Field | Type | Meaning |
|---|---|---|---|
| product | title | string | Display name |
| product | description | string | Short description |
| product | price | number | Unit price in the shop's currency |
| product | active | boolean | Whether it can be sold |
| cart | status | string | open, checkout_started, payment_captured, ordered, payment_failed |
| cart | currency | string | Three-letter code, for example USD |
| cart | item_count | number | Sum of the lines' quantities |
| cart | subtotal | number | Sum of the lines' totals |
| cart | user_id | string | The signed-in shopper who opened it |
| cart_line | cart_id | string | The cart this line belongs to |
| cart_line | product_id | string | The product |
| cart_line | title | string | Product title at the time |
| cart_line | quantity | number | How many |
| cart_line | unit_price | number | Price at the time it was added |
| cart_line | line_total | number | quantity times unit_price |
| payment_approval | cart_id | string | The cart, from the checkout reference |
| payment_approval | paypal_order_id | string | PayPal's order id, used to capture |
| payment_approval | status | string | approved, then captured |
| shop_order | cart_id | string | The cart that was paid |
| shop_order | status | string | paid |
| shop_order | payment_id | string | PayPal's capture id |
| shop_order | amount | number | Amount captured |
| shop_order | currency | string | Currency captured |
| shop_order | paid_at | string | When PayPal took the payment |
| shop_order | item_count | number | Total items, set by create-order-items |
| shop_order_item | order_id | string | The order |
| shop_order_item | product_id | string | The product |
| shop_order_item | title | string | Product title |
| shop_order_item | quantity | number | How many |
| shop_order_item | unit_price | number | Unit price |
| shop_order_item | line_total | number | Line total |
Logic Blocks
| Logic Block | Input | Steps in short | Returns |
|---|---|---|---|
| add-to-cart | product_id (required), quantity, cart_id | get the product; if a cart id was given get that cart, otherwise create one; find an existing line for the product; raise it or add a new one; list the cart's lines and recompute item count and subtotal; get the cart | cart_id, cart, items |
| capture-paypal-order | order_id, approval_id, cart_id (all required) | PayPal connector step Capture payment with order_id; set the approval's status to captured; set the cart's status to payment_captured | capture |
| create-order-items | order_id (required) | get the order; list the cart's lines; for each line create a shop_order_item; store the item count on the order; set the cart's status to ordered | order_id, items_copied |
The capture step, as it appears in the Logic Block's JSON:
{
"id": "capture",
"type": "connector",
"config": {
"connectorId": "paypal",
"actionId": "capture_order",
"fieldMappings": [{ "field": "order_id", "value": "{{input.order_id}}" }]
}
} Automations
| Automation | On | Runs | Field mapping |
|---|---|---|---|
| capture-paypal-order-on-approval | payment_approval created (after) | capture-paypal-order | order_id = the record's paypal_order_id; approval_id = its id; cart_id = its cart_id |
| create-order-items-on-order | shop_order created (after) | create-order-items | order_id = the record's id |
Webhook landing
{
"CHECKOUT.ORDER.APPROVED": {
"target_entity": "payment_approval", "operation": "insert",
"field_mapping": { "cart_id": "{{ipaas_ref.entityRef}}", "paypal_order_id": "{{resource.id}}", "status": "approved" }
},
"PAYMENT.CAPTURE.COMPLETED": {
"target_entity": "shop_order", "operation": "insert",
"field_mapping": { "cart_id": "{{ipaas_ref.entityRef}}", "status": "paid", "payment_id": "{{resource.id}}",
"amount": "{{resource.amount.value}}", "currency": "{{resource.amount.currency_code}}",
"paid_at": "{{resource.create_time}}" }
},
"PAYMENT.CAPTURE.DENIED": {
"target_entity": "cart", "operation": "update", "target_id": "{{ipaas_ref.entityRef}}",
"field_mapping": { "status": "payment_failed" }
}
} Using another payment provider
The records, the add-to-cart block and the order-items block do not change. You change the checkout, the landing and (for PayPal only) drop the capture step. Check each provider's action and events in Payment connectors; this study ran end to end on PayPal only.
| Provider | Items in a cart line | Separate capture step |
|---|---|---|
| PayPal | name, quantity, unit price, plus the order total | Yes: capture_order after the approval |
| Stripe | A real Stripe Price id and a quantity | No: the payment completes at checkout |
| Paddle | A real Paddle Price id and a quantity | No |
| Dodo | A Dodo product id and a quantity | No |
| Provider | Event that means paid | Failure event |
|---|---|---|
| PayPal | PAYMENT.CAPTURE.COMPLETED | PAYMENT.CAPTURE.DENIED |
| Stripe | checkout.session.completed | checkout.session.async_payment_failed |
| Paddle | transaction.completed | transaction.payment_failed |
| Dodo | payment.succeeded | payment.failed |
All four use the checkout_cart action for a multi-item cart.
Providers with a price catalog
Stripe, Paddle and Dodo keep prices in their own catalog, so a cart line carries the provider's price id rather than a name and amount. Add a price id field to product and read it in your checkout. PayPal has no price catalog, which is why its items carry a name and a unit price.
Troubleshooting
| Symptom | Likely cause | What to do |
|---|---|---|
| The checkout call fails with a PayPal error about the return URL | A success or cancel URL has no https:// | Tenant Settings → Connectors → PayPal → Checkout redirect URLs: use full addresses and save |
| The browser reports a CORS error calling your app | The calling site is not on Allowed Domains | Org Settings → Allowed Domains: add the site's address |
| The PayPal call is refused for the domain | The PayPal API domain is not allowed | Add the domains the PayPal connector card lists (api-m.sandbox.paypal.com for Test Mode) |
| A Logic Block or Glyph returns 'not enabled for this org' | The connector is not adopted | Org Settings → Connectors: add PayPal |
| No payment_approval appears after approving | The webhook is not registered, has the wrong events, or the Webhook ID is wrong | Check the URL and the three events in PayPal, and the Webhook ID on the tenant card; the Debug Log shows webhooks that did arrive |
| The approval exists but the order never appears | The capture did not run or PayPal has not sent the completed event | Debug Log: look for the capture-paypal-order run and the PayPal response; confirm PAYMENT.CAPTURE.COMPLETED is subscribed |
| The order exists but there are no order items | The create-order-items automation is off or failed | API Studio → shop_order → Automations: check it is enabled; the Debug Log shows its run |
| A shopper gets 403 on create or run | The app user has the read-only policy | Tenant Management → App Users → ACL: give the shopper memberacl |
| The checkout total is rejected | The items do not add up to the total | Build the total from the same items you send; use two decimal places as strings |