Web-Nothing Docs
Guides

Accept Payments

Accept Payments in Your App

Web-Nothing apps can charge end-customers via your own Stripe account. The platform handles credential storage, the public checkout-session endpoint, and the webhook that flips your order's status to paid when the customer completes checkout. Stripe owns the checkout UX, fraud detection, and refund UI — you don't write any payment code.

How it works

  1. Customer browses your app's public page (cart of menu items, booking form, etc.).
  2. Customer submits the order. Web-Nothing creates the order record with payment_status: "pending".
  3. The cart calls /api/public/{appId}/stripe/checkout-session which uses your stored Stripe secret key to create a Checkout Session.
  4. Browser redirects to Stripe's hosted Checkout page.
  5. Customer pays. Stripe redirects back to your public app page.
  6. Stripe sends a checkout.session.completed webhook to your app's webhook URL. Web-Nothing flips the order to payment_status: "paid" and stamps paid_at, stripe_session_id, stripe_payment_intent_id.
  7. If you refund the customer in your Stripe Dashboard, Stripe sends a charge.refunded webhook. Web-Nothing flips the order to payment_status: "refunded" and stamps refunded_at.

Path A (custom action primitive) was rejected — Path B keeps the platform thin and gives Stripe everything it does best.

Choosing how customers pay (online / cash / both)

A cart with paymentMode: "stripe" is simply a paid cart. You — the app owner — choose how customers actually pay, in the workspace Payments panel:

  • Pay online (Stripe) — customers pay by card at checkout. Requires your Stripe key (see Setup below).
  • Cash · pay at counter — no Stripe key needed. The order is still recorded with the correct total, and the customer sees an on-screen "Order received — pay at the counter" confirmation showing their order ID. Great for restaurants, cafés, market stalls, and pop-ups that collect payment in person.
  • Both — customer chooses — customers pick "Pay online" or "Pay at counter" at checkout. Only available once your Stripe key is configured.

Works out of the box without Stripe. If you haven't added a Stripe key yet, a paid cart automatically falls back to cash / pay-at-counter — so a freshly installed ordering template takes orders from day one. Add your key later to turn on online payment; nothing else in your app needs to change.

Either way the order total is calculated by Web-Nothing from your menu prices (customers can't tamper with it), so the amount you collect at the counter is always correct.

Setup (5 minutes)

1. Create a Restricted Key in your Stripe Dashboard

You can use your full Secret Key, but a Restricted Key is safer for app-level use. In Stripe Dashboard → Developers → API keys → Create restricted key:

  • Name: web-nothing-{your-app-name}
  • Permissions:
    • Checkout Sessions: Write
    • Payment Intents: Read (for refund matching)
    • Charges: Read
    • Balance: Read (used during validation)
  • All other resources: None

Save the key (rk_test_… for sandbox, rk_live_… for production).

2. Configure your app

Open your app in Web-Nothing workspace. Find the App-level Stripe Payments panel below the Connectors panel. Click Configure.

  • Webhook URL: copy this — you'll paste it into Stripe Dashboard next.
  • Stripe Secret Key: paste your rk_test_… or rk_live_… key.
  • Webhook Signing Secret: leave blank for now — you'll fill this in step 3.
  • Currency: pick the currency you charge in (USD, HKD, GBP, etc.).

Don't click Save yet.

3. Create the webhook in Stripe Dashboard

Stripe Dashboard → Developers → Webhooks → Add endpoint:

  • Endpoint URL: paste the webhook URL from your app's Stripe panel (it looks like https://web-nothing.vercel.app/api/apps/{appId}/stripe/webhook).
  • Events to send:
    • checkout.session.completed
    • charge.refunded

Click Add endpoint. Stripe shows the Signing secret — copy it (whsec_…).

⚠️ Each app gets its own webhook URL with its own signing secret. Don't reuse a webhook from another product (e.g. Web-Nothing's own subscription billing or Study App). If multiple apps share a Stripe account, create a separate webhook endpoint per app.

4. Save the credentials in Web-Nothing

Back in the Stripe panel, paste the whsec_… signing secret into Webhook Signing Secret and click Save. Web-Nothing validates your secret key with a Stripe API roundtrip; if the key is bad, you'll see a clear error right here — no broken checkout in production.

The badge flips to Configured · sandbox|live · CURRENCY.

5. Add payment fields to your order schema

Your order schema must declare these five fields (all string):

  • payment_status
  • paid_at
  • refunded_at
  • stripe_session_id
  • stripe_payment_intent_id

