identify a contact
POST /v1/contacts/identifyThe identify endpoint upserts a contact by externalUserId. Use it from your backend after every login, plan change, or trait update — same payload, same final state. No need to track whether a contact already exists.
why identify and not create?
Section titled “why identify and not create?”POST /v1/contacts always inserts. If externalUserId is already taken, it returns 409. That works for one-shot imports; it’s wrong for the “every login, sync the user” pattern.
POST /v1/contacts/identify is the upsert path:
- If a contact with
externalUserIdexists in this workspace → update (with caveats below) and return 200. - If not → insert and return 201.
Both responses have the same body shape ({ data: Contact }); the status tells you whether the contact is new.
trait-fill behavior (the “admin edits stick” rule)
Section titled “trait-fill behavior (the “admin edits stick” rule)”Identify is not PATCH. It never overwrites an existing trait with a real value. The rule:
| existing value | payload value | result |
|---|---|---|
null | ”pro” | set to "pro" |
"pro" | ”enterprise” | kept "pro" ← |
"pro" | omitted | kept "pro" |
"pro" | null | kept "pro" |
Why: identify is called from automation (backends, widgets). Admins editing the spirby ui shouldn’t have their edits stomped on the next page load. To force-set a trait, use PATCH /v1/contacts/{contactId} — that path overwrites unconditionally.
the lastSeenAt-only optimization
Section titled “the lastSeenAt-only optimization”If an identify call’s payload contains traits but none of them differ from the existing contact, the only change is bumping lastSeenAt. Spirby suppresses the contact.updated webhook in this case — a widget that calls identify on every page load won’t flood your subscribers with no-op events.
The contact row is still updated (lastSeenAt advances). Only the webhook fires conditionally.
constraints
Section titled “constraints”Trait values are validated server-side; bad values get a 422 with code: ERR_VALIDATION.
email— trimmed, lowercased, max 320 characters, valid email format.name— trimmed, 1-200 characters.plan— trimmed, 1-100 characters.mrrCents— integer, 0-100,000,000 (cap at $1M).currency— strict ISO 4217: three uppercase letters (e.g.USD,EUR).mrrCentsandcurrencymust be paired — set both or omit both. A 422 fires otherwise.metadata— record of<string, unknown>, max 100 keys.externalUserId— 1-255 characters. Required for this endpoint.
curl -s -X POST "$SPIRBY_BASE/v1/contacts/identify" \ -H "Authorization: Bearer $SPIRBY_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalUserId": "usr_42", "email": "[email protected]", "name": "Acme Corp", "plan": "enterprise", "mrrCents": 49900, "currency": "USD", "metadata": { "signupSource": "google-ads", "trialEndsAt": "2026-06-01" } }' | jq201 (new contact) or 200 (existing) with:
{ "data": { "id": "ctc_01HXXX...", "externalUserId": "usr_42", "name": "Acme Corp", "plan": "enterprise", "mrrCents": 49900, "currency": "USD", "metadata": { "signupSource": "google-ads", "trialEndsAt": "2026-06-01" }, "firstSeenAt": "2026-05-14T16:32:00.000Z", "lastSeenAt": "2026-05-14T16:32:00.000Z", "createdAt": "2026-05-14T16:32:00.000Z", "updatedAt": "2026-05-14T16:32:00.000Z" }}scope, rate limits, idempotency
Section titled “scope, rate limits, idempotency”- Required api key scope:
read:write. - Counts against the standard per-key rate budget. Bulk identify calls (post-import) — pace them.
- Naturally idempotent. No
Idempotency-Keyheader needed in v1; planned for the broader api (see HEX-135 if you have ambitions for the rest of the surface).
related
Section titled “related”- machine-readable schema — openapi reference.
- list and filter contacts — list endpoint.
- the mcp equivalent —
identify_contact(snake_case fields). - webhook events — webhooks → events.