Skip to content
ONIWC
iwc 0.1.0

Tutorial 12: Implement payments

Objective: sell workshop tickets with a hosted checkout from Razorpay (or Stripe, PayPal, Cashfree), and mark an order paid only after the server confirms the payment with the provider.

Prerequisites: Tutorials 5 and 6. For live payments, a provider account; until then, development uses IWC's simulated provider, so every step below runs without one.

Project files
payments/
├── actions/
│   ├── confirm-order.action.iwc
│   └── place-order.action.iwc
├── api/
│   ├── order-create.api.iwc
│   └── order-status.api.iwc
├── database/
│   └── order.schema.iwc
├── integrations/
│   └── payments.integration.iwc
├── styles/
│   └── main.css
├── tests/
│   └── payments.test.iwc
├── ui/
│   ├── home.ui.iwc
│   └── order.ui.iwc
├── app.iwc
└── package.iwc

The rule

Never mark an order paid because the customer came back. Anyone can open your success URL. Fulfil only after getPayment (or a verified webhook) says the payment succeeded, for the right amount and the right order. iwc security flags payments that are never confirmed.

Step 1: Install the payments package

Terminal
iwc add @iwc/payments --provider razorpay

iwc add asks for each setting, stores secrets encrypted in the development vault (never in the project), tests the connection and writes the integration file. This project marks it optional:

integrations/payments.integration.iwc
{
  "package": "@iwc/payments",
  "provider": "razorpay",
  "optional": true,
  "config": { "keyId": { "env": "RAZORPAY_KEY_ID" } },
  "credentials": {
    "keySecret": { "env": "RAZORPAY_KEY_SECRET" },
    "webhookSecret": { "env": "RAZORPAY_WEBHOOK_SECRET" }
  }
}
  • config holds non-secret settings; credentials are always {"env": "NAME"}.
  • "optional": true: in development, IWC uses the simulated provider until the credentials are set; in production the app starts without them and a call answers 503 integration_not_configured.
  • Switching to Stripe later changes only this file: iwc config set payments.provider stripe.

Step 2: The order table

database/order.schema.iwc
{
  "name": "order",
  "fields": {
    "email": { "type": "email", "required": true },
    "ticket": { "type": "enum", "values": ["standard", "supporter"], "required": true },
    "amount": { "type": "integer", "required": true, "min": 0, "description": "in paise" },
    "status": { "type": "enum", "values": ["pending", "paid", "failed"], "default": "pending", "required": true },
    "paymentId": { "type": "string", "maxLength": 120 }
  },
  "access": { "read": "none", "create": "none", "update": "none", "delete": "none" }
}

Amounts are integers in minor units (paise). access is closed: only actions — trusted server code — touch orders.

Step 3: Start the payment

actions/place-order.action.iwc
{
  "name": "PlaceOrder",
  "description": "Creates a pending order priced on the server, then starts a hosted payment for it.",
  "transaction": true,
  "input": {
    "email": { "type": "email", "required": true },
    "ticket": { "type": "string", "required": true, "enum": ["standard", "supporter"] }
  },
  "steps": [
    { "operation": "set", "as": "amount", "value": "input.ticket == 'supporter' ? 249900 : 99900" },
    { "operation": "create", "resource": "order.schema.iwc", "as": "order", "values": { "email": "=input.email", "ticket": "=input.ticket", "amount": "=amount" } },
    {
      "operation": "call",
      "integration": "payments.integration.iwc",
      "method": "createPayment",
      "input": {
        "amount": "=amount",
        "currency": "INR",
        "reference": "=order.id",
        "description": "Workshop ticket",
        "customerEmail": "=input.email",
        "successUrl": "=concat(request.origin, '/orders/', order.id)",
        "cancelUrl": "=concat(request.origin, '/orders/', order.id)"
      },
      "as": "payment"
    },
    { "operation": "update", "resource": "order.schema.iwc", "where": { "id": "=order.id" }, "values": { "paymentId": "=payment.id" } },
    { "operation": "return", "value": { "orderId": "=order.id", "checkoutUrl": "=payment.checkoutUrl" } }
  ]
}
  • The price comes from the server (set amount), never from the form.
  • createPayment gets the amount, currency, your reference (the order id) and where to return. It answers with a checkoutUrl.
  • request.origin is the site's own address, so the URLs work on any domain.
api/order-create.api.iwc
{
  "name": "placeOrder",
  "method": "POST",
  "path": "/orders",
  "action": "place-order.action.iwc",
  "request": {
    "payload": {
      "email": { "type": "email", "required": true },
      "ticket": { "type": "string", "required": true, "enum": ["standard", "supporter"] }
    }
  },
  "response": { "status": 201, "fields": { "orderId": "string", "checkoutUrl": "string" } },
  "security": { "rateLimit": "10/1m" }
}

Step 4: Send the customer to the checkout

ui/home.ui.iwc
{
  "name": "Tickets",
  "route": "/",
  "title": "Book a workshop ticket",
  "sections": [
    {
      "type": "section",
      "className": "page",
      "layout": { "direction": "column", "gap": "16px" },
      "elements": [
        { "type": "text", "variant": "h1", "content": "Brand design workshop" },
        { "type": "text", "content": "Saturday 6 December · Jaipur" },
        {
          "type": "form",
          "inputs": [
            { "name": "email", "type": "email", "label": "Email for your ticket", "required": true, "autocomplete": "email" },
            {
              "name": "ticket",
              "type": "radio",
              "label": "Ticket",
              "value": "standard",
              "options": [
                { "label": "Standard — ₹999", "value": "standard" },
                { "label": "Supporter — ₹2,499", "value": "supporter" }
              ]
            }
          ],
          "submit": { "api": "order-create.api.iwc", "label": "Pay securely", "success": { "redirect": "checkoutUrl" } }
        }
      ]
    }
  ]
}

