Skip to main content

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 response
  • Idempotency-Key — accepted by CORS; explicit enforcement varies by route
  • X-Cron-Secret — required on /internal/cron/* routes
  • X-Square-HMACSHA256-Signature — Square webhook signature
  • X-Clover-Auth — Clover webhook signature

Content type

Content-Type: application/json

Middleware Stack (per request)

Order is enforced in app.ts:

  1. secureHeaders()
  2. cors() — restricted to CORS_ALLOWED_ORIGINS
  3. correlationMiddleware
  4. dbMiddleware
  5. Per-route: authMiddlewaretenantMiddleware (when :tenantId) → requirePermission(...) → handler
  6. 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.

RoutePermissionPurpose
GET /consumer/walletsWALLET_VIEW_OWNList wallets across all tenants for the user (includes tenant branding)
GET /consumer/wallets/:walletIdWALLET_VIEW_OWNWallet detail (ownership-checked)
GET /consumer/wallets/:walletId/history?limit=20WALLET_HISTORY_OWNLedger entries newest-first; limit capped at 100
POST /consumer/wallets/:walletId/redeem/previewWALLET_REDEEMProject discount + new balance
POST /consumer/wallets/:walletId/redeem/confirmWALLET_REDEEMGenerate 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:

  1. Verify balance against latest ledger
  2. Insert wallet_ledger debit row
  3. Update wallet.points_balance
  4. Insert redemption_codes row (code = 6-byte hex, retried on collision)
  5. Fire-and-forget sendRedemptionCodeEmail

Client (Merchant) Routes

Prefix: /client. All require authMiddleware. Tenant-scoped routes also pass through tenantMiddleware.

Workspace bootstrap

RoutePermission
POST /client/bootstrap-merchant-workspaceTENANT_PROFILE_UPDATE

Idempotent. Creates the merchant tenant if missing for a client user.

{ "tenantId": "uuid", "created": true }

Tenant overview and onboarding

RoutePermission
GET /client/tenants/:tenantIdTENANT_VIEW
PATCH /client/tenants/:tenantId/onboardingTENANT_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

RoutePermission
POST /client/tenants/:tenantId/branding/logoTENANT_PROFILE_UPDATE
DELETE /client/tenants/:tenantId/branding/logoTENANT_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

RoutePermission
GET /client/tenants/:tenantId/pos-connectionsTENANT_VIEW
POST /client/tenants/:tenantId/pos-connections/square/connectTENANT_POS_CONNECT
POST /client/tenants/:tenantId/pos-connections/clover/connectTENANT_POS_CONNECT
DELETE /client/tenants/:tenantId/pos-connections/:connectionIdTENANT_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 disconnectedactive in place. Disconnect is a soft delete (status only).

Reward rules

RoutePermission
GET /client/tenants/:tenantId/reward-rulesTENANT_VIEW
POST /client/tenants/:tenantId/reward-rulesTENANT_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

RoutePermission
GET /client/tenants/:tenantId/store-operatorsTENANT_TEAM_INVITE
POST /client/tenants/:tenantId/store-operator-invitesTENANT_TEAM_INVITE
DELETE /client/tenants/:tenantId/store-operator-invites/:inviteIdTENANT_TEAM_INVITE
DELETE /client/tenants/:tenantId/store-operators/:userIdTENANT_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

RoutePermission
GET /client/tenants/:tenantId/transactions?limit=50TENANT_ANALYTICS_VIEW
GET /client/tenants/:tenantId/consumers?limit=50TENANT_CONSUMER_VIEW

Debug

RoutePermission
GET /client/debug/squareTENANT_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.

RoutePurpose
GET /pos/contextResolve operator's single-tenant context (tenant id, name, branding)
POST /pos/redemptions/lookupLook up a redemption code without burning it
POST /pos/redemptions/completeAtomically 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

RoutePermission
GET /admin/tenantsADMIN_TENANTS_VIEW_ALL
GET /admin/tenants/:tenantIdADMIN_TENANTS_VIEW_ALL

Wallet governance

RoutePermission
POST /admin/wallets/:walletId/freezeADMIN_WALLET_FREEZE
POST /admin/wallets/:walletId/unfreezeADMIN_WALLET_FREEZE
POST /admin/wallets/:walletId/adjustADMIN_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

RoutePermission
GET /admin/audit-logs?limit=50ADMIN_AUDIT_LOGS_VIEW
GET /admin/users/search?q=...ADMIN_USER_SEARCH
PATCH /admin/users/:userId/approveADMIN_USER_MANAGE
PATCH /admin/users/:userId/roleADMIN_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_id to a single tenant (pos_type=square-scoped lookup)
  • Stores in pos_webhook_events_raw, returns 200
  • 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 state to recover tenantId
  • Exchanges code for { accessToken, refreshToken, merchantId, expiresAt }
  • Encrypts tokens (AES-256-GCM) and upserts pos_connections (unique on tenant_id, pos_type)
  • Redirects to ${FRONTEND_URL}/pos?connected=true&provider=square (or with error=...)

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.

Written byDhruv Doshi