---
title: "Update contact properties (with transition detection)"
method: PUT
path: "/api/v1/ackdb/entities/{entityId}/contacts/{contactId}"
tags: ["ackDB"]
---

# Update contact properties (with transition detection)

`PUT /api/v1/ackdb/entities/{entityId}/contacts/{contactId}`

## Request

- Base URL: `http://localhost:3009`
- URL: `http://localhost:3009/api/v1/ackdb/entities/{entityId}/contacts/{contactId}`
- Auth: HTTP bearer

Only provided fields are written. Transitions become EVENTS (events are truth, state is cache):

- `title` changing from one non-null value to a different one ⇒ `contact.role_changed` { from_title, to_title }
- `status: "former"` ⇒ the affiliation ENDED: `endedAt` is stamped (body value or now) and `contact.ended` { reason: "manual" } is emitted — the manual sibling of a source-asserted job change.

Reactivating a `former` edge is **422**: an ended stint is history and is never resurrected. A returning employee gets a NEW stint row from the normal resolution path.

`linkedinUrl` is NOT a contact column — it is a durable key of the contact's PERSON. Setting it here writes `person_identity_links` (normalized to `linkedin.com/in/<slug>`), so the value is visible to identity resolution instead of being an inert string. It is therefore deployment-wide and first-writer-wins: a LinkedIn already owned by a DIFFERENT human is **409** + a `merge_candidates` row, never a silent re-point. Send `null` to unlink it. Every affiliation of the same human shows the same LinkedIn — that is the point.

`email` is a PAIRED write (#472): the edge's context column AND the person's `email_address` durable key, applied together so the human stays findable by email. First-writer-wins like linkedin: a value owned by a DIFFERENT person is **409** + merge candidate, and a value already used by another contact at this company is **409** (one address, one edge). Role/shared inboxes (info@, support@, …) write the column only — a mailbox is not a human. `null` clears the column; the person's keys are never auto-removed (that is `DELETE /persons/{id}/links/...`). A CHANGED email that contradicts the company's `domain` links emits `contact.domain_mismatch` and returns a `warnings[]` entry.

## Path parameters

- `entityId` string, required
- `contactId` string, required

## Request body

- AckdbContactUpdateRequest: Any subset. `title` and `status` transitions emit timeline events (see the endpoint description).
  - `name` string
  - `email` string
  - `phone` string
  - `title` string
  - `department` string
  - `linkedinUrl` string
  - `avatarUrl` string, nullable: Photo URL — written to the edge's PERSON (http(s) only). `null` clears it.
  - `isExternal` boolean
  - `status` 'active' | 'former' | 'disabled': `former` ends the affiliation (stamps endedAt + emits contact.ended).
  - `endedAt` string, date-time: When the affiliation ended. Only read when status becomes `former`; defaults to now.

## Response `200`

`{ data: Contact, emittedEvents: string[], warnings?: string[] }`.

- object
  - `data` unknown, required

## Other responses

- `400`: Invalid status or endedAt.
- `404`: Contact not found.
- `409`: `linkedinUrl`/`email` is a durable key of another person (merge candidate recorded), or the email is already another contact's work email at this company.
- `422`: Attempt to reactivate a former (ended) affiliation, or an invalid `linkedinUrl`/`email` value.

---

[API](https://skmtc.dev/floqer/apis/floqer-public-api.md) - [All operations](https://skmtc.dev/floqer/apis/floqer-public-api/llms.txt) - [OpenAPI document](https://skmtc.dev/floqer/apis/floqer-public-api/revisions/fce50dc07b20?raw)