Web-Nothing rejects spec writes that turn on paymentMode: "stripe" without these fields, so the webhook can't fail silently when a payment lands.

If you prompt an agent to add Stripe checkout (for example, "add Stripe payment to my cart"), Web-Nothing adds these required fields for you automatically.

6. Turn on paymentMode: "stripe" for your cart

In your app spec, set the cart prop:

{
  "component": "cart",
  "props": {
    "source": "menu_items",
    "priceField": "price_hkd",
    "submitActionId": "create_order",
    "paymentMode": "stripe",
    "currencyPrefix": "HK$"
  }
}

The agent or workspace UI handles this. paymentMode: "stripe" marks the cart as a paid cart; how customers actually pay (online / cash / both) is the owner toggle described in Choosing how customers pay above. You can also drop a payment-link mode: "stripe" button on a confirmation / record-detail page for unpaid retries — it picks the order id from formData.id and creates a fresh Stripe Checkout Session.

Testing in sandbox

Use Stripe sandbox keys (rk_test_… / whsec_… from a test-mode webhook).

Stripe's test card: 4242 4242 4242 4242, any future expiry, any CVC.

Walk a customer flow:

  1. Open your public app URL.
  2. Add items to the cart.
  3. Click Order & Pay (or whatever your submitLabel says).
  4. Cart submits the order, then redirects to Stripe Checkout.
  5. Pay with 4242 4242 4242 4242.
  6. Stripe redirects you back to /app/{appId}?payment=success&record=….
  7. Within ~3 seconds the webhook fires; check the order in workspace — payment_status should now be paid with paid_at populated.

To test refunds, refund the test charge in Stripe Dashboard. The order's payment_status flips to refunded and refunded_at is set.

Switching to production

When you're ready to charge real money:

  1. Stripe Dashboard → toggle to Live mode.
  2. Re-create the same Restricted Key in live mode (rk_live_…).
  3. Re-create the same webhook endpoint in live mode (new whsec_…).
  4. In the Web-Nothing Stripe panel, click Replace credentials and paste the live values.

Sandbox and live credentials are stored separately — you can keep both configurations in your notes for ops.

Booking + Payment (race-aware)

If your app takes payment to confirm a booking slot (lesson, salon appointment, restaurant deposit, hotel hold), there's a real-world race that will lock slots forever unless you handle it explicitly:

  1. Customer A picks the 10:00 slot → submits → slot is reserved → redirected to Stripe Checkout.
  2. Customer A closes the tab, never completes payment.
  3. The slot stays locked because A's record still exists and still claims the time.
  4. Tutor sees "10:00 booked", customer never paid. Repeat at scale: 30% of attempts abandon → 30% of slots lost.

The platform fixes this with a three-piece pattern. You add it once to the booking schema; the platform handles the rest at conflict-check time and at webhook delivery.

The three pieces

1. Add a payment_expires_at field to your parent booking schema. Field type must be string (not datetime). When the customer submits, this field captures the moment they reserved the slot. Five minutes later, if no payment confirmation has arrived, the slot frees up for someone else.

2. Set payment_expires_at: "$now" in the multiRecord submit action's parent dataMapping. The platform resolves $now to the current ISO timestamp at insert time. This is a built-in placeholder — no client code or agent prompt change needed.

3. Add excludeWhen.staleAfter to your slot field's availabilityCheck. This is the lazy filter that releases stale slots at conflict-check time — no cron, no background sweeper, no eventual consistency. Pair it with the existing in: ["cancelled"] predicate so cancelled bookings also release.

Worked example — TutorHub-Pro lesson booking

{
  "name": "appointments",
  "fields": [
    { "name": "slot_datetime", "type": "datetime",
      "availabilityCheck": {
        "partitionField": "tutor_id",
        "defaultDurationMinutes": 60,
        "capacity": 1,
        "excludeWhen": {
          "field": "payment_status",
          "in": ["cancelled"],
          "staleAfter": { "field": "payment_expires_at", "minutesPast": 5 }
        },
        "minLeadTimeMinutes": 60,
        "maxLeadTimeDays": 90
      }
    },
    { "name": "tutor_id", "type": "relation", "relationTo": "tutors" },
    { "name": "payment_status", "type": "string" },
    { "name": "paid_at", "type": "string" },
    { "name": "refunded_at", "type": "string" },
    { "name": "stripe_session_id", "type": "string" },
    { "name": "stripe_payment_intent_id", "type": "string" },
    { "name": "payment_expires_at", "type": "string" }
  ]
}

