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.
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.iwcThe 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
iwc add @iwc/payments --provider razorpayiwc 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:
{
"package": "@iwc/payments",
"provider": "razorpay",
"optional": true,
"config": { "keyId": { "env": "RAZORPAY_KEY_ID" } },
"credentials": {
"keySecret": { "env": "RAZORPAY_KEY_SECRET" },
"webhookSecret": { "env": "RAZORPAY_WEBHOOK_SECRET" }
}
}configholds non-secret settings;credentialsare 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 answers503 integration_not_configured.- Switching to Stripe later changes only this file:
iwc config set payments.provider stripe.
Step 2: The order table
{
"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
{
"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. createPaymentgets the amount, currency, yourreference(the order id) and where to return. It answers with acheckoutUrl.request.originis the site's own address, so the URLs work on any domain.
{
"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
{
"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
{
"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.
{
"name": "orderStatus",
"method": "GET",
"path": "/orders/:id",
"action": "confirm-order.action.iwc",
"response": { "fields": { "id": "string", "status": "string", "amount": "number" } }
}{
"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
iwc dev --openChoose 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
{
"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 } }
]
}iwc test && iwc security ✓ 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 findingsStep 8: Go live
- Set
RAZORPAY_KEY_ID,RAZORPAY_KEY_SECRETandRAZORPAY_WEBHOOK_SECRETon the server (test keys first). - 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 withverifyWebhook. See Packages & services. iwc integrations verifytests the live connection.
Troubleshooting
| Problem | Fix |
|---|---|
503 integration_not_configured in production | The credentials' environment variables are missing. |
| Calling a method the provider lacks | A compile error naming the providers that offer it (iwc packages @iwc/payments shows the table). |
An API calling createPayment can't be queued offline | Correct: payments need the server while the person waits. |
Next: Implement push notifications.
Source: apps/marketing/content/docs/tutorials/payments.md