Sync clients with a CRM or ERP
The client exists in your CRM or ERP and in Faturei Hoje. This guide shows how to move the record from one side to the other without creating duplicate clients.
There are two directions, and each has its own tool:
| Direction | How |
|---|---|
| From your system to Faturei | Look up, then create or update. This is what this page shows |
| From Faturei to your system | The client.created, client.updated and client.deleted events tell you right away, and incremental sync makes sure nothing was left behind |
The key
Section titled “The key”| Permission | What for |
|---|---|
clients:read | Look the client up before writing |
clients:create | Create whoever does not exist yet |
clients:update | Update whoever already exists |
Keep the Faturei id in your system
Section titled “Keep the Faturei id in your system”The safest way to link the two records is the id. The first time you find or create the client, store the Faturei Hoje id in your system’s record. From then on, update straight through it with PATCH /public/v1/clients/{id}, without looking it up again.
Looking up by email, phone or document is for the first link, when you do not have the id yet.
Look up before creating
Section titled “Look up before creating”POST /public/v1/clients does not check whether the client already exists: two calls with the same email create two records. So look up first. GET /public/v1/clients takes three exact-match filters:
| Filter | How it compares |
|---|---|
email | Same email, ignoring upper and lower case |
phone | The same phone, with or without the ninth digit. An invalid phone returns 422 |
cpf_cnpj | The same digits, with or without punctuation |
There is also search, the same search as the clients screen, by name, email, phone or document. It is good for people searching, not for deciding on its own whether two clients are the same.
The base may already have duplicate records from before the integration. If the lookup returns more than one, do not pick blindly: log the case and sort it out with the company.
Create or update
Section titled “Create or update”# 1. Look up by emailcurl "https://api.fatureihoje.com/public/v1/clients?email=contato%40marcenariasouza.com.br&limit=2" \ -H "Authorization: Bearer $FH_API_KEY"
# 2a. Not found: create# Generate the key once and keep it: on a retry, repeat with the same value.CREATE_KEY=$(uuidgen)curl -X POST "https://api.fatureihoje.com/public/v1/clients" \ -H "Authorization: Bearer $FH_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $CREATE_KEY" \ -d '{ "name": "Marcenaria Souza", "email": "contato@marcenariasouza.com.br", "phone": "+5511988887777", "cpf_cnpj": "12345678000190", "person_type": "pj" }'
# 2b. Found: update through the id that came back in the lookupCLIENT_ID="0d9a7c5e-3f1b-4d2a-8c6e-4a2b0c8e6f1d"curl -X PATCH "https://api.fatureihoje.com/public/v1/clients/$CLIENT_ID" \ -H "Authorization: Bearer $FH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phone": "+5511988887777" }'import { randomUUID } from 'node:crypto';
const API = 'https://api.fatureihoje.com/public/v1';
async function call(method, path, body, idempotencyKey) { const headers = { Authorization: `Bearer ${process.env.FH_API_KEY}` }; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
const res = await fetch(`${API}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`); } return data;}
// The client as it is in your CRM.const crm = { name: 'Marcenaria Souza', email: 'contato@marcenariasouza.com.br', phone: '+5511988887777', document: '12345678000190',};
const fields = { name: crm.name, email: crm.email, phone: crm.phone, cpf_cnpj: crm.document, person_type: 'pj',};
// One key per operation, generated before the first attempt. On a retry, reuse// it: that is what makes the API return the stored response instead of writing// again.const createKey = randomUUID();
// limit=2: two results mean the base has a duplicate record.const found = await call('GET', `/clients?email=${encodeURIComponent(crm.email)}&limit=2`);if (found.data.length > 1) throw new Error('More than one record with this email: sort it out with the company before writing.');
const client = found.data.length === 1 ? await call('PATCH', `/clients/${found.data[0].id}`, fields) : await call('POST', '/clients', fields, createKey);
// Store client.id in your CRM record.console.log(client.id);<?php
const API = 'https://api.fatureihoje.com/public/v1';
function call(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array{ $headers = ['Authorization: Bearer ' . getenv('FH_API_KEY')]; if ($body !== null) { $headers[] = 'Content-Type: application/json'; } if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; }
$ch = curl_init(API . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); }
$data = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($status >= 400) { throw new RuntimeException($status . ' ' . $data['error']['code'] . ': ' . $data['error']['message']); } return $data;}
// The client as it is in your CRM.$crm = [ 'name' => 'Marcenaria Souza', 'email' => 'contato@marcenariasouza.com.br', 'phone' => '+5511988887777', 'document' => '12345678000190',];
$fields = [ 'name' => $crm['name'], 'email' => $crm['email'], 'phone' => $crm['phone'], 'cpf_cnpj' => $crm['document'], 'person_type' => 'pj',];
// One key per operation, generated before the first attempt. On a retry, reuse// it: that is what makes the API return the stored response instead of writing// again.$createKey = bin2hex(random_bytes(16));
// limit=2: two results mean the base has a duplicate record.$found = call('GET', '/clients?email=' . rawurlencode($crm['email']) . '&limit=2');if (count($found['data']) > 1) { throw new RuntimeException('More than one record with this email: sort it out with the company before writing.');}
$client = count($found['data']) === 1 ? call('PATCH', '/clients/' . $found['data'][0]['id'], $fields) : call('POST', '/clients', $fields, $createKey);
// Store the id in your CRM record.echo $client['id'], PHP_EOL;import jsonimport osimport urllib.errorimport urllib.parseimport urllib.requestimport uuid
API = "https://api.fatureihoje.com/public/v1"
def call(method, path, body=None, idempotency_key=None): headers = {"Authorization": f"Bearer {os.environ['FH_API_KEY']}"} data = None if body is not None: headers["Content-Type"] = "application/json" data = json.dumps(body).encode() if idempotency_key: headers["Idempotency-Key"] = idempotency_key
request = urllib.request.Request(API + path, data=data, method=method, headers=headers) try: with urllib.request.urlopen(request) as response: return json.load(response) except urllib.error.HTTPError as failure: error = json.load(failure)["error"] raise SystemExit(f"{failure.code} {error['code']}: {error['message']}")
# The client as it is in your CRM.crm = { "name": "Marcenaria Souza", "email": "contato@marcenariasouza.com.br", "phone": "+5511988887777", "document": "12345678000190",}
fields = { "name": crm["name"], "email": crm["email"], "phone": crm["phone"], "cpf_cnpj": crm["document"], "person_type": "pj",}
# One key per operation, generated before the first attempt. On a retry, reuse# it: that is what makes the API return the stored response instead of writing# again.create_key = str(uuid.uuid4())
# limit=2: two results mean the base has a duplicate record.query = urllib.parse.urlencode({"email": crm["email"], "limit": 2})found = call("GET", f"/clients?{query}")if len(found["data"]) > 1: raise SystemExit("More than one record with this email: sort it out with the company before writing.")
if found["data"]: client = call("PATCH", f"/clients/{found['data'][0]['id']}", fields)else: client = call("POST", "/clients", fields, create_key)
# Store the id in your CRM record.print(client["id"])The record takes the same fields as the dashboard form, including the tax ones (legal name, registrations, tax address). The full list, with the format and length of each, is in the Reference.
When the call fails
Section titled “When the call fails”| Response | What to do |
|---|---|
422 validation_failed | Fix the field listed in errors (an invalid phone in the filter, for example) |
404 resource_not_found | The client in the PATCH no longer exists, or does not belong to the company |
429 rate_limit_exceeded | Wait for Retry-After and retry. See Rate limits |
| 5xx or network failure | Retry; on POST, with the same Idempotency-Key |
Send only what your system owns
Section titled “Send only what your system owns”PATCH changes only the fields that come in the call. A field that is not sent stays exactly as it is, and null clears an optional field.
Use that in your favor: send only the fields your system owns. If the team handles the notes in the dashboard and your ERP handles the document and the tax address, the ERP never sends notes, and nobody wipes out the other side’s work.
Avoid the update loop
Section titled “Avoid the update loop”With both directions on, a loop is easy to create: your system updates the client, Faturei Hoje sends client.updated, your system writes again, and so on.
- Compare before writing. If the incoming value equals what you already have, do not write and do not call the API.
- In a
client.updated,changed_fieldstells you what changed. If those are only fields your system just sent, with the same values, the event is the echo of your own write.
Addresses
Section titled “Addresses”A client created with an address already starts with the “Principal” address in the address list. Extra addresses (job site, branch, beach house) live in GET /public/v1/clients/{client_id}/addresses and POST /public/v1/clients/{client_id}/addresses.
Deleting
Section titled “Deleting”DELETE /public/v1/clients/{id} removes the record for good, as in the dashboard, together with addresses, notes, group links, portal access, inventory and projects. Service orders, sales, quotes, tasks, transactions, contracts and invoices stay, without the client. There is no undo.
If in your system “delete” means “no longer a client”, what you may want is status: "inactive" in a PATCH. The decision is yours; the API does what you ask.
Next step
Section titled “Next step”- Incremental sync with
updated_after: the other direction, from Faturei Hoje to your system. - Idempotency: why
POSTcarries anIdempotency-KeyandPATCHdoes not need one.