# Contacts Contacts are the core CRM record: leads, customers, and partners owned by the authenticated user. All endpoints scope by user ownership; cross-user access is rejected with 403/404 depending on policy. ## List contact properties `GET /api/contacts/properties` Lists every property a contact can carry in this location: the native ones (sent at the root of the payload) and this location's custom fields (sent inside `custom_field_values`, keyed by `key`, `uuid` or numeric id). It exists because otherwise the only way to know what you may send is to read the source: `/api/custom-fields` lists only the custom ones, and the native ones were written down nowhere. Each entry says where it goes — `location` is `root` for top-level fields and `custom_field_values` for the rest — and which ones cannot be written here, with the alternative route when there is one. ### Example request ```bash curl --request GET \ --get "https://klozzo.com/api/contacts/properties" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "data": [ { "key": "first_name", "label": "Nombre", "type": "text", "group": "native", "location": "root", "writable": true, "required": true } ] } ``` ## List lead stages `GET /api/lead-stages` Lists the lead stages this account defines — the values `lead_stage_id` accepts. **Read this before you send `lead_stage_id`.** Lead stages are a catalogue each account defines for itself — not a fixed list shipped with the product — so the numbers are different in every account and there is no way to guess them. Call this endpoint once, keep the mapping, and send the `id`: ```json { "lead_stage_id": 1 } ``` `null` leaves the contact with no stage. An `id` from another account is rejected with `422`, which is the failure this endpoint exists to prevent. They come back in the order the settings screen shows them (`position`), so you can render your own picker without re-sorting. `slug` is stable when somebody renames a stage — match on it if you keep a mapping in your own system. `contacts_count` is informational and moves on its own. `meta.usage` repeats the one-line instruction, so a caller that only ever looks at the response body still finds out what to do with the `id`. ### Example request ```bash curl --request GET \ --get "https://klozzo.com/api/lead-stages" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` — A location with the default stages ```json { "data": [ { "id": 1, "name": "Nuevo", "slug": "nuevo", "color": "#22c55e", "position": 0, "contacts_count": 42 }, { "id": 2, "name": "Contactado", "slug": "contactado", "color": "#3b82f6", "position": 1, "contacts_count": 17 }, { "id": 3, "name": "Calificado", "slug": "calificado", "color": null, "position": 2, "contacts_count": 4 } ], "meta": { "usage": "Manda el `id` en `lead_stage_id` al crear o actualizar un contacto: {\"lead_stage_id\": 1}. `null` lo deja sin estado." } } ``` ### Response `200` — No location resolved for this token ```json { "data": [], "meta": { "usage": "Manda el `id` en `lead_stage_id` al crear o actualizar un contacto: {\"lead_stage_id\": 1}. `null` lo deja sin estado." } } ``` ### Response `403` — The token cannot read contacts ```json { "message": "This action is unauthorized." } ``` ## List contacts `GET /api/contacts` Returns a paginated list of contacts owned by the authenticated user. Supports full-text search across name, email, phone, and company. ### Query parameters - `search` (string) — Fragment matched against first/last name, email, phone, company. - `contact_type` (string) — Filter by type. One of `lead`, `customer`, `partner`. - `sort` (string) — Column to sort by (e.g. `created_at`, `last_name`). Defaults to newest first. - `direction` (string) — Sort direction, `asc` or `desc`. - `per_page` (integer) — Rows per page. Default 25, maximum 100. See [Lists, paging and filters](#lists-paging-and-filters). ### Example request ```bash curl --request GET \ --get "https://klozzo.com/api/contacts?search=ada&contact_type=lead&sort=created_at&direction=desc&per_page=25" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "data": [ { "id": null, "ulid": null, "first_name": "Morgan", "last_name": "Hirthe", "full_name": "Morgan Hirthe", "email": "dare.emelie@example.com", "email_verified_at": null, "email_verification_excluded": false, "email_verification_sent_at": null, "phone": "+12025550100", "phone_e164": null, "phone_country": "US", "phone_type": null, "has_whatsapp": null, "whatsapp_checked_at": null, "whatsapp_history_synced_until": null, "whatsapp_history_exhausted": false, "avatar_url": null, "additional_emails": [], "additional_phones": [], "company_name": "McLaughlin, Leuschke and Bauch", "contact_type": "lead", "timezone": "Asia/Famagusta", "source": "import", "source_number": null, "date_of_birth": "2003-07-15", "dnd_all": false, "dnd_email": false, "dnd_sms": false, "dnd_calls": false, "dnd_voicemail": false, "dnd_gmb": null, "dnd_fb_messenger": null, "dnd_meta": [], "last_activity_at": "2026-08-17T14:16:42+00:00", "created_at": null, "updated_at": null, "owner_id": null, "lead_stage_id": null }, { "id": null, "ulid": null, "first_name": "Lucienne", "last_name": "Haag", "full_name": "Lucienne Haag", "email": "lwisoky@example.net", "email_verified_at": null, "email_verification_excluded": false, "email_verification_sent_at": null, "phone": "+12025550101", "phone_e164": null, "phone_country": "US", "phone_type": null, "has_whatsapp": null, "whatsapp_checked_at": null, "whatsapp_history_synced_until": null, "whatsapp_history_exhausted": false, "avatar_url": null, "additional_emails": [], "additional_phones": [], "company_name": "Nitzsche-Ankunding", "contact_type": "customer", "timezone": "Asia/Ho_Chi_Minh", "source": "import", "source_number": null, "date_of_birth": "2022-07-10", "dnd_all": false, "dnd_email": false, "dnd_sms": false, "dnd_calls": false, "dnd_voicemail": false, "dnd_gmb": null, "dnd_fb_messenger": null, "dnd_meta": [], "last_activity_at": "2026-08-03T06:11:58+00:00", "created_at": null, "updated_at": null, "owner_id": null, "lead_stage_id": null } ], "links": { "first": "/?page=1", "last": "/?page=1", "prev": null, "next": null }, "meta": { "current_page": 1, "from": 1, "last_page": 1, "links": [ { "url": null, "label": "« Previous", "page": null, "active": false }, { "url": "/?page=1", "label": "1", "page": 1, "active": true }, { "url": null, "label": "Next »", "page": null, "active": false } ], "path": "/", "per_page": 25, "to": 2, "total": 2 } } ``` ## Create a contact `POST /api/contacts` > **Coming from a form, an ad callback or an automation? Use > [`POST /api/leads`](#which-endpoint-do-i-use) instead.** This endpoint > answers `422` and drops the whole request when the person already > exists — including the campaign they came from and what they asked for > this time. `/api/leads` never rejects somebody who comes back. Creates a new contact owned by the authenticated user. Use it when you are deliberately writing a record you know is new: a migration, an import script, an internal tool. Server-side duplicate detection applies: if the location's `duplicate_mode = block`, a 422 is returned when email or phone collides with an existing contact in the same scope. ### Body parameters - `first_name` (string, required) — Contact first name. - `last_name` (string) — Contact last name. - `email` (string) — Primary email; unique within scope if duplicate-block is on. - `phone` (string) — E.164 with the country code — `+525555555555`. A local number is only accepted together with `phone_country`; without either the request is rejected. Stored normalised to E.164. - `phone_country` (string) — ISO-3166-1 alpha-2, e.g. `MX`. Only needed when `phone` has no `+` country code; ignored when it does. - `phone_type` (string) — One of `mobile`, `home`, `work`, `other`. - `company_name` (string) - `company_id` (integer) — The id of an existing record in the companies table. - `contact_type` (string) — One of `lead`, `customer`, `partner`. - `lead_stage_id` (integer) — The id of an existing record in the lead_stages table. - `timezone` (string) — IANA timezone. - `source` (string) — Free-form acquisition source. - `channel_integration_id` (integer) — The id of an existing record in the channel_integrations table. - `date_of_birth` (string) — ISO-8601 date. - `avatar` (string) — Image, max 2MB. Only via multipart/form-data. - `additional_emails` (string[]) — Up to 5 extra email addresses. - `additional_phones` (string[]) — Up to 5 extra numbers, same rules as `phone`. They share the single `phone_country`, so send them in E.164 if they are from different countries. - `dnd_all` (boolean) — Master Do-Not-Disturb toggle. - `dnd_email` (boolean) — Block email for this person. Independent of `dnd_all`, which blocks everything. - `dnd_sms` (boolean) — Block SMS for this person. - `dnd_calls` (boolean) — Block outbound calls for this person. - `dnd_voicemail` (boolean) — Block leaving voicemail for this person. - `dnd_gmb` (boolean) — Block Google Business Messages for this person. - `dnd_fb_messenger` (boolean) — Block Facebook Messenger for this person. - `auto_engage` (boolean) — Enable automated engagement workflows. - `custom_field_values` (object) — Map of `custom_field_id => value`. ### Example request ```bash curl --request POST \ "https://klozzo.com/api/contacts" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: multipart/form-data" \ --header "Accept: application/json" \ --form "first_name=Ada"\ --form "last_name=Lovelace"\ --form "email=ada@example.com"\ --form "phone=+525555555555"\ --form "phone_country=MX"\ --form "phone_type=mobile"\ --form "company_name=Analytical Engines Ltd."\ --form "company_id=16"\ --form "contact_type=lead"\ --form "lead_stage_id=16"\ --form "timezone=America/Mexico_City"\ --form "source=web"\ --form "channel_integration_id=16"\ --form "date_of_birth=1815-12-10"\ --form "additional_emails[]=architecto"\ --form "additional_phones[]=architecto"\ --form "dnd_all="\ --form "dnd_email="\ --form "dnd_sms="\ --form "dnd_calls="\ --form "dnd_voicemail="\ --form "dnd_gmb="\ --form "dnd_fb_messenger="\ --form "auto_engage=1"\ --form "avatar=@/private/var/folders/yw/tssyhl110hs29p29scc2m0jc0000gn/T/phpn370553gbibdcAe8NFE" ``` ### Response `200` ```json { "data": { "id": null, "ulid": null, "first_name": "Christelle", "last_name": "Bailey", "full_name": "Christelle Bailey", "email": "rowan.gulgowski@example.com", "email_verified_at": null, "email_verification_excluded": false, "email_verification_sent_at": null, "phone": "+12025550102", "phone_e164": null, "phone_country": "CO", "phone_type": null, "has_whatsapp": null, "whatsapp_checked_at": null, "whatsapp_history_synced_until": null, "whatsapp_history_exhausted": false, "avatar_url": null, "additional_emails": [], "additional_phones": [], "company_name": "Dach-Gaylord", "contact_type": "customer", "timezone": "Africa/Dakar", "source": "import", "source_number": null, "date_of_birth": null, "dnd_all": false, "dnd_email": false, "dnd_sms": false, "dnd_calls": false, "dnd_voicemail": false, "dnd_gmb": null, "dnd_fb_messenger": null, "dnd_meta": [], "last_activity_at": null, "created_at": null, "updated_at": null, "owner_id": null, "lead_stage_id": null } } ``` ### Response `403` — not authorized ```json { "message": "This action is unauthorized." } ``` ### Response `422` — duplicate email blocked ```json { "message": "Ya existe un contacto con este correo electrónico.", "errors": { "email": [ "Ya existe un contacto con este correo electrónico." ] } } ``` ## Fetch a contact `GET /api/contacts/{id}` Returns one contact by id, with everything stored on it. Returns 404 if the contact does not belong to the authenticated user, 403 if policy denies access. ### Path parameters - `id` (integer, required) — The contact ID. ### Example request ```bash curl --request GET \ --get "https://klozzo.com/api/contacts/42" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "data": { "id": null, "ulid": null, "first_name": "Morgan", "last_name": "Hirthe", "full_name": "Morgan Hirthe", "email": "imclaughlin@example.org", "email_verified_at": null, "email_verification_excluded": false, "email_verification_sent_at": null, "phone": "+12025550103", "phone_e164": null, "phone_country": "MX", "phone_type": null, "has_whatsapp": null, "whatsapp_checked_at": null, "whatsapp_history_synced_until": null, "whatsapp_history_exhausted": false, "avatar_url": null, "additional_emails": [], "additional_phones": [], "company_name": "O'Keefe Inc", "contact_type": "lead", "timezone": "Pacific/Tongatapu", "source": "manual", "source_number": null, "date_of_birth": null, "dnd_all": false, "dnd_email": false, "dnd_sms": false, "dnd_calls": false, "dnd_voicemail": false, "dnd_gmb": null, "dnd_fb_messenger": null, "dnd_meta": [], "last_activity_at": "2026-08-09T18:01:18+00:00", "created_at": null, "updated_at": null, "owner_id": null, "lead_stage_id": null } } ``` ### Response `404` ```json { "message": "No query results for model [App\\Models\\Contact]." } ``` ## Update a contact `PUT /api/contacts/{id}` Updates one contact by id, changing only the fields you send. Audit log records the diff between old and new values. Avatar is not updatable here (re-upload via store or future endpoint). ### Path parameters - `id` (integer, required) — The contact ID. ### Body parameters - `first_name` (string) - `last_name` (string) - `email` (string) - `phone` (string) — E.164 with the country code — `+525555555555`. A local number is only accepted together with `phone_country`; without either the request is rejected. Stored normalised to E.164. - `phone_country` (string) — ISO-3166-1 alpha-2, e.g. `MX`. Only needed when `phone` has no `+` country code; ignored when it does. - `phone_type` (string) — One of `mobile`, `home`, `work`, `other`. - `additional_phones` (string[]) — Up to 5 extra numbers, same rules as `phone`. They share the single `phone_country`, so send them in E.164 if they are from different countries. - `company_name` (string) - `company_id` (integer) — The id of an existing record in the companies table. - `contact_type` (string) — One of `lead`, `customer`, `partner`. - `lead_stage_id` (integer) — The id of an existing record in the lead_stages table. - `timezone` (string) — IANA timezone. - `source` (string) - `date_of_birth` (string) — ISO-8601 date. - `dnd_all` (boolean) - `dnd_email` (boolean) — Block email for this person. Independent of `dnd_all`, which blocks everything. - `dnd_sms` (boolean) — Block SMS for this person. - `dnd_calls` (boolean) — Block outbound calls for this person. - `dnd_voicemail` (boolean) — Block leaving voicemail for this person. - `dnd_gmb` (boolean) — Block Google Business Messages for this person. - `dnd_fb_messenger` (boolean) — Block Facebook Messenger for this person. - `custom_field_values` (object) — Map of `custom_field_id => value`. ### Example request ```bash curl --request PUT \ "https://klozzo.com/api/contacts/42" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --data "{ \"first_name\": \"Ada\", \"last_name\": \"Lovelace\", \"email\": \"ada@example.com\", \"phone\": \"+525555555555\", \"phone_country\": \"MX\", \"phone_type\": \"mobile\", \"additional_phones\": [ \"architecto\" ], \"company_name\": \"Analytical Engines Ltd.\", \"company_id\": 16, \"contact_type\": \"customer\", \"lead_stage_id\": 16, \"timezone\": \"America\\/Mexico_City\", \"source\": \"web\", \"date_of_birth\": \"1815-12-10\", \"dnd_all\": false, \"dnd_email\": false, \"dnd_sms\": false, \"dnd_calls\": false, \"dnd_voicemail\": false, \"dnd_gmb\": false, \"dnd_fb_messenger\": false, \"custom_field_values\": [] }" ``` ### Response `200` ```json { "data": { "id": null, "ulid": null, "first_name": "Christelle", "last_name": "Bailey", "full_name": "Christelle Bailey", "email": "jdach@example.org", "email_verified_at": null, "email_verification_excluded": false, "email_verification_sent_at": null, "phone": "+12025550104", "phone_e164": null, "phone_country": "AR", "phone_type": null, "has_whatsapp": null, "whatsapp_checked_at": null, "whatsapp_history_synced_until": null, "whatsapp_history_exhausted": false, "avatar_url": null, "additional_emails": [], "additional_phones": [], "company_name": "Runte-Considine", "contact_type": "lead", "timezone": "America/Indiana/Tell_City", "source": "web", "source_number": null, "date_of_birth": "1985-10-22", "dnd_all": false, "dnd_email": false, "dnd_sms": false, "dnd_calls": false, "dnd_voicemail": false, "dnd_gmb": null, "dnd_fb_messenger": null, "dnd_meta": [], "last_activity_at": "2026-07-31T16:34:00+00:00", "created_at": null, "updated_at": null, "owner_id": null, "lead_stage_id": null } } ``` ## Delete a contact `DELETE /api/contacts/{id}` Soft-deletes one contact by id: it stops appearing anywhere in the CRM and is recoverable. All associated activity, conversations, and tags remain intact for audit purposes. ### Path parameters - `id` (integer, required) — The contact ID. ### Example request ```bash curl --request DELETE \ "https://klozzo.com/api/contacts/42" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "message": "Contact deleted." } ``` ### Response `403` ```json { "message": "This action is unauthorized." } ``` ## Bulk action on contacts `POST /api/contacts/bulk` Performs an action on multiple contacts in one call. Currently only `delete` is supported. Bulk delete is permanent (no soft-delete) and emits a single audit log entry covering all affected IDs. ### Body parameters - `action` (string, required) — Action to perform. Currently only `delete`. - `ids` (integer[], required) — IDs of contacts to act on (min 1). ### Example request ```bash curl --request POST \ "https://klozzo.com/api/contacts/bulk" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --data "{ \"action\": \"delete\", \"ids\": [ 16 ] }" ``` ### Response `200` ```json { "message": "Bulk action completed." } ``` ### Response `422` — invalid action ```json { "message": "The selected action is invalid.", "errors": { "action": [ "The selected action is invalid." ] } } ``` ## Check whether a contact's email really exists `POST /api/contacts/{contact_id}/verify-email` Queues a deliverability check against the configured verification provider and answers `202` straight away — the result is not ready when this call returns. Read `email_verification_status` on the contact (or wait for the `contact.updated` webhook) to find out how it went. Use it before a first send to a list you did not collect yourself. A bounce rate above a few percent is what gets a sending domain blocked, and that is not something you undo in an afternoon. The check costs money per address on most providers, so it is deliberately a call you make, never something the CRM does on its own. ### Path parameters - `contact_id` (string, required) — The contact's id. ### Body parameters - `driver` (string) — Force a specific provider instead of the account default. One of `zerobounce`, `mailgun`, `ses`, `null`. ### Example request ```bash curl --request POST \ "https://klozzo.com/api/contacts/42/verify-email" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --data "{ \"driver\": \"zerobounce\" }" ``` ### Response `202` ```json { "message": "Email verification queued.", "contact_id": 42, "status": "unverified" } ``` ### Response `403` — Read-only role ```json { "message": "This action is unauthorized." } ``` ### Response `422` — The contact has no email ```json { "message": "Contact has no email to verify.", "errors": { "email": [ "Contact has no email to verify." ] } } ``` ### Response `422` — The contact is excluded from verification ```json { "message": "Contact is excluded from email verification.", "errors": { "email": [ "Contact is excluded from email verification." ] } } ``` ## Reassign contact owner `PATCH /api/contacts/{contact_id}/owner` Transfers ownership of a contact to a different user. Pass `owner_id = null` to unassign. Requires the `reassignOwner` policy. ### Path parameters - `contact_id` (integer, required) — The contact ID. ### Body parameters - `owner_id` (integer) — User ID of the new owner. Set to `null` to unassign. ### Example request ```bash curl --request PATCH \ "https://klozzo.com/api/contacts/42/owner" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --data "{ \"owner_id\": 7 }" ``` ### Response `200` ```json { "data": { "id": null, "ulid": null, "first_name": "Audra", "last_name": "Crooks", "full_name": "Audra Crooks", "email": "rempel.chadrick@example.org", "email_verified_at": null, "email_verification_excluded": false, "email_verification_sent_at": null, "phone": "+12025550105", "phone_e164": null, "phone_country": "CO", "phone_type": null, "has_whatsapp": null, "whatsapp_checked_at": null, "whatsapp_history_synced_until": null, "whatsapp_history_exhausted": false, "avatar_url": null, "additional_emails": [], "additional_phones": [], "company_name": "Gaylord and Sons", "contact_type": "customer", "timezone": "America/Eirunepe", "source": "manual", "source_number": null, "date_of_birth": "1991-08-13", "dnd_all": false, "dnd_email": false, "dnd_sms": false, "dnd_calls": false, "dnd_voicemail": false, "dnd_gmb": null, "dnd_fb_messenger": null, "dnd_meta": [], "last_activity_at": null, "created_at": null, "updated_at": null, "owner_id": null, "lead_stage_id": null } } ``` ## Add a follower to a contact `POST /api/contacts/{contact_id}/followers` Followers receive notifications about activity on the contact. Idempotent: adding an existing follower is a no-op. ### Path parameters - `contact_id` (integer, required) — The contact ID. ### Body parameters - `user_id` (integer, required) — ID of the user to add as follower. ### Example request ```bash curl --request POST \ "https://klozzo.com/api/contacts/42/followers" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --data "{ \"user_id\": 7 }" ``` ### Response `201` ```json { "message": "Follower added.", "followers": [ 7, 12 ] } ``` ## Remove a follower from a contact `DELETE /api/contacts/{contact_id}/followers/{user}` Stops sending this user notifications about the contact. It does not touch ownership: the owner is who the contact belongs to, a follower is only somebody watching, and removing the last follower leaves the owner intact. Idempotent — removing somebody who was not following answers `200` all the same, so a sync that cannot remember what it already did is safe. ### Path parameters - `contact_id` (integer, required) — The contact ID. - `user` (integer, required) — ID of the user to remove. ### Example request ```bash curl --request DELETE \ "https://klozzo.com/api/contacts/42/followers/7" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "message": "Follower removed.", "followers": [ 12 ] } ``` ## List a contact's do-not-disturb settings `GET /api/contacts/{contact_id}/dnd-settings` Lists the do-not-disturb settings of one contact: one entry per channel this person has asked not to be reached on. An empty list means nothing is blocked — the absence of a row is the permission. `is_active` is the field to read: a setting can exist and be inactive because it was scheduled until a date that has already passed. ### Path parameters - `contact_id` (string, required) — The contact's id. ### Example request ```bash curl --request GET \ --get "https://klozzo.com/api/contacts/42/dnd-settings" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "data": [ { "id": 9, "contact_id": 42, "channel": "sms", "enabled": true, "scheduled_until": null, "set_by": 3, "set_at": "2026-08-23T17:04:00+00:00", "is_active": true } ] } ``` ### Response `404` — Not yours, or does not exist ```json { "message": "No query results for model [App\\Models\\Contact]." } ``` ## Block a channel for a contact `POST /api/contacts/{contact_id}/dnd-settings` Records that this person does not want to be reached on one channel. The CRM checks it before every send, so blocking a channel here stops messages queued by anything — a sequence, an automation, an agent typing in the inbox. Sending the same `channel` twice updates the existing setting instead of creating a second one, so a retry is safe. Pass `scheduled_until` for a temporary block ("not until after the holidays"); leave it out for an indefinite one. Every change is written to the audit log with who made it — consent decisions have to be provable. ### Path parameters - `contact_id` (string, required) — The contact's id. ### Body parameters - `channel` (string, required) - `enabled` (boolean) - `scheduled_until` (string) — Must be a valid date. Must be a date after now. ### Example request ```bash curl --request POST \ "https://klozzo.com/api/contacts/42/dnd-settings" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --data "{ \"channel\": \"architecto\", \"enabled\": true, \"scheduled_until\": \"2052-09-18\" }" ``` ### Response `201` ```json { "data": { "id": 9, "contact_id": 42, "channel": "sms", "enabled": true, "scheduled_until": null, "set_by": 3, "set_at": "2026-08-23T17:04:00+00:00", "is_active": true } } ``` ### Response `403` — Role without consent permissions ```json { "message": "This action is unauthorized." } ``` ### Response `422` — Unknown channel ```json { "message": "The selected channel is invalid.", "errors": { "channel": [ "The selected channel is invalid." ] } } ``` ## Unblock a channel for a contact `DELETE /api/contacts/{contact_id}/dnd-settings/{dndSetting_id}` Removes the setting, which means the channel is open again. Use it when somebody opts back in — never to "clean up" a list, because a deleted block is a lost consent decision and the audit entry is the only trace left of it. A setting that belongs to another contact answers `404`, even if the id exists. ### Path parameters - `contact_id` (string, required) — The contact's id. - `dndSetting_id` (string, required) — The setting's id, from the list endpoint. ### Example request ```bash curl --request DELETE \ "https://klozzo.com/api/contacts/42/dnd-settings/9" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "message": "DND setting removed." } ``` ### Response `403` — Role without consent permissions ```json { "message": "This action is unauthorized." } ``` ### Response `404` — The setting belongs to another contact ```json { "message": "Not Found" } ``` ## Read a contact's custom field values `GET /api/contacts/{contact_id}/custom-field-values` Returns the custom field values of one contact as a flat object keyed by the field's `key` — the same keys you send when writing. Fields the contact has never been given a value for are simply absent; there is no `null` placeholder for them. To find out which keys exist in this account, call `GET /api/contacts/properties` (everything a contact can carry) or `GET /api/custom-fields` (the custom ones with their type and options). ### Path parameters - `contact_id` (string, required) — The contact's id. ### Example request ```bash curl --request GET \ --get "https://klozzo.com/api/contacts/42/custom-field-values" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "data": { "presupuesto_mensual": "25000", "canal_preferido": "whatsapp" } } ``` ### Response `404` — Not visible from your location ```json { "message": "No query results for model [App\\Models\\Contact]." } ``` ## Write a contact's custom field values `PUT /api/contacts/{contact_id}/custom-field-values` Writes the custom field values of one contact. Send the values you want to set inside `values`, keyed by the field's `key`, `uuid` or numeric id. **Only the keys you send are touched** — this is a merge, not a replacement, so omitting a field leaves it alone and there is no way to accidentally blank the rest of the record. Unlike `POST /api/leads`, an unknown key here is an **error**, not something kept aside: this endpoint exists to write values on purpose, so a typo is a bug worth stopping. You get a `422` naming every key that did not match, and nothing is written. Values are validated against the field's own type — a date field rejects `"mañana"`, a dropdown rejects an option that is not on its list. Custom fields belong to the **location**, not to whoever created them: a field another agent added is yours to write too. ### Path parameters - `contact_id` (string, required) — The contact's id. ### Body parameters - `values` (object, required) — The values to set, keyed by field. ### Example request ```bash curl --request PUT \ "https://klozzo.com/api/contacts/42/custom-field-values" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" \ --data "{ \"values\": { \"presupuesto_mensual\": \"25000\" } }" ``` ### Response `200` ```json { "message": "Custom field values synced." } ``` ### Response `403` — Read-only role ```json { "message": "This action is unauthorized." } ``` ### Response `422` — A key that does not exist ```json { "message": "El campo personalizado «presupesto» no existe o no está disponible en tu cuenta.", "errors": { "values.presupesto": [ "El campo personalizado «presupesto» no existe o no está disponible en tu cuenta." ] } } ``` ### Response `422` — A value the field's type rejects ```json { "message": "The values.fecha_de_alta is not a valid date.", "errors": { "values.fecha_de_alta": [ "The values.fecha_de_alta is not a valid date." ] } } ``` ## List a contact's activity timeline `GET /api/contacts/{contact_id}/activity` Lists the activity of one contact, newest first: pages visited, forms submitted, calls, notes, tag changes, messages. It is the same feed the contact screen shows. **Paginated by cursor, not by page number.** The timeline grows while you read it, and a page number would either repeat or skip events as new ones arrive. Follow `next_cursor` until it comes back `null`; do not build `?page=2` by hand. Filter with `event_type` to sync one kind of thing — pass it more than once for several. The payload shape depends on the event type, so read `event_type` before reaching into `payload`. ### Path parameters - `contact_id` (string, required) — The contact's id. ### Query parameters - `per_page` (integer) — Rows per page. Default 25, maximum 100. See [Lists, paging and filters](#lists-paging-and-filters). - `cursor` (string) — The `next_cursor` from the previous response. - `event_type` (string[]) — Only these event types. ### Example request ```bash curl --request GET \ --get "https://klozzo.com/api/contacts/42/activity?per_page=25&cursor=eyJpZCI6MTUsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0&event_type[]=page_visited&event_type[]=form_submitted" \ --header "Authorization: Bearer {YOUR_API_TOKEN}" \ --header "Content-Type: application/json" \ --header "Accept: application/json" ``` ### Response `200` ```json { "data": [ { "id": 881, "event_type": "form_submitted", "subject_type": "contact", "subject_id": 42, "actor_type": null, "actor_id": null, "location_id": 3, "payload": { "form_id": "newsletter", "utm_source": "adwords" }, "occurred_at": "2026-08-23T17:04:00+00:00" } ], "next_cursor": "eyJpZCI6ODgxfQ", "prev_cursor": null } ``` ### Response `404` — Not visible from your location ```json { "message": "No query results for model [App\\Models\\Contact]." } ```