Skip to content
Docs/API

Contacts API

Add people to your factory’s CRM from your own backend — a signup handler, a lead form, a nightly sync from another system. Your RevOps seat works the pipeline these contacts land in.

Base URL: https://api.sapient.works Auth: a secret key (dfsk_…) in an Authorization: Bearer header.

This is a server-to-server API. It sends no CORS headers and it refuses any request that carries an Origin header — so a secret key that ends up in browser code fails loudly instead of working for a while and then being abused. If you want to capture leads from a web page, put this call behind your own endpoint, or use the bug-reporting SDK’s publishable-key path for browser-side capture.

curl -X POST https://api.sapient.works/api/v1/contacts \
-H "Authorization: Bearer $SAPIENT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@acme.com",
"name": "Ada Lovelace",
"title": "CTO",
"company": "Acme",
"companyDomain": "acme.com"
}'
HTTP/1.1 201 Created
{
"contact": {
"id": "ct_9f2c1ba47e0d3a55",
"factoryId": "fac_…",
"companyId": "co_…",
"email": "ada@acme.com",
"name": "Ada Lovelace",
"title": "CTO",
"source": "api",
"createdAt": "2026-07-28T14:03:11Z",
"updatedAt": "2026-07-28T14:03:11Z"
},
"created": true
}

POST /api/v1/contacts is an upsert keyed on the email address. There is no separate create and update, and no PUT.

The contact id is derived from your factory id and the lowercased email — ct_ + the first 16 hex characters of sha256(factoryId + ":" + email). Two consequences worth internalizing:

  • Retries are free. The same email always maps to the same record, so a retried request after a network timeout updates that record instead of creating a second one. You don’t need an idempotency key; the identity contract already gives you idempotency.
  • You never have to store our ids. If you know the email, you can read the contact back (see below). Storing id is an optimization, not a requirement.

created in the response tells you which happened, and mirrors the status code:

201 Created + "created": truethis call brought the contact into existence
200 OK + "created": falsethe contact already existed and was updated

Fields you omit keep their stored values. Sending {"email": "ada@acme.com", "phone": "+1 555-0100"} adds the phone; it does not erase the name and title from the first call. createdAt survives every update.

This is deliberate: a partial form submission or a thin sync from a secondary system should enrich a contact, never hollow it out. There is no “clear this field” via this API — that’s an edit in the control room.

Email matching is case-insensitive: Ada@Acme.com and ada@acme.com are one person.

If you pass companyDomain, the contact is linked to a company — looked up by domain and created if it’s new, named from company (or the domain itself when you don’t send a name). Domains are normalized, so https://www.Acme.com/ and acme.com are the same company.

A company name without a domain creates nothing: without a stable dedupe key we’d generate a new company per spelling. Pass the domain if you want a company record.

A contact’s company link is set once and never overwritten by a later call — if someone changes employers, that’s an edit in the control room, not a silent reassignment by a form submission.

FieldTypeNotes
emailstringRequired. Must be a valid address, at most 254 characters, exactly one @.
namestringFull name. Clipped at 200 characters.
companystringCompany display name, used only when companyDomain mints a new company. Clipped at 200.
companyDomainstringThe company dedupe key. Normalized to a bare lowercase host.
titlestringJob title. Clipped at 200.
phonestringClipped at 64.
sourcestringapi (default), hubspot, ads, or manual. Anything else is a 422.
pagestringThe page or context the contact came from. Clipped at 2048.

Unknown fields are ignored, so a newer client sending extra keys won’t break against an older server.

Free-text fields are clipped server-side. Identity-bearing input is rejected rather than truncated — an over-long or malformed email is a 422, because silently storing a truncated address would create a contact that is quietly the wrong person.

By id:

curl https://api.sapient.works/api/v1/contacts/ct_9f2c1ba47e0d3a55 \
-H "Authorization: Bearer $SAPIENT_SECRET_KEY"

By email — derives the same id the upsert wrote:

curl -G https://api.sapient.works/api/v1/contacts \
--data-urlencode "email=ada@acme.com" \
-H "Authorization: Bearer $SAPIENT_SECRET_KEY"

Both return 200 with {"contact": {…}}, or 404 if there’s no such contact in your factory. A contact belonging to someone else is a 404, never a 403 — the API won’t confirm that an id exists elsewhere.

The factory always comes from the key. There’s no factory id in any request, and no way to reach another tenant’s data with your key.

async function upsertContact(contact) {
const res = await fetch('https://api.sapient.works/api/v1/contacts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SAPIENT_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(contact),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After') ?? 1);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return upsertContact(contact); // safe: the upsert is idempotent
}
if (!res.ok) throw new Error(`sapient: ${res.status} ${await res.text()}`);
const { contact: stored, created } = await res.json();
return { stored, created };
}

Run this on your server. In a browser it fails with 403 — by design.

type Contact struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
Company string `json:"company,omitempty"`
CompanyDomain string `json:"companyDomain,omitempty"`
Title string `json:"title,omitempty"`
}
func UpsertContact(ctx context.Context, c Contact) (created bool, err error) {
body, _ := json.Marshal(c)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.sapient.works/api/v1/contacts", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SAPIENT_SECRET_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return false, fmt.Errorf("sapient: %s", resp.Status)
}
var out struct {
Created bool `json:"created"`
}
return out.Created, json.NewDecoder(resp.Body).Decode(&out)
}
import os, requests
def upsert_contact(**fields):
r = requests.post(
"https://api.sapient.works/api/v1/contacts",
headers={"Authorization": f"Bearer {os.environ['SAPIENT_SECRET_KEY']}"},
json=fields,
timeout=10,
)
r.raise_for_status()
body = r.json()
return body["contact"], body["created"]

The upsert is idempotent, so a backfill is just a loop — re-running it after a crash costs nothing and creates nothing. Stay under your key’s rate limit (30,000/minute by default) and back off on 429.

StatusMeaning
400Malformed JSON body, or a GET without the email parameter.
401Missing, unknown, revoked, or wrong-type key. A publishable dfpk_ key here is rejected exactly like an unknown one, and a key in a query parameter isn’t read at all.
403The request carried an Origin header. You’re calling from a browser; don’t.
404No such contact in your factory.
405Wrong method for that path. The Allow header lists what’s accepted.
413Body over 512 KiB.
422Valid JSON, invalid content — a missing or malformed email, or an unknown source.
429Rate limited. Wait Retry-After seconds.
503We couldn’t complete the write. Retry — the upsert is idempotent, so a retry after a 503 cannot double-create.

Every error body is {"error": "…"}. Full detail in errors and limits.

Batch writes, deleting a contact, and listing/searching contacts aren’t in this API yet. Deletion and browsing live in the control room and the CLI; batch will arrive when someone’s volume actually needs it. Tell us if that’s you.

Contacts written through this API are not metered — you’re not billed per contact.