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.
Quickstart
Section titled “Quickstart”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}Upsert by email
Section titled “Upsert by email”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
idis an optimization, not a requirement.
created in the response tells you which happened, and mirrors the status code:
201 Created + "created": true | this call brought the contact into existence |
200 OK + "created": false | the contact already existed and was updated |
Merging never downgrades
Section titled “Merging never downgrades”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.
Companies
Section titled “Companies”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.
Request body
Section titled “Request body”| Field | Type | Notes |
|---|---|---|
email | string | Required. Must be a valid address, at most 254 characters, exactly one @. |
name | string | Full name. Clipped at 200 characters. |
company | string | Company display name, used only when companyDomain mints a new company. Clipped at 200. |
companyDomain | string | The company dedupe key. Normalized to a bare lowercase host. |
title | string | Job title. Clipped at 200. |
phone | string | Clipped at 64. |
source | string | api (default), hubspot, ads, or manual. Anything else is a 422. |
page | string | The 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.
Reading a contact
Section titled “Reading a contact”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.
Examples
Section titled “Examples”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)}Python
Section titled “Python”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"]Backfilling an existing list
Section titled “Backfilling an existing list”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.
Errors
Section titled “Errors”| Status | Meaning |
|---|---|
400 | Malformed JSON body, or a GET without the email parameter. |
401 | Missing, 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. |
403 | The request carried an Origin header. You’re calling from a browser; don’t. |
404 | No such contact in your factory. |
405 | Wrong method for that path. The Allow header lists what’s accepted. |
413 | Body over 512 KiB. |
422 | Valid JSON, invalid content — a missing or malformed email, or an unknown source. |
429 | Rate limited. Wait Retry-After seconds. |
503 | We 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.
Not in v1
Section titled “Not in v1”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.