Vendor SFTP feeds into HubSpot custom objects on AWS
A private-markets wealth platform
- Financial services
- Canada
- Integration, delivered in 2026
- Fields mapped across five files
- 235
- Vendor report formats handled
- 5
- Files re-driven at go live, no failures
- 137
- Fund detail records created at go live
- 1,353
- Processing time, largest report
- 2 to 3 minutes
Two data providers deliver five CSV reports to a wealth platform by SFTP, and the CRM needed them as contacts with their holdings, transactions, fund details and engagement history attached. A file landing in storage now starts a four step workflow that parses it, validates 235 mapped fields against their types and allowed values, and batch loads the result into HubSpot. A file that fails lands in its own folder and emails the people who can fix it.
01 The Challenge
Both providers deliver scheduled CSV reports over SFTP. That is the interface the engagement had to build on, so the shape of the work was settled before it started.
The files are wide and they are not tidy. The three largest reports carry 46, 58 and 90 columns. Dates arrive in three formats. Currency arrives with a symbol and thousands separators. A single cell can hold several email addresses separated by a semicolon, a comma or a slash. Enumerated fields gain a new option whenever the provider adds one, with no notice. Some rows on the advisor report have no email address at all, which makes them unidentifiable rather than invalid.
The harder constraint was regulatory. This is financial services, and the client’s compliance lead set the real specification in a review: how long does our data sit on your side, what is the encryption standard on the transfer, who is allowed to upload, and how does an auditor see that the environment is being maintained. A pipeline that only moved data correctly would still have failed that review.
The two providers’ data also had to stay separated end to end, at rest and in flight, with no step that merges or compares across them.
02 The Solution
Managed SFTP gives each provider its own upload-only account, with no ability to delete or modify anything already there. Each maps to its own inbound folder, so the two feeds never share a path.
A file landing raises an event that starts a workflow of four ordered steps. Extract parses the CSV, moves it into a processing folder so the same file cannot run twice, and writes the rows out as JSON. Validate does the real work, Load writes to HubSpot, and a fourth step moves the file to a processed folder.
Validation lives in configuration rather than in code. Each file type has a mapping of source field to HubSpot property, a type, and for enumerated fields the allowed values. The validator strips the byte order mark from the first header, matches header names case insensitively, remaps each field, then runs the validator for its type: emails split on any of the three separators, with the first going to the email property and the rest to an overflow field; currency strips the symbol and the commas; dates accept all three formats.
Two decisions there matter more than the rest. An enumerated value that is not in the allowed list becomes “Other”, and the original value is kept in a companion property, so a new option from a provider never stops a file. And a row that cannot be identified is skipped and counted rather than failed, while a row that is identifiable but wrong fails the batch, because a partly loaded file is worse than a rejected one.
Load runs in batches of 100. Contacts upsert on email with the advisor registration number as the fallback key. Four custom objects hold what a contact cannot: fund details, engagement activities, holdings and transactions, each associated back to the contact that owns it.
03 The Outcome
Go live was a sandbox to production cutover, ordered so that a mistake stayed recoverable. We took a full snapshot of the production account, then ran an automated schema comparison against it, which found five properties missing from one custom object. Those were created before any write.
Then the 137 files already processed in the sandbox were re-driven through the production pipeline in batches of 10, with record counts read back from the CRM after each batch. All 137 completed with no failures, producing 1,353 fund detail records, 178 engagement activities and 386 contacts. Contacts already in the portal that matched nothing incoming were untouched. A run takes about 30 seconds for the small activity report and 2 to 3 minutes for the larger detail report.
That go live covered the first provider’s two reports. The second provider’s three reports are built and tested, and wait on the provider before they run in production, so none of the counts above include them.
The compliance answers are now in the delivered documentation rather than folklore: a rolling one month retention, the named cipher suites, upload only accounts scoped to one folder each, and a monthly report covering patches, security status and risks found.
How the lifecycle runs now
-
File arrives
Each provider, upload only
- Source: Provider uploads a CSV report over SFTP Next: Lands in that provider's own inbound folder.
- Lands in that provider's own inbound folder Next: The landing raises an event.
- The landing raises an event Next: CSV parsed, in Extract.
-
Extract
Extract step, one retry
- CSV parsed Next: Moved to processing, rows written as JSON.
- Moved to processing, rows written as JSON Next: Headers matched, every field remapped and typed, in Validate.
-
Validate
Validate step, one retry
- Headers matched, every field remapped and typed Next: Unlisted value becomes "Other", original kept.
- Unlisted value becomes "Other", original kept Next: Can the row be identified?.
- Decision: Can the row be identified? Yes: Contacts upsert on email, with a fallback key, in Load. No: Skipped and counted, not failed.
- Skipped and counted, not failed
-
Load
Load step, batches of 100, two retries
-
In HubSpot: Contacts upsert on email, with a fallback key Next: Four custom objects, each tied to its contact.
-
In HubSpot: Four custom objects, each tied to its contact Next: Any step failed after its retries?, in Outcome.
-
-
Outcome
The state machine
- Decision: Any step failed after its retries? No: File moved to the processed folder. Yes: Failed folder, alert routed by provider.
- Outcome, done: File moved to the processed folder
- Outcome, stops: Failed folder, alert routed by provider
04 Stack
The pipeline is AWS serverless: managed SFTP, object storage with a folder tree per provider, an event rule, a state machine and three functions in Node.js and TypeScript.
The state machine has 21 states and only four do the work. The other 17 are error handling and endings: three build an error context, three move the file to failed, three choose a destination by provider, six publish the alert, and two are terminal. Retries are per step: one each on extract and validate, two with backoff on the load because a rate limit is worth waiting out, none on the move.
Nothing needs patching at the operating system level, which answered most of the maintenance question. The debt that exists is written down rather than buried: the runtime and the cloud SDK are both past end of life, each with a written upgrade path. There is no infrastructure as code, because the stack is built once and left running.
05 Architecture and data model
Five report files arrive by SFTP into per-provider inbound folders. A file landing raises an event, the workflow extracts, validates and loads it, and the file moves to a processed folder. The only branch is the failure path: any step failing after its retries moves the file to a failed folder and sends an alert routed by provider, and nothing retries on its own after that. Recovery is re-uploading a corrected file, which starts a clean run.
Direction is one way for every object. HubSpot is never read back into a vendor file, and no step compares one provider’s rows against the other’s, in storage or in memory. A row that cannot be keyed at all is counted and skipped rather than failed, so a file’s row count and its load count rarely match, and that difference is the number worth watching.
The mapping runs to 235 rows across the five files. The excerpt below is 12 of them, chosen to show a contact field, a custom object field, a required key and an allowed value list.
| Source file | HubSpot object | Direction | Match key |
|---|---|---|---|
| User detail report | Contact | To HubSpot | Email, or the advisor registration number |
| User detail report | Fund details, custom object | To HubSpot | Fund id joined to the advisor email |
| Engagement activity report | Engagement activities, custom object | To HubSpot | Created each run, associated by email |
| Shareholder names and addresses | Contact | To HubSpot | Email, or the shareholder id |
| Shareholdings | Holdings, custom object | To HubSpot | Shareholder id |
| Shareholder transactions | Transactions, custom object | To HubSpot | Deal number |
| Source file | Source field | Type | HubSpot property | Rule |
|---|---|---|---|---|
| User detail report | Fund Name | string | Fund Name | Required |
| User detail report | PAF ID | string | PAF / Fund ID | Required |
| User detail report | Advisor E-mail | Split on semicolon, comma or slash | ||
| User detail report | Advisor CRD | string | Advisor CRD | Fallback key when there is no email |
| User detail report | Role Type | enumeration | Role Type | Sales, AI/QP, QP, Delegate, RIA, SFO, Interested Parties, Interested Party |
| User detail report | User Accreditation | boolean | User Accreditation | Accepts true, false, 1, 0, yes, no, t, f, y, n |
| Engagement activity report | Activity | enumeration | Activity | Course Passed, Document Viewed, Fund Followed, Fund Unfollowed, Product Summary, Viewed Fund Profile |
| Engagement activity report | Engagement Score | number | Engagement Score | Numeric check only |
| Shareholdings | shareholder_id | string | Shareholder ID | Required, and the association key |
| Shareholdings | valuation_date | date | Valuation Date | ISO 8601, or two written date formats |
| Shareholdings | percent_holding | number | Percent Holding | Numeric check only |
| Shareholder transactions | TRADE_TYPE | enumeration | Trade Type | Redeem, Redeem Full, Issue, Transfer, Transfer Full |
- Hosting
- AWS serverless, one region
- Runtime
- Node.js and TypeScript functions, sequenced by a state machine
- Cadence
- Event driven, a run starts when a file lands
- Pattern
- SFTP file pipeline
- Fields mapped
- 235
235 mapped fields across five vendor files, keyed on the contact's email with the vendor's own identifier as the fallback.
What we would do again, and differently
What we would do again
- Keep the field mapping and the allowed values as configuration rather than code, so a new column from a provider is an edit and not a deployment.
- Let an unexpected enumeration value fall through to “Other” with the original value preserved beside it, because a file blocked at two in the morning costs more than an odd value a human can read later.
What we would do differently
- Agree the retention policy in the first week, when it is one lifecycle rule, rather than at the compliance review when it is a change request against a system already running.
- Pin the runtime and the cloud SDK to supported releases at build time, instead of handing over an upgrade as the first maintenance item.
Systems in this build
Partnerships and accreditations
HubSpot accreditations
Industry specializations
Partnerships
Have a system in the same shape?
Send the systems, the direction and the record volume. We reply with a scope and an effort band within one business day.
Thanks, we will reply within one business day
Prash or Sean reads every one of these and comes back with a scope, or with the one question we need answered first.