Stripe, Paddle, Dodo Payments, and PayPal follow the same setup as any other connector — an org admin or developer adopts it, tenant enters credentials — plus two things unique to payments: an inbound webhook landing (a payment confirmation arrives asynchronously, not in the checkout response) and a per-tenant Product Catalog.
1 · Org admin or developer customizes the webhook landing
Open the connector's Customize → Webhook tab. For each event type the provider can send, choose:
- Target entity — which entity a confirmed event writes to.
- Operation — insert a new record, or update an existing one.
- Target row id — a template for which row, usually the entity reference your app passed when it started the checkout.
- Field mapping — which entity fields get which values from the event payload.
An event that updates a record but names none (no entity_ref, nothing to look up) is acknowledged and skipped, so the provider does not see a failed delivery and retry it.
One record per provider object
Some providers send many events about one object (a pull request, a subscription, a booking) that you want on a single record. Set a correlation key on the event, a template for the provider's own id of that object, for example {{pull_request.html_url}}. The first event inserts the record and nostackai remembers it; later events with the same key update that record instead of inserting another, and an *update* event with no entity_ref finds its record the same way. Leave it empty to get one record per event. It is set through the set_connector_webhook_landing MCP tool or the org settings API (correlation_key on the event); the console form does not show it yet.
2 · Tenant enters credentials
Tenant Settings → Connectors → the provider → Credentials:
- Secret/API key — the provider account's server-side credential nostackai uses to call the provider on the tenant's behalf. Stripe: API keys. Paddle: Authentication (Paddle Dashboard → Developer Tools → Authentication → API keys — needs write access to both Transactions and Subscriptions). Dodo Payments: sign in at app.dodopayments.com → Developer settings. PayPal: a Client ID and Client secret from a REST API app in the PayPal Developer Dashboard (pick Test Mode or Live in the connector's Environment field to match).
- Webhook signing secret — a separate secret, generated when registering nostackai's webhook URL in the provider's own dashboard, used only to verify an incoming webhook actually came from the provider. Stripe: Webhooks (find it under each endpoint in the Dashboard's Webhooks section). Paddle: Webhooks overview. Dodo Payments: Webhook signing key. PayPal has no signing secret: it verifies each event by asking PayPal itself, using the Webhook ID you get when you register nostackai's webhook URL (see Setting up a PayPal account below).
- Paddle only — client-side token — a public token for launching Paddle's inline checkout overlay from your own frontend; never stored by nostackai (see the overlay section below).
3 · Tenant sets up its Product Catalog
A checkout action's product field is a label the tenant chooses, not the provider's raw price/product id — this keeps the tenant's own app code stable even if prices change later. Tenant Settings → Connectors → Product Catalog → add rows mapping a label to the real id from the provider account:
{ "starter_monthly": "price_1AbC...", "starter_annual": "price_1XyZ..." } There's no platform-level list of valid labels — whatever's entered here is what the tenant's own app can reference. A checkout call with a label that isn't in the catalog fails with a clear error rather than reaching the provider with garbage. This resolution runs identically no matter which surface calls checkout — directly, from an automation, or from a logic block.
4 · Calling checkout
POST /app/{orgcode}/ipaas/{connector_id}
{ "action_id": "checkout_price_recurring", "fields": { "product_name": "starter_monthly", "success_url": "...", "cancel_url": "..." } }
→ { "result": { "checkout_url": "https://..." } } product_name is the label from step 3, resolved server-side — the raw provider id never appears in the tenant's own app code. Redirect the customer to result.checkout_url. Confirmation arrives later via the webhook, landing on whatever entity was configured in step 1. There's no field for who's paying — that's stamped automatically from the authenticated caller.
Single-product checkout, per provider
- Stripe —
checkout_price(one-time) andcheckout_price_recurring(subscription); both catalog-label-based, the amount always comes from the Price the label points at. - Paddle — one
checkoutaction handles both one-time and subscription; which mode runs depends on which price the label points at. - Dodo Payments —
checkout(subscription) andcheckout_onetime(one-time); same Product Catalog mechanism, point a second label at a one-time product. - PayPal —
checkout(subscription, the label points at a PayPal plan id) andcheckout_onetime(one-time order, a single amount) andcheckout_cart(one-time order with several items). Both one-time actions take the amount and currency from the caller, so build them from your own data (a Logic Block, a glyph or an agent), not straight from a browser.checkout_onetimeis for Logic Blocks only;checkout_cartcan also be called from the app.
All four also have a cancel action for ending an active subscription. PayPal cancels immediately; it has no end-of-period option.
Multi-item cart checkout
Each provider also has a checkout_cart action for checking out several products in one call — an ecommerce-style cart rather than a single SaaS plan. Its line_items field takes an array, one entry per item:
{ "line_items": [ { "price": "price_1AbC...", "quantity": 2 }, { "price": "price_1XyZ...", "quantity": 1 } ] } Cart items use the real provider id, not a catalog label
The Product Catalog resolves one label per field, not one label per array entry — so checkout_cart's line items take the provider's real price/product id directly, already resolved. Typically this means looking each cart line's own product id up against its real price id beforehand (e.g. with a logic block's loop step over the cart, doing an entity lookup per line) rather than typing raw provider ids by hand.
PayPal carts
PayPal has no price catalog: a product in PayPal's catalog has a name but no price, and prices live only on subscription plans. So PayPal's checkout_cart items are not provider ids. Each item carries its own name, quantity and unit price, and the action adds the total and currency_code:
{ "line_items": [ { "name": "Notebook", "quantity": "2", "unit_amount": { "currency_code": "USD", "value": "9.99" } }, { "name": "Pen", "quantity": "1", "unit_amount": { "currency_code": "USD", "value": "1.50" } } ], "currency_code": "USD", "total": "21.48" } total must equal the sum of quantity × unit price, and PayPal rejects the order when it does not. The response has the same checkout_url as the other PayPal checkouts: send the customer there, and the webhook updates the record named by entity_ref when they pay.
An approved PayPal order is not paid until you capture it
When the customer returns from PayPal, a one-time order (checkout_onetime or checkout_cart) is only *approved*. PayPal sends CHECKOUT.ORDER.APPROVED, and no money moves and no PAYMENT.CAPTURE.COMPLETED follows until you call capture_order with the order id. It is called from server code, not from a browser: a glyph (ctx.connector.invoke('paypal', 'capture_order', {'order_id': id})) or an agent. The usual setup: land CHECKOUT.ORDER.APPROVED as a record that holds resource.id, and run a Logic Block on that record's creation with a glyph step that captures the order. PayPal then sends PAYMENT.CAPTURE.COMPLETED, which is the event to treat as paid. Tick both events on your PayPal webhook.
The prices come from whoever calls
Because PayPal takes the prices in the request, a call straight from a browser trusts the browser with them. Build the items on the server from your own cart or order records: in a Logic Block, a glyph (ctx.connector.invoke('paypal', 'checkout_cart', {...})) or an agent. Called through the app's iPaaS route, the items come from the entity you mapped in your org's settings, so map the price fields from the entity rather than from the request.
One-time and subscription. PayPal tells them apart by the API: checkout_cart and checkout_onetime create an *order* (paid once), while checkout creates a *subscription* from a plan you made in PayPal and put in the Product Catalog. A subscription cannot sit inside a cart. If your cart mixes both, make two checkouts: checkout_cart for the one-time items and checkout for the plan. Most apps sell one kind or the other.
From a glyph. A glyph can start the checkout itself with ctx.connector.invoke, using the signed-in user and your own records. Here a cart and its lines are read from your entities, the PayPal items are built from them, and the total is added up on the server:
# Python Glyph. Your cart lives in your own entity (here: a "cart" with "cart_line" rows).
def handler(request, ctx):
cart = ctx.app_db.get_entity("cart", request["cartId"])
lines = ctx.app_db.list_entities("cart_line", filters=[{"field": "cart_id", "op": "eq", "value": cart["id"]}])["items"]
items = [{"name": l["title"], "quantity": str(l["qty"]),
"unit_amount": {"currency_code": "USD", "value": l["unit_price"]}} for l in lines]
total = sum(int(i["quantity"]) * float(i["unit_amount"]["value"]) for i in items)
out = ctx.connector.invoke("paypal", "checkout_cart",
{"line_items": items, "currency_code": "USD", "total": f"{total:.2f}"},
entity_ref=cart["id"])
return {"checkoutUrl": out["result"]["checkout_url"]} For a cart that mixes one-time items and a subscription, split it into two checkouts and send the customer to each checkout_url:
# Python Glyph. A mixed cart is two checkouts, one per kind.
def handler(request, ctx):
cart = ctx.app_db.get_entity("cart", request["cartId"])
one_time = [l for l in cart["lines"] if l["kind"] == "one_time"]
plan = next((l for l in cart["lines"] if l["kind"] == "subscription"), None)
urls = {}
if one_time:
items = [{"name": l["title"], "quantity": str(l["qty"]), "unit_amount": {"currency_code": "USD", "value": l["unit_price"]}} for l in one_time]
total = sum(int(i["quantity"]) * float(i["unit_amount"]["value"]) for i in items)
urls["oneTime"] = ctx.connector.invoke("paypal", "checkout_cart", {"line_items": items, "currency_code": "USD", "total": f"{total:.2f}"}, entity_ref=cart["id"])["result"]["checkout_url"]
if plan:
urls["subscription"] = ctx.connector.invoke("paypal", "checkout", {"product_name": plan["plan_label"]}, entity_ref=cart["id"])["result"]["checkout_url"]
return urls Setting up a PayPal account
PayPal keeps Test Mode (PayPal calls it Sandbox) and Live completely separate: each has its own app, credentials, plans and webhook. Set up Test Mode first, try a purchase, then repeat for Live.
PayPal has no webhook secret
Stripe, Paddle and Dodo Payments hand you a signing secret for webhooks. PayPal does not. It gives you a Webhook ID when you register the URL, and nostackai checks every incoming event by asking PayPal: it sends the event and that Webhook ID to PayPal's verification service and accepts the event only if PayPal confirms it sent it. That is why this connector asks for the Client ID and Client Secret as well as the Webhook ID, and why the Environment must match the app they came from. If PayPal cannot be reached, or the credentials are wrong, the event is rejected and PayPal tries again later.
P-. Menu names can change; PayPal's subscriptions guide has the current ones.monthly. The value is the plan ID from step 3. See Product Catalog for PayPal below.checkout_url, sign in with the test Personal account from step 1, and approve. When PayPal activates the subscription, the webhook updates your record.Events to subscribe to
In PayPal's webhook form the events appear by name, for example *Billing subscription activated*. Tick these:
BILLING.SUBSCRIPTION.ACTIVATED— the customer approved and PayPal activated the subscription. Updates the record with the subscription ID, plan, status and the subscriber's email.BILLING.SUBSCRIPTION.CANCELLED,BILLING.SUBSCRIPTION.SUSPENDEDandBILLING.SUBSCRIPTION.EXPIRED— the subscription ended or was paused. Update the status and the time it changed.BILLING.SUBSCRIPTION.PAYMENT.FAILED— a payment did not go through. Records the amount and currency that failed.CHECKOUT.ORDER.APPROVED,PAYMENT.CAPTURE.COMPLETEDandPAYMENT.CAPTURE.DENIED— for one-time purchases: the customer approved the order, and PayPal then took the payment or refused it.
Ticking more events does no harm: nostackai ignores any it has no landing for. What each event writes onto your record is set in the connector's Customize → Webhook tab, as for the other providers. Renewal payments (PAYMENT.SALE.COMPLETED) are not matched to a record yet.
Product Catalog for PayPal
PayPal does not take a plan ID from your app directly. Your app sends a label, and the Product Catalog turns it into the plan ID on the server, so you can point a label at a different plan later without changing your app. A catalog for two plans looks like this:
{ "monthly": "P-0AB12345CD678901E", "annual": "P-9ZY87654XW321098V" } POST /app/{orgcode}/ipaas/paypal
{ "action_id": "checkout", "fields": { "product_name": "monthly" }, "entity_ref": "<your order id>" }
→ { "result": { "checkout_url": "https://www.paypal.com/...", "subscription_id": "I-..." } } - Send the customer to
checkout_url. When they approve, PayPal activates the subscription and the webhook updates the record named byentity_ref. - Plan IDs differ between Test Mode and Live. When you switch the Environment, replace the catalog values with the Live plan IDs.
checkout_onetimehas no catalog. The amount and currency come from the Logic Block that calls it, so call it from a logic block that reads them from your own data.
Going live
Repeat steps 2, 3 and 4 for Live, using your real PayPal Business account and the app's Live credentials (step 1 is Test Mode only). PayPal only gives live credentials to a verified Business or Premier account, so a Personal account must be upgraded and verified first. Then save the new credentials with Environment set to *Live*, and update the Product Catalog. A Test Mode client ID will not work against Live.
How PayPal differs from the other providers
PayPal cancels a subscription immediately; there is no end-of-period option.
Paddle only: the inline overlay option
Every provider's checkout response includes a redirect URL. Paddle's response *also* includes a raw transaction id, which a tenant's own frontend can use to open Paddle's checkout as an inline overlay instead of a page redirect, using Paddle's Paddle.js SDK and the tenant's own client-side token:
import { initializePaddle } from '@paddle/paddle-js';
const paddle = await initializePaddle({ token: yourClientSideToken, environment: 'sandbox' });
paddle.Checkout.open({ transactionId: result.transaction_id }); The client-side token is entirely the tenant's own concern — never sent to or stored by nostackai.
Connectors
What a connector is and the two roles that set one up.
Using a connector
Calling checkout directly, from an automation, or from a logic block.
Events & automations
React to a landed payment — grant access, send a receipt.
Case study: e-commerce checkout
A whole shop on PayPal: cart, checkout, paid order and order items, built and tested step by step.