Capture leads from your website
Someone filled in the contact form on your website. With one call, that contact lands in Faturei Hoje as a lead and a task is opened for someone on the team to get back to them.
The shape of it
Section titled “The shape of it”visitor's browser -> your server -> POST /public/v1/leadsThe form posts to your server, and your server calls the API. The key never goes to the browser: anyone who opened the page source would walk away with it.
The API has no bot protection for your form. Spam filtering, captcha and per-visitor limits are on your side, before the call.
The key
Section titled “The key”Create a key just for the website, with the minimum:
| Permission | What for |
|---|---|
leads:create | Register the lead |
tasks:create | Open the contact task. Without it, you can only register with task: null |
team:read | Optional. Only if you are going to choose who owns the task |
The member the key acts as needs access to clients in the dashboard and, when the capture opens a task, to tasks as well. See Permissions.
The call
Section titled “The call”POST /public/v1/leads takes the name and at least one contact, email or phone. With neither, the response is 422.
# Generate the key once and keep it: on a retry, repeat with the same value.LEAD_KEY=$(uuidgen)curl -X POST "https://api.fatureihoje.com/public/v1/leads" \ -H "Authorization: Bearer $FH_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $LEAD_KEY" \ -d '{ "name": "Ana Souza", "email": "ana@example.com", "phone": "+5511999990000", "source": "website", "notes": "I would like a quote for installing security cameras.", "task": { "due_in_hours": 4, "priority": "high" } }'import { randomUUID } from 'node:crypto';
// In production, this comes from the form your server received.const form = { name: 'Ana Souza', email: 'ana@example.com', phone: '+5511999990000', message: 'I would like a quote for installing security cameras.',};
// One key per form submission. Store it with the submission to retry the same way.const idempotencyKey = randomUUID();
const res = await fetch('https://api.fatureihoje.com/public/v1/leads', { method: 'POST', headers: { Authorization: `Bearer ${process.env.FH_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ name: form.name, email: form.email, phone: form.phone, source: 'website', notes: form.message, task: { due_in_hours: 4, priority: 'high' }, }),});
const body = await res.json();
if (!res.ok) { throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);}
console.log(body.lead.id, body.deduplicated ? 'existing record reused' : 'new lead');<?php
// In production, this comes from the form your server received.$form = [ 'name' => 'Ana Souza', 'email' => 'ana@example.com', 'phone' => '+5511999990000', 'message' => 'I would like a quote for installing security cameras.',];
// One key per form submission. Store it with the submission to retry the same way.$idempotencyKey = bin2hex(random_bytes(16));
$ch = curl_init('https://api.fatureihoje.com/public/v1/leads');curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('FH_API_KEY'), 'Content-Type: application/json', 'Idempotency-Key: ' . $idempotencyKey, ], CURLOPT_POSTFIELDS => json_encode([ 'name' => $form['name'], 'email' => $form['email'], 'phone' => $form['phone'], 'source' => 'website', 'notes' => $form['message'], 'task' => ['due_in_hours' => 4, 'priority' => 'high'], ]),]);
$body = json_decode(curl_exec($ch), true);$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status !== 201) { throw new RuntimeException($status . ' ' . $body['error']['code'] . ': ' . $body['error']['message']);}
echo $body['lead']['id'], ' ', $body['deduplicated'] ? 'existing record reused' : 'new lead', PHP_EOL;import jsonimport osimport urllib.errorimport urllib.requestimport uuid
# In production, this comes from the form your server received.form = { "name": "Ana Souza", "email": "ana@example.com", "phone": "+5511999990000", "message": "I would like a quote for installing security cameras.",}
# One key per form submission. Store it with the submission to retry the same way.idempotency_key = str(uuid.uuid4())
payload = { "name": form["name"], "email": form["email"], "phone": form["phone"], "source": "website", "notes": form["message"], "task": {"due_in_hours": 4, "priority": "high"},}
request = urllib.request.Request( "https://api.fatureihoje.com/public/v1/leads", data=json.dumps(payload).encode(), method="POST", headers={ "Authorization": f"Bearer {os.environ['FH_API_KEY']}", "Content-Type": "application/json", "Idempotency-Key": idempotency_key, },)
try: with urllib.request.urlopen(request) as response: body = json.load(response)except urllib.error.HTTPError as failure: error = json.load(failure)["error"] raise SystemExit(f"{failure.code} {error['code']}: {error['message']}")
print(body["lead"]["id"], "existing record reused" if body["deduplicated"] else "new lead")What each field does, with its maximum length, is in the Reference. The ones worth a comment:
sourcesays where the lead came from. Use one value per form or campaign (website,black-friday-landing) and you can tell leads apart later.notesis the message the person wrote. It goes to the record’s notes.taskcontrols the contact task. Without the field, the default task opens, due in 24 hours. With an object, you choose title, description, due time (due_in_hoursordue_at, one or the other), priority and owner. Withtask: null, no task is opened.
What comes back
Section titled “What comes back”The response is 201 with a lead_capture object:
lead: the record, with itsid. That id is the client’s id and works inGET /public/v1/clients/{id}.task: the contact task that was opened, ornullwhen you senttask: null.deduplicated:truewhen the record already existed.
A contact that is already in the base
Section titled “A contact that is already in the base”If the phone or the email already belongs to one of the company’s records, the API reuses that record and overwrites nothing: not the name, not the contact details, not the status. An active client who fills in the form again stays active. The contact task opens all the same, because someone still needs to reply.
When that happens and the key has neither leads:read nor clients:read, lead and task come with only object and id. That is on purpose: a key that only registers does not read the record of someone who was already there.
Choosing who replies
Section titled “Choosing who replies”Without an owner, the task is created unassigned. To send it to someone, look the person up in GET /public/v1/team and send their id in task.assignee_id, together with their type in task.assignee_type. One without the other returns 422.
{ "name": "Ana Souza", "phone": "+5511999990000", "task": { "assignee_id": "3f6c2a9e-8b1d-4e5f-9a7c-2d4b6e8f0a1c", "assignee_type": "org_member" }}The team list rarely changes. Read it once and keep it, instead of fetching it for every lead.
When the call fails
Section titled “When the call fails”Store the form submission on your side before calling the API. If the call fails on network, timeout, 429 or 5xx, try again later with the same Idempotency-Key: the lead is not registered twice. See Idempotency.
| Response | What to do |
|---|---|
422 validation_failed | Fix the field listed in errors. Retrying as is will not help |
403 permission_missing | The key is missing a permission, usually tasks:create |
403 member_permission_denied | The key’s member has no access to clients in the dashboard, or to tasks when the capture opens a task |
429 rate_limit_exceeded | Wait for Retry-After and retry. See Rate limits |
| 5xx or network failure | Retry with the same Idempotency-Key |
The full catalog is in Errors.
Next step
Section titled “Next step”- Sync clients with a CRM or ERP: take the record into your system.
- Webhooks: the
client.createdevent tells your system when a new record comes in, whether from the website, the dashboard or WhatsApp.