| Field | Value |
|---|---|
| Document | docs/api/API.md |
| Title | Cirquo Backend API — Overview, Conventions & Error Model |
| Platform | Cirquo — Circular Food Recovery Platform |
| Event | DSDC ANFORCOM 2026 |
| Market | Indonesia (Semarang) — IDR, WIB (UTC+7) |
| Backend runtime | Convex (TypeScript, reactive document database) |
| Transport | Convex WebSocket RPC (not REST) |
| HTTP surface | Exactly one endpoint: the Midtrans payment webhook (httpAction) |
| Payments | Midtrans Snap — Sandbox |
| Maps | Mapbox GL JS (client-side rendering only; no server geo index) |
| Units | Weight = integer grams · Money = integer IDR · Time = integer epoch milliseconds UTC |
| Methodology version | impact-v1 (stamped on every ledger event) |
| Status legend | ✅ source available · 🧪 UAT required · 📋 planned |
| Audience | Backend engineers, frontend engineers, judges auditing the material chain |
This is the entry point for every Cirquo backend function. It defines:
- why Cirquo's API is not a REST API, and why documenting it as one would be actively misleading;
- a REST-equivalence table for readers who arrive expecting
POST /api/orders; - the complete function index across all four actor roles;
- naming, argument, and return conventions;
- the canonical error-code catalogue and how the client maps codes to user-facing toasts;
- reactivity and subscription semantics;
- idempotency, rate limiting, and pagination;
- the one real HTTP endpoint — the Midtrans webhook — documented in full;
- versioning, deprecation, and local testing.
Per-role function documentation lives in five sibling files:
| File | Covers |
|---|---|
API_AUTH.md |
Registration, login, sessions, guards, profile creation, verification gate |
API_CONSUMER.md |
Discovery, reservation, payment, pickup code, disputes, consumer impact |
API_MERCHANT.md |
Rescue Item lifecycle, Dynamic Rescue Pricing, pickup confirmation, no-shows |
API_PROCESSOR.md |
Circular Routing queue, accept/decline, measured intake, processed outcome |
API_ADMIN.md |
Verification, moderation, ledger audit, disputes, integrity checks, system health |
API_IMPACT.md |
M6 ledger-derived summaries and scope contract |
Source boundary — 2026-08-29. M1–M5 exports, M6 ledger summaries, and role-scoped dashboards are implemented in source. M3–M6 still require deployment/Sandbox UAT; sections labelled 📋 are target contracts for later milestones. See IMPLEMENTATION_STATUS.md.
Cirquo is a Circular Food Recovery Platform, not a food delivery app. There is no delivery — consumers collect in person from the merchant. A Merchant lists surplus as a Rescue Item. A Consumer finds it on a Mapbox map, reserves it, pays through Midtrans Sandbox, and collects it using a pickup code; that outcome is Rescued. If the item goes unclaimed or expires, Circular Routing matches it to a verified Organic Processor (BSF larvae, compost, biogas, or animal feed). The processor accepts the offer, logs a measured intake weight, then logs an outcome; the converted mass is Recovered and the unconvertible remainder is Residual. Every single state change writes an immutable event to the Material Flow Ledger, and every impact metric on the platform is derived from that ledger — never from a counter column.
The API's job is therefore not merely CRUD. Its job is to guarantee that no gram of material changes state without a corresponding ledger entry committed in the same transaction.
A REST document promises a specific mental model:
- resources addressable by URL path;
- HTTP verbs mapping to intent;
- HTTP status codes carrying semantics;
- request/response as a discrete, stateless round trip;
- polling or a separate channel for freshness.
Convex satisfies none of these. The client does not build URLs, does not choose verbs, does not read status codes, and does not poll. It calls typed TypeScript functions over a persistent WebSocket, and read functions stay subscribed. If we published POST /api/orders in this repository, a frontend engineer would write fetch() calls that cannot work, and a judge auditing our architecture would conclude we bolted a serverless database onto a REST design we never built. Both outcomes are worse than the mild inconvenience of learning four function kinds.
So: this document describes function signatures with v.* validators, not routes.
| Kind | Callable from client | Transactional | Can write DB | Can call external network | Typical Cirquo use |
|---|---|---|---|---|---|
query |
Yes (via useQuery, auto-subscribing) |
Read-consistent snapshot | No | No | Map listings, order list, ledger audit, impact summaries |
mutation |
Yes (via useMutation) |
Yes — all-or-nothing | Yes | No | Reserve, confirm pickup, accept batch, log intake, verify merchant |
action |
Yes (via useAction) |
No | No — must call a mutation | Yes | Midtrans Snap token creation, email dispatch |
internalQuery / internalMutation / internalAction |
No | Same as public counterpart | Same | Same | Routing engine, cron bodies, webhook-invoked writes |
httpAction |
Via raw HTTP | No | No — calls internal mutations | Yes | Only the Midtrans webhook |
crons |
N/A (scheduler) | N/A | Via internal mutations | Via internal actions | Payment-hold expiry, listing expiry, routing sweep, offer TTL |
The single most important architectural fact in Cirquo:
A Convex
mutationruns as a serializable transaction across all tables. Either every write commits or none do.
That is what makes the Material Flow Ledger trustworthy. orders.reserve decrements surplusItems.remainingQuantity, inserts an orders row, and appends a RESERVED ledger event. If the ledger insert throws, the quantity decrement is rolled back too. There is no window in which inventory moved but the ledger did not record it.
This is also why the ledger write must never happen in an action. Actions are not transactional; a crash between the external call and the mutation would leave the ledger permanently short an event. The rule is absolute:
// ✅ correct — ledger written inside the same transaction as the state change
export const reserve = mutation({
args: { /* ... */ },
handler: async (ctx, args) => {
// ... validation, quantity decrement, order insert ...
await recordLedgerEvent(ctx, { /* RESERVED */ }) // same transaction
return orderId
},
})
// ❌ forbidden — non-transactional, ledger can silently diverge
export const reserveViaAction = action({ /* ... */ })
// ❌ forbidden — client can lie about, skip, or replay the event
await convex.mutation(api.ledger.append, { eventType: 'RESCUED' })useQuery is not "fetch once". Convex tracks which documents and index ranges a query read, and pushes a fresh result to every subscribed client whenever a committed mutation touches that read set. The consequences for API design:
- there is no
GET /orders?since=...delta endpoint and none is needed; - there is no cache-invalidation header story — invalidation is server-computed;
- a Merchant's
orders.listForMerchantpanel updates the instant a Consumer'sorders.reservecommits, with no code on either side coordinating that; - queries must be cheap and deterministic, because they may re-run frequently. No
Math.random(), noDate.now()used as a filter boundary in a way that makes the read set unbounded.
Honesty about trade-offs, since a judge will ask:
| Trade-off | Impact on Cirquo | Mitigation |
|---|---|---|
No geospatial index. Convex has no GEO index or ST_DWithin. |
discovery.listNearby cannot push distance filtering into the database. |
Fetch active items via the by_status index, then filter with Haversine in application code, bounded by city. Documented explicitly in API_CONSUMER.md. Acceptable at hackathon and early-city scale (hundreds of active items); a real geohash prefix column is the migration path. |
| No SQL joins. | Listing a consumer's orders with merchant names requires N follow-up ctx.db.get calls. |
Convex document reads by Id are point lookups and cheap; we batch with Promise.all and keep fan-out bounded by page size. |
| Actions cannot write. | Midtrans flows need an extra hop. | payments.createTransaction (action) → Midtrans → internal.payments.savePendingTransaction (mutation). Explicit and auditable. |
| Vendor coupling. | Migrating off Convex means rewriting the function layer. | The domain rules live in pure helpers (recordLedgerEvent, requireRole, routing eligibility predicates) that take ctx as a parameter and are portable. |
| No native cursor SQL. | Pagination is Convex-flavoured. | We use paginationOptsValidator on list queries; see §8. |
For readers whose mental model is HTTP. These routes do not exist. The table is a translation aid only.
| If you expected… | Cirquo actually uses | Kind | Notes |
|---|---|---|---|
POST /api/auth/register |
auth.register |
action | Role chosen at registration; admin rejected |
POST /api/auth/login |
auth.login |
action | Returns session token; rate limited |
POST /api/auth/logout |
auth.logout |
mutation | Deletes the sessions row |
GET /api/me |
auth.getCurrentUser |
query | Reactive — reflects suspension instantly |
GET /api/listings?lat=&lng= |
discovery.listNearby |
query | Haversine in app code, not a geo index |
GET /api/listings/:id |
discovery.getListing |
query | Id<'surplusItems'> is opaque, not a slug |
POST /api/listings |
surplusItems.create |
mutation | Merchant must be verified |
PATCH /api/listings/:id |
surplusItems.update |
mutation | Rejected once any quantity is reserved |
DELETE /api/listings/:id |
surplusItems.cancel |
mutation | Only if untouched; never a hard delete |
POST /api/orders |
orders.reserve |
mutation | Decrements quantity at reservation |
GET /api/orders |
orders.listMine |
query | Auto-updating; no polling |
POST /api/orders/:id/pay |
payments.createTransaction |
action | Must be an action — calls Midtrans |
POST /api/orders/:id/pickup |
orders.confirmPickup |
mutation | 📋 M4: Merchant-side; verifies pickup code + window |
DELETE /api/orders/:id |
orders.cancel |
mutation | 📋 Target status transition, not deletion |
GET /api/recovery-batches |
recoveryBatches.listQueue |
query | Processor-scoped, eligibility-filtered |
POST /api/recovery-batches/:id/accept |
recoveryBatches.accept |
mutation | Capacity + eligibility re-checked server-side |
POST /api/recovery-batches/:id/intake |
recoveryBatches.logIntake |
mutation | Measured weight, processor only |
POST /api/recovery-batches/:id/outcome |
recoveryBatches.logOutcome |
mutation | Emits PROCESSED; splits Recovered/Residual |
GET /api/impact |
impact.getPlatformSummary |
query | Derived from ledger, never a counter |
GET /api/admin/users |
admin.listUsers |
query | Paginated |
POST /api/admin/merchants/:id/verify |
admin.verifyMerchant |
mutation | Audited |
POST /api/webhooks/midtrans |
POST /midtrans/webhook |
httpAction |
✅ This one is a real HTTP endpoint — see §11 |
GET /api/health |
admin.getSystemHealth |
query | Not a load-balancer probe |
Only the Midtrans webhook row describes an address you can actually curl.
Status: ✅ = exists in convex/ today · 📋 = specified, not yet implemented.
The table below is checked against convex/ as of 2026-08-29. Function
sections in the role documents marked 📋 remain target contracts; do not call
them until the matching export exists in source.
5.1 Authentication & profiles → API_AUTH.md
| Function | Kind | Auth | Status |
|---|---|---|---|
auth.register |
action | Public | ✅ |
auth.login |
action | Public | ✅ |
auth.logout |
mutation | Any session | ✅ |
auth.getCurrentUser |
query | Any session | ✅ |
auth.refreshSession |
mutation | Any session | 📋 |
auth.requestPasswordReset |
action | Public | 📋 |
auth.resetPassword |
mutation | Reset token | 📋 |
auth.changePassword |
mutation | Any session | 📋 |
auth.getVerificationStatus |
query | Merchant/Processor | 📋 |
users.getByEmail |
query | Internal-facing | ✅ |
merchants.createProfile |
mutation | Merchant | ✅ |
merchants.getByOwner |
query | Merchant | ✅ |
processors.createProfile |
mutation | Processor | ✅ |
profiles.update |
mutation | Owner | 📋 |
5.2 Consumer → API_CONSUMER.md
| Function | Kind | Auth | Status |
|---|---|---|---|
discovery.listNearby |
query | Public | ✅ |
discovery.getListing |
query | Public | ✅ |
discovery.getFilters |
query | Public | 📋 |
orders.reserve |
mutation | Consumer | ✅ |
payments.createTransaction |
action | Consumer (owner) | ✅ |
orders.listMine |
query | Consumer | ✅ |
orders.listByUser |
internalQuery | Internal only | ✅ |
orders.get |
query | Consumer (owner) | ✅ |
orders.cancel |
mutation | Consumer (owner) | 📋 |
orders.getPickupCode |
query | Consumer (owner) | 📋 |
impact.getConsumerSummary |
query | Consumer | ✅ |
notifications.listMine |
query | Any session | 📋 |
notifications.markRead |
mutation | Owner | 📋 |
disputes.raise |
mutation | Consumer/Merchant | 📋 |
ratings.submit |
mutation | Consumer (owner) | 📋 (priority C) |
5.3 Merchant → API_MERCHANT.md
| Function | Kind | Auth | Status |
|---|---|---|---|
surplusItems.create |
mutation | Merchant (verified) | ✅ |
surplusItems.suggestPrice |
query | Merchant (verified) | 📋 |
surplusItems.update |
mutation | Merchant (owner) | ✅ |
surplusItems.publish |
mutation | Merchant (owner) | ✅ |
surplusItems.cancel |
mutation | Merchant (owner) | ✅ |
surplusItems.markProcessingOnly |
mutation | Merchant (owner) | 📋 |
surplusItems.listMine |
query | Merchant | ✅ |
surplusItems.getMine |
query | Merchant (verified, owner) | ✅ |
surplusItems.listByStatus |
query | Internal/Admin | ✅ |
surplusItems.get |
query | Merchant (owner) | 📋 |
orders.listForMerchant |
query | Merchant (verified) | ✅ |
orders.confirmPickup |
mutation | Merchant (verified, owner) | ✅ |
orders.reportNoShow |
mutation | Merchant (owner) | 📋 |
impact.getMerchantSummary |
query | Merchant | ✅ |
merchants.getMine |
query | Merchant | 📋 |
merchants.updateProfile |
mutation | Merchant (owner) | 📋 |
recoveryBatches.listForMerchant |
query | Merchant (verified, owner) | ✅ |
5.4 Organic Processor → API_PROCESSOR.md
| Function | Kind | Auth | Status |
|---|---|---|---|
recoveryBatches.listQueue |
query | Processor (verified) | 📋 |
recoveryBatches.listByStatus |
query | Internal/Admin | ✅ |
recoveryBatches.get |
query | Processor (offered/assigned) | 📋 |
recoveryBatches.accept |
mutation | Processor (verified) | 📋 |
recoveryBatches.decline |
mutation | Processor (verified) | 📋 |
recoveryBatches.logIntake |
mutation | Processor (assigned) | 📋 |
recoveryBatches.logOutcome |
mutation | Processor (assigned) | 📋 |
processors.getMine |
query | Processor | 📋 |
processors.updateProfile |
mutation | Processor (owner) | 📋 |
processors.updateCapacity |
mutation | Processor (owner) | 📋 |
impact.getProcessorSummary |
query | Processor | ✅ |
5.5 Admin → API_ADMIN.md
| Function | Kind | Auth | Status |
|---|---|---|---|
admin.listUsers |
query | Admin | 📋 |
admin.listPendingVerifications |
query | Admin | 📋 |
admin.verifyMerchant |
mutation | Admin | 📋 |
admin.verifyProcessor |
mutation | Admin | 📋 |
admin.rejectAccount |
mutation | Admin | 📋 |
admin.suspendUser |
mutation | Admin | 📋 |
admin.moderateListing |
mutation | Admin | 📋 |
admin.listReportedListings |
query | Admin | 📋 |
admin.getItemLedger |
query | Admin | 📋 |
admin.searchLedger |
query | Admin | 📋 |
admin.getPlatformImpact |
query | Admin | 📋 |
admin.listDisputes |
query | Admin | 📋 |
admin.resolveDispute |
mutation | Admin | 📋 |
admin.rerouteBatch |
mutation | Admin | 📋 |
admin.checkWeightConservation |
query | Admin | 📋 |
admin.checkLedgerCompleteness |
query | Admin | 📋 |
admin.getSystemHealth |
query | Admin | 📋 |
admin.listCrons |
query | Admin | 📋 |
impact.getPlatformSummary |
query | Admin | ✅ |
| Function | Kind | Trigger | Status |
|---|---|---|---|
internal.routing.findEligibleProcessors |
internalQuery | Called by routing engine | 📋 |
internal.routing.offerBatch |
internalMutation | Cron / cascade | 📋 |
internal.routing.expireOffers |
internalMutation | Cron, every 15 min | 📋 |
internal.orders.expireHold |
internalMutation | Per-reservation runAt timer |
✅ |
internal.surplusItems.expireListings |
internalMutation | Cron, every 5 min | 📋 |
internal.payments.savePendingTransaction |
internalMutation | From payments.createTransaction |
✅ |
POST /midtrans/webhook |
httpAction |
Midtrans Sandbox callback | 🧪 |
internal.impact.snapshotDaily |
internalMutation | Cron, daily 00:05 WIB | 📋 |
internal.notifications.push |
internalMutation | Called by many mutations | 📋 |
| Rule | Example | Rationale |
|---|---|---|
| Namespace = Convex file name; function = export name | orders.reserve → convex/orders.ts export reserve |
Path is the API; no router config to drift |
| Namespaces are plural nouns for entity tables | surplusItems, recoveryBatches, notifications |
Matches table names in ../domain/DATABASE.md |
| Namespaces are singular domain nouns for capability groups | auth, discovery, impact, admin |
These are not tables |
| Queries read as noun phrases | listNearby, getPickupCode, getMerchantSummary |
Reads never imply mutation |
| Mutations read as imperative verbs | reserve, confirmPickup, logIntake, verifyMerchant |
Names carry intent, since HTTP verbs do not |
listX returns an array or page; getX returns one document or null |
orders.get vs orders.listMine |
Predictable nullability |
getMine / listMine = implicitly scoped to the caller |
merchants.getMine |
No caller-supplied id means no IDOR surface |
Ledger event types are SCREAMING_SNAKE_CASE |
RESCUED, INTAKE_ACCEPTED |
Visually distinct from statuses in code and logs |
Statuses are lower_snake_case |
recovery_pending, picked_up |
Matches schema enums exactly |
Error codes are SCREAMING_SNAKE_CASE |
PICKUP_WINDOW_CLOSED |
Stable contract for the client switch |
Internal functions live under internal.* |
internal.routing.offerBatch |
Compiler-enforced non-exposure |
| Domain | Type | Unit | Example | Never |
|---|---|---|---|---|
| Weight | v.int64() / number integer |
grams | 2500 = 2.5 kg |
Floats, kilograms, "2.5kg" strings |
| Money | integer | IDR | 15000 = Rp 15.000 |
Decimals, cents, Rp prefix, floats |
| Time | integer | epoch ms UTC | 1771200000000 |
ISO strings, WIB-local, Date objects |
| Distance | integer | metres | 5000 = 5 km |
Kilometres, degrees |
| Coordinates | v.number() |
decimal degrees WGS84 | -6.9932, 110.4203 |
DMS strings |
Floating-point money and weight is how impact numbers stop reconciling. Every value that must sum to zero in admin.checkWeightConservation is an integer for exactly that reason. IDR has no minor unit in practice, so integer rupiah is both correct and simple. Times are stored UTC and formatted to WIB only at the presentation layer.
Id<'tableName'> is an opaque string. Clients must treat it as a token:
- do not parse it, slice it, or infer creation order from it;
- do not build URLs by concatenating fragments of it;
- validate with
v.id('surplusItems')— Convex verifies the id actually belongs to that table, which removes a whole class of cross-table confusion bugs; - never expose an
Idfor a document the caller is not authorised to read, since ids are capability-shaped in careless designs. Our guards check ownership on read, not just on write.
| Convention | Meaning |
|---|---|
v.optional(v.string()) in args |
Caller may omit the field entirely |
v.optional(...) in schema |
Field may be absent on the document |
Query returns null |
Document does not exist or caller may not see it — deliberately indistinguishable, to avoid existence oracles |
Query returns [] |
Query is valid and correctly matched nothing |
- Mutations return the minimum needed to continue the flow — usually a new
Idor a small object such as{ orderId, pickupCode, holdExpiresAt }. They never return the whole document, because the client is already subscribed to it via a query and will receive the update reactively. - Queries return denormalised view models, not raw rows, when the UI needs joined data.
discovery.listNearbyreturns items enriched with merchant name, coordinates, and computeddistanceMeters. - Every list query returns a stable sort order. Convex index order is deterministic; we never rely on insertion order without an index.
- No function returns
passwordHash, a sessiontokenbelonging to someone else, or apickupCodeto a non-owner. Redaction happens server-side, never by omission in the UI.
| Field | Meaning |
|---|---|
createdAt |
Row insert time |
publishedAt |
Rescue Item became active and publicly discoverable |
paymentHoldExpiresAt |
Reservation auto-expires at this instant if unpaid (15 min) |
paidAt |
Midtrans settlement confirmed via webhook |
pickedUpAt |
Merchant confirmed pickup → Rescued |
offerExpiresAt |
Routing offer TTL (6 h) |
acceptedAt / completedAt |
Processor accepted / logged outcome |
occurredAt |
Ledger event time — the canonical time axis for all impact metrics |
Cirquo uses Convex's built-in cursor pagination for any list that can exceed roughly 50 rows.
import { paginationOptsValidator } from 'convex/server'
import { query } from './_generated/server'
import { v } from 'convex/values'
export const listMine = query({
args: {
sessionToken: v.string(),
status: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
const user = await requireAuth(ctx, args.sessionToken)
return await ctx.db
.query('orders')
.withIndex('by_user', (q) => q.eq('userId', user._id))
.order('desc')
.paginate(args.paginationOpts)
},
})| Aspect | Behaviour |
|---|---|
| Request | { numItems: 20, cursor: null } for the first page |
| Response | { page: T[], isDone: boolean, continueCursor: string } |
| Client hook | usePaginatedQuery(api.orders.listMine, { sessionToken }, { initialNumItems: 20 }) |
| Reactivity | Loaded pages stay subscribed; an update to a row on page 1 pushes down while page 2 is open |
| Default page size | 20 |
Maximum accepted numItems |
100 — larger values are clamped server-side, never rejected |
| Which lists paginate | orders.listMine, orders.listForMerchant, surplusItems.listMine, recoveryBatches.listQueue, notifications.listMine, admin.listUsers, admin.searchLedger, admin.listDisputes |
| Which lists do not paginate | discovery.listNearby (bounded by radius + city + active status, hard-capped at 200 results), discovery.getFilters, all impact.* summaries, admin.getItemLedger (a single item's history is inherently small) |
discovery.listNearby is deliberately unpaginated: it renders a map viewport, and paginating map pins produces a worse experience than capping the result set. The cap is enforced server-side after distance sorting, so the nearest 200 are always the ones returned.
All application errors are thrown as ConvexError with a structured payload. Never throw bare Error in a public function — bare errors are surfaced to the client as opaque server failures and cannot be branched on.
import { ConvexError } from 'convex/values'
export type CirquoErrorData = {
code: string // stable, SCREAMING_SNAKE_CASE — the client switches on this
message: string // English, developer-facing; the client does NOT display this raw
field?: string // offending argument, for form-level highlighting
details?: Record<string, string | number | boolean>
}
export function fail(data: CirquoErrorData): never {
throw new ConvexError(data)
}
// usage
if (item.remainingQuantity < args.quantity) {
fail({
code: 'INSUFFICIENT_QUANTITY',
message: 'Requested quantity exceeds remaining quantity.',
field: 'quantity',
details: { requested: args.quantity, remaining: item.remainingQuantity },
})
}Because ConvexError is thrown inside a mutation, the transaction aborts and every write in that mutation rolls back — including any partial ledger write. This is why validation ordering (§9.4) is a correctness concern and not merely a UX one.
"HTTP equiv." is the status a REST API would have returned. Convex sends no status code; the column exists to orient readers and to guide logging severity.
| Code | HTTP equiv. | Meaning | Thrown by | Client handling |
|---|---|---|---|---|
AUTH_REQUIRED |
401 | No session token, malformed token, or expired session | Every guarded function | Clear stored token, redirect to /login, toast "Please sign in again." |
SESSION_EXPIRED |
401 | Token matched a sessions row whose expiresAt has passed |
requireAuth |
Same as above; attempt auth.refreshSession once first |
FORBIDDEN |
403 | Authenticated but wrong role, or not the owner of the resource | requireRole, requireOwnership |
Toast "You do not have permission to do that."; do not retry |
NOT_VERIFIED |
403 | Merchant/Processor account is pending or rejected |
surplusItems.create, recoveryBatches.accept, logIntake |
Route to the verification-pending screen; explain what is waiting on Admin |
ACCOUNT_SUSPENDED |
403 | users.status = 'suspended' |
requireAuth |
Force logout, show a support-contact screen |
NOT_FOUND |
404 | Document does not exist, or caller may not see it | Any get/update |
Toast "That item is no longer available."; navigate back to the list |
VALIDATION_FAILED |
422 | Argument failed a domain rule beyond validator type-checking | Most mutations | Highlight field; keep the form open with values preserved |
INVALID_TRANSITION |
409 | Requested state change is illegal from the current status | Every lifecycle mutation | Toast "This item has already moved on."; queries refresh reactively |
INSUFFICIENT_QUANTITY |
409 | Fewer units remain than requested | orders.reserve |
Toast with remaining count; reactive query already shows the new number |
ALREADY_RESERVED |
409 | Edit/cancel attempted on an item with reservations | surplusItems.update, surplusItems.cancel |
Disable edit UI; explain the edit-lock rule |
PRICE_BELOW_FLOOR |
422 | currentPrice < floorPrice |
surplusItems.create, update |
Highlight the price field; show the floor value from details |
PRICE_ABOVE_ORIGINAL |
422 | currentPrice >= originalPrice |
surplusItems.create, update |
Same; a Rescue Item must be discounted |
PICKUP_WINDOW_CLOSED |
409 | now is outside [pickupStartAt, pickupEndAt] |
orders.confirmPickup |
Toast; offer the Merchant the "report no-show" path or Admin override |
INVALID_PICKUP_CODE |
403 | Code does not match the order | orders.confirmPickup |
Toast "Incorrect pickup code."; rate limited after 5 failures per order |
PAYMENT_HOLD_EXPIRED |
409 | 15-minute hold elapsed before payment | payments.createTransaction, webhook apply |
Toast "Your reservation expired."; return to listing |
PAYMENT_FAILED |
402 | Midtrans returned a failure status | webhook apply | Toast; order returns to a cancellable state |
CAPACITY_EXCEEDED |
409 | Batch weight exceeds the processor's remaining daily headroom | recoveryBatches.accept |
Toast with remaining capacity; leave the batch in the queue for others |
MATERIAL_TYPE_REJECTED |
422 | materialType not in acceptedMaterialTypes |
recoveryBatches.accept |
Hide the batch from that processor's queue entirely |
OUT_OF_SERVICE_RADIUS |
422 | Distance exceeds maxPickupRadiusMeters |
recoveryBatches.accept |
Same as above |
OFFER_EXPIRED |
409 | Offer TTL (6 h) elapsed | recoveryBatches.accept |
Toast; batch has already been re-offered or marked unroutable |
WEIGHT_EXCEEDS_ACCEPTED |
422 | residualWeightGrams > acceptedWeightGrams, or outputs exceed intake |
recoveryBatches.logOutcome |
Highlight the field; show the accepted intake as the ceiling |
INTAKE_NOT_LOGGED |
409 | Outcome logged before intake | recoveryBatches.logOutcome |
Route to the intake form first |
RATE_LIMITED |
429 | Too many attempts in the window | auth.login, auth.requestPasswordReset, orders.confirmPickup, orders.reserve |
Toast with details.retryAfterMs; disable the button until then |
IDEMPOTENCY_CONFLICT |
409 | Same idempotency key reused with different arguments | orders.reserve, webhook |
Log loudly; this signals a client bug, not user error |
LEDGER_WRITE_FAILED |
500 | recordLedgerEvent could not append |
Any state-changing mutation | Generic error toast; the entire mutation rolled back, so no state diverged |
SIGNATURE_INVALID |
401 | Midtrans signature_key mismatch |
webhook httpAction |
N/A — no browser client; returns HTTP 401 and logs a security event |
INTERNAL_ERROR |
500 | Unhandled server fault | Anywhere | Generic toast; report id logged for triage |
// src/lib/errors.ts
import { ConvexError } from 'convex/values'
import { toast } from 'sonner'
const MESSAGES: Record<string, string> = {
AUTH_REQUIRED: 'Please sign in to continue.',
SESSION_EXPIRED: 'Your session expired. Please sign in again.',
FORBIDDEN: 'You do not have permission to do that.',
NOT_VERIFIED: 'Your account is awaiting verification.',
ACCOUNT_SUSPENDED: 'This account has been suspended.',
NOT_FOUND: 'That item is no longer available.',
VALIDATION_FAILED: 'Please check the highlighted fields.',
INVALID_TRANSITION: 'This item has already moved on.',
INSUFFICIENT_QUANTITY: 'Someone just reserved the last one.',
ALREADY_RESERVED: 'This listing is locked because it already has reservations.',
PRICE_BELOW_FLOOR: 'Price cannot go below your floor price.',
PRICE_ABOVE_ORIGINAL: 'A Rescue Item must be priced below the original price.',
PICKUP_WINDOW_CLOSED: 'The pickup window for this order is closed.',
INVALID_PICKUP_CODE: 'Incorrect pickup code. Please check with the customer.',
PAYMENT_HOLD_EXPIRED: 'Your 15-minute reservation hold expired.',
PAYMENT_FAILED: 'Payment was not completed.',
CAPACITY_EXCEEDED: 'This batch exceeds your remaining capacity today.',
MATERIAL_TYPE_REJECTED: 'Your facility does not accept this material type.',
OUT_OF_SERVICE_RADIUS: 'This pickup is outside your service radius.',
OFFER_EXPIRED: 'This offer expired and has been re-routed.',
WEIGHT_EXCEEDS_ACCEPTED: 'Logged weight cannot exceed the intake weight.',
INTAKE_NOT_LOGGED: 'Log the measured intake before recording the outcome.',
RATE_LIMITED: 'Too many attempts. Please wait a moment.',
IDEMPOTENCY_CONFLICT: 'That request was already processed.',
LEDGER_WRITE_FAILED: 'Something went wrong. Nothing was changed.',
INTERNAL_ERROR: 'Something went wrong on our side.',
}
export function handleError(err: unknown): string {
if (err instanceof ConvexError) {
const data = err.data as { code?: string; details?: Record<string, unknown> }
const code = data?.code ?? 'INTERNAL_ERROR'
let text = MESSAGES[code] ?? MESSAGES.INTERNAL_ERROR
if (code === 'INSUFFICIENT_QUANTITY' && typeof data.details?.remaining === 'number') {
text = `Only ${data.details.remaining} left. Please lower your quantity.`
}
if (code === 'RATE_LIMITED' && typeof data.details?.retryAfterMs === 'number') {
const s = Math.ceil(data.details.retryAfterMs / 1000)
text = `Too many attempts. Try again in ${s}s.`
}
toast.error(text)
return code
}
toast.error(MESSAGES.INTERNAL_ERROR)
return 'INTERNAL_ERROR'
}Two deliberate choices:
- The server
messageis never rendered. It is developer-facing English for logs. User-facing copy lives in the client map so it can be localised to Bahasa Indonesia without touching backend code. detailsis structured, not interpolated server-side. The client composes the sentence, which keeps the backend free of presentation concerns.
Every mutation validates in this order. Cheap and security-relevant checks come first so we never leak information or burn database reads on a request that was doomed:
- Authentication —
requireAuth→AUTH_REQUIRED/SESSION_EXPIRED/ACCOUNT_SUSPENDED - Role —
requireRole→FORBIDDEN - Existence —
ctx.db.get→NOT_FOUND - Ownership —
requireOwnership→FORBIDDEN(neverNOT_FOUND; the caller already proved the doc exists only if they own it) - Verification gate —
NOT_VERIFIED - State machine —
INVALID_TRANSITION - Domain invariants —
PRICE_BELOW_FLOOR,INSUFFICIENT_QUANTITY,CAPACITY_EXCEEDED, … - Time windows —
PICKUP_WINDOW_CLOSED,PAYMENT_HOLD_EXPIRED,OFFER_EXPIRED - Rate limit —
RATE_LIMITED(after identity is known, so limits can be per-user) - Writes +
recordLedgerEvent— last, together, atomically
Convex records the read set of each query execution: the specific documents fetched by id, and the index ranges scanned. When a mutation commits, Convex determines which read sets intersect the written documents and re-runs exactly those subscriptions.
| Mutation | Invalidates | Visible effect |
|---|---|---|
orders.reserve |
discovery.listNearby, discovery.getListing, orders.listMine, orders.listForMerchant |
Every open map instantly shows the decremented quantity |
orders.confirmPickup |
orders.get, orders.listForMerchant, impact.getConsumerSummary, impact.getMerchantSummary, admin.getItemLedger |
Impact counters tick up without any refetch code |
recoveryBatches.accept |
recoveryBatches.listQueue for every processor that was offered the batch |
The batch disappears from competing queues immediately |
recoveryBatches.logOutcome |
All impact.* queries, admin.getPlatformImpact |
Circularity rate recomputes live during the demo |
admin.verifyMerchant |
auth.getVerificationStatus, merchants.getMine |
The Merchant's blocked "Create Listing" button unlocks without reload |
- Keep read sets narrow. A query that does
ctx.db.query('surplusItems').collect()with no index reads the whole table and will be invalidated by any listing write anywhere on the platform. Always usewithIndex. - Never call
Date.now()in a way that changes the read set on every run. FilteringpickupEndAt > Date.now()in application code after an indexed fetch is fine; using it as an index bound creates constant churn. Expiry is driven by crons that write a status change, and the status change is what invalidates. - Queries must be pure. No randomness, no external calls, no writes. Convex may re-execute a query many times.
Use them only where the outcome is near-certain and rollback is cheap:
| Function | Optimistic? | Why |
|---|---|---|
notifications.markRead |
✅ Yes | Trivially reversible, zero business risk |
orders.cancel |
✅ Yes | Terminal for the user; a failure just restores the card |
orders.reserve |
❌ No | Can genuinely lose a race for the last unit. Showing "Reserved!" then reverting is worse than 200 ms of a spinner. |
orders.confirmPickup |
❌ No | Emits a RESCUED ledger event; the Merchant must see the real, committed result before handing over food |
recoveryBatches.accept |
❌ No | Competitive — another processor may have taken it |
recoveryBatches.logIntake / logOutcome |
❌ No | Authoritative measured data; must never appear recorded when it is not |
const markRead = useMutation(api.notifications.markRead).withOptimisticUpdate(
(localStore, args) => {
const current = localStore.getQuery(api.notifications.listMine, {
sessionToken: args.sessionToken,
})
if (current === undefined) return
localStore.setQuery(
api.notifications.listMine,
{ sessionToken: args.sessionToken },
current.map((n) => (n._id === args.notificationId ? { ...n, read: true } : n)),
)
},
)The rule: never apply an optimistic update to anything that writes a ledger event. The ledger is the source of truth for impact claims; the UI must not display an outcome the ledger has not yet accepted.
| Property | Value |
|---|---|
| Path | POST /midtrans/webhook |
| Full URL | https://<deployment>.convex.site/midtrans/webhook |
| Kind | httpAction, registered in convex/http.ts |
| Called by | Midtrans Sandbox HTTP Notification only |
| Auth | SHA512 signature verification — no session token, no bearer |
| Idempotency | Enforced on (providerTransactionId, transaction_status) |
| Success response | 200 OK, body OK |
| Failure responses | 401 signature invalid · 400 malformed body · 200 for already-applied duplicates |
Note .convex.site, not .convex.cloud — HTTP actions are served from the site domain. Configure the notification URL in the Midtrans Sandbox dashboard under Settings → Configuration → Payment Notification URL.
| Header | Example | Handling |
|---|---|---|
Content-Type |
application/json |
Required; anything else → 400 |
User-Agent |
Veritrans |
Logged, not trusted — trivially spoofable |
X-Forwarded-For |
103.x.x.x |
Logged for forensics only; we do not IP-allowlist, because Sandbox egress ranges are not contractually stable |
There is deliberately no shared secret in a header. The signature in the body is the authentication mechanism.
{
"transaction_time": "2026-02-14 19:04:22",
"transaction_status": "settlement",
"transaction_id": "9aed1f0e-6d6b-4a2c-9f2f-2c1c4a8f0f11",
"status_message": "midtrans payment notification",
"status_code": "200",
"signature_key": "b7c1...e93a",
"settlement_time": "2026-02-14 19:04:25",
"payment_type": "gopay",
"order_id": "CIRQUO-ORD-k57d9m2p3q8xw1v4",
"merchant_id": "G123456789",
"gross_amount": "15000.00",
"fraud_status": "accept",
"currency": "IDR"
}| Field | Type | Use |
|---|---|---|
order_id |
string | Our reference: CIRQUO-ORD-<Id<'orders'>>. Parsed back to the order id. |
status_code |
string | Part of the signature payload; "200" on success |
gross_amount |
string with 2 decimals | Part of the signature payload; also re-verified against orders.totalPrice |
signature_key |
string | SHA512 hex digest to verify |
transaction_status |
string | Drives the state mapping in §11.5 |
fraud_status |
string | capture is only honoured when fraud_status === 'accept' |
transaction_id |
string | Stored as payments.providerTransactionId; idempotency key |
payment_type |
string | Stored as payments.method |
settlement_time |
string (WIB) | Parsed to epoch ms UTC for payments.settledAt |
The entire raw body is stored in payments.rawPayload for dispute forensics, unmodified.
Midtrans defines:
signature_key = SHA512(order_id + status_code + gross_amount + ServerKey)
Concatenation is direct — no separators, no spaces. gross_amount must be used exactly as received ("15000.00", not "15000"), and ServerKey is the Sandbox Server Key, held in the MIDTRANS_SERVER_KEY environment variable and never committed.
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
import { internal } from './_generated/api'
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
return diff === 0
}
async function sha512Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input)
const digest = await crypto.subtle.digest('SHA-512', bytes)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
const midtransWebhook = httpAction(async (ctx, request) => {
if (request.headers.get('content-type')?.includes('application/json') !== true) {
return new Response('Unsupported Media Type', { status: 400 })
}
let body: Record<string, string>
try {
body = await request.json()
} catch {
return new Response('Malformed JSON', { status: 400 })
}
const { order_id, status_code, gross_amount, signature_key, transaction_status } = body
if (!order_id || !status_code || !gross_amount || !signature_key) {
return new Response('Missing required fields', { status: 400 })
}
const serverKey = process.env.MIDTRANS_SERVER_KEY
if (!serverKey) {
console.error('[midtrans] MIDTRANS_SERVER_KEY is not configured')
return new Response('Server misconfigured', { status: 500 })
}
const expected = await sha512Hex(order_id + status_code + gross_amount + serverKey)
if (!timingSafeEqualHex(expected, signature_key.toLowerCase())) {
// Do NOT touch the database. Do NOT echo which part failed.
console.error('[midtrans] SIGNATURE_INVALID', {
order_id,
status_code,
ip: request.headers.get('x-forwarded-for'),
})
return new Response('Unauthorized', { status: 401 })
}
// Signature valid — hand off to a transactional internal mutation.
await ctx.runMutation(internal.payments.applyWebhook, {
orderRef: order_id,
transactionId: body.transaction_id,
transactionStatus: transaction_status,
fraudStatus: body.fraud_status,
paymentType: body.payment_type,
grossAmount: gross_amount,
settlementTime: body.settlement_time,
rawPayload: JSON.stringify(body),
})
return new Response('OK', { status: 200 })
})
const http = httpRouter()
http.route({ path: '/midtrans/webhook', method: 'POST', handler: midtransWebhook })
export default httpThree points worth defending:
- Comparison is timing-safe. A naive
===on a hex digest leaks byte-by-byte match progress under repeated probing. The cost of the constant-time loop is negligible. - The
httpActionwrites nothing. It verifies, then delegates tointernal.payments.applyWebhook, a mutation, so the payment row, order status, andPAIDledger event commit atomically. An action cannot give us that guarantee. - On signature mismatch we do nothing at all — no order lookup, no partial write, no informative error body. A
401with a bare string is the correct response to an unauthenticated party.
transaction_status |
fraud_status |
payments.status |
orders.status |
Ledger event | Notes |
|---|---|---|---|---|---|
capture |
accept |
settled |
paid |
PAID |
Card flow, fraud screening passed |
capture |
challenge |
pending |
reserved |
— | Held; hold clock keeps running |
capture |
deny |
failed |
cancelled |
CANCELLED |
Quantity restored |
settlement |
any | settled |
paid |
PAID |
The common Sandbox success path |
pending |
any | pending |
reserved |
— | Awaiting VA/e-wallet completion |
deny |
any | failed |
cancelled |
CANCELLED |
Quantity restored |
cancel |
any | cancelled |
cancelled |
CANCELLED |
Quantity restored |
expire |
any | expired |
expired |
EXPIRED |
Quantity restored; item may re-enter Circular Routing |
refund / partial_refund |
any | refunded |
refunded |
CANCELLED |
Post-settlement; ledger negates the reserved weight |
Whenever an order leaves the reserved-or-paid track, internal.payments.applyWebhook restores surplusItems.remainingQuantity in the same transaction as the status change and the ledger event. That is exactly why the quantity was decremented at reservation: the restore path is a single atomic step, not a compensating job.
Midtrans retries notifications on non-200 responses, and can also deliver duplicates. The handler is idempotent by construction:
// convex/payments.ts (internal)
export const applyWebhook = internalMutation({
args: {
orderRef: v.string(),
transactionId: v.string(),
transactionStatus: v.string(),
fraudStatus: v.optional(v.string()),
paymentType: v.optional(v.string()),
grossAmount: v.string(),
settlementTime: v.optional(v.string()),
rawPayload: v.string(),
},
handler: async (ctx, args) => {
const orderId = parseOrderRef(args.orderRef) // 'CIRQUO-ORD-<id>' -> Id<'orders'>
const order = await ctx.db.get(orderId)
if (!order) {
console.error('[midtrans] order not found', { ref: args.orderRef })
return { applied: false, reason: 'NOT_FOUND' as const }
}
// 1) Duplicate detection: same provider txn already at this status -> no-op.
const existing = await ctx.db
.query('payments')
.withIndex('by_provider_txn', (q) => q.eq('providerTransactionId', args.transactionId))
.unique()
const nextStatus = mapPaymentStatus(args.transactionStatus, args.fraudStatus)
if (existing && existing.status === nextStatus) {
return { applied: false, reason: 'DUPLICATE' as const }
}
// 2) Amount tamper check — the signature proves origin, not correctness.
const grossIdr = Math.round(Number(args.grossAmount))
if (grossIdr !== order.totalPrice) {
console.error('[midtrans] amount mismatch', {
orderId, expected: order.totalPrice, received: grossIdr,
})
return { applied: false, reason: 'AMOUNT_MISMATCH' as const }
}
// 3) Late-settlement guard: hold already expired and order released.
if (nextStatus === 'settled' && order.status === 'expired') {
await ctx.db.insert('payments', { /* ...recorded as 'settled_late' for refund triage... */ })
await ctx.runMutation /* n/a */
return { applied: false, reason: 'HOLD_EXPIRED' as const }
}
// 4) Apply — payment row, order status, ledger event: one transaction.
// ... see API_CONSUMER.md for the full body ...
return { applied: true }
},
})| Scenario | Behaviour | HTTP response |
|---|---|---|
| First delivery | Applied; ledger event written | 200 OK |
| Exact duplicate | Detected via by_provider_txn + same status; no-op |
200 OK |
Out-of-order (pending after settlement) |
Ignored — status transitions are monotonic per the state machine | 200 OK |
| Amount mismatch | Rejected, logged as a security event, no state change | 200 OK (do not invite retries of a tampered payload) |
| Signature mismatch | Rejected before any DB access | 401 |
| Malformed JSON | Rejected | 400 |
| Our mutation throws | Nothing committed | non-200, so Midtrans retries |
Returning 200 for duplicates and business-level rejections is intentional: retrying will not change the outcome, and repeated retries would only add noise. We reserve non-200 for cases where a retry could genuinely succeed.
# 1) Compute a valid signature
ORDER_ID="CIRQUO-ORD-k57d9m2p3q8xw1v4"
STATUS_CODE="200"
GROSS="15000.00"
SERVER_KEY="SB-Mid-server-XXXXXXXXXXXXXXXX"
SIG=$(printf '%s%s%s%s' "$ORDER_ID" "$STATUS_CODE" "$GROSS" "$SERVER_KEY" | sha512sum | cut -d' ' -f1)
# 2) Post it at the deployment
curl -X POST "https://<deployment>.convex.site/midtrans/webhook" \
-H 'Content-Type: application/json' \
-d "{
\"order_id\": \"$ORDER_ID\",
\"status_code\": \"$STATUS_CODE\",
\"gross_amount\": \"$GROSS\",
\"signature_key\": \"$SIG\",
\"transaction_status\": \"settlement\",
\"transaction_id\": \"local-test-0001\",
\"payment_type\": \"gopay\",
\"fraud_status\": \"accept\",
\"settlement_time\": \"2026-02-14 19:04:25\"
}"
# expect: OK
# 3) Negative test — tamper the amount, signature must fail
curl -X POST "https://<deployment>.convex.site/midtrans/webhook" \
-H 'Content-Type: application/json' \
-d "{ \"order_id\": \"$ORDER_ID\", \"status_code\": \"200\", \"gross_amount\": \"1.00\",
\"signature_key\": \"$SIG\", \"transaction_status\": \"settlement\",
\"transaction_id\": \"local-test-0002\" }"
# expect: Unauthorized (401)Convex retries a mutation internally on optimistic-concurrency conflicts, and the client may retry after a network drop. Most Cirquo mutations are naturally idempotent because they are state-machine transitions: attempting orders.confirmPickup twice fails the second time with INVALID_TRANSITION, which is safe.
The exception is orders.reserve, which creates a new document on every call. It accepts an optional client-generated key:
export const reserve = mutation({
args: {
sessionToken: v.string(),
surplusItemId: v.id('surplusItems'),
quantity: v.number(),
idempotencyKey: v.optional(v.string()), // client UUID v4
},
handler: async (ctx, args) => {
const user = await requireRole(ctx, args.sessionToken, ['consumer'])
if (args.idempotencyKey) {
const prior = await ctx.db
.query('orders')
.withIndex('by_idempotency_key', (q) => q.eq('idempotencyKey', args.idempotencyKey))
.unique()
if (prior) {
if (prior.userId !== user._id ||
prior.surplusItemId !== args.surplusItemId ||
prior.quantity !== args.quantity) {
fail({ code: 'IDEMPOTENCY_CONFLICT', message: 'Key reused with different arguments.' })
}
return { orderId: prior._id, pickupCode: prior.pickupCode,
holdExpiresAt: prior.paymentHoldExpiresAt, replayed: true }
}
}
// ... normal reservation path ...
},
})| Function | Idempotency mechanism |
|---|---|
orders.reserve |
Explicit idempotencyKey (UUID v4 generated once per checkout attempt) |
orders.cancel, orders.confirmPickup, orders.reportNoShow |
State machine — repeat calls throw INVALID_TRANSITION |
recoveryBatches.accept / decline |
State machine + declinedByProcessorIds set membership |
recoveryBatches.logIntake / logOutcome |
Status guard — cannot re-log once collected / processed |
auth.register |
Unique index on users.email → VALIDATION_FAILED |
notifications.markRead |
Naturally idempotent (read = true is a fixpoint) |
| Midtrans webhook | by_provider_txn + status comparison (§11.6) |
admin.* verification mutations |
State guard; re-verifying an already-verified account is a no-op that still writes an audit entry |
Convex has no built-in rate limiter, so we implement token-bucket counters in a rateLimits table keyed by (bucket, subject), checked inside the mutation before any expensive work.
| Function | Limit | Window | Subject | Rationale |
|---|---|---|---|---|
auth.login |
5 attempts | 15 min | email + IP hash | Credential stuffing |
auth.requestPasswordReset |
3 | 1 h | Mailbox flooding, enumeration probing | |
auth.register |
5 | 1 h | IP hash | Fake-account spam |
orders.reserve |
10 | 10 min | userId | Inventory-locking abuse (reserve-all-then-abandon) |
orders.confirmPickup |
5 failed code attempts | per order | orderId | Pickup-code brute force (codes are 6 digits) |
disputes.raise |
5 | 24 h | userId | Dispute spam |
payments.createTransaction |
5 | 10 min | orderId | Snap token churn |
recoveryBatches.accept |
30 | 1 h | processorId | Queue thrash |
admin.* |
none | — | — | Trusted, manually provisioned accounts; all actions audited instead |
Exceeding a limit throws RATE_LIMITED with details.retryAfterMs. Failed pickup-code attempts are additionally surfaced to Admin, because a merchant repeatedly guessing codes is a fraud signal, not a UX problem.
sequenceDiagram
autonumber
participant C as Consumer (React + Capacitor)
participant CX as Convex WebSocket
participant M as mutation orders.reserve
participant DB as Convex DB (transaction)
participant L as materialFlowLedger
participant N as notifications
participant S as Scheduler (crons)
participant MER as Merchant client
C->>CX: useMutation(api.orders.reserve)({ surplusItemId, quantity, idempotencyKey })
CX->>M: invoke handler
activate M
Note over M,DB: Transaction BEGIN — serializable
M->>DB: requireAuth(sessionToken)
DB-->>M: user{ role: 'consumer', status: 'active' }
M->>M: requireRole(['consumer'])
M->>DB: get(surplusItemId)
DB-->>M: item{ status: 'active', remainingQuantity: 1, currentPrice, weightPerItemGrams }
alt item missing / not active
M--xC: ConvexError { code: 'INVALID_TRANSITION' }
else quantity insufficient
M--xC: ConvexError { code: 'INSUFFICIENT_QUANTITY', details: { remaining } }
else pickup window already ended
M--xC: ConvexError { code: 'PICKUP_WINDOW_CLOSED' }
end
M->>DB: patch(surplusItemId, { remainingQuantity: 0, status: 'sold_out' })
Note right of M: Decrement at RESERVATION,<br/>not at payment — prevents overselling
M->>DB: insert('orders', { status:'reserved', unitPrice (locked),<br/>rescuedWeightGrams (snapshot), pickupCode,<br/>paymentHoldExpiresAt: now + 15min })
DB-->>M: orderId
M->>L: recordLedgerEvent(ctx, { eventType:'RESERVED',<br/>weightDeltaGrams: 0, surplusItemId, orderId,<br/>actorId, actorRole:'consumer', methodologyVersion:'v1' })
L-->>M: ledgerEventId
alt ledger insert fails
M--xC: ConvexError { code: 'LEDGER_WRITE_FAILED' }
Note over DB,L: Transaction ROLLBACK —<br/>quantity decrement undone, no order exists
end
M->>N: internal push -> Merchant "New reservation"
M->>S: scheduler.runAt(paymentHoldExpiresAt,<br/>internal.orders.expireHold, { orderId })
Note over M,DB: Transaction COMMIT — all writes atomic
deactivate M
M-->>CX: { orderId, pickupCode, holdExpiresAt }
CX-->>C: resolved value
par Reactive fan-out (no client polling)
CX-->>C: orders.listMine invalidated -> pushed
CX-->>MER: orders.listForMerchant invalidated -> pushed
CX-->>MER: surplusItems.listMine invalidated -> pushed
end
Note over C,S: If unpaid at holdExpiresAt, the timer restores<br/>quantity, sets order 'expired',<br/>and writes CANCELLED (0 g), reason PAYMENT_HOLD_EXPIRED.
The critical property the diagram encodes: the ledger write is inside the transaction boundary. There is no reachable state in which inventory moved and the ledger did not record it, or vice versa.
// convex/lib/ledger.ts
import { MutationCtx } from '../_generated/server'
import { Id } from '../_generated/dataModel'
import { ConvexError } from 'convex/values'
export const METHODOLOGY_VERSION = 'v1'
export type LedgerEventType =
| 'LISTED' | 'PRICE_ADJUSTED' | 'RESERVED' | 'PAID' | 'RESCUED'
| 'CANCELLED' | 'EXPIRED' | 'ROUTED' | 'ROUTING_FAILED'
| 'INTAKE_ACCEPTED' | 'INTAKE_DECLINED' | 'PROCESSED' | 'MODERATED'
export async function recordLedgerEvent(
ctx: MutationCtx,
input: {
surplusItemId: Id<'surplusItems'>
orderId?: Id<'orders'>
recoveryBatchId?: Id<'recoveryBatches'>
eventType: LedgerEventType
weightDeltaGrams: number
actorId?: Id<'users'>
actorRole?: 'consumer' | 'merchant' | 'processor' | 'admin' | 'system'
metadata?: Record<string, string | number | boolean>
occurredAt?: number
},
): Promise<Id<'materialFlowLedger'>> {
if (!Number.isInteger(input.weightDeltaGrams)) {
throw new ConvexError({
code: 'LEDGER_WRITE_FAILED',
message: 'weightDeltaGrams must be an integer number of grams.',
})
}
return await ctx.db.insert('materialFlowLedger', {
surplusItemId: input.surplusItemId,
orderId: input.orderId,
recoveryBatchId: input.recoveryBatchId,
eventType: input.eventType,
weightDeltaGrams: input.weightDeltaGrams,
actorId: input.actorId,
actorRole: input.actorRole,
metadata: input.metadata,
methodologyVersion: METHODOLOGY_VERSION,
occurredAt: input.occurredAt ?? Date.now(),
})
}Rules, enforced by review and by the integrity checks in API_ADMIN.md:
| Rule | Enforcement |
|---|---|
Only callable with a MutationCtx |
TypeScript signature — an ActionCtx will not compile |
| Never callable from the client | No public ledger.* mutation exists |
Append-only — no patch, no delete, ever |
Code review; admin.checkLedgerCompleteness detects gaps |
| Every state-changing mutation calls it exactly once per state change | admin.checkLedgerCompleteness cross-references statuses against events |
weightDeltaGrams is a signed integer in grams |
Runtime check above |
methodologyVersion is always stamped |
Set by the helper, never by the caller |
| Terminal items sum to zero | admin.checkWeightConservation |
Full event semantics and the derivation of Rescued / Recovered / Residual and the circularity rate live in ../impact/MATERIAL_LEDGER.md and ../impact/ALGORITHM.md.
Cirquo ships a web client and a Capacitor-wrapped mobile client from a single codebase against a single deployment. Installed mobile builds can lag, so backward compatibility is a real constraint even at hackathon scale.
| Policy | Rule |
|---|---|
| No URL versioning | There are no URLs. Versioning is per function. |
| Additive changes are free | Adding an v.optional() argument or a new return field is non-breaking; ship it. |
| Breaking changes create a new function | orders.reserveV2 alongside orders.reserve. Never silently change the meaning of an existing argument. |
| Deprecation marker | JSDoc @deprecated plus a ⚠️ Deprecated row in the index table of the relevant role doc, with the replacement named. |
| Deprecation window | Minimum one release cycle, or until client telemetry shows zero calls — whichever is longer. |
| Removal | Only after the window; the removal is a CHANGELOG.md entry under Removed. |
Ledger methodologyVersion |
Never rewritten. If the impact methodology changes, new events carry v2 and the metric layer computes per-version. Historical claims must remain reproducible; retroactively restating impact numbers would destroy the ledger's audit value. |
| Schema migrations | New fields land as v.optional(), are backfilled by an internalMutation, and only then become required. Never a destructive in-place migration. |
| Enum additions | Adding a materialType or outputType is additive; clients must treat unknown enum values as "other" rather than crashing. |
| Error codes | Codes are a public contract. New codes may be added; existing codes are never repurposed. Unknown codes fall through to INTERNAL_ERROR copy on the client. |
# Terminal 1 — Convex dev server: pushes functions, watches for changes
npx convex dev
# Terminal 2 — Vite frontend
npm run dev
# One-off function calls from the CLI (no UI required)
npx convex run surplusItems:listByStatus '{"status":"active"}'
# impact.getPlatformSummary requires an Admin session; see API_IMPACT.md.
npx convex run users:getByEmail '{"email":"merchant@example.com"}'
# Seed demo data (planned)
npx convex run seed:demoDataset '{}'
# Inspect data and logs
npx convex dashboard
npx convex logs --tail
# Environment variables (never committed)
npx convex env set MIDTRANS_SERVER_KEY "SB-Mid-server-XXXXXXXXXXXXXXXX"
npx convex env list| Test layer | Tool | Scope |
|---|---|---|
| Unit | Vitest | Pure helpers: Haversine, Dynamic Rescue Pricing, pickup-code generation, routing eligibility predicates |
| Integration | convex-test |
Full mutations against an in-memory Convex backend, including transactional rollback assertions |
| Concurrency | convex-test with parallel invocations |
Two consumers racing orders.reserve for the last unit — exactly one must win |
| Ledger integrity | convex-test |
After each lifecycle scenario, assert admin.checkWeightConservation returns zero imbalance |
| Webhook | curl with a computed SHA512 (§11.7) |
Valid signature, invalid signature, duplicate delivery, amount tamper |
| E2E | Manual demo script | Merchant lists → Consumer reserves → pays → collects → Admin opens admin.getItemLedger |
The concurrency and ledger-integrity suites are the two that matter most for judging. They are what let us claim "every kilogram is accounted for" without hand-waving. See ../engineering/TESTING.md.
../domain/DOMAIN.md— the vocabulary (Rescue Item, Rescued, Recovered, Residual, Circular Routing)../domain/DATABASE.md— tables, indexes, enums../domain/STATE_MACHINE.md— legal transitions, which this API enforces../impact/MATERIAL_LEDGER.md— why the ledger write is inside the transaction- This document — conventions and the error model
- Your role file:
API_AUTH.md→API_CONSUMER.md→API_MERCHANT.md→API_PROCESSOR.md→API_ADMIN.md ../security/PERMISSIONS.md— the authoritative role/permission matrix
| Document | Path | Why |
|---|---|---|
| Docs index | ../README.md |
Full documentation map |
| Auth API | ./API_AUTH.md |
Sessions, guards, verification gate |
| Consumer API | ./API_CONSUMER.md |
Discovery, reservation, payment |
| Merchant API | ./API_MERCHANT.md |
Rescue Item lifecycle, pickup |
| Processor API | ./API_PROCESSOR.md |
Circular Routing, intake, outcome |
| Admin API | ./API_ADMIN.md |
Verification, audit, integrity checks |
| Database schema | ../domain/DATABASE.md |
Authoritative table definitions |
| Data model | ../domain/DATA_MODEL.md |
Entity relationships |
| State machine | ../domain/STATE_MACHINE.md |
Legal status transitions |
| Domain glossary | ../domain/DOMAIN.md |
Canonical terminology |
| Material Flow Ledger | ../impact/MATERIAL_LEDGER.md |
Event semantics, append-only rules |
| Impact algorithm | ../impact/ALGORITHM.md |
Dynamic Rescue Pricing, circularity rate |
| Impact metrics | ../impact/IMPACT.md |
Metric definitions and derivation |
| Permissions matrix | ../security/PERMISSIONS.md |
Role capability grid |
| Auth design | ../security/AUTH.md |
Session and password design |
| Security overview | ../security/SECURITY.md |
Threat model |
| Backend architecture | ../architecture/BACKEND.md |
Convex layering |
| Realtime | ../architecture/REALTIME.md |
Subscription behaviour |
| Scheduler | ../architecture/SCHEDULER.md |
Cron definitions |
| Style guide | ../engineering/STYLE_GUIDE.md |
Code conventions |
| Testing | ../engineering/TESTING.md |
Test strategy |
| Features | ../spec/FEATURES.md |
Feature/PRD references |
| Roles | ../spec/ROLES.md |
Actor definitions |
Built for DSDC ANFORCOM 2026
Platform: Cirquo — Closing the Loop, Saving Every Meal