Identity
The anonymous → identified model — email-only contacts, a nullable externalId, fill-in-link, merge/alias, and the contactProperties vs eventProperties split.
Hogsend's contact identity is a canonical id + resolvable keys model. This is what lets you create an email-only contact today and link it to a userId tomorrow without splitting their history. This page explains how identity resolves, what the created / linked flags mean, and how the property split interacts with it.
Contacts are not keyed solely by an immutable id
The old model keyed a contact on an immutable externalId (your user id). The Data API model is different:
contacts.id— an internal uuid — stays the single canonical identity. It never changes.emailandexternalId(youruserId) are both resolvable keys. The API accepts{ email },{ userId }, or both.externalIdis nullable. A contact can exist with only an email (an anonymous-but-emailed contact) and gain itsuserIdlater.
So you can do this:
# 1. A marketing form captures just an email — no userId yet.
curl -X PUT /v1/contacts -d '{ "email": "ada@example.com", "properties": { "source": "waitlist" } }'
# → { "id": "…", "created": true, "linked": false }
# 2. Later they sign up and you finally have a userId.
curl -X PUT /v1/contacts -d '{ "email": "ada@example.com", "userId": "user_123" }'
# → { "id": "…", "created": false, "linked": true } ← same contact, now linkedA subsequent find by email and a find by userId both return that single contact.
The created and linked flags
Every upsert / resolve returns two booleans that tell you what happened:
| Flag | Meaning |
|---|---|
created | A brand-new contact row was inserted |
linked | An existing contact gained a key it was missing (e.g. an email-only contact just received its userId, or vice versa) |
The four common outcomes:
| Scenario | created | linked |
|---|---|---|
| First time you've seen this email/userId | true | false |
| Known contact, same keys, just a property update | false | false |
| Email-only contact now linked to a userId (fill-in) | false | true |
| New key resolves an alias from a prior merge | false | true |
Resolution: create → fill-in-link → merge
resolveOrCreateContact (the engine function behind every write) resolves identity in one transaction:
- Look up by
userId(external id) if present, else byemail(and consult the alias table for keys that belonged to a since-merged contact). - Not found → insert a new contact with whatever keys you supplied (email-only, userId-only, or both).
- Found, missing the other key → fill it in and record the link (
linked: true). - Found by two keys that point at two different rows → merge: a deterministic survivor absorbs the loser's history (events, journey states, email sends, bucket memberships, preferences), the loser is soft-deleted, and the loser's keys are recorded as aliases pointing at the survivor.
Aliases keep stale keys resolving
After a merge, the loser's old keys live on a soft-deleted row. The resolver consults a contact_aliases table on a miss, so the next event arriving under a stale key resolves to the survivor instead of minting a fresh contact and re-splitting history.
Anonymous → identified
A contact can be keyed on an anonymousId (a stable distinct id) before any email or userId exists, then promoted/merged into an identified contact. anonymousId is an optional, third-precedence resolvable key — after externalId, then email. The public /v1/events and /v1/contacts bodies accept it (see Events); it is an extra that rides alongside an email or userId, never a third identity arm on its own (anon-only public ingest stays an abuse vector, so one of email/userId is still required).
This is also the lever for identity stitching into your analytics provider — keeping one human as one PostHog person — described next.
Identity stitching (analytics)
The contact model above keeps one human as one Hogsend contact. Identity stitching extends that guarantee into your analytics provider so one human is also one PostHog person — instead of fragmenting into a web-anonymous person, a logged-in person, a Discord person, and one per email click.
Stitching shipped in @hogsend/*@0.23.0. Every path here is off by default — it activates only when you forward an anonymousId, enable TRACKING_IDENTITY_TOKEN, or wire Discord /link. The full design reference (merge physics, the alias-direction footgun, threat model, rollout) lives at docs/posthog-identity-stitching.md in the repo.
One canonical person
The governing rule: exactly one distinct id per human ever becomes identified in the analytics person store — the canonical Hogsend contact key (externalId ?? anonymousId ?? contacts.id). Every other id a human generates — a browser posthog-js anon session, a login userId, a Discord account — is absorbed into that canonical key while still anonymous, never identified on its own.
This direction is load-bearing because of PostHog's merge physics: an anonymous person can be folded into an identified one, but two already-identified persons cannot be merged on the safe path. So Hogsend only ever keeps one key identified and absorbs the rest.
Zero-merge: forward anonymousId from the browser
The cheapest stitch is to never fork. Send the browser's own analytics distinct id as anonymousId on the first identifying call (with the email, before any userId/externalId exists). The contact key then becomes that anon id, so the browser's posthog-js events and the server's events land on the same person with zero merge calls.
const anonymousId = posthog.get_distinct_id(); // the browser's current anon id
// Send it to the server alongside the email…
await fetch("/v1/events", {
method: "POST",
body: JSON.stringify({ name: "subscribed", email, anonymousId }),
});
// …the returned contact key == anonymousId, so this is now a self-alias no-op:
posthog.identify(anonymousId);Because posthog.identify(<contactKey>) targets the same id the server keyed the contact on, both sides agree without any $create_alias. The @hogsend/client SDK accepts the same optional anonymousId on event and contact inputs.
Under posthog-js persistence: "memory" the browser anon id regenerates on every full page load, so this alignment is session-scoped. Upgrade persistence to localStorage+cookie on explicit consent to make it durable. The server-side paths below carry the cross-pageload load when persistence stays in memory.
The login key-flip — one $create_alias
When a userId/externalId later attaches to an email- or anon-keyed contact (the fill-in-link, linked: true), the canonical key flips from the old anon/uuid key to the new externalId. At that single moment the engine fires exactly one server-side alias to fold the old anon person into the new canonical one:
- Survivor / canonical = the new key (
distinctId). - Absorbed / anonymous = the old anon/uuid key (
alias).
Server-side stitching always uses alias in that direction — never a server identify, which can only stamp properties and merges nothing (the server has no anonymous session to fold). The alias fires once, inside the idempotency-guarded ingest block, so retries don't re-emit it. A contact whose old key was already an externalId (a true two-identified-persons collision) is not aliased — it's recorded as a residual instead of mis-merging (see limits below).
Identity across email and Discord
The same canonical-person guarantee extends past the browser:
- Email link clicks (opt-in). With
TRACKING_IDENTITY_TOKEN=true, an email link can carry a scoped, single-subject token. On click, the engine server-side aliases the clicker's browser session into the email recipient's canonical person at/v1/t/identify. The token only ever lets a clicker fold their own session into the subject — it can never overwrite the subject or let a forwardee become them. Referral and shareable links are token-less by default (a forwarded link must never mis-identify a stranger); per-prospect stitching is opt-in via single-use tokens only. - Discord
/link. The/linkflow verifies an email, links thediscord_id, and merges that Discord-keyed contact into the email contact — then fires the same alias path so the human's Discord-platform events land on their one canonical person.
Known limits
Stitching stops new forks and stitches forward; it does not heal history.
- It does not retroactively merge pre-existing duplicate persons created before stitching was enabled.
- It cannot safely merge two already-identified persons (PostHog refuses this on the safe path; only the deferred
$merge_dangerouslyrepair could). So the honest guarantee is "one email → one person, except across two prior identified persons." Absorbing ids early (zero-merge threading) makes this residual rarer, but does not eliminate it.
contactProperties merge vs eventProperties
Identity and the property split are two halves of the same idea: a contact accumulates who they are, while events record what happened.
contactPropertiesare merged onto the durable contact viaCOALESCE(properties, '{}') || patch:- Merge is additive — supplying
{ "plan": "pro" }updatesplanand leaves every other key intact. - An explicit
nullclears a key ({ "plan": null }removesplan). - On merge, the survivor's properties win conflicts, then the current call's
contactPropertiesare applied last.
- Merge is additive — supplying
eventPropertiesare written to the event row only and are what journeytrigger.where/exitOnevaluate. They are never merged onto the contact.
// POST /v1/events
{
"name": "upgrade",
"userId": "user_123",
"eventProperties": { "from_plan": "free", "to_plan": "pro" }, // event row + trigger.where
"contactProperties": { "plan": "pro" } // contact record only
}After this event: user_events carries from_plan / to_plan; the contact's properties.plan is now "pro"; and the event's properties never leaked onto the contact. Buckets that segment on plan will re-evaluate against the updated contact state. See Events for the full request shape.
Campaigns
POST/GET /v1/campaigns — broadcast one template to a list or bucket, schedule it for a future instant, cancel it, and list campaigns. Plus defineCampaign() for committing a broadcast to your repo.
Outbound webhooks
Subscribe to Hogsend's signed event stream — a Standard Webhooks HMAC-SHA256 feed of contact, email, journey, and bucket events delivered at-least-once with durable retries.