Skip to main content

Contacts

Your organization's contacts — the unified list built from form submissions, member sign-ups, orders, and bookings, plus anyone your own systems add through this API.

A contact is unified on its email address. Everything else about the resource follows from that: the email is the identity, so the API will not let you change it, and a second create for an address already present is a conflict rather than a second row.

The contact object​

{
"id": "k7d2b9f104",
"object": "contact",
"email": "wholesale@example.com",
"name": "Robin Wholesale",
"tags": ["b2b"],
"notes": "Renews in March.",
"marketingConsent": true,
"consentSites": ["site_a1b2c3"],
"sources": ["form", "order"],
"phone": "+15125550123",
"jobTitle": "Head of Purchasing",
"companyId": "c_1a2b",
"address": { "line1": "1 Main St", "city": "Austin", "state": "TX", "postalCode": "78701", "country": "US" },
"ownerUid": "u_9f1c",
"lifecycleStage": "customer",
"leadSource": "Trade show",
"salutation": "Ms.",
"firstName": "Robin",
"lastName": "Wholesale",
"department": "Purchasing",
"mobilePhone": "+15125550124",
"homePhone": null,
"otherPhone": null,
"fax": null,
"birthdate": "1984-07-21",
"assistantName": "Sam Rivera",
"assistantPhone": "+15125550125",
"reportsToContactId": "k3c8e1a207",
"otherAddress": null,
"doNotCall": false,
"companyIds": ["c_1a2b"],
"alternateEmails": ["robin.w@example.org"],
"created": "2026-07-20T18:23:23.941Z",
"updated": "2026-07-20T18:23:23.941Z"
}
FieldTypeNotes
idstringOpaque contact id.
objectstringAlways "contact".
emailstring | nullThe identity a contact is unified on. Read-only.
namestring | nullDisplay name, when known. Writable.
tagsstring[]Tags, the same ones the console's tag editor writes. Writable.
notesstring | nullFree-text notes, the same field the console's contact record writes. Writable.
marketingConsentbooleanWhether any site may market to this person. false means a recorded refusal, which stands against every site. Writable — see consentSiteId.
consentSitesstring[]The sites this person has opted in to. Consent runs to a brand, not to your organization, so a person who signed up on one of your sites is not reachable from another unless they opted in there too. Read-only — write through consentSiteId.
sourcesstring[]Where this person came from — form, member, order, booking, newsletter, or api for one added through this API. Multiple entries mean one person did several things. Read-only.
phonestring | nullE.164 (+15125550123). Normalized before storing; a number that cannot be normalized confidently is a 400. Writable — see the CRM profile.
jobTitlestring | null120 characters. Writable.
companyIdstring | nullThe company this person works for, as this site knows it. Must exist. Writable.
addressobject | nullThe mailing address: line1, line2, city, state, postalCode, country (two-letter ISO code). Blank parts dropped; an empty address is stored as null. Writable.
ownerUidstring | nullThe team member responsible for the relationship. Must be a member of your organization. Writable.
lifecycleStagestring | nullsubscriber, lead, marketing-qualified, sales-qualified, opportunity, customer, evangelist or other. Writable.
leadSourcestring | nullWhere this person came from, as this site records it — one of the organization's lead source values, the same list a lead's leadSource is restricted to. Writable — see the CRM profile: restricted to the list's active values, matched without regard to case and stored as the list spells it; any other value is refused with 400 validation_failed, the allowed values named under fields.leadSource. The value the site's profile already holds is kept even after it is deactivated.
salutationstring | nullOne of your organization's salutation values — Mr., Ms., Mrs., Dr., Prof. and any you added — matched without regard to case and stored as the list spells it. A value the list does not hold is a 400 naming the values allowed; one the contact already holds is kept. Writable.
firstName / lastNamestring | null59 characters each. While either is set, the name the named site shows is made of them, First Last; the top-level name is the shared identity and is not rewritten, except that a create with no name takes theirs. Writable.
departmentstring | null120 characters. Writable.
mobilePhone / homePhone / otherPhone / faxstring | nullE.164, normalized like phone; a number that cannot be normalized is a 400. Writable.
birthdatestring | nullYYYY-MM-DD, a real date that is not in the future; anything else is a 400. Writable.
assistantNamestring | null120 characters. Writable.
assistantPhonestring | nullE.164, normalized like phone. Writable.
reportsToContactIdstring | nullThe contact this person reports to. Must be a contact of your organization, never this contact, and never one whose own chain — through the named site's profiles — already reaches this contact; each is a 400 that says which. Cleared when the contact it names is deleted or erased; a merge moves it to the surviving contact. Writable.
otherAddressobject | nullA second postal address, shaped like address. Writable.
doNotCallbooleanThe person asked not to be phoned. false when unset; send false or null to clear it. Writable.
mediaIdsstring[]Files from the organization's media library attached to this person by the named site, by media id, at most 20. Part of the CRM profile, so a write needs consentSiteId; a read with no site named returns the first holder's list. An empty array clears them. Writable.
companyIdsstring[]Every company any of your sites has filed this person under — the set of the per-site companyIds. What ?companyId= queries. Read-only.
alternateEmailsstring[]The other addresses this person answers to — each one the address of a record merged into this one. A capture on any of them lands here. Read-only — written by a merge.
created / updatedstring | nullISO 8601.

