Back to blog

Why B2B Analytics Needs a Fresh Start

A deep dive into the API design decisions behind Capturable's event ingestion schema - single-call B2B identification, the $ prefix convention, and why we batch everything.

Capturable Team
January 1, 2026
4 min read
Share:

Building in public, one decision at a time.


I have been deep in API design this week, and I wanted to share the thinking behind Capturable's event ingestion schema. Not the "what" - you can read the docs for that - but the why.

The Problem With Existing Tools

Here's what frustrated me about Mixpanel, Amplitude, and even Segment when building B2B products:

Three calls to do one thing. Want to identify a user and their company? That's an identify() call, a set_group() call, and maybe a get_group().set() call. Three network requests. Three chances to fail. Three opportunities for your data to end up in a weird partial state.

I have debugged too many "why is this user not linked to their company?" issues. It's always a race condition or a dropped request.

One Call To Rule Them All

So Capturable's $identify does everything at once:

{
  "batch": [{
    "id": "evt_001",
    "event": "$identify",
    "distinct_id": "anonymous_session_abc",
    "timestamp": "2024-12-31T10:00:00Z",
    "properties": {
      "traits": {
        "$user_id": "user_123",
        "$email": "jane@acme.com",
        "$name": "Jane Smith",
        "$created_at": "2024-12-31T10:00:00Z",
        "$group": {
          "$group_id": "acme_inc",
          "$group_type": "company",
          "$name": "Acme Inc",
          "$created_at": "2024-01-01T00:00:00Z",
          "$plan": "enterprise"
        }
      }
    }
  }],
  "sent_at": "2024-12-31T10:00:01Z"
}

One request. User created. Company created (or updated). User linked to company. Anonymous session merged. Done.

If it fails, nothing happened. No zombie users. No orphaned companies. No "user exists but isn't linked" nightmares.

The $ Prefix Convention

I borrowed this from PostHog (they use $ for default properties), but took it further.

Fields with $ are system fields - reserved names with special meaning to Capturable. These power core features: $email for identity, $group for company relationships, $plan for revenue analytics.

Fields without $ are your fields - track whatever matters to your product. feature_used, export_format, team_size. All fully queryable.

The convention keeps things clean: you'll never accidentally overwrite a system field, and we'll never clash with your custom properties.

Anonymous → Known: Solving the Identity Gap

Every analytics tool struggles with this: user browses anonymously, then signs up. How do you connect those sessions?

The distinct_id + $user_id pattern handles it cleanly:

  1. Track anonymous user with distinct_id: "anon_xyz"
  2. User signs up
  3. Send $identify with both the anonymous ID and the new $user_id
  4. System merges the history

Now you can see: "This user visited the pricing page 4 times before converting." That's the insight that actually matters.

Cookieless? We've Got You Covered

But what happens when there's no anonymous ID at all? Privacy-focused browsers, cookie blockers, incognito mode, or sometimes you just don't have a distinct_id to work with.

That's where fingerprinting comes in. When the SDK can't persist an anonymous ID, Capturable falls back to device fingerprinting, a combination of browser characteristics, screen resolution, timezone, and other signals that create a probabilistic identifier.

It's not 100% foolproof, but it's good enough to stitch together most anonymous sessions. And when the user finally identifies themselves, all that fingerprint-linked history merges into their profile.

Important: No personal data is gathered or stored during the fingerprinting process. We're creating an anonymous device identifier, not collecting PII, so no privacy laws are being violated.

Batching By Default

There's no /track endpoint. No /identify endpoint. Everything goes through /batch.

Even if you're sending one event.

Why? Because:

  • SDKs can queue events and flush periodically
  • Offline-first becomes trivial
  • Retry logic is simpler (retry the batch, not individual calls)
  • Fewer connections = less overhead

It's a small API surface that handles everything.

Context Separation

Event data is split between properties (what happened) and context (where/how):

{
  "properties": {
    "feature_name": "export",
    "format": "csv",
    "row_count": 1500
  },
  
  "context": {
    "page": {
      "url": "https://app.example.com/reports",
      "path": "/reports",
      "title": "Reports Dashboard",
      "referrer": "https://app.example.com/home"
    },
    "screen": {
      "width": 1920,
      "height": 1080
    },
    "library": {
      "name": "capturable-js",
      "version": "1.0.0"
    },
    "locale": "en-US",
    "userAgent": "Mozilla/5.0..."
  }
}

Why this helps:

  • Clean separation of concerns
  • context can be auto-collected by SDKs (page, screen, library info)
  • properties stay focused on business-meaningful data
  • Easier to filter out noise when analyzing

Flexible Group Types

I didn't hardcode "company." The schema uses $group_type:

"$group_type": "company"

Because B2B isn't always Company → Users. Sometimes it's:

  • Company → Workspace → Users
  • Organization → Project → Members
  • Franchise → Location → Staff

One schema handles all of it.

What's Next

The schema is done (for now 😉). Now comes the fun part: building the SDKs and seeing if this actually holds up in production.

I'll share the JavaScript SDK implementation next - including the batching logic, retry handling, and automatic context collection.

If you're building something similar or have thoughts on the schema, I'd love to hear them.


This is part of my series on building Capturable in public. Follow along as I figure this out.

Capturable Team

Capturable Team

Building the future of user intelligence. We help teams understand their users deeply through unified analytics, surveys, and engagement.

Limited early access spots

Ready to know your users?

Join the waitlist and get early access with lifetime benefits.

Join 500+ others already on the waitlist