API Reference
Base path: /api/v1
This page reflects current routes implemented in apps/api/src/routes, grouped by mount prefix in app.ts.
Authentication and Headers
Bearer auth
All protected routes expect:
Authorization: Bearer <supabase_access_token>
The token is a Supabase JWT. authMiddleware validates it server-side and rejects tokens whose user is not active, or admin tokens whose email domain is not @doshidhruv.com.
Optional operational headers
X-Correlation-Id— request tracing; echoed back on the responseIdempotency-Key— accepted by CORS; explicit enforcement varies by routeX-Cron-Secret— required on/internal/cron/*routesX-Square-HMACSHA256-Signature— Square webhook signatureX-Clover-Auth— Clover webhook signature
Content type
Content-Type: application/json
Middleware Stack (per request)
Order is enforced in app.ts:
secureHeaders()cors()— restricted toCORS_ALLOWED_ORIGINScorrelationMiddlewaredbMiddleware- Per-route:
authMiddleware→tenantMiddleware(when:tenantId) →requirePermission(...)→ handler app.onError→ standard error envelope (see Error Response Shape)
Root and Health
GET /
Service info endpoint.
{
"service": "salesarc-api",
"links": { "health": "/health", "api": "/api/v1" }
}
GET /health
{ "status": "ok", "version": "0.1.0", "env": "development" }
Auth
GET /auth/me
Returns user profile and app usability flags. Uses lenient authProfileMiddleware so pending-approval and admin-domain-mismatch users still receive a response (with the appropriate flags) instead of 401.
{
"userId": "uuid",
"role": "consumer | client | pos_operator | admin",
"tenantIds": ["uuid"],
"status": "active | pending_approval | suspended",
"canUseApp": true,
"needsMerchantOnboarding": false,
"email": "user@example.com",
"phone": null,
"code": "PENDING_APPROVAL"
}
code is present only when applicable (e.g. PENDING_APPROVAL, ADMIN_EMAIL_REQUIRED, SUSPENDED).
Consumer Routes
Prefix: /consumer. All require authMiddleware and a permission check.
| Route | Permission | Purpose |
|---|---|---|
GET /consumer/wallets | WALLET_VIEW_OWN | List wallets across all tenants for the user (includes tenant branding) |
GET /consumer/wallets/:walletId | WALLET_VIEW_OWN | Wallet detail (ownership-checked) |
GET /consumer/wallets/:walletId/history?limit=20 | WALLET_HISTORY_OWN | Ledger entries newest-first; limit capped at 100 |
POST /consumer/wallets/:walletId/redeem/preview | WALLET_REDEEM | Project discount + new balance |
POST /consumer/wallets/:walletId/redeem/confirm | WALLET_REDEEM | Generate single-use redemption code (15-min TTL) |
Redeem preview
Request:
{ "pointsToRedeem": 100 }
Response:
{
"eligiblePoints": 100,
"discountValueMinorUnits": 100,
"currentBalance": 500,
"newBalance": 400
}
Returns 422 when balance is insufficient or the active reward rule rejects the redemption.
Redeem confirm
Same request body as preview. Response:
{ "redemptionCode": "A1B2C3", "expiresAt": "2026-04-07T12:30:00.000Z" }
Performs atomically inside one DB transaction:
- Verify balance against latest ledger
- Insert
wallet_ledgerdebit row - Update
wallet.points_balance - Insert
redemption_codesrow (code = 6-byte hex, retried on collision) - Fire-and-forget
sendRedemptionCodeEmail
Client (Merchant) Routes
Prefix: /client. All require authMiddleware. Tenant-scoped routes also pass through tenantMiddleware.
Workspace bootstrap
| Route | Permission |
|---|---|
POST /client/bootstrap-merchant-workspace | TENANT_PROFILE_UPDATE |
Idempotent. Creates the merchant tenant if missing for a client user.
{ "tenantId": "uuid", "created": true }
Tenant overview and onboarding
| Route | Permission |
|---|---|
GET /client/tenants/:tenantId | TENANT_VIEW |
PATCH /client/tenants/:tenantId/onboarding | TENANT_PROFILE_UPDATE |
GET returns:
{
"tenant": { "id": "...", "name": "...", "country": "US", "branding": { "logoUrl": "..." } },
"posConnection": { "id": "...", "posType": "square", "status": "active" },
"activeRule": { "id": "...", "version": 3, "ruleConfig": { "...": "..." } },
"stats": { "consumerCount": 42, "transactionCount": 187 }
}
PATCH /onboarding body (all fields optional, JSON-merged into onboarding_profile):
{
"posProviderPreference": "square | clover | other",
"businessName": "...",
"country": "US",
"profile": {
"taxId": "...",
"addressLine": "...",
"city": "...",
"squareLocationHint": "...",
"cloverMerchantRef": "...",
"otherPosNotes": "..."
}
}
Branding
| Route | Permission |
|---|---|
POST /client/tenants/:tenantId/branding/logo | TENANT_PROFILE_UPDATE |
DELETE /client/tenants/:tenantId/branding/logo | TENANT_PROFILE_UPDATE |
Logo upload body:
{ "contentType": "image/png", "base64Data": "iVBORw0KGgo..." }
Base64 length is capped (MAX_TENANT_LOGO_BASE64_CHARS, ~10 MB). Logo is stored in Supabase Storage; the path is persisted in tenants.branding_logo_path.
POS connections
| Route | Permission |
|---|---|
GET /client/tenants/:tenantId/pos-connections | TENANT_VIEW |
POST /client/tenants/:tenantId/pos-connections/square/connect | TENANT_POS_CONNECT |
POST /client/tenants/:tenantId/pos-connections/clover/connect | TENANT_POS_CONNECT |
DELETE /client/tenants/:tenantId/pos-connections/:connectionId | TENANT_POS_CONNECT |
POST /connect returns { "authorizationUrl": "..." }. The Square host is hard-validated to prevent state-tampering. The unique (tenant_id, pos_type) constraint guarantees a single row per provider per tenant; reconnecting flips status from disconnected → active in place. Disconnect is a soft delete (status only).
Reward rules
| Route | Permission |
|---|---|
GET /client/tenants/:tenantId/reward-rules | TENANT_VIEW |
POST /client/tenants/:tenantId/reward-rules | TENANT_REWARD_CONFIG |
Versioned and append-only. POST deactivates the previous version and inserts a new row.
{
"conversionRate": 1,
"minSpendMinorUnits": 500,
"maxPointsPerTransaction": 500,
"minBalanceToRedeem": 100,
"maxRedemptionPercentage": 100,
"roundingPolicy": "floor | ceil | round",
"effectiveFrom": "2026-04-07T00:00:00.000Z"
}
Store operators
| Route | Permission |
|---|---|
GET /client/tenants/:tenantId/store-operators | TENANT_TEAM_INVITE |
POST /client/tenants/:tenantId/store-operator-invites | TENANT_TEAM_INVITE |
DELETE /client/tenants/:tenantId/store-operator-invites/:inviteId | TENANT_TEAM_INVITE |
DELETE /client/tenants/:tenantId/store-operators/:userId | TENANT_TEAM_INVITE |
POST /store-operator-invites accepts { "email": "..." }. Sends a Supabase Auth invite. Response: { ok, email, inviteEmailStatus: "sent" | "existing_auth_user" }. On the operator's first login, the invite is auto-accepted and the user is added to tenant_users as pos_operator. Removing an operator downgrades users.role if no pos_operator membership remains.
Analytics and consumers
| Route | Permission |
|---|---|
GET /client/tenants/:tenantId/transactions?limit=50 | TENANT_ANALYTICS_VIEW |
GET /client/tenants/:tenantId/consumers?limit=50 | TENANT_CONSUMER_VIEW |
Debug
| Route | Permission |
|---|---|
GET /client/debug/square | TENANT_VIEW |
Returns non-secret OAuth diagnostics: environment (sandbox/production), connect origin, whether SQUARE_APP_ID/SQUARE_APP_SECRET are set.
POS Operator Routes
Prefix: /pos. Require authMiddleware and REDEMPTION_VERIFY permission. Used by the in-store operator to verify and burn redemption codes.
| Route | Purpose |
|---|---|
GET /pos/context | Resolve operator's single-tenant context (tenant id, name, branding) |
POST /pos/redemptions/lookup | Look up a redemption code without burning it |
POST /pos/redemptions/complete | Atomically mark a code as used (rejects expired/already-used) |
Request bodies for both lookup and complete:
{ "code": "A1B2C3" }
Codes are normalized server-side (whitespace stripped, uppercased). complete uses SELECT FOR UPDATE on redemption_codes to make burn-once safe under concurrent operator scans.
Admin Routes
Prefix: /admin. Require authMiddleware and admin permissions.
Tenant governance
| Route | Permission |
|---|---|
GET /admin/tenants | ADMIN_TENANTS_VIEW_ALL |
GET /admin/tenants/:tenantId | ADMIN_TENANTS_VIEW_ALL |
Wallet governance
| Route | Permission |
|---|---|
POST /admin/wallets/:walletId/freeze | ADMIN_WALLET_FREEZE |
POST /admin/wallets/:walletId/unfreeze | ADMIN_WALLET_FREEZE |
POST /admin/wallets/:walletId/adjust | ADMIN_WALLET_ADJUST |
/adjust body:
{ "points": -50, "reason": "manual correction" }
points may be positive (credit) or negative (debit) but never zero. A debit must not drop the balance below zero (returns 422). Every wallet action writes an admin_audit_logs entry.
Audit and user admin
| Route | Permission |
|---|---|
GET /admin/audit-logs?limit=50 | ADMIN_AUDIT_LOGS_VIEW |
GET /admin/users/search?q=... | ADMIN_USER_SEARCH |
PATCH /admin/users/:userId/approve | ADMIN_USER_MANAGE |
PATCH /admin/users/:userId/role | ADMIN_USER_MANAGE |
/users/search returns pending_approval users when q is empty; otherwise case-insensitive ilike on email and mobile. Approving a client user auto-provisions a merchant workspace (idempotent).
/role body:
{ "role": "consumer | client | pos_operator | admin" }
Setting role: "client" flips status to pending_approval.
Webhooks
Prefix: /webhooks. No bearer auth; signature verified per provider. Both endpoints follow the fast-ack pattern — store raw payload, return 200, process asynchronously via setImmediate.
POST /webhooks/square
- Headers:
X-Square-HMACSHA256-Signature,X-Square-Event-Type - Verifies HMAC against
SQUARE_WEBHOOK_SIGNATURE_KEY - Routes by
merchant_idto a single tenant (pos_type=square-scoped lookup) - Stores in
pos_webhook_events_raw, returns200 - Async:
processSquareWebhook(...)→ normalize → idempotency check → ingest → credit wallet
POST /webhooks/clover
- Headers:
Authorization,X-Clover-Auth,X-Clover-Event-Type - Verifies signature against
CLOVER_WEBHOOK_SECRET - Handles Clover verification handshake (POST body is
{verificationCode}only) - Rejects payloads referencing multiple merchant IDs
- Async:
processCloverWebhook(...)→ fetch full payment via REST → normalize → ingest
OAuth Callback Routes
Prefix: /oauth. No bearer auth; called by the POS provider's authorization server.
GET /oauth/square/callback?code=...&state=...
- Decodes signed
stateto recovertenantId - Exchanges code for
{ accessToken, refreshToken, merchantId, expiresAt } - Encrypts tokens (AES-256-GCM) and upserts
pos_connections(unique ontenant_id, pos_type) - Redirects to
${FRONTEND_URL}/pos?connected=true&provider=square(or witherror=...)
GET /oauth/clover/callback?code=...&state=...
Same flow as Square, except Clover does not issue a refresh token.
Internal Cron
Prefix: /internal/cron. Each route requires X-Cron-Secret: <CRON_SECRET>.
POST /internal/cron/square-poll-payments
Calls runSquarePaymentsPollForAllMerchants(db). Per active Square connection: refresh token if within 180s of expiry, page ListPayments since square_payments_watermark_at (max 25 pages, 100/page), normalize and ingest each, advance watermark (clamped to now()).
{ "ok": true, "connectionsScanned": 3, "paymentsProcessed": 17, "errors": 0 }
POST /internal/cron/process-pending-webhooks
Webhook retry sweeper. Scans pos_webhook_events_raw rows with processed=false and received_at > now() - 7 days, processes up to 200 events per run, routes by provider to processSquareWebhook or processCloverWebhook. Both processors are idempotent. This is the only recovery path for Clover (which has no ListPayments-equivalent poll).
{ "ok": true, "scanned": 12, "succeeded": 12, "failed": 0, "limit": 200 }
Error Response Shape
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"requestId": "req_..."
}
Validation failures may also include issues (Zod errors). Common codes: UNAUTHORIZED, FORBIDDEN, INSUFFICIENT_ROLE, TENANT_NOT_FOUND, TENANT_FORBIDDEN, VALIDATION_ERROR, NOT_FOUND, IDEMPOTENCY_REPLAY, RULE_REJECTED, INSUFFICIENT_BALANCE, INTERNAL_ERROR.
Stack traces are never returned to the client. All errors are logged with correlationId; unhandled errors are reported to Sentry.