Every writable field in that table is also returned, so you can read back what you wrote. The interaction timeline shown in the console isn't exposed over the API.

The CRM profile is per site​

phone, jobTitle, companyId, address, ownerUid, lifecycleStage and leadSource — and Salesforce's standard contact fields beside them, salutation through doNotCall — are one site's knowledge of a person, not the person's own facts. A contact is one record shared by every site that has captured them, and an agency's two brands that both know somebody must not read each other's notes on them — so the console stores these per site (strictly, per consent group), and the API does the same. Two consequences:

  • Writing any of them names the site, through consentSiteId — the same parameter an opt-in takes, because it is the same question: which of your sites is this write made on behalf of. Sending a profile field with no consentSiteId is a 400.
  • Reading them is organization-wide by default. A key is an organization credential and every site's profile is the organization's own, so without a site named the object carries the union — each field from the first site, in stable order, that has set it. Pass ?consentSiteId= on a GET to read one site's profile alone, which is what a per-brand sync wants; a PATCH reads back through the site it wrote.

tags, notes and marketingConsent predate this and keep their existing behavior.

What you can't write, and why​

email, sources and alternateEmails are refused rather than ignored — sending any of them is a 400 validation_failed naming the key, not a silent drop.

  • email is the dedupe key the whole CRM unifies on. Changing it through this API would merge or split people's records as a side effect of an edit. To move a contact to a different address, delete it and create the new one.
  • sources is provenance. It records where a person actually came from, which stops being true the moment an integration can write it.
  • alternateEmails is written by a merge and by nothing else: an alternate address is a record that was folded in, not a label.

Endpoints​

List contacts​

GET /v1/contacts — scope contacts:read. Paginated, ordered by contact id (see ordering — not newest-first).

ParamNotes
emailExact lookup. Normalized the same way a stored address is, so case and surrounding spaces don't matter. Returns 0 or 1 contact.
tagContacts carrying this tag. Exact match on one entry of the tags array — not a prefix or a substring.
companyIdThe people at one company — anyone any of your sites has filed under it. See CRM filters.
lifecycleStageContacts at this stage. One of the eight values; anything else is a 400. Checked on the page — see CRM filters.
ownerUidContacts this member owns. Checked on the page — see CRM filters.
consentSiteIdRead the CRM profile of this site alone, and evaluate lifecycleStage and ownerUid against it. Changes what the rows say, not which rows match email, tag or companyId.
limit, cursorStandard pagination.
# the whole audience, a page at a time
curl "https://app.aglyn.com/api/v1/contacts?limit=50" \
-H "Authorization: Bearer aglyn_sk_…"

# one person
curl "https://app.aglyn.com/api/v1/contacts?email=robin%40example.com" \
-H "Authorization: Bearer aglyn_sk_…"

# one segment
curl "https://app.aglyn.com/api/v1/contacts?tag=newsletter" \
-H "Authorization: Bearer aglyn_sk_…"

Look a contact up before you create it​

?email= is the call a sync should start with. Contacts are organization-wide, so without it "do I already have this person?" means paging your entire audience — and every page is a billed request against your per-minute limit. On a 50,000-contact audience that is ~500 requests to answer one question, and the answer is stale by the time you finish.

