Skip to content

identify a contact

POST /v1/contacts/identify

The 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.

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 externalUserId exists 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 valuepayload valueresult
null”pro”set to "pro"
"pro"”enterprise”kept "pro"
"pro"omittedkept "pro"
"pro"nullkept "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.

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.

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).
  • mrrCents and currency must 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.
Terminal window
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" }
}' | jq

201 (new contact) or 200 (existing) with:

{
"data": {
"id": "ctc_01HXXX...",
"externalUserId": "usr_42",
"email": "[email protected]",
"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"
}
}
  • 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-Key header needed in v1; planned for the broader api (see HEX-135 if you have ambitions for the rest of the surface).