And the cart submit action's parent dataMapping:

{ "payment_status": "pending", "payment_expires_at": "$now" }

How the race plays out

TimeEvent
10:00:00Customer A picks 10:00 slot → submits. Record A: payment_status: "pending", payment_expires_at: "10:00:00". Stripe Checkout opens.
10:00:30Customer B tries the same slot → 400 conflict, slot is held.
10:00:30Customer A walks away — never returns to Stripe.
10:05:01Slot is now stale (10:05 - 10:00 > 5 min). A's record no longer counts toward capacity.
10:06:00Customer C tries the same slot → success. Record C: payment_status: "pending", payment_expires_at: "10:06:00".
10:08:00A's payment finally succeeds (slow card auth). Stripe webhook fires.
10:08:00Platform writes A: payment_status: "paid", paid_at: "10:08:00".
10:08:00Platform also logs an orphan event so you can see it in your hosting provider's error filters: [STRIPE-APP] Orphan checkout.session.completed.
10:08:00Two records now hold the same slot (A paid, C pending). You decide how to handle it.

What about A's "orphan" payment?

A's payment is real money — it's recorded as paid. The platform never auto-refunds in v1. You see the orphan log in your hosting dashboard, review the situation in your Stripe Dashboard, and decide:

  • Refund A and let C keep the slot (most common).
  • Keep both — your double-booked slot is now your operational problem (overbook policy, second tutor, comped rebook, etc.).
  • Refund neither — A pays for the inconvenience too, you keep two paid customers and offer something else later.

Refund decisions live in Stripe Dashboard for v1. A future version may add a one-click "refund + flag" workflow.

Common mistakes

  • Forgetting payment_expires_at: "$now" in dataMapping. Without it, the slot stays held forever — the auto-release mechanism never fires. Re-check your multiRecord parent dataMapping.
  • Typing payment_expires_at as datetime. The strict datetime field validation rejects millisecond-precision ISO strings; $now resolves to one. Use type: "string" — the platform's stale-slot filter accepts string-typed timestamp fields.
  • Non-standard payment_status values. The webhook's orphan-log guard treats "pending" and "pending_payment" as expected prior states. Other values like "awaiting" or "draft" will fire orphan logs every time even on the happy path. Stick to "pending" (the platform-blessed convention).
  • Setting minutesPast: 0. That releases the slot the same instant the record is created — defeating the hold window entirely. The smallest useful value is 5 minutes (typical Stripe Checkout completion window). Use larger values for slow-payment flows like 3D Secure (10-15 min).

When NOT to use this

If your booking app doesn't take payment to confirm (free RSVP, internal calendar), you don't need staleAfter. Plain excludeWhen.in: ["cancelled"] is sufficient.

Limits & gotchas

  • Currency is configured at the app level, not per cart. Pick once; every checkout goes through that currency. Switching currencies later is fine but won't reprocess existing pending orders.
  • Refunds: v1 treats any refund as terminal — a partial refund flips payment_status to refunded. The Stripe Dashboard remains the canonical record of refund amounts.
  • Disputes / chargebacks: not currently auto-tracked. Watch your Stripe Dashboard.
  • Stripe Connect: not yet supported. Each app uses its own Stripe account directly — no platform fee plumbing in v1.
  • Live key warning: Web-Nothing accepts both sk_test_ / sk_live_ and rk_test_ / rk_live_ keys. If you paste a live key you'll see live in the configuration badge. Sandbox keys will say sandbox.

Troubleshooting

  • Customer paid but order still pending: Check Stripe Dashboard → Webhooks → your endpoint → Recent attempts. If you see 400 "Invalid signature", you copied the wrong whsec_… (e.g. from a different webhook in another app). Replace credentials with the correct one.
  • Cart button says "Payments not configured by owner": The Stripe panel shows "Not configured", or stripe.balance.retrieve failed during save. Re-paste credentials.
  • Cannot resolve payment amount: Your order schema's price field doesn't match what the cart submitted as subtotal or doesn't follow the total_<currency> convention. Either set the cart's priceField, or ensure the order has a total_<currency> field that gets populated via the multiRecord parent dataMapping.

See also

  • API Connectors — for talking to your own Stripe account from inside actions (e.g. listing the customer's invoices).
  • Workflows — for chaining post-payment actions (notification email, inventory deduction).

On this page