The address is normalized before it is matched, using the same rule that normalizes a stored one. ?email=%20Robin@Example.COM%20 and ?email=robin@example.com find the same contact. This matters more than it looks: without it, a lookup could answer "no such contact" for an address that POST refuses as a duplicate, and you would have two endpoints disagreeing about whether a person exists.

The lookup answers for every address a contact holds, not only email: an address that a merge folded into a record as one of its alternateEmails finds that record, and the response's email is the surviving identity rather than the address you asked for. The page is at most one contact and never carries a cursor.

An address that isn't a usable email at all is a 400 bad_request (code: "validation_failed", fields: { "email": … }) rather than an empty page. No stored contact can match one, so an empty page would be true and useless — it reads exactly like "we don't have them" and sends you looking for a missing person instead of a typo.

There is still no filter by source. Provenance is set by whichever capture point recorded the contact, and it's on the object — page and filter client-side for that one.

Combining email and tag​

You can send both. email does the narrowing and tag is applied to the result, so the answer is "this person, if they carry that tag" — a 0- or 1-row page. That makes it a short page by the standard rule: check has_more, not the row count.

The CRM filters​

companyId can narrow the query itself: it matches the companyIds array, which exists on the record precisely so that "everyone at this account" is one indexed lookup. lifecycleStage and ownerUid cannot — they live on a per-site profile, and a field inside one site's profile is not something an organization-wide list can ask the index for — so both are checked on the page, against the same profile the row publishes (the named site's, or the union). A page filtered by either can come back short, or empty, with has_more still true; the cursor is the only termination signal.

When several filters are sent, one narrows the query — email, else companyId, else tag — and the rest are checked on the page.

# everyone at one account
curl "https://app.aglyn.com/api/v1/contacts?companyId=c_1a2b" \
-H "Authorization: Bearer aglyn_sk_…"

# one site's customers, read through that site's profile
curl "https://app.aglyn.com/api/v1/contacts?lifecycleStage=customer&consentSiteId=site_a1b2c3" \
-H "Authorization: Bearer aglyn_sk_…"

Retrieve a contact​

GET /v1/contacts/{contactId} — scope contacts:read.

Returns a contact object, or 404 not_found ("No such contact").

Add a contact​

POST /v1/contacts — scope contacts:write. Accepts an Idempotency-Key.

Body

FieldTypeRequiredNotes
emailstringyesNormalized before storing: trimmed and lowercased. An address that isn't usable is a 400.
namestringnoTrimmed, truncated to 120 characters. An explicitly empty string is a 400 rather than a way to clear it.
tagsstring[]noBlanks dropped; each tag truncated to 60 characters, at most 50 kept.
notesstringnoTruncated to 2,000 characters.
marketingConsentbooleannotrue also stamps the consent timestamp, and requires consentSiteId.
phone, jobTitle, companyId, address, ownerUid, lifecycleStage, leadSourcesee the objectnoThe CRM profile. Each requires consentSiteId, and lands on that site's profile of the person.
consentSiteIdstringwith marketingConsent: true or any profile fieldThe site this write is made on behalf of: the site the person opted in to, and the site whose profile the fields land on. Required for an opt-in and for a profile field; rejected alongside marketingConsent: false unless a profile field needs it. A site your organization does not own is a 400.
consentGroupIdstringnoWith marketingConsent: true, the id of the consent group consentSiteId belongs to — see opting in for a whole group. Without it the opt-in is recorded for consentSiteId alone.
curl -X POST "https://app.aglyn.com/api/v1/contacts" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Idempotency-Key: 9a41f0c2-…" \
-H "Content-Type: application/json" \
-d '{"email":"wholesale@example.com","name":"Robin Wholesale","tags":["b2b"]}'

Returns 201 with the created contact — or 200 with the original contact when an Idempotency-Key replays. The status is how you tell a fresh create from a replay.

The contact is created with sources: ["api"], so the console shows at a glance which people an integration put there rather than a site captured.

The email is already in use​

If a contact with that address already exists you get 409 conflict with code: "contact_exists", and the message names the existing id. An address that a merge folded into a record as an alternate counts as in use too — the conflict names the surviving contact, since creating another would re-mint the duplicate the merge removed:

{
"error": {
"type": "conflict",
"message": "A contact with this email already exists (k7d2b9f104). Update it instead.",
"code": "contact_exists"
}
}

