Contacts API
A Contact is the identified person behind one or more sessions. Personal data lives on the contact; a session carries only a contact_id, set once and never changed.
| Method | Path | Scope |
|---|---|---|
| POST | /v2/property/contacts/identify | contacts:write |
| GET | /v2/property/contacts | contacts:read |
| GET | /v2/property/contacts/{contact} | contacts:read |
| GET | /v2/property/contacts/{contact}/sessions | contacts:read |
| POST | /v2/property/contacts/{contact} | contacts:write |
| DELETE | /v2/property/contacts/{contact} | contacts:write |
Every {contact} lookup is scoped to the token’s property. An id from another property returns 404 with contact not found in this property.
The contact shape
Section titled “The contact shape”Every endpoint that returns a contact uses this shape:
| Field | Type | Notes |
|---|---|---|
id | string | |
external_id | string, null | Your own user id |
email | string, null | |
first_name, last_name, full_name | string, null | full_name is derived |
phone, company, job_title, website, country, city | string, null | |
lifecycle_stage | string | Free text. SnipForm’s own pickers offer lead, subscriber, customer, user. Defaults to user |
state | active, unsubscribed, deleted | deleted means redacted and is only reachable through DELETE |
state_label, state_color | string | Display helpers |
meta | array of {key, value} | Your custom fields |
first_seen_ts, identified_ts, last_seen_ts | integer | Unix seconds |
session_count | integer |
Identify
Section titled “Identify”POST /v2/property/contacts/identify
Finds or creates the contact, merges the traits you send, and links it to a session if one is supplied and not already linked. This is the server-side twin of signals.identify() in the tracker.
| Field | Rules |
|---|---|
session_id | string, optional. Also read from the X-Snipform-Session-Id header or the snip_session_id field, in that order |
external_id | string, max 255 |
email | valid email, max 255 |
traits.first_name, traits.last_name | string, max 120 |
traits.phone | string, max 64 |
traits.company, traits.job_title, traits.website | string, max 255 |
traits.country, traits.city | string, max 120 |
traits.lifecycle_stage | string, max 64 |
traits.meta | array of {key, value}; key required, string, max 120 |
At least one of external_id or email is required. Otherwise the response is 422 with identify requires at least one of external_id or email.
Matching: if external_id is present the contact is looked up by it; otherwise by email (the most recently seen contact with that address). An existing contact has its traits merged; a missing one is created with state: active.
A session is optional. Without one the contact is still created or updated, session_id comes back null and linked_to_session is false. With one, the link is set once: a session that already belongs to a contact is not re-linked, and linked_to_session tells you whether this call did the linking.
curl -X POST https://api.snipform.io/v2/property/contacts/identify \ -H "Authorization: Bearer $SNIPFORM_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "session_id": "9f1c2c1e-7b1a-4f0e-9f2b-1d2c3b4a5e6f", "external_id": "user_8812", "email": "[email protected]", "traits": { "first_name": "Jane", "company": "Acme", "lifecycle_stage": "customer", "meta": [{ "key": "plan", "value": "pro" }] } }'{ "code": 200, "status": "OK", "data": { "contact": { "id": "66c4d2f1a9b3c8e4f0a1b2c3", "external_id": "user_8812", "first_name": "Jane", "last_name": null, "full_name": "Jane", "phone": null, "company": "Acme", "job_title": null, "website": null, "country": null, "city": null, "lifecycle_stage": "customer", "state": "active", "state_label": "Active", "state_color": "success", "meta": [{ "key": "plan", "value": "pro" }], "first_seen_ts": 1755780000, "identified_ts": 1755782400, "last_seen_ts": 1755782400, "session_count": 1 }, "session_id": "9f1c2c1e-7b1a-4f0e-9f2b-1d2c3b4a5e6f", "linked_to_session": true }}GET /v2/property/contacts
| Query | Rules |
|---|---|
search | string, max 255 |
state | active, unsubscribed, deleted |
lifecycle_stage | string, max 64 |
page | integer, min 1 |
per_page | integer, 1 to 100, default 25 |
curl "https://api.snipform.io/v2/property/contacts?lifecycle_stage=customer&per_page=50" \ -H "Authorization: Bearer $SNIPFORM_TOKEN"The response data is a Laravel paginator spread at the root: data (array of contacts), current_page, last_page, per_page, total, from, to, first_page_url, last_page_url, next_page_url, prev_page_url, path, links.
{ "code": 200, "status": "OK", "data": { "current_page": 1, "per_page": 50, "total": 1, "last_page": 1, "next_page_url": null, "prev_page_url": null }}GET /v2/property/contacts/{contact} returns { "contact": {...} }.
Sessions for a contact
Section titled “Sessions for a contact”GET /v2/property/contacts/{contact}/sessions
Cursor paginated, newest first, by entry_ts.
| Query | Rules |
|---|---|
per_page | integer, 1 to 100, default 25 |
cursor | integer. Pass the previous page’s next_cursor; returns sessions with entry_ts strictly before it |
{ "code": 200, "status": "OK", "data": { "sessions": [ { "id": "9f1c2c1e-7b1a-4f0e-9f2b-1d2c3b4a5e6f", "sid": "3a0c...", "entry_ts": 1755780000, "last_ts": 1755780420, "entry_url": "https://acme.com/pricing", "exit_url": "https://acme.com/signup", "bounced": false, "views": 3, "channel": { "category": "paid_search", "name": "Google Ads", "source": "google.com" }, "geo": { "country": "South Africa", "city": "Cape Town" }, "device": "desktop" } ], "paging": { "per_page": 25, "has_more": false, "next_cursor": null } }}Update
Section titled “Update”POST /v2/property/contacts/{contact}
Every field is optional; send only what changes. Sending null clears a nullable field.
| Field | Rules |
|---|---|
external_id | string, max 255 |
email | valid email, max 255 |
first_name, last_name | string, max 120 |
phone | string, max 64 |
company, job_title, website | string, max 255 |
country, city | string, max 120 |
lifecycle_stage | string, max 64 |
state | active or unsubscribed only. deleted cannot be assigned |
meta | array of {key, value}; key required, string, max 120 |
curl -X POST https://api.snipform.io/v2/property/contacts/66c4d2f1a9b3c8e4f0a1b2c3 \ -H "Authorization: Bearer $SNIPFORM_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "state": "unsubscribed", "lifecycle_stage": "churned" }'Returns { "contact": {...} }.
| Status | error_code | When |
|---|---|---|
| 404 | not_found | Not in this property |
| 410 | error | contact was deleted - a redacted contact cannot be edited |
| 422 | validation_failed | Rule failure, including state: deleted |
Delete
Section titled “Delete”DELETE /v2/property/contacts/{contact}
Deletion is redaction in place so that sessions and events linked to the contact keep their shape. The row survives with:
external_id, names, phone, company, job title, website, country, city set tonullmetaemptiedemailreplaced withdeleted_{random}@redacted.invalidstateset todeleted- identify events on its sessions scrubbed of the email and external id
curl -X DELETE https://api.snipform.io/v2/property/contacts/66c4d2f1a9b3c8e4f0a1b2c3 \ -H "Authorization: Bearer $SNIPFORM_TOKEN"{ "code": 200, "status": "OK", "data": { "deleted": true } }Deleting an already deleted contact returns the same 200. This is the endpoint to call for an erasure request; see Privacy & Compliance.