POS Integrations
SalesArck supports two POS providers: Square and Clover, both production-grade.
Both integrations share the same ingestion pipeline, idempotency model, and wallet credit path.
Provider Architecture
POS Provider → Webhook/Poll → Adapter (normalize) → Ingestion Pipeline → Wallet Credit
Each provider has three layers:
- OAuth helpers (
lib/square.ts,lib/clover.ts) — URL building, state signing, token exchange, webhook verification - Adapter (
services/square-adapter.ts,services/clover-adapter.ts) — Normalize provider-specific payment shapes toNormalizedTransaction - API client (
services/square-api.ts,services/clover-api.ts) — REST API calls for payment retrieval, customer enrichment
Square Integration
OAuth Flow
-
Initiate:
POST /api/v1/client/tenants/:tenantId/pos-connections/square/connect- Validates Square env is configured
- Builds HMAC-signed OAuth state with tenant binding (10-minute TTL)
- Constructs authorization URL on
connect.squareupsandbox.comorconnect.squareup.com - Hard-validates host before returning URL to client
-
Callback:
GET /api/v1/oauth/square/callback- Parse
code,state, optionalerror - Decode and verify signed state -> tenant context
- Exchange code for tokens via Square token endpoint
- Encrypt tokens at rest (AES-256-GCM)
- Upsert
pos_connectionsfor tenant +posType=square - Redirect to frontend (
/pos?connected=trueor error)
- Parse
OAuth State Model
apps/api/src/lib/square.ts:
- Compact payload (
tenantId, expiry) - HMAC-signed with key material derived from
ENCRYPTION_KEY - Validated with timing-safe comparison
- TTL-limited (10 minutes)
OAuth Host Safety
Square endpoints use connect.* origins only:
- sandbox:
https://connect.squareupsandbox.com - production:
https://connect.squareup.com
Server validates host before returning URL. Web client adds an additional safety net in squareOAuthUrl.ts to normalize/validate malformed hosts before browser redirect.
Token Encryption at Rest
apps/api/src/lib/encryption.ts uses AES-256-GCM:
- key source:
ENCRYPTION_KEY(64 hex chars = 32 bytes) - ciphertext format:
iv.authTag.ciphertext(base64url parts) - OAuth helper uses encrypted tokens for storage and decrypts only when needed
Webhook Processing
Route: POST /api/v1/webhooks/square
Fast-ack pattern:
- Read raw body and
x-square-hmacsha256-signatureheader - Verify HMAC signature when key configured
- Parse payload
- Extract
merchant_id, resolve tenant by matching against activepos_connections - Store raw payload in
pos_webhook_events_raw - Return
200quickly - Process event asynchronously (
setImmediate) viaprocessSquareWebhook(...)
Polling Fallback and Backfill
Internal route: POST /api/v1/internal/cron/square-poll-payments
Auth: X-Cron-Secret header.
Worker function runSquarePaymentsPollForAllMerchants(...):
- Loops active Square connections
- Refreshes expired access token if needed (180s buffer)
- Calls
ListPaymentswith per-merchant watermark (default: 7-day lookback) - Paginates up to 25 pages per connection
- Normalizes payments, optionally enriches customer contact
- Reuses same ingestion path as webhooks
- Updates
square_payments_watermark_atandlast_synced_at
This creates a robust webhook-plus-poll model for resiliency and recovery.
Required Square Scopes
PAYMENTS_READORDERS_READMERCHANT_PROFILE_READITEMS_READCUSTOMERS_READ
Customer enrichment in ingestion relies on CUSTOMERS_READ when only customer ID is present.
Square Payment Normalization
normalizeSquarePaymentRecord() in square-adapter.ts:
- Filters by status
COMPLETEDonly - Extracts: id, total_money/totalMoney, buyer_email_address, buyer_phone_number, customer_id
- Handles both
snake_caseandcamelCasefield names (webhook vs REST shapes) - Returns
NormalizedTransactionor null
Clover Integration
Environment and URLs
apps/api/src/lib/clover.ts:
| Environment | OAuth/Connect Origin | REST API Base |
|---|---|---|
| sandbox | https://sandbox.dev.clover.com | https://sandbox.dev.clover.com |
| production | https://www.clover.com | https://api.clover.com |
OAuth Flow
-
Initiate:
POST /api/v1/client/tenants/:tenantId/pos-connections/clover/connect- Validates Clover env is configured
- Reuses same HMAC-signed state helpers as Square (provider-agnostic)
- Constructs authorization URL on
sandbox.dev.clover.comorwww.clover.com - Hard-validates host before returning URL
-
Callback:
GET /api/v1/oauth/clover/callback- Same code/state/error flow as Square
- Exchange code via
POST /oauth/tokenwith client_id + client_secret - Handles variable response shapes (flat JSON or nested
messageobject) - Falls back to
GET /v3/merchants/currentif merchant_id not in token response - Encrypts access token at rest (AES-256-GCM)
- Stores empty refresh token (Clover tokens are long-lived, not refreshable)
- Upsert
pos_connectionsfor tenant +posType=clover
Key Differences from Square
| Concern | Square | Clover |
|---|---|---|
| Token lifecycle | Short-lived, refresh token provided | Long-lived, no refresh token |
| Webhook payload | Full payment data in webhook body | Notification-only (payment IDs) |
| Payment retrieval | Not needed for webhooks | Must call REST API per payment ID |
| HMAC header | x-square-hmacsha256-signature | Authorization: Bearer <base64-hmac> |
| Signature skip | Skip if key not configured | Skip in non-production only; fail closed in production if secret is unset |
| Customer data | In payment or via RetrieveCustomer API | Embedded when using ?expand=customers |
Webhook Processing
Route: POST /api/v1/webhooks/clover
Fast-ack pattern (same as Square):
- Read raw body and
Authorizationheader - Verify HMAC-SHA256 signature (timing-safe comparison). Fails closed in production if
CLOVER_WEBHOOK_SECRETis unset. - Parse payload
- Reject payloads with multiple merchant IDs (returns 400) — Clover sends one merchant per webhook; multi-merchant payloads would cause the raw event to be stored under the wrong tenant
- Extract merchant_id from
merchantsmap, resolve tenant - Store raw payload in
pos_webhook_events_raw - Return
200quickly - Process event asynchronously via
processCloverWebhook(...)
Clover Webhook Payload Structure
{
"merchants": {
"<merchantId>": {
"metaEventType": "PAYMENT",
"eventTypes": {
"CREATE": ["paymentId1", "paymentId2"]
},
"time": 1712345678000
}
}
}
Clover webhooks are notification-only: they carry merchant ID and payment IDs but NOT the full payment data. The processing pipeline must call the Clover REST API to retrieve each payment before normalizing.
Clover Payment Normalization
normalizeCloverPayment() in clover-adapter.ts:
- Filters by result
SUCCESSonly (rejects DECLINED, VOIDED, REFUNDED, FRAUD) - Amount is already in cents (minor units)
- Timestamps are epoch milliseconds
- Customer contact extracted from
payment.customers.elements[0]when using?expand=customers - Returns
NormalizedTransactionor null
Clover REST API Calls
clover-api.ts:
getCloverPayment(accessToken, merchantId, paymentId)—GET /v3/merchants/{mId}/payments/{paymentId}?expand=customersgetCloverCustomer(accessToken, merchantId, customerId)—GET /v3/merchants/{mId}/customers/{customerId}
Provider-Agnostic Ingestion
Both providers converge to the same executeIngestion() function in services/ingestion.ts:
NormalizedTransaction → idempotency check → identity resolve → store transaction
→ load reward rule → evaluate reward → credit wallet
This design means adding a new POS provider requires only:
- OAuth helpers (lib)
- Payment adapter (normalize to
NormalizedTransaction) - API client (if webhook is notification-only)
- Route handlers (webhook receiver + optional connect endpoint)
Frontend POS Settings
PosSettingsPage.tsx renders both Square and Clover connection cards:
- Square: always visible, disabled if merchant chose Clover/Other during onboarding
- Clover: only visible when
posPreference === "clover" - Both show: status badge, merchant ID, last synced, token expiry, connect/disconnect buttons
- Active connection shows "Live — transactions are being ingested" banner
- Connection filter: only rows with
status === "active"count as connected; disconnected rows (status-flagged, not deleted) do not show as connected - Post-OAuth redirect includes
?provider=squareor?provider=cloverso the success toast displays the correct provider name
Environment Variables
Square
| Variable | Required | Description |
|---|---|---|
SQUARE_APP_ID | For Square OAuth | OAuth app ID |
SQUARE_APP_SECRET | For Square OAuth | OAuth app secret |
SQUARE_ENVIRONMENT | No (default: sandbox) | sandbox or production |
SQUARE_WEBHOOK_SIGNATURE_KEY | For webhook verification | HMAC signing key |
ENCRYPTION_KEY | When Square configured | 64 hex chars for token encryption |
Clover
| Variable | Required | Description |
|---|---|---|
CLOVER_APP_ID | For Clover OAuth | OAuth app ID |
CLOVER_APP_SECRET | For Clover OAuth | OAuth app secret |
CLOVER_ENVIRONMENT | No (default: sandbox) | sandbox or production |
CLOVER_WEBHOOK_SECRET | For webhook verification | HMAC signing secret |