PATCH that id instead — or avoid the round trip entirely by looking the address up first with GET /v1/contacts?email=, which returns the whole contact rather than an id embedded in a sentence. We don't silently upsert here: two upstream systems both claiming to own a record is a real integration bug, and quietly merging them would hide it and make POST and PATCH the same call.

Because the address is normalized first, Robin@Example.com and robin@example.com are the same contact. That is the same rule the capture points on your sites use, so the API and a form can't disagree about who is a duplicate.

That refusal releases the key — see plan gates below, which explains the rule both of this endpoint's refusals follow.

Plan gates​

Contacts are an audience band, not a hard cap, on every plan that includes the API. Adding people past the included band meters onto your invoice exactly as a form capture does — monthly quota and overage covers how that is billed. There is no separate "API contacts" allowance: a contact is a contact, whoever added it.

When a plan does hard-band, a create past the band is refused:

codeMeans
contact_quotaThe plan's CRM records band — contacts, companies and deals together — is full and this plan doesn't meter the overage. The message names the limit.

Neither this nor contact_exists consumes an Idempotency-Key. Both clear — one when somebody upgrades, the other when the duplicate is removed — and the retry that should finally succeed has to be able to, rather than replaying the refusal forever.

A create that succeeds is different: it is remembered, so a retry with the same key replays it even when that create filled the last slot in the band. Without that, the retry after a lost response would be refused and you would have no way to tell whether the contact exists.

Update a contact​

PATCH /v1/contacts/{contactId} — scope contacts:write. Takes no Idempotency-Key and doesn't need one: the same body twice lands the same state and returns the same 200.

Send only what changes — name, tags, notes, marketingConsent and the six profile fields are independent:

curl -X PATCH "https://app.aglyn.com/api/v1/contacts/k7d2b9f104" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Content-Type: application/json" \
-d '{"tags":["b2b","vip"],"notes":"Renews in March."}'
  • An omitted key is left alone, never cleared. A body of {} is a no-op that returns the current contact.
  • tags is replaced wholesale, not merged — send the full list. An explicitly empty tags: [] does clear them; this is the one field where empty means empty, because an integration has to be able to undo its own tagging.
  • An opt-in has to name the site it was given to. marketingConsent: true requires consentSiteId, because an API key belongs to your organization and an organization is not a brand: an agency's key reaches every client it runs. The grant is recorded against that site and no other, unless the body also carries consentGroupId — see opting in for a whole group. There is no default — picking your only site would work until you had two.
  • Setting marketingConsent: true stamps the consent timestamp. Setting it back to false withdraws consent but leaves the original timestamp in place — it is the evidence of when the person opted in, and an audit needs it. A withdrawal takes no consentSiteId: it applies to every site, because withholding mail is recoverable and sending it is not.
  • A profile field names the site too. {"consentSiteId":"site_a1b2c3","lifecycleStage":"customer"} writes that site's profile and no other site's; an explicit null clears a field there. Changing companyId moves the person in companyIds — the old id leaves once no site files them under it any more. The response reads back through the site you named.
  • Editing is never refused by the audience band. An edit doesn't grow the audience, and a downgraded organization still has to be able to correct its own data.
  • 404 not_found ("No such contact") if it isn't there.

Where your organization has declared several sites one sender, a person who signs up on one of them may be emailed by all of them — but only if they were told so. A signup form on a grouped site says it by name; an integration has to say it explicitly, by sending the group's id as consentGroupId beside consentSiteId:

curl -X PATCH "https://app.aglyn.com/api/v1/contacts/k7d2b9f104" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Content-Type: application/json" \
-d '{"marketingConsent":true,"consentSiteId":"site_a1b2c3","consentGroupId":"cg_4k2x9q0m7r1t5w8z"}'
  • Send it only when the person was shown the group's name where they signed up — that is what makes the opt-in cover every site in it.
  • A group's id is shown under its name on your organization's Emails → Consent groups page.
  • Without consentGroupId, or with an id that is not the current group of consentSiteId, the opt-in is recorded for consentSiteId alone. Narrower is the safe answer: the person can always be asked again, and mail sent without consent cannot be unsent.
  • It changes nothing about a withdrawal: marketingConsent: false already applies to every site.

Delete a contact​

DELETE /v1/contacts/{contactId} — scope contacts:write. Accepts an Idempotency-Key.

