S
supero.docs
Documentation/Building Your App/Transactional Services

Transactional Services

Prebuilt backend capabilities — cart, order, payment, booking, membership, and more — with a real state machine, idempotency guarantees, and a single wire primitive: POST /api/v1/services/execute.

Overview

Transactional services are prebuilt backend capabilities you opt into by extending your own schema from a platform base schema — instead of hand-rolling a cart, order, or booking state machine yourself. Each service ships a schema (with a real lifecycle/status field), a state machine that enforces legal transitions, and a small set of operations (verbs) that mutate a record safely: validated input, state guards, idempotency, and an audit trail.
You still read and list your records with ordinary CRUD. The only new thing transactional services add is how you mutate lifecycle state: instead of PUT-ing a new value onto a status field, you call an operation, and the platform enforces whether that transition is legal from the record's current state.

🚨 Never write a lifecycle field directly

Do not PUT status (or any other lifecycle field) on a transactional record directly. It bypasses input validation, the state machine, and the audit trail, and can leave related records (e.g. order totals, cart items) out of sync. Always call the operation.

The Model: extends + one wire primitive

You opt in by declaring extends on your own schema, pointing at a platform base schema in "<service>:base_<name>" form — see the Schema Reference for how extends resolves. Your schema inherits the base's fields (status, timestamps, computed totals, etc.) plus whatever fields you add.
json
{
  "name": "luxe_cart",
  "parent_type": "tenant",
  "extends": "cart:base_cart",
  "attributes": [
    { "name": "gift_wrap", "type": "boolean", "default-value": false }
  ]
}

{
  "name": "luxe_order",
  "parent_type": "tenant",
  "extends": "order:base_order",
  "attributes": []
}
Once a service is imported into your project (via the services catalog — see Managed Integrations & Provider Catalog), every lifecycle mutation on that entity goes through exactly one wire primitive:
http
POST https://api.supero.dev/api/v1/services/execute
X-API-Key: ak_...
Content-Type: application/json

{
  "service_id": "cart",
  "operation": "checkout_cart",
  "input": {
    "cart_uuid": "5b6e...",
    "extending_schema": "myapp:luxe_cart",
    "cart_item_extending_schema": "myapp:luxe_cart_item",
    "order_extending_schema": "myapp:luxe_order",
    "order_item_extending_schema": "myapp:luxe_order_item"
  },
  "domain": "mydomain",
  "project_uuid": "8f2a..."
}
json
{
  "success": true,
  "service_id": "cart",
  "operation": "checkout_cart",
  "output": {
    "record_uuid": "5b6e...",
    "order_uuid": "9c1d...",
    "status": "checked_out",
    "checked_out_at": "2026-07-18T09:12:03Z"
  },
  "side_effects": ["created myapp:luxe_order 9c1d..."],
  "record_uuid": "5b6e...",
  "error": null
}
Reading and listing the underlying records (a cart, an order, a booking) is ordinary CRUD: GET/PUT/DELETE https://api.supero.dev/api/v1/crud/{domain}/{type}[/{uuid}], rich filtered reads via POST .../crud/{domain}/query, and bulk fetches via POST .../crud/{domain}/list-bulk. Only the lifecycle mutation goes through /services/execute.

ℹ️ extending_schema tells the platform which of your schemas to operate on

Because multiple app schemas can extend the same base (your own cart plus, say, an imported billing integration's own customer schema), every operation input includes the qualified name(s) of your extending schema(s) — extending_schema, and for two-schema services like cart/order/approval/inventory/loyalty_points/document_signature, a second slot (e.g. cart_item_extending_schema). Register these once (see Client Access below) instead of repeating them on every call.

Service Catalog

Discover what's available and already imported at runtime with GET https://api.supero.dev/api/v1/services/catalog (summary of every service) or GET .../api/v1/services/catalog/{service_id} for one service's full schema/operation detail. The catalog below groups the ~23 transactional services by the kind of app they support.

Commerce & fulfillment

ServiceWhat it modelsBase schema(s)
cartA shopping cart and its line items.cart:base_cart, cart:base_cart_item
orderAn order created from a cart (or standalone, for invoice-style apps).order:base_order, order:base_order_item
paymentThe Supero-side record of a payment as it moves through authorize → capture → refund/void. Not a payment processor itself — pair it with a gateway like stripe_checkout or billing_razorpay.payment:base_payment
inventoryStock-keeping: total/available quantity per SKU, plus reservations held until commit or release.inventory:base_inventory, inventory:base_reservation

Payment gateways (services catalog connectors)

ServiceWhat it does
stripe_checkoutHosted Stripe checkout sessions and payment links: create_checkout_session, retrieve_checkout_session, create_payment_link, expire_session, deactivate_payment_link, get_publishable_config.
billing_razorpayUPI/cards/wallets/net-banking via Razorpay: create_order, create_refund.

Scheduling

ServiceWhat it modelsBase schema
appointmentA provider-based visit (clinics, salons, consultants).appointment:base_appointment
bookingA time-slot reservation against a resource (rooms, equipment, classes).booking:base_booking
rentalAn asset taken out for a date/time range and returned — priced by duration × rate, with a security deposit. Distinct from booking (the asset leaves the premises).rental:base_rental

Membership & subscriptions

ServiceWhat it modelsBase schema
membershipRole-bearing org/team/group membership (member of X) — distinct from billing.membership:base_membership
recurring_planA recurring-billing plan (SaaS subscriptions, retainers, gym memberships). Named recurring_plan because "subscription" is a reserved schema name.recurring_plan:base_plan
loyalty_pointsA points balance plus a transaction ledger for accrual/redemption/expiry.loyalty_points:base_points_account, loyalty_points:base_points_txn

Catalog primitives

ServiceWhat it modelsBase schema
productA tangible, carted/ordered good.product:base_product
serviceAn intangible, performed offering (a haircut, a consult, a rental day-rate) — sold via cart/order and/or scheduled via appointment/booking.service:base_service
customerThe commerce-side profile linked to a platform user account. Identity fields live on the user account; this holds commerce fields.customer:base_customer

Workflow, support & content

ServiceWhat it modelsBase schema
taskLightweight TODO tracking (done/not-done, no resolution stages).task:base_task
ticketSupport tickets / helpdesk cases with an assignee and priority.ticket:base_ticket
approvalSingle-step or sequential multi-step approval flows (expense reports, PTO, procurement).approval:base_approval, approval:base_approval_step
document_signatureMulti-party document signing (contracts, NDAs, consent forms) — not for invoices/receipts.document_signature:base_document, document_signature:base_signature
reviewsCustomer reviews with a moderation lifecycle, verified-purchase identity, and business responses.reviews:base_review
feedbackRatings/surveys attached to any record.feedback:base_feedback
commentThreaded comments on any record.comment:base_comment
attachmentFile-attachment metadata and its own upload/scan/archive lifecycle.attachment:base_attachment
notificationIn-app notification records (queue → sent → seen/dismissed) — distinct from the email/SMS connector services.notification:base_notification

💡 Let Supero pick services for you

When you describe your app in plain language, Supero's app generator can select an appropriate starting set of services for you (e.g. a salon booking app gets service + appointment + payment). You can always add, remove, or reconfigure services afterward from the Services page in your project — see Build Your First App (App Studio) and Managed Integrations & Provider Catalog.

Per-Journey Operation Maps

The operations below are the actual verbs each service exposes. Call them through the client access patterns in the next section, or raw via the wire primitive above.

Commerce: cart → checkout → order → pay → fulfill

StepOperationKey inputNotes
Add to cartcart.add_to_cartproduct_uuid, quantity, unit_priceCreates the Customer + open Cart if they don’t exist yet, and upserts the line. Calling it again for the same product increases quantity — it is cumulative, not idempotent.
Recompute totalscart.recompute_totalscart_uuidSafe to call anytime; naturally idempotent.
Checkoutcart.checkout_cartcart_uuid (+ your cart/cart_item/order/order_item extending schemas)open → checked_out (terminal). Creates a pending Order. Calling it again on an already-checked-out cart returns the same order — safe to retry.
Confirm orderorder.place_orderorder_uuidpending → confirmed.
Chargestripe_checkout.create_checkout_session or billing_razorpay.create_ordersuccess_url / amount (major units)Creates a hosted payment session; mirror the result onto a payment record with payment.authorize_payment → payment.capture_payment.
Fulfillorder.fulfill_orderorder_uuidconfirmed → fulfilled.
Cancelorder.cancel_orderorder_uuid, cancellation_reasonpending/confirmed → cancelled.

Booking: check availability → confirm

StepOperationKey inputNotes
Check availabilitybooking.check_availability / appointment.check_provider_availabilityresource_uuid, resource_field, start_time, end_timeAdvisory only — an overlap check, not a hold. A slot can still be taken between checking and confirming, so handle a 409 on confirm as a normal race, not a bug.
Confirmbooking.book_slot / appointment.schedule_appointmentbooking_uuid or appointment_uuidrequested → confirmed. booking.book_slot also supports a create-and-confirm call shape (pass resource_uuid + start_time + end_time instead of an existing uuid).
Reschedulebooking.reschedule_booking / appointment.reschedule_appointmentstart_time, end_timeField-only update — not idempotent; don’t auto-retry.
Complete / no-showbooking.complete_booking, appointment.complete_appointment / mark_no_showbooking_uuid or appointment_uuidconfirmed → completed or no_show (terminal).

Subscriptions, membership & loyalty

JourneyOperationsNotes
Subscribe / manage a planrecurring_plan.start_plan → pause_plan / resume_plan / cancel_planinactive → active → paused → cancelled/expired. There is no built-in recurring-charge scheduler — billing ticks (charge + renew_period) are something your app composes on a schedule.
Membershipmembership.enroll_member → suspend_member / reinstate_member / expel_member / transfer_memberpending → active → suspended/expelled/transferred.
Loyalty pointsloyalty_points.accrue_points / redeem_points / expire_points / reverse_txnredeem_points 409s if the balance is less than the amount requested.

State Machines & Error Handling

Every transactional base schema ships a declared state machine: an initial state, a set of states (some terminal), and named transitions, each restricted to a from set of allowed source states.
ServiceInitial stateStates
cartopenopen → checked_out (terminal) | abandoned | expired
orderpendingpending → confirmed → fulfilled (terminal) | cancelled (terminal) | refunded (terminal)
paymentpendingpending → authorized → captured → refunded (terminal) | partially_refunded | voided (terminal) | failed (terminal)
appointment / bookingrequestedrequested → confirmed → completed (terminal) | cancelled (terminal) | no_show (terminal)
membershippendingpending → active ⇄ suspended → expelled (terminal) | transferred (terminal) | expired (terminal)
recurring_planinactiveinactive ⇄ active ⇄ paused → cancelled (terminal) | expired (terminal)
Calling an operation that isn't legal from the record's current state does not raise a transport-level error — the HTTP status of the /services/execute call itself stays 200. Check the success and error_code fields in the response body, not the transport status:
json
{
  "success": false,
  "service_id": "cart",
  "operation": "checkout_cart",
  "output": {
    "error_code": "INVALID_STATE_TRANSITION",
    "error": "Cannot checkout_cart from state 'checked_out'. Allowed from: ['open'].",
    "current_state": "checked_out",
    "current_state_terminal": true,
    "attempted_operation": "checkout_cart",
    "allowed_from_states": ["open"],
    "target_state": "checked_out",
    "remediation": {
      "type": "terminal_state",
      "message": "Record is in terminal state 'checked_out'; no further operations are valid.",
      "available_operations_from_current_state": []
    }
  },
  "error": "Cannot checkout_cart from state 'checked_out'. Allowed from: ['open'].",
  "status_code": 409,
  "error_code": "INVALID_STATE_TRANSITION"
}
remediation.available_operations_from_current_state tells you exactly which operations are legal right now — use it to drive which action buttons your UI shows, instead of hardcoding a transition map client-side.

💡 Discover legal transitions ahead of time

GET https://api.supero.dev/api/v1/services/transactional-catalog returns every imported service's operations, idempotency categories, and full state machine (states, terminal flags, allowed transitions) in one call — useful for building a generic "what can I do to this record" UI without hardcoding per-service logic.

Idempotency Guidance

Not every operation is safe to retry the same way. Each operation falls into one of three categories — know which one you're calling before you decide to retry on a timeout or 5xx.
CategoryMeaningSafe to auto-retry?Examples
State-guarded (idempotent)Retrying after the transition already happened returns the same end state (often with a reason field signaling "already done") instead of erroring or duplicating.Yescart.checkout_cart, cart.get_or_create_open_cart, order.place_order, order.fulfill_order, booking.book_slot, payment.authorize_payment, payment.capture_payment
Naturally idempotentThe operation recomputes a derived value; calling it twice is harmless because it doesn’t depend on prior calls.Yescart.recompute_totals, order.recompute_order_totals, task.block_task / unblock_task, ticket.assign_ticket
Unsafe to retry (cumulative)Each call has an additive or one-way effect — a blind retry double-counts or double-charges.No — check first, or use a client-supplied dedup keycart.add_to_cart (quantity grows on repeat calls), payment.refund_payment, recurring_plan.renew_period, inventory.adjust_quantity, approval.approve_step / reject_step
For cart.add_to_cart specifically, pass a stable client_line_id when you want a retry to be treated as the same add intent rather than a second line — the server dedups exactly on that value, so retries must resend the same id every time.

⚠️ Prefer a pre-check over a blind retry on Category 3 ops

Before retrying refund_payment, re-read the payment record and check its status is still captured/partially_refunded — a payment already in refunded means the earlier call succeeded and the response just didn't reach you. The SDKs' built-in retry logic already treats these operations as non-retryable for exactly this reason.

Client Access

Two supported ways to call transactional operations, depending on where your code runs.

From your app’s browser frontend

Register your app's extending schema(s) for each service once — the client injects them into every call so you never repeat qualified schema names — then call operations through the per-service accessor.
javascript
client.transactional.registerExtensions({
  cart: {
    extending_schema:           'myns:luxe_cart',
    cart_item_extending_schema: 'myns:luxe_cart_item',
    customer_extending_schema:  'myns:luxe_customer'   // required for add_to_cart
  },
  order: { extending_schema: 'myns:luxe_order', order_item_extending_schema: 'myns:luxe_order_item' }
});

await client.transactional.cart.addToCart({ productUuid: sku, quantity: 1, unitPrice: 42.0 });
await client.transactional.cart.checkout(cartUuid);
await client.transactional.order.fulfill(orderUuid, 'shipped via courier');

// Raw escape hatch — any service/operation not covered by a typed accessor
await client.transactional.execute('loyaltyPoints', 'accrue_points', { txn_uuid: txnUuid });
A failed call rejects with a typed error you can branch on: TxnStateConflictError (409 — check err.currentState / err.isStateTransitionError), TxnNotFoundError (404), TxnBadRequestError (400/422), TxnPlatformError (502 — transient), TxnSoftFailureError (a 200 response with success:false).

From server-side Python (scripts, jobs, backend integrations)

bash
pip install supero
python
from supero.service_lib import ServiceLib

sl = ServiceLib(api_key='ak_...', domain='mydomain', project_uuid='8f2a...')
# or ServiceLib.from_env() reading SUPERO_URL / SUPERO_API_KEY / SUPERO_DOMAIN / SUPERO_PROJECT_UUID

sl.transactional.register_extensions({
    'cart':  ('myns:luxe_cart', 'myns:luxe_cart_item'),   # two-schema services take a tuple
    'order': ('myns:luxe_order', 'myns:luxe_order_item'),
    'payment': 'myns:luxe_payment',                        # single-schema services take a string
})

result = sl.cart.checkout(cart_uuid)   # ServiceResult — dict-like, plus .record_uuid / .status / .reason
sl.order.fulfill(result['order_uuid'], fulfillment_notes='shipped via courier')

try:
    sl.payment.refund(payment_uuid, refund_amount=30.00)
except StateConflictError as e:
    # already refunded, or not yet captured — inspect e.message
    pass
The Python errors mirror the JS ones exactly: BadRequestError (400), NotFoundError (404), StateConflictError (409), PlatformError (502 — the SDK auto-retries this one up to 3 times for state-guarded and naturally-idempotent operations only), and SoftFailureError (200 with success:false). All import from supero.transactional (also re-exported from supero.service_lib).

ℹ️ Two-schema services

cart, order, approval, inventory, loyalty_points, and document_signature each need two extending schemas registered (a header and a line/step/item schema) — pass a tuple/array in that order, or the explicit two-key object form shown in the JS example above.

Next steps