Skip to main content

Contacts API

Create and look up the people your assistants call and chat with.

Base URL​

https://api.yourcalls.ai

Authentication​

Include your API key on every request:

Authorization: Bearer YOUR_API_KEY

You can also use:

x-api-key: YOUR_API_KEY

Your key determines which company the contact belongs to. See Authentication.


POST /public/v1/contacts​

Create a contact, or update the one that already holds this phone number or email address. Safe to call repeatedly with the same details.

curl --request POST "https://api.yourcalls.ai/public/v1/contacts" \
--header "Authorization: Bearer YOUR_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"phone": "+442079460958",
"email": "alex@example.com",
"first_name": "Alex",
"last_name": "Smith",
"postcode": "SW1A 1AA",
"custom_fields": { "age": 42 }
}'

Fields​

At least one of phone or email is required. Everything else is optional, including the name, so an integration that only holds a phone number is never refused.

fieldnotes
phonestored in E.164. Send +442079460958 or 02079460958; both are stored the same way. A number with no country code is read as a UK number. The number must be a real, dialable one: a value that only looks like a phone number is refused with a 400.
emaillower-cased and trimmed.
first_name, last_nameup to 200 characters each.
salutationup to 40 characters. A trailing full stop is removed and the value is title-cased, so MR. is stored as Mr.
job_title, company_nameup to 200 characters each.
address_line1, address_line2up to 200 characters each.
city, countyup to 120 characters each.
postcodeup to 20 characters. Upper-cased, with runs of whitespace collapsed to a single space.
countrya two-letter ISO code, for example GB. Case does not matter, and the value is stored upper-cased. New contacts default to GB.
sms_opt_out, call_opt_out, email_opt_outbooleans.
custom_fieldsan object keyed by the custom fields you have set up under Contacts, then Fields. Values may be text, numbers, booleans or null.

The body is checked strictly. Any field not in the table above is rejected with a 400 rather than ignored, so a typo such as firstname tells you straight away instead of silently creating a contact without the name you meant to send.

Fields you cannot set​

Several columns on a contact are deliberately absent from this body, and each is therefore rejected as an unrecognised field:

  • company_id is taken from your API key.
  • source is stamped as api on every contact this endpoint creates.
  • folder_id, assigned_user_id and is_test are set in the dashboard.
  • owner_user_id is an attribution your key cannot claim.
  • import_row is recorded for you, and is described next.

Every contact keeps a copy of the request that created it, under import_row. This endpoint fills it in automatically from the body you sent, so a wrong value on a contact can be traced back to what arrived rather than to what was made of it. It is written on the create only. Matching an existing contact leaves the provenance of the write that first made it untouched. import_row is never returned by the API.

Custom fields​

custom_fields keys must already exist as fields on your account. The API never creates a field for you. A key it does not recognise comes back as a 400 naming that key, so a typo cannot quietly write a value nothing will ever read. Values are checked against the field's type: a number field rejects "forty", a date field wants YYYY-MM-DD, and a dropdown field only accepts one of its options. An archived field can no longer be written to and is refused the same way.

A key sent as null or as an empty string is stored empty on a new contact. On a contact that already exists it is ignored rather than treated as an instruction to erase a value somebody curated.

How a contact is matched​

  • When you send a phone number, it identifies the contact. If a contact already holds that number, you get that contact back.
  • When you send only an email address, the email identifies the contact.
  • When you send both and no contact holds the number, an existing contact with that email and no phone number recorded claims the number rather than a second contact being created.
  • Fields you send are only written into blank fields on an existing contact. Details already recorded are never overwritten, so a partial sync cannot erase a name someone typed in by hand.
  • Opt-outs only ever tighten. Sending "sms_opt_out": true records the objection; sending false never clears one already on the record.
  • A deleted contact never matches. A fresh request carrying the same number creates a new contact.

Responses​

201 when a contact was created, 200 when an existing one was matched:

{
"success": true,
"data": { "id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "created": true },
"error": null,
"requestId": "req-abc123",
"apiVersion": 1
}

Branch on data.created rather than on the status code if you want to log creates separately from updates. The two always agree.


GET /public/v1/contacts​

Look a contact up by phone number or email address.

curl "https://api.yourcalls.ai/public/v1/contacts?phone=%2B442079460958" \
--header "Authorization: Bearer YOUR_API_KEY"

One of phone or email is required, and when you send both a readable phone number wins. Each value is normalised the same way it is on a write, so 02079460958 finds the contact stored as +442079460958. If neither value can be read as a phone number or an email address, the request is a 400. A miss is a 404 NOT_FOUND.

Remember to URL-encode the leading + on a phone number as %2B.

GET /public/v1/contacts/:id​

Fetch one contact by its id.

curl "https://api.yourcalls.ai/public/v1/contacts/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" \
--header "Authorization: Bearer YOUR_API_KEY"

Both this route and the lookup above return the same body: the contact's core fields plus custom_fields.

{
"success": true,
"data": {
"id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"phone": "+442079460958",
"email": "alex@example.com",
"salutation": null,
"first_name": "Alex",
"last_name": "Smith",
"job_title": null,
"company_name": null,
"address_line1": null,
"address_line2": null,
"city": null,
"county": null,
"postcode": "SW1A 1AA",
"country": "GB",
"source": "api",
"sms_opt_out": false,
"call_opt_out": false,
"email_opt_out": false,
"custom_fields": { "age": 42 },
"created_at": "2026-09-02T09:00:00.000Z",
"updated_at": "2026-09-02T09:00:00.000Z"
},
"error": null,
"requestId": "req-abc123",
"apiVersion": 1
}

source records how the contact first reached your account. A contact created through this API reads api, and one created by an inbound call, an import, a web form or the dashboard reads the route it came in by.

A contact that has been deleted or erased in the dashboard is a 404 here, on the lookup, and on the export below.

GET /public/v1/contacts/:id/export​

Everything held about one contact: their details, their calls with transcripts, their chats with messages and form answers, and their leads. Use this to answer a subject access request.

curl "https://api.yourcalls.ai/public/v1/contacts/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/export" \
--header "Authorization: Bearer YOUR_API_KEY"
{
"success": true,
"data": {
"contact": {
"id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"first_name": "Alex",
"custom_fields": { "age": 42 }
},
"calls": [
{
"id": "ffffffff-1111-4222-8333-444444444444",
"direction": "outbound",
"started_at": "2026-09-01T14:02:11.000Z",
"ended_at": "2026-09-01T14:04:52.000Z",
"duration_seconds": 161,
"outcome": "callback",
"summary": "Alex asked to be rung back on Thursday morning.",
"transcript": "Assistant: Hello, is that Alex? …"
}
],
"chats": [
{
"id": "bbbbbbbb-2222-4333-8444-555555555555",
"started_at": "2026-08-28T10:15:00.000Z",
"ended_at": "2026-08-28T10:19:40.000Z",
"messages": [
{ "role": "user", "content": "Do you cover Leeds?", "created_at": "2026-08-28T10:15:04.000Z" }
],
"form_data": { "postcode": "SW1A 1AA" }
}
],
"leads": [],
"exported_at": "2026-09-02T09:00:00.000Z"
},
"error": null,
"requestId": "req-abc123",
"apiVersion": 1
}

contact is the same body the two reads above return, shortened here for space. Each history entry carries only the fields shown. Recording links, internal identifiers and the machinery a campaign uses to run a lead are all deliberately absent, so treat these three arrays as a fixed shape rather than as a window onto the underlying record.

A call carries id, direction, started_at, ended_at, duration_seconds, outcome, summary and transcript. direction is inbound or outbound. outcome is one of callback, transferred, unreachable, no_booking or error, and is empty on a call that finished without one being recorded.

A chat carries id, started_at, ended_at, a messages array of role, content and created_at, and the form_data the visitor filled in.

A lead carries id, created_at, status and source. status is that lead's own outcome, and is empty while the lead is still open. leads is an empty array for an account that runs no lead campaigns.

This response is not paged. It carries every call, chat and lead attributed to the contact, so for someone with a long history it can be large. Read it into a file rather than into a log line, and allow a generous client timeout.

Not available yet​

Listing or paginating contacts, and deleting a contact, are not part of this release. Delete a contact from the dashboard.

Errors​

Failures use the standard envelope. There are three you should handle by name.

A field that could not be saved is a 400, and the details object names every one of them:

{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Some fields could not be saved",
"details": {
"fields": [
{ "field": "age", "reason": "expected a number", "expectedType": "number" }
]
}
},
"requestId": "req-abc123",
"apiVersion": 1
}

A body that does not match the shape above, such as an unrecognised field or a country that is not two characters, is also a 400 VALIDATION_ERROR, but its details is an array locating the problem inside your request rather than an object of field errors. Check whether details is an array before reading it.

A phone number or email address already held by a different live contact is a 409 CONFLICT, with details.field naming which of the two collided. This is rare, and it means the details you sent point at two different people. Reconcile them at your end rather than retrying.

A contact that does not exist, or that has been deleted or erased, is a 404 NOT_FOUND on every route on this page.

See Errors for the shared codes and the authentication failures.