A contact is shared by every site that has captured the person — one human is one record — so a delete removes your organization's relationship with them: the tags, notes, timeline and consent your sites hold. The record itself is destroyed once nothing is holding it.

{ "id": "k7d2b9f104", "object": "contact", "deleted": true }

Deleting a contact that isn't there returns 404 not_found — unless the call carries the Idempotency-Key of the delete that removed it, in which case the original 200 receipt is replayed. Send a key whenever a deletion runs from a script, which is most of them: an erasure request on somebody else's deadline is exactly the case where a response lost to a timeout must not read as a failure.

This removes the contact record, clears every other contact's reportsToContactId that named the person, in every site's profile, and takes the person off every deal's contactRoles — clearing a deal's contactId where they were its Primary. It does not remove the form submissions, orders, or bookings that person left behind — those are separate records with their own endpoints.

Merge two contacts​

POST /v1/contacts/{contactId}/merge — scope contacts:write. Accepts an Idempotency-Key with the delete's semantics.

A contact is one record per email address, so one person with a work address and a personal one is two records. This folds the second into the first: the contact in the path survives and keeps its address as the identity; the contact named in the body is merged into it and deleted.

Body

FieldTypeRequiredNotes
sourceContactIdstringyesThe contact to merge into this one. It is deleted. Must be a different contact from the one in the path. No other field is accepted.
curl -X POST "https://app.aglyn.com/api/v1/contacts/k7d2b9f104/merge" \
-H "Authorization: Bearer aglyn_sk_…" \
-H "Idempotency-Key: 2c9e5a11-…" \
-H "Content-Type: application/json" \
-d '{"sourceContactId":"m3f8a1c207"}'

Returns 200 with the surviving contact, read back the way GET reads it — the organization-wide profile, or one site's with ?consentSiteId= — with the source's address now in alternateEmails.

What the merge does, on every site's profile of the person under one rule:

  • A scalar — name, phone, job title, company, stage, owner, address, a custom value — is the survivor's where it has one, and fills from the source where the survivor is empty. Nothing the survivor holds is overwritten.
  • Tags, campaign filings, the timeline, the sites that captured the person and companyIds are combined. Notes are appended, survivor first. Order counts and lifetime value are added together.
  • Every deal, task and activity that named the source contact now names the survivor, and so does a lead converted into it.
  • An opt-in on either record stands on the survivor; so does a recorded opt-out.
  • The source's address becomes an alternate on the survivor, so a later capture on it — a form, an order — lands on the survivor rather than creating the source again.

A retry after a lost response finds no source contact and answers 404; send an Idempotency-Key and the original 200 is replayed instead.

Errors​

StatustypeWhen
400bad_requestcode: "validation_failed" — on a write, a missing or unusable email, a non-boolean marketingConsent, a marketingConsent: true with no
consentSiteId (or one naming a site the organization does not own), a profile field with no consentSiteId, a phone that does not normalize, a lifecycleStage outside the list, a leadSource that is not one of the organization's active lead source values, a companyId that does not exist, an ownerUid who is not a member, or an attempt
to write email/sources/alternateEmails. On a merge, a missing sourceContactId, one naming the contact in the path, or any other key. On the list, an ?email= that isn't a usable address, a ?lifecycleStage= outside the list, or a ?consentSiteId= naming a site the organization does not own. fields names each offending key.
403plan_requiredcode: "contact_quota" — the CRM records band (contacts, companies and deals together) is full on a plan that doesn't meter the overage.
403insufficient_scopeKey lacks contacts:read / contacts:write. Checked before the method, so a write attempt with a read-only key returns 403, not 405.
404not_found"No such contact". On a merge, code: "source_not_found" — the contact in the body is not there, which after a merge that already ran is the ordinary case.
405method_not_allowedMethod not supported on that path. The Allow header lists what is: GET, POST on /v1/contacts, GET, PATCH, DELETE on one contact, POST on its /merge.
409conflictcode: "contact_exists" — that email is already a contact. code: "idempotency_in_progress" — an earlier write with the same key is still running.

See Conventions → Errors for the shared envelope.

  • CRM — how contacts are captured, and what the audience band means for your plan.
  • Companies, deals, tasks and activities — the records that sit beside a contact, each pointing back at it by contactId.
  • Usage — how much of the audience band you've used, and whether crossing it bills or refuses.