"success": {"redirect": "checkoutUrl"} sends the browser to the URL in that response field. Only paths on your site and addresses of your configured providers are allowed, so it can't be turned into an open redirect.

Step 5: Confirm on the server

actions/confirm-order.action.iwc
{
  "name": "ConfirmOrder",
  "description": "Asks the provider for the payment's real state and marks the order paid only when it succeeded for the right amount.",
  "transaction": true,
  "input": { "id": { "type": "string", "required": true } },
  "steps": [
    { "operation": "find", "resource": "order.schema.iwc", "where": { "id": "=input.id" }, "as": "order", "required": true, "error": "Order not found" },
    { "operation": "if", "condition": "order.status != 'pending'", "then": [{ "operation": "return", "value": { "id": "=order.id", "status": "=order.status", "amount": "=order.amount" } }] },
    { "operation": "call", "integration": "payments.integration.iwc", "method": "getPayment", "input": { "id": "=order.paymentId" }, "as": "p" },
    {
      "operation": "if",
      "condition": "p.status == 'succeeded' && p.amount == order.amount && p.reference == order.id",
      "then": [{ "operation": "update", "resource": "order.schema.iwc", "where": { "id": "=order.id", "status": "pending" }, "values": { "status": "paid" } }],
      "else": [{ "operation": "if", "condition": "p.status == 'failed'", "then": [{ "operation": "update", "resource": "order.schema.iwc", "where": { "id": "=order.id", "status": "pending" }, "values": { "status": "failed" } }] }]
    },
    { "operation": "find", "resource": "order.schema.iwc", "where": { "id": "=order.id" }, "as": "now", "required": true },
    { "operation": "return", "value": { "id": "=now.id", "status": "=now.status", "amount": "=now.amount" } }
  ]
}

The order flips to paid only when the provider says succeeded and the amount and reference match. The guarded update (where status = pending) makes the confirmation safe to run twice.

api/order-status.api.iwc
{
  "name": "orderStatus",
  "method": "GET",
  "path": "/orders/:id",
  "action": "confirm-order.action.iwc",
  "response": { "fields": { "id": "string", "status": "string", "amount": "number" } }
}
ui/order.ui.iwc
{
  "name": "Order",
  "route": "/orders/:id",
  "title": "Your order",
  "sections": [
    {
      "type": "section",
      "className": "page",
      "data": { "source": "API.orderStatus", "mode": "single" },
      "layout": { "direction": "column", "gap": "12px" },
      "elements": [
        { "type": "text", "variant": "h1", "content": "=status == 'paid' ? 'You are booked!' : (status == 'failed' ? 'Payment failed' : 'Waiting for payment')" },
        { "type": "text", "content": "=concat('Amount: ', format(amount / 100, 'currency', 'INR'))" },
        { "type": "link", "label": "Try again", "target": "/", "visible": "=status != 'paid'" }
      ],
      "error": { "type": "text", "content": "We couldn't find that order." }
    }
  ]
}

Step 6: Try it with the simulated provider

Terminal
iwc dev --open

Choose a ticket and press Pay securely. The simulated provider's checkout is a page on your own site (/_iwc/simpay/<id>) showing the amount with Pay and Decline. Pay returns to /orders/<id>, which shows "You are booked!"; Decline shows "Payment failed".

Step 7: Test it

tests/payments.test.iwc
{
  "name": "payments",
  "cases": [
    { "name": "placing an order returns a hosted checkout", "request": { "method": "POST", "path": "/api/orders", "body": { "email": "riya@example.com", "ticket": "supporter" } }, "expect": { "status": 201, "contains": ["checkoutUrl"] }, "save": { "orderId": "orderId" } },
    { "name": "the browser cannot set the price", "request": { "method": "POST", "path": "/api/orders", "body": { "email": "riya@example.com", "ticket": "supporter", "amount": 1 } }, "expect": { "status": 422 } },
    { "name": "the order is confirmed with the provider before it counts as paid", "request": { "path": "/api/orders/{{orderId}}" }, "expect": { "status": 200, "body": { "status": "paid", "amount": 249900 } } },
    { "name": "the confirmation page says so", "request": { "path": "/orders/{{orderId}}" }, "expect": { "status": 200, "contains": ["booked!", "₹2,499.00"] } },
    { "name": "unknown orders", "request": { "path": "/api/orders/nope" }, "expect": { "status": 404 } }
  ]
}
Terminal
iwc test && iwc security
Output
  ✓ payments › placing an order returns a hosted checkout
  ✓ payments › the browser cannot set the price
  ✓ payments › the order is confirmed with the provider before it counts as paid
  ✓ payments › the confirmation page says so
  ✓ payments › unknown orders

5 passed, 0 failed
✓ no security findings

Step 8: Go live

  1. Set RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET and RAZORPAY_WEBHOOK_SECRET on the server (test keys first).
  2. Add a webhook API so payments completed after the customer closed the tab are still recorded: an API with "request": {"rawBody": true, "headers": {…}} and "security": {"csrf": false}, whose action starts with verifyWebhook. See Packages & services.
  3. iwc integrations verify tests the live connection.

Troubleshooting

ProblemFix
503 integration_not_configured in productionThe credentials' environment variables are missing.
Calling a method the provider lacksA compile error naming the providers that offer it (iwc packages @iwc/payments shows the table).
An API calling createPayment can't be queued offlineCorrect: payments need the server while the person waits.

Next: Implement push notifications.

Source: apps/marketing/content/docs/tutorials/payments.md