Skip to main content

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:

  1. OAuth helpers (lib/square.ts, lib/clover.ts) — URL building, state signing, token exchange, webhook verification
  2. Adapter (services/square-adapter.ts, services/clover-adapter.ts) — Normalize provider-specific payment shapes to NormalizedTransaction
  3. API client (services/square-api.ts, services/clover-api.ts) — REST API calls for payment retrieval, customer enrichment

Square Integration

OAuth Flow

  1. 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.com or connect.squareup.com
    • Hard-validates host before returning URL to client
  2. Callback: GET /api/v1/oauth/square/callback

    • Parse code, state, optional error
    • Decode and verify signed state -> tenant context
    • Exchange code for tokens via Square token endpoint
    • Encrypt tokens at rest (AES-256-GCM)
    • Upsert pos_connections for tenant + posType=square
    • Redirect to frontend (/pos?connected=true or error)

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:

  1. Read raw body and x-square-hmacsha256-signature header
  2. Verify HMAC signature when key configured
  3. Parse payload
  4. Extract merchant_id, resolve tenant by matching against active pos_connections
  5. Store raw payload in pos_webhook_events_raw
  6. Return 200 quickly
  7. Process event asynchronously (setImmediate) via processSquareWebhook(...)

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 ListPayments with 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_at and last_synced_at

This creates a robust webhook-plus-poll model for resiliency and recovery.

Required Square Scopes

  • PAYMENTS_READ
  • ORDERS_READ
  • MERCHANT_PROFILE_READ
  • ITEMS_READ
  • CUSTOMERS_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 COMPLETED only
  • Extracts: id, total_money/totalMoney, buyer_email_address, buyer_phone_number, customer_id
  • Handles both snake_case and camelCase field names (webhook vs REST shapes)
  • Returns NormalizedTransaction or null

Clover Integration

Environment and URLs

apps/api/src/lib/clover.ts:

EnvironmentOAuth/Connect OriginREST API Base
sandboxhttps://sandbox.dev.clover.comhttps://sandbox.dev.clover.com
productionhttps://www.clover.comhttps://api.clover.com

OAuth Flow

  1. 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.com or www.clover.com
    • Hard-validates host before returning URL
  2. Callback: GET /api/v1/oauth/clover/callback

    • Same code/state/error flow as Square
    • Exchange code via POST /oauth/token with client_id + client_secret
    • Handles variable response shapes (flat JSON or nested message object)
    • Falls back to GET /v3/merchants/current if 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_connections for tenant + posType=clover

Key Differences from Square

ConcernSquareClover
Token lifecycleShort-lived, refresh token providedLong-lived, no refresh token
Webhook payloadFull payment data in webhook bodyNotification-only (payment IDs)
Payment retrievalNot needed for webhooksMust call REST API per payment ID
HMAC headerx-square-hmacsha256-signatureAuthorization: Bearer <base64-hmac>
Signature skipSkip if key not configuredSkip in non-production only; fail closed in production if secret is unset
Customer dataIn payment or via RetrieveCustomer APIEmbedded when using ?expand=customers

Webhook Processing

Route: POST /api/v1/webhooks/clover

Fast-ack pattern (same as Square):

  1. Read raw body and Authorization header
  2. Verify HMAC-SHA256 signature (timing-safe comparison). Fails closed in production if CLOVER_WEBHOOK_SECRET is unset.
  3. Parse payload
  4. 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
  5. Extract merchant_id from merchants map, resolve tenant
  6. Store raw payload in pos_webhook_events_raw
  7. Return 200 quickly
  8. 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 SUCCESS only (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 NormalizedTransaction or null

Clover REST API Calls

clover-api.ts:

  • getCloverPayment(accessToken, merchantId, paymentId)GET /v3/merchants/{mId}/payments/{paymentId}?expand=customers
  • getCloverCustomer(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:

  1. OAuth helpers (lib)
  2. Payment adapter (normalize to NormalizedTransaction)
  3. API client (if webhook is notification-only)
  4. 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=square or ?provider=clover so the success toast displays the correct provider name

Environment Variables

Square

VariableRequiredDescription
SQUARE_APP_IDFor Square OAuthOAuth app ID
SQUARE_APP_SECRETFor Square OAuthOAuth app secret
SQUARE_ENVIRONMENTNo (default: sandbox)sandbox or production
SQUARE_WEBHOOK_SIGNATURE_KEYFor webhook verificationHMAC signing key
ENCRYPTION_KEYWhen Square configured64 hex chars for token encryption

Clover

VariableRequiredDescription
CLOVER_APP_IDFor Clover OAuthOAuth app ID
CLOVER_APP_SECRETFor Clover OAuthOAuth app secret
CLOVER_ENVIRONMENTNo (default: sandbox)sandbox or production
CLOVER_WEBHOOK_SECRETFor webhook verificationHMAC signing secret
Written byDhruv Doshi