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:

json
{ "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

http
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) and checkout_price_recurring (subscription); both catalog-label-based, the amount always comes from the Price the label points at.
  • Paddle — one checkout action handles both one-time and subscription; which mode runs depends on which price the label points at.
  • Dodo Payments — checkout (subscription) and checkout_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) and checkout_onetime (one-time order, a single amount) and checkout_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_onetime is for Logic Blocks only; checkout_cart can 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:

json
{ "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:

json
{ "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
# 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
# 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.

1Find or create your test accounts (Test Mode only). In the PayPal developer dashboard open Testing Tools → Sandbox Accounts. PayPal creates two for you when you sign up for a developer account: a Business account (your test seller) and a Personal account (your test buyer). To add another, choose Create Account, set Account Type to *Business* or *Personal*, pick a Country, and create it. Each account has an email and a system-generated password: choose the (…) next to it, then View/Edit Account to see them (the Profile tab lets you change the password). Sign in to either at sandbox.paypal.com/signin. Live mode has no test accounts: there you use your own PayPal Business account.
2Create an app. Open Apps & Credentials and choose Create App. Set the Type to *Merchant*, since the app takes payments for your own business. Each app has separate credentials for Sandbox and for Live: copy the Client ID and Client secret of the set that matches the Environment you will choose in nostackai. The same credentials are under Testing Tools → Sandbox Accounts → (…) → View/Edit Account → API Credentials. Then open the app's settings and check its features: Accept payments must be on, and if PayPal lists a subscriptions option for your account, turn that on too. PayPal renames these now and then, so if the first subscription request fails with a permissions error, this is the place to look.
3Create a product and a plan. Sign in with your Business account: in Test Mode the Business test account from step 1, at sandbox.paypal.com/signin; in Live your own PayPal Business account. Go to Sales → Subscriptions → Create plan. Add a product, then set the billing cycle and price, and make sure the plan is active. Copy the plan's ID. It starts with P-. Menu names can change; PayPal's subscriptions guide has the current ones.
4Add the webhook. Back in your app on the PayPal developer dashboard, scroll to Webhooks and choose Add webhook. For the URL, copy Inbound webhook URL from the PayPal card in nostackai (Tenant Settings → Connectors → PayPal). It already contains your tenant's key, so paste it exactly as shown. Tick the events listed under Events to subscribe to below, save, and copy the Webhook ID PayPal shows.
5Enter the credentials in nostackai. Tenant Settings → Connectors → PayPal: set Environment to *Test Mode* (or *Live*), then enter the Client ID, Client secret and Webhook ID. Also set the Success URL and Cancel URL on the same card, under *Checkout redirect URLs*. They are where PayPal sends the customer back to, set once and not sent on each call. Each checkout action (subscription, one-time, cart) has its own pair, so you can send each one to a different page; an empty field starts as your app URL, and you can replace it with any URL.
6Set up the Product Catalog. Still on the PayPal card, open Product Catalog and add one row for each plan you sell. The label is a name you choose, such as monthly. The value is the plan ID from step 3. See Product Catalog for PayPal below.
7Try it. In the Playground, run the PayPal Checkout action with your label. Open the returned 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.SUSPENDED and BILLING.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.COMPLETED and PAYMENT.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:

json
{ "monthly": "P-0AB12345CD678901E", "annual": "P-9ZY87654XW321098V" }
http
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 by entity_ref.
  • Plan IDs differ between Test Mode and Live. When you switch the Environment, replace the catalog values with the Live plan IDs.
  • checkout_onetime has 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:

js
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.