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.
what is a contact?
Section titled “what is a contact?”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-formmetadata. - Provenance:
source(how the row entered the system) andconsentBasis(lawful basis). Both are immutable post-insert. - Auto-maintained
firstSeenAt,lastSeenAt,createdAt,updatedAt.
Contacts are workspace-scoped. They never appear across orgs.
provenance and consent
Section titled “provenance and consent”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.
| source | consentBasis | when it appears | can you email them? |
|---|---|---|---|
identify | sdk_identify | your app called POST /v1/contacts/identify or the widget called identify on page-load | yes — your app’s TOS established consent before identify fired |
admin | admin_created | a workspace admin created the row manually (admin UI or POST /v1/contacts) | you decide — admin attested they have lawful basis |
public_post | public_interaction_opt_in | end-user submitted a post on a public board AND ticked “notify me of updates” | yes — explicit opt-in on the board |
public_vote | public_interaction_opt_in | end-user voted on a public board AND ticked the opt-in box | yes — explicit opt-in |
public_comment | public_interaction_opt_in | end-user commented on a public board AND ticked the opt-in box | yes — explicit opt-in |
| any of the above | legacy_inferred | row was backfilled from pre-opt-in historical activity | treat 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”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" | jqSix lines from zero to “show me my top-mrr contacts.” See identify for the full semantics, and list for filtering options.
identify vs create
Section titled “identify vs create”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.”
widget (coming soon)
Section titled “widget (coming soon)”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).
counts: posts, votes, comments
Section titled “counts: posts, votes, comments”GET /v1/contacts/{contactId} returns aggregate counts:
postCount— exact, frompost.contactId.voteCount,commentCount— derived from a hashed-email join withvoteandcommenttables.
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.
error codes
Section titled “error codes”| code | http | when |
|---|---|---|
ERR_CONFLICT | 409 | externalUserId is already taken in this workspace |
ERR_PLAN_LIMIT | 402 | contact-cap exceeded on the current plan; details include limit, current, resource |
ERR_VALIDATION | 422 | trait constraint violated (email > 320 chars, mrr/currency unpaired, metadata > 100 keys) |
ERR_NOT_FOUND | 404 | contact 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.
mcp tools
Section titled “mcp tools”Every rest endpoint has an mcp counterpart for agent integration. The tool reference lists all 18 tools; the seven contact tools are:
list_contactsget_contactlist_posts_by_contactcreate_contactidentify_contactupdate_contactdelete_contact
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.
webhooks
Section titled “webhooks”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 bumplastSeenAtare suppressed so a chatty widget doesn’t flood your subscribers.contact.deleted— attached posts survive withcontactIdcleared to null.
- semantics of upsert + idempotency — identify endpoint.
- filtering by plan, search by email, sort by mrr — list endpoint.
- the full request/response schema for every contact path — api reference.
- mcp tool details — tool reference.