Declare your own source

Define a source of your own — its event types, fields and identifiers — and test it with a dry run before saving.

Shape your deployment before, or while, data flows: data models for your sources, attributes for your own fields, identity link types for your own ids. Everything here is a plain API call, and everything predefined already works with zero setup.

1. Think before you define

One question decides almost everything: does it happen, or is it true?

It is an event ifIt is an attribute if
It happened at a moment ("viewed /pricing at 9:14")It holds until someone changes it ("tier: strategic")
You will count, window, or sequence it over timeYou will filter or group by its current value
The source keeps producing more of themA human or a workflow declares it once

Then three habits keep a deployment clean:

  • Extend before you create. If a predefined data model already carries the concept, add a field or an event type to it instead of minting a parallel source.
  • Promote only what you query. Promoted fields become columns and scoring inputs; everything else stays in the payload, retrievable but out of the way.
  • Scope and name for the reader. company for account facts, person for what survives job changes, contact for one affiliation; bare specific event types (person_visited, never activity); descriptions written for the AI agent that will read them.

2. Discover data models

Fresh deployments carry the four person-first data models (job, education, web, signup) plus ackDB's own read-only system model; earlier deployments may also carry previously shipped ones like slack or stripe. List everything yours knows:

GET /schemas/sources
curl -s "$ACKDB_URL/schemas/sources" -H "Authorization: Bearer $ACKDB_API_KEY"

Drill into one to get its contract, which is what a workflow builder maps against:

  • GET /schemas/sources/stripe: the definition, its identity link types, its traits.
  • GET /schemas/sources/stripe/events: every event type with typed fields.
  • GET /schemas/sources/stripe/events/invoice.paid: one event in full. Each field carries a computeHint (where it comes from in the source API) and each event an ingestExample, a complete request body to start from.

3. Create a data model

A custom source lands in the same tables as the predefined ones, so ingest, discovery, and validation treat it identically:

POST /schemas/sources
{
  "sourceType": "hiring",
  "displayName": "Hiring Signals",
  "description": "Open roles from job boards: who is hiring, for what, where.",
  "identityLinkTypes": ["domain"],
  "eventTypeField": "event_type",
  "eventTypePrefix": "hiring_",
  "events": [{
    "eventType": "job.posted",
    "description": "The company opened a role.",
    "eventScope": "entity",
    "fields": {
      "title": { "type": "string", "required": true, "description": "The role title." },
      "location": { "type": "string", "required": false, "description": "Where the role sits." },
      "job_url": { "type": "string", "required": true, "description": "The posting URL, a natural dedupe key." },
      "is_gtm_role": { "type": "boolean", "required": false, "description": "Whether this is a go-to-market hire." }
    },
    "promotedMetadataFields": ["title", "location", "is_gtm_role"]
  }]
}

In this definition:

  1. identityLinkTypes must include domain, so items can resolve to or mint companies.
  2. eventTypeField names where the bare event type rides in metadata; eventTypePrefix is how it is stored (hiring_job.posted).
  3. eventScope: "entity" fits here, the company is the actor; person-scope events instead carry a contactMapping naming which fields hold the acting person, so people resolve automatically.
  4. promotedMetadataFields become filterable emf__ columns and scoring inputs, so "hiring for GTM right now" is a segment away.
  5. ?dryRun=true validates the whole payload without persisting anything, and job_url doubles as a natural idempotency key when you push.

Add more event types later with POST /schemas/sources/{sourceType}/events; that works on predefined models too. Edits are guarded: additions free, destructive changes refused unless explicitly forced, and GET /schemas/sources/{sourceType}/editability tells you what is currently allowed.

4. Create attributes

Attributes are your own fields on records: set directly, never written by ingest. Define one:

POST /attributes
{
  "key": "tier",
  "scope": "company",
  "type": "string",
  "allowedValues": ["strategic", "growth", "standard"],
  "description": "Account tier, set by the CS team."
}

scope is company, person (survives job changes), or contact (one affiliation only). type is string, number, boolean, date, or string_array; description is read by AI agents. Keys are snake_case and cannot collide with a source's <source>_ prefix.

Then set values, by id or by domain, all-or-nothing:

PUT /entities/piedpiper.com/attributes
{
  "values": { "tier": "strategic" },
  "actor": "monica@raviga.com"
}

null clears a value. Every real change emits a trait.updated event on the timeline, so edits are history too. Contacts and persons have the same route.

Registered link types are the ids ackDB will accept and match on. Register a universal one:

POST /schemas/identity-link-types
{
  "key": "duns_number",
  "description": "D-U-N-S business identifier",
  "canMint": false
}

canMint: true makes the type a minting anchor: it may create a company, which is how domainless companies get records. Everything else is match-only, so a typo can never mint a phantom.

Attach ids to existing records directly, without an ingest:

POST /entities/piedpiper.com/links
{
  "externalIdType": "duns_number",
  "externalIdValue": "123456789"
}
  • PUT /entities/{ref}/links/{type} sets all values of one type declaratively.
  • DELETE /entities/{ref}/links/{type}/{value} removes one.
  • /contacts/{contactId}/links is the same surface for people's ids.
  • The domain link is managed by resolution and record creation, read-only here.

Linking emits identity.linked and identity.unlinked events, so the identity map has history like everything else.

7. Create records directly

When there is no event to ingest, mint the record itself:

POST /entities
{
  "domain": "sliceline.com",
  "name": "Sliceline",
  "actor": "monica@raviga.com"
}

Idempotent: a fresh domain returns 201 with created: true; an existing one returns its record with created: false, safe for workflow re-runs. Domainless companies use anchorType and anchorValue with a mintable link type instead.