Skip to content

extraction — ServiceTitan Sync Lambda (Java / Spring Boot)

Module path: st-integration/extraction/ · Maven artifact com.maktix.jb:extraction:0.0.1-SNAPSHOT

A Spring Boot / Spring Cloud Function application deployed as an AWS Lambda that performs bidirectional sync between ServiceTitan and the JB360 MySQL staging database (jbwst).

It is triggered per tenant (one ServiceTitan tenant = one dealer) by an SQS message whose JSON body is a Tenant object. Each invocation:

  1. Inbound (ServiceTitan → JB DB): pulls invoices + line items, enriches locations with phone/email, and pulls installed-equipment details (manufacturer/model/serial) for agreements missing equipment data.
  2. Outbound (JB DB → ServiceTitan): takes agreements and claims that JB360 has processed and are flagged ready-to-send, and PATCHes them back into ServiceTitan — JB tags, custom fields (JB Agreement Number, claim status/amount/date/number), location notes, and warranty start/end dates on installed equipment.
  3. Audit: records every run in tblTransmissionDetails (current status per tenant) and tblTransmissionDetailsHistory (append-only, includes the captured in-memory run log).
ConcernChoice
Language / runtimeJava 17
FrameworkSpring Boot 3.0.12, Spring Cloud 2022.0.4
Lambda adapterspring-cloud-function-adapter-aws (function bean loadInvoices)
PersistenceSpring Data JPA (Hibernate) + JdbcTemplate for raw SQL
DatabaseMySQL 8 on Amazon RDS, schema jbwst
HTTP clientRestTemplate + Apache HttpClient 5
JSONGson
AWS SDKsSecrets Manager (v2), RDS IAM auth (v1), Lambda events
BuildMaven (mvnw), maven-shade-plugin produces the Lambda jar (classifier aws)
Entry pointFileNotes
Lambda handlersrc/main/java/com/maktix/jb/extraction/ExtractionApplication.java@Bean Function<SQSEvent,String> loadInvoices() — parses each SQS record body into Tenant, calls JbDataLoadService.loadInvoices
HTTP (local/dev)controller/InvoiceController.javaPOST /api/invoices/load with an SQSEvent-shaped JSON body — same flow

Environment variables (no defaults — all required at runtime)

Section titled “Environment variables (no defaults — all required at runtime)”
VariablePurposeRead in
ST_URLServiceTitan API base URLservice/InvoiceService.java
ST_AUTH_URLServiceTitan OAuth token base URLservice/TokenService.java
ST_APP_KEYServiceTitan app key (sent as ST-App-key header)service/TokenService.java
DATABASE_ENDPOINT / DATABASE_PORT / DATABASE_REGIONRDS host / port / regionconfig/JpaConfiguration.java
SECRET_NAMESecrets Manager secret id holding DB credentialsconfig/JpaConfiguration.java

Standard AWS credential chain is also required (RDS IAM token generation, Secrets Manager, SQS).

Per-message configuration (model/Tenant.java)

Section titled “Per-message configuration (model/Tenant.java)”

Each SQS message carries: tenantId, dealerId, clientId + clientSecret (ServiceTitan OAuth client-credentials, per dealer), batchId, syncStartDate (yyyy-MM-dd'T'HH:mm:ss.SSS'Z'), invoiceStatus.

In Lambda, the datasource is built in code (config/JpaConfiguration.java): username comes from the Secrets Manager secret, and the password is a generated RDS IAM auth token (no static password). The datasource in src/main/resources/application.yml is a dev/fallback only — see Caveats.

Orchestrated by service/InvoiceService.loadInvoices(Tenant):

SQS message (Tenant)
├─ 1. OAuth token from ServiceTitan (client credentials per dealer)
├─ 2. GET accounting/v2 invoices (paged 1000) ──► upsert tblInvoice / tblInvoiceItem
├─ 3. GET crm/v2 location contacts (250/chunk) ─► fill missing phone/email on tblInvoice
├─ 4. GET equipmentsystems/v2 installed-equipment ─► fill tblAgreementEquipments
│ (only agreements with dataReceivedFromST = 0)
├─ 5. Outbound push (callServiceTitan):
│ agreements PAID & isSentToST=0 ─► PATCH ST location (tag + custom field),
│ POST location note, PATCH installed-equipment
│ claims isReadyForST=1 & isSentToST=0 ─► PATCH ST job (status/amount/date tags+fields)
└─ 6. Audit ─► tblTransmissionDetails (update) + tblTransmissionDetailsHistory (insert)

Notable transforms on inbound invoices: customer name split into first/last, address preference (location address over customer address), field truncation to 44/45 chars, line-item totals summed into customerPurchasePrice, missingEquipment flag, isSeaCoast flag when SKU is JBSEA_COAST.

All in service/InvoiceService.java unless noted:

EndpointDirectionPurpose
POST {ST_AUTH_URL}tokenOAuth client-credentials (TokenService.java)
GET accounting/v2/tenant/{t}/invoicesinInvoice extract (paged, status/date filters)
GET crm/v2/tenant/{t}/locations/contacts?locationIds=…inBatched phone/email enrichment
GET equipmentsystems/v2/tenant/{t}/installed-equipment?locationIds=…inEquipment details
GET/PATCH crm/v2/tenant/{t}/locations/{id}outRead/merge/write tags & custom fields
POST crm/v2/tenant/{t}/locations/{id}/notesoutAgreement note
GET/PATCH equipmentsystems/v2/tenant/{t}/installed-equipment/{id}outWarranty dates + tags on equipment
GET/PATCH jpm/v2/tenant/{t}/jobs/{id}outClaim status update on jobs

Writes: tblInvoice, tblInvoiceItem, tblAgreement (flags/comments), tblAgreementEquipments, tblClaims (flags), tblTransmissionDetails, tblTransmissionDetailsHistory. Reads: tblProducts, tblTenantCustomTags, tblTenantCustomFields (via raw SQL in repository/JbRepository.java).

Terminal window
# Build (produces shaded Lambda jar, classifier "aws")
./mvnw clean package
# Run locally as HTTP service (requires the env vars above)
./mvnw spring-boot:run
# then: POST http://localhost:8080/api/invoices/load (SQSEvent-shaped JSON)

Deployment: the shaded jar is deployed as a Lambda with the Spring Cloud Function handler; the trigger is an SQS queue (see the jbw-sam module for infrastructure).

  • The whole run is wrapped in try/catch(Throwable) — failure sets syncStatus=FAILED but the audit row is still written.
  • Per-agreement and per-claim loops catch exceptions individually, so one bad record does not abort the batch.
  • A per-run in-memory log (util/LogContext.java) is joined and stored in tblTransmissionDetails.logDetails for auditing.
  • There is no internal scheduler — cadence is driven entirely by whatever enqueues the SQS messages (see jbw-sam).
  • src/main/resources/application.yml contains committed plaintext DB credentials and a real RDS hostname (used as dev/fallback; production uses env vars + Secrets Manager + RDS IAM).
  • Several outbound UPDATE statements in InvoiceService.callServiceTitan are built by string concatenation, not parameterized.
  • The RDS IAM auth token and ServiceTitan bearer tokens are printed to logs (System.out.println used heavily).
  • Some exceptions are silently swallowed (contact-fetch and location-v2 paths).
  • README.md is empty; HELP.md is Spring boilerplate.