Skip to content

contacts api

A contact is one of your end-users — the person whose email, plan, and mrr you care about when triaging feedback. Once a contact exists, posts can be attached to it, and the admin ui surfaces every post a customer has filed in one place.

This page is the orientation; for endpoint detail see identify, list, and the openapi reference for everything else.

A contact has:

  • An opaque spirby id (ctc_01H8...) issued at creation.
  • An optional external user id (your system’s id) — unique per workspace. Pass this and identify becomes idempotent.
  • Trait fields: email, name, plan, mrrCents, currency, free-form metadata.
  • Provenance: source (how the row entered the system) and consentBasis (lawful basis). Both are immutable post-insert.
  • Auto-maintained firstSeenAt, lastSeenAt, createdAt, updatedAt.

Contacts are workspace-scoped. They never appear across orgs.

Every contact carries a source and a consentBasis. Both are returned by every read endpoint (REST, MCP, webhook payloads). Treat the enums as open — new values may be added; consumers that fail closed on unknown values will break on minor upgrades.

sourceconsentBasiswhen it appearscan you email them?
identifysdk_identifyyour app called POST /v1/contacts/identify or the widget called identify on page-loadyes — your app’s TOS established consent before identify fired
adminadmin_createda workspace admin created the row manually (admin UI or POST /v1/contacts)you decide — admin attested they have lawful basis
public_postpublic_interaction_opt_inend-user submitted a post on a public board AND ticked “notify me of updates”yes — explicit opt-in on the board
public_votepublic_interaction_opt_inend-user voted on a public board AND ticked the opt-in boxyes — explicit opt-in
public_commentpublic_interaction_opt_inend-user commented on a public board AND ticked the opt-in boxyes — explicit opt-in
any of the abovelegacy_inferredrow was backfilled from pre-opt-in historical activitytreat as suppressed for marketing. DSAR-eligible.

Public-board interactions where the end-user did not tick the opt-in box do not create a contact at all — the post/vote/comment stays as a feedback artifact with contact_id = null.

quickstart — identify your first contact

Section titled “quickstart — identify your first contact”
Terminal window
export SPIRBY_KEY="spk_<your_key>"
export SPIRBY_BASE="https://api.spirby.com"
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]", "plan": "pro", "mrrCents": 14900, "currency": "USD" }' | jq
curl -s "$SPIRBY_BASE/v1/contacts?sort=mrr&limit=10" \
-H "Authorization: Bearer $SPIRBY_KEY" | jq

Six lines from zero to “show me my top-mrr contacts.” See identify for the full semantics, and list for filtering options.

POST /v1/contacts/identify upserts by externalUserId. Call it from your backend after every login, subscription change, or trait update — same payload, same final state.

POST /v1/contacts always inserts. If externalUserId is already taken, it returns 409 ERR_CONFLICT with { "code": "ERR_CONFLICT", "message": "external_user_id already taken" }. Use identify if you want upsert semantics; use create only when you genuinely intend “insert or fail.”

When the spirby widget ships, identify happens automatically on every page load — your userId from your auth provider becomes externalUserId, traits flow from your session. Until then, call identify from your backend on the events that matter (login, subscription change).

GET /v1/contacts/{contactId} returns aggregate counts:

  • postCount — exact, from post.contactId.
  • voteCount, commentCount — derived from a hashed-email join with vote and comment tables.

During the widget rollout, vote and comment counts reflect email-hash matching against existing activity. Treat them as approximate until the widget ships and per-row contactId lands on votes and comments.

codehttpwhen
ERR_CONFLICT409externalUserId is already taken in this workspace
ERR_PLAN_LIMIT402contact-cap exceeded on the current plan; details include limit, current, resource
ERR_VALIDATION422trait constraint violated (email > 320 chars, mrr/currency unpaired, metadata > 100 keys)
ERR_NOT_FOUND404contact id not in your workspace

On ERR_PLAN_LIMIT, details.current and details.limit tell you the gap (limit - current); details.resource names which cap was hit (e.g. "contact"). Surface that to the admin and link them to your workspace billing page.

Every rest endpoint has an mcp counterpart for agent integration. The tool reference lists all 18 tools; the seven contact tools are:

The mcp surface accepts snake_case field names (external_user_id, mrr_cents); the rest api uses camelCase (externalUserId, mrrCents). Same constraints, same data — choose the surface that fits your tool. The mcp tools validate every refine the rest schemas enforce (paired mrr/currency, capped mrr_cents, ≤ 100 metadata keys), so failures surface MCP-side instead of as REST-side stack traces.

Three new events fire on contact mutations. See webhooks → events for the events table and per-event payload shapes:

  • contact.created — every insert path (rest, mcp, admin, or identify-when-created).
  • contact.updated — emitted only when a trait field actually changes. Identify calls that only bump lastSeenAt are suppressed so a chatty widget doesn’t flood your subscribers.
  • contact.deleted — attached posts survive with contactId cleared to null.