Skip to content

CRM and ERP integration, named by 9 of 26 clients

A real HubSpot account, written to while you watch

Run it twice.
The second time, nothing happens.

Nine of the twenty six clients asked for CRM work, and not one of them asked whether an API can be called. What they asked was how duplicates get handled and what happens when the job runs again.

So press the button, then press it again. The first run creates three contacts and links them to a company. The second makes two requests, one to look and one to check, and writes nothing at all, because there is nothing left to do. A job that knows that is a job you can put on a schedule.

The addresses below carry a short tag that changes on every visit, so the three people you create are yours and the second run is genuinely a second run. Without it you would be the hundredth person to sync the same three contacts and would only ever see the empty version.

Shouting, a duplicate, a broken address, an empty row, and a personal email.

emailfirstlastphonecompany
1 Rowan.Patel@northwindcivil.ca ROWANpatel(403) 555-0134
2rowan.patel@northwindcivil.ca+1 403 555 0134Northwind Civil
3dana@northwindcivil.cadanao'shea403.555.0199
4not an emailNobody
5kit@gmail.comkitMENSAH
6Also nobody

What it is doing, and why each part is there

01
The email is the key, not the row number

Two rows with the same address are the same person written twice, whatever else differs, and the same address arriving next month is that person again rather than a new one. HubSpot is told this too, with idProperty=email on the batch read, so the same person is the same record here and there. Without it this would have to search per row, which is slower and a different answer, because search is eventually consistent and a batch read is not.

02
A record that already matches is not written at all

Unchanged is a real outcome and it gets its own count. It is what makes the second run free: no call, no modified timestamp, and none of the workflows a client has hanging off "contact updated" firing for nothing. Press the button twice and watch the number of requests fall.

03
A blank column does not empty a field

A field missing from this week’s export is not an instruction to delete what somebody typed in by hand last year. Blank incoming values are ignored, so clearing a field has to be a deliberate act rather than a side effect of a missing column. This is the one that quietly destroys a client’s data, and it is four lines of code to get right.

04
The merge fills gaps rather than overwriting

When two rows describe one person, the later one fills in what the earlier one did not have. Last one wins would throw away a phone number because the second row happened not to carry one, and nobody finds out until somebody cannot be rung.

05
The budget is read off HubSpot, not counted here

Every response says how much of the window is left, and that is what the page shows. A counter kept on this side is wrong the moment anything else uses the same key, which on a real client account is always.

06
Only 429 and 5xx are retried

A 400 will be a 400 every time and retrying a 401 burns the budget on a key that is still wrong. When HubSpot sends Retry-After it is obeyed, because it knows when the window opens and this does not. Four attempts, then it gives up and says so.

What stops this touching anything it should not

It can only reach one account

The same login also reaches a live client account with real people in it. Before the first write, the key is asked which account it belongs to, and anything but the allowed one is refused. The never list beats the allow list, so the client account is refused even if somebody wrongly allows it, and an unset allow list means nothing is allowed rather than everything.

The key can do five things

Contacts and companies, read and write, plus reading the contact schema. Not deals, not marketing, not files. Reading contacts answers 200 and reading deals answers 403, so least privilege is a fact here rather than an intention.

You cannot choose what gets written

Three fixed lists, no text box. A free field pointed at a real contacts database is an open relay for whatever a stranger wants to put in it, and "it is only a demo" is what everybody says before it is used for exactly that.

The code

Everything that can be decided without a network is decided without one, so the rules are checked against fixtures in a millisecond and the part that talks to HubSpot stays small enough to read.

lib/hubspot/sync.ts        the rules, no network in it
lib/hubspot/sync.test.ts   66 tests on those rules
lib/hubspot/guard.ts       which account this may touch
lib/hubspot/guard.test.ts  26 tests on that
lib/hubspot/client.ts      the requests, retries and budget
app/api/crm/route.ts       one batch read, the writes, a read back

Wiring · CRM and ERP integration · 9 of 26 clients asked for it

Need this on your own system?

Tell me what is broken. I will tell you what it takes, honestly, including when the answer is that it is not worth building. The code ends up in your account, not mine.