> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hacktionbase.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Contact Capture Integration

> Connect any lead source — contact forms, quote requests, newsletter signups — to Hacktionbase with a single authenticated POST.

## Overview

The Contact Capture API turns any form submission into a **Contact** — a first-class person identity that exists before (and independently of) a logged-in user account. Every submission is deduplicated, enriched, and emits events that flow into segments, automations, inbox, campaigns, analytics, and AI.

| Endpoint                  | Purpose                                                  |
| ------------------------- | -------------------------------------------------------- |
| `POST /contacts`          | Upsert a contact from a submission (creates or updates)  |
| `POST /contacts/identify` | Resolve and identify an existing contact (never creates) |

**Base URL:** `https://api.hacktionbase.com/v1`

Both endpoints are **asynchronous**: the submission is validated at the edge, accepted with `202`, and queued. Deduplication, identity resolution, and event emission happen downstream within seconds.

## Authentication

Both endpoints require a **public API key** (`hb_public_*`), sent as a Bearer token:

```
Authorization: Bearer hb_public_xxxxx
```

Public keys are issued from **Settings → API Keys** in the dashboard (key type *Public*). They can be rotated and revoked there at any time.

<Warning>
  Public keys are **secret server-side credentials** — never embed them in browser code. If your form is client-side, proxy the submission through your own backend (see [Server-side proxy pattern](#server-side-proxy-pattern) below).
</Warning>

## Upsert a contact

`POST /contacts` accepts a form submission and either creates a new contact or updates the matching one.

```bash theme={null}
curl -X POST https://api.hacktionbase.com/v1/contacts \
  -H "Authorization: Bearer hb_public_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@acme.com",
    "firstName": "Jane",
    "lastName": "Doe",
    "phone": "+33612345678",
    "source": "website",
    "formId": "quote-form",
    "formType": "quote",
    "event": "quote.requested",
    "traits": {
      "company": "Acme Inc",
      "projectType": "redesign",
      "budget": "10-25k",
      "message": "We need a quote for..."
    },
    "consent": {
      "marketing": true,
      "termsAccepted": true
    }
  }'
```

### Request fields

| Field                   | Type   | Notes                                                                                                                           |
| ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `externalUserId`        | string | Your business identifier for the person (strongest matching key)                                                                |
| `anonymousId`           | string | SDK anonymous visitor id, if known                                                                                              |
| `email`                 | string | Normalized (trim + lowercase) — the default dedup key                                                                           |
| `phone`                 | string | Only used for matching when confidently parseable as E.164                                                                      |
| `firstName`, `lastName` | string | Profile fields — never overwrite an existing value                                                                              |
| `source`                | string | Where the submission came from (e.g. `website`, `landing-page`)                                                                 |
| `formId`, `formType`    | string | Form provenance, stored in `sources[]`                                                                                          |
| `event`                 | string | Optional **business event** to emit alongside system events (e.g. `quote.requested`, `newsletter.subscribed`, `demo.requested`) |
| `traits`                | object | Arbitrary custom attributes, stored opaque and merged per key                                                                   |
| `consent`               | object | `{ marketing?: boolean, termsAccepted?: boolean }` — timestamped on capture                                                     |

At least one identifier (`externalUserId`, `email`, `phone`, or `anonymousId`) is **required**.

Limits: string fields are capped at 2048 characters; `traits` is capped at 100 keys; keys containing `$` or `.` are silently dropped.

Provenance is captured automatically on every submission: `ip`, `userAgent`, and `receivedAt` are stored in the contact's `sources[]` array along with `source`, `formId`, and `formType`.

### Response

`202 Accepted` — the submission is queued for processing:

```json theme={null}
{
  "ok": true,
  "requestId": "9d2c5a44-6f9a-4a3e-b1a0-1234567890ab"
}
```

Downstream, the submission either creates a new contact or updates the matching one. When it matches **two or more** existing contacts, a new contact is still created — flagged `identityStatus: "conflict"` with the candidate ids in `conflictsWith` — and a `contact.identity_conflict` event is emitted. Conflicts are never auto-merged; resolve them from the dashboard (or by assigning the contact to a user).

## Identify a contact

`POST /contacts/identify` resolves an existing contact and marks it identified — it **never creates** one. Use it when identity information arrives after the initial capture (e.g. the visitor signs up).

```bash theme={null}
curl -X POST https://api.hacktionbase.com/v1/contacts/identify \
  -H "Authorization: Bearer hb_public_xxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "anonymousId": "anon_8f3b2c",
    "email": "jane@acme.com",
    "externalUserId": "user_123"
  }'
```

The response is the same `202 Accepted` acknowledgment. Downstream:

* **Match found** — the contact is enriched and marked identified (`contact.identified` emitted).
* **No match** — the submission is dropped (identify never creates; a later re-submission will re-identify).
* **Ambiguous match** — nothing is merged; a `contact.identity_conflict` event is emitted and the conflict surfaces in the dashboard.

## Identity resolution

Matching walks the identifiers in priority order over all active contacts (leads, anonymous visitors, and users):

1. `externalUserId`
2. `email` (normalized)
3. `phone` (E.164 only)
4. `anonymousId`

| Resolution                                 | Behavior                                                                               |
| ------------------------------------------ | -------------------------------------------------------------------------------------- |
| No match                                   | New contact created (`kind: lead` if a strong identifier is present, else `anonymous`) |
| Single match                               | Contact updated — traits merged per key, profile fields enriched without overwriting   |
| Match is a **user**                        | The user record is enriched ("the user always wins") and `contact.merged` is emitted   |
| Match is **anonymous** + strong id arrives | Promoted to `kind: lead`, `contact.identified` emitted                                 |
| Multiple matches                           | Conflict — surfaced, never silently merged                                             |

## Events

Every processed submission generates at least one event — there is no submission without an event. Events are written to the tenant event store during processing, then fanned out to the event bus (automations, segments, inbox, analytics, messaging, AI).

**System events:** `contact.created`, `contact.updated`, `contact.form_submitted`, `contact.identified`, `contact.merged`, `contact.identity_conflict`.

**Business events:** pass any name in the `event` field (e.g. `quote.requested`, `newsletter.subscribed`, `demo.requested`). Business events are dynamic — consolidate and map them per tenant in **Analytics → Discovery** with the *Map Events* modal.

## Server-side proxy pattern

Keep the key on your server and forward form submissions from your own endpoint:

```javascript theme={null}
// Node.js / Express
app.post('/api/contact-form', async (req, res) => {
  const { email, firstName, lastName, message } = req.body;

  const response = await fetch('https://api.hacktionbase.com/v1/contacts', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.HB_PUBLIC_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email,
      firstName,
      lastName,
      source: 'website',
      formId: 'contact-form',
      formType: 'contact',
      event: 'support.requested',
      traits: { message },
      consent: { marketing: !!req.body.marketing, termsAccepted: true },
    }),
  });

  // 202 — the submission is queued; processing happens downstream.
  res.status(response.ok ? 200 : 502).json({ ok: response.ok });
});
```

Pairing with the SDK: if the visitor is tracked by the Hacktionbase SDK, include its anonymous id in the submission (`anonymousId`) so the form contact links to the browsing session. The SDK exposes it via the session — the same id the widget uses for `Hacktionbase.identify()`.

## Error reference

Only edge validation errors are returned synchronously:

| Status | Body                             | Meaning                                   |
| ------ | -------------------------------- | ----------------------------------------- |
| `400`  | `{ "error": "invalid_body" }`    | Invalid or empty JSON body                |
| `401`  | `{ "error": "invalid_api_key" }` | Missing, revoked, or malformed public key |
| `429`  | —                                | Rate limited                              |

Business outcomes are resolved downstream and never fail the request: a submission with no identifier, an identify with no match, an exhausted trial contact quota, or a frozen trial tenant is **dropped** (logged server-side). Ambiguous matches emit a `contact.identity_conflict` event and surface in the dashboard.

## Operations note — kind backfill migration

Deployments that predate the Contact model must run the `kind` backfill on every tenant DB **before** shipping this feature (otherwise legacy users become invisible to kind-scoped reads):

```bash theme={null}
cd api && npm run migrate:contact-kind
```

The migration is idempotent and safe to re-run.
