Manage the people you talk to. Contacts live in lists, carry standard details and any custom fields you define, and can be archived rather than deleted outright.
Success is rarely 200 here
Creating something answers 201. Updating, deleting and archiving answer 202. Validation failures answer 406, not 400. Code that treats anything other than 200 as a failure will report successful writes as errors — see What the status codes mean.
A list is the unit of organisation
Almost everything hangs off a list: contacts belong to one, custom fields are defined per list, and segments are read per list. Create a list first.
Download for AI review
Copy or download this guide as Markdown to paste into an AI assistant for
help integrating against it.
Environment
You are reading the guide for the host that served this page. The badge in the header and every example below already point at it — nothing to substitute by hand, and no other environment's addresses appear on this page.
Property
Value
Environment
Base URL
https://crm-dev.jirafix.net
Contact paths
are under /v1/contacts
Channel counts
are under /v1/{channel}
Authentication
Every endpoint needs a bearer token, and this host does not issue one. Exchange your credentials at the Authenticate API — on this environment that is https://authenticate-dev.jirafix.net — then send what it returns as Authorization: Bearer <access_token> on each request here.
This API holds personal data
Contacts are real people's names, phone numbers and email addresses. Credentials embedded in a browser page or a mobile app are published credentials — request the token from your own backend and never ship it to a client.
POSThttps://authenticate-dev.jirafix.net/v1/token
Exchanges your credentials for an access token.
Tokens last 3600 seconds by default. Request a new one when it expires — there is no separate refresh call, though the response does include a refresh_token. Full detail is on the Authenticate guide at https://authenticate-dev.jirafix.net/docs.
Parameters
Name
Type
Required
Description
username
string
Yes
The account's username, usually an email address.
password
string
Yes
The account's password. Server-side only.
privatetoken
string
Yes
Your account's private token, from the portal's configuration section. Note the spelling — one word, all lower case.
validity
integer
No
How long the token should last, in seconds. Defaults to 3600.
Responses
Status
Meaning
200
Returns access_token, refresh_token, token_type and expires_in.
400
The body was missing or a required field was absent.
401
The username, password or private token was not accepted.
# 1. get a token from the Authenticate host
ACCESS_TOKEN=$(curl -s -X POST https://authenticate-dev.jirafix.net/v1/token \
-H "Content-Type: application/json" \
-d '{"username":"you@yourcompany.com","password":"'"$OLANZO_PASSWORD"'","privatetoken":"'"$OLANZO_PRIVATE_TOKEN"'"}' \
| jq -r .access_token)
# 2. spend it here
curl -X GET "https://crm-dev.jirafix.net/v1/contacts/lists" \
-H "Authorization: Bearer $ACCESS_TOKEN"
using var auth = new HttpClient { BaseAddress = new Uri("https://authenticate-dev.jirafix.net") };
var tokenResponse = await auth.PostAsJsonAsync("/v1/token", new
{
username = "you@yourcompany.com",
password = Environment.GetEnvironmentVariable("OLANZO_PASSWORD"),
privatetoken = Environment.GetEnvironmentVariable("OLANZO_PRIVATE_TOKEN"),
});
// the property is access_token, not accessToken
var payload = await tokenResponse.Content.ReadFromJsonAsync<JsonElement>();
var token = payload.GetProperty("access_token").GetString();
using var api = new HttpClient { BaseAddress = new Uri("https://crm-dev.jirafix.net") };
api.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
var response = await api.SendAsync(
new HttpRequestMessage(HttpMethod.Get, "/v1/contacts/lists"));
// 1. get a token from the Authenticate host
const tokenResponse = await fetch("https://authenticate-dev.jirafix.net/v1/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "you@yourcompany.com",
password: process.env.OLANZO_PASSWORD,
privatetoken: process.env.OLANZO_PRIVATE_TOKEN,
}),
});
// note the underscore — accessToken is undefined
const { access_token } = await tokenResponse.json();
// 2. spend it here
const response = await fetch("https://crm-dev.jirafix.net/v1/contacts/lists", {
method: "GET",
headers: { Authorization: `Bearer ${access_token}` },
});
import os, requests
# 1. get a token from the Authenticate host
token_response = requests.post(
"https://authenticate-dev.jirafix.net/v1/token",
json={
"username": "you@yourcompany.com",
"password": os.environ["OLANZO_PASSWORD"],
"privatetoken": os.environ["OLANZO_PRIVATE_TOKEN"],
},
)
# note the underscore — "accessToken" raises KeyError
access_token = token_response.json()["access_token"]
# 2. spend it here
response = requests.get(
"https://crm-dev.jirafix.net/v1/contacts/lists",
headers={"Authorization": f"Bearer {access_token}"},
)
The fields are access_token, refresh_token, token_type and expires_in — not accessToken or expiresIn. Reading the camelCase spelling gives you nothing, with no error to explain it.
Use the Authenticate host for this same environment
A token carries the API domains it is allowed to reach. One issued by a different environment's Authenticate host is rejected here with a 401 that reads like bad credentials, so check the pair before you check your password: this page is https://crm-dev.jirafix.net and its Authenticate host is https://authenticate-dev.jirafix.net.
What the status codes mean
Worth reading before you write any error handling — this API's codes do not follow the pattern most do.
Code
When
Note
200
A read succeeded.
Reads only.
201
Something was created.
Creating a list, a custom field, or a contact that did not exist.
202
An update, delete or archive was accepted.
The common success code for writes — not200.
400
The request body was missing or unreadable.
Structural, not business rules.
401
The token is missing, expired or invalid.
406
A business rule rejected the request.
Where most APIs would answer 400.
Treat 2xx as success, not 200
The single most common integration mistake here is if (status == 200). Creating a contact returns 201, updating one returns 202, and both are successes.
Add your first contact
Create a list, then put someone in it. The list id from step one is what step two needs.
There is no separate update endpoint for a contact. Post the contact and we match it against what already exists — 201 means we created a new one, 202 means we updated a match.
POST/v1/contacts/insertupdate
Adds one contact, or updates it if it already exists.
Parameters
Name
Type
Required
Description
ListId
guid
Yes
The list to add the contact to.
Email
string
No
Email address. Supply this, a mobile number, or both.
MobileCountryCode
integer
No
Dialling code as a number, e.g. 506 for Costa Rica.
MobileNumber
number
No
Mobile number as a number, without the country code.
EmailOptIn
boolean
No
Whether they have agreed to email.
MobileOptIn
boolean
No
Whether they have agreed to SMS.
PropertyFields
array
No
Your custom fields for this contact — see Custom fields.
ContactId
number
No
Target a specific existing contact instead of matching on email or mobile.
Responses
Status
Meaning
201
A new contact was created.
202
An existing contact was updated.
401
Missing, expired or invalid bearer token.
406
A business rule rejected it — for example neither an email nor a mobile number.
curl -X GET https://crm-dev.jirafix.net/v1/contacts/9f1c7d2e-4b3a-4c1d-9e8f-2a7b6c5d4e3f/Status \
-H "Authorization: Bearer <your-token>"
Reading contacts
Two reads use the OPTIONS method
Searching contacts and listing archived contacts are served over HTTP OPTIONS with a JSON body, not POST or GET. That is deliberate and is what the API expects — but some HTTP clients, proxies and browser fetch wrappers will not send a body on OPTIONS, or will intercept it as a CORS preflight. If your client silently drops the body, that is why. Call these from a server-side client that lets you control the method.
GET/v1/contacts/{contactId}
Reads one contact by its id.
Parameters
Name
Type
Required
Description
contactId
number
Yes
The contact's numeric id.
Responses
Status
Meaning
200
The contact.
401
Missing, expired or invalid bearer token.
curl -X GET https://crm-dev.jirafix.net/v1/contacts/104213 \
-H "Authorization: Bearer <your-token>"
OPTIONS/v1/contacts
Searches contacts by list or by contact id.
Note the method: OPTIONS, with a JSON body. Supply ListIds, ContactIds, or both.
How many contacts you can actually reach on each channel — which is not the same as how many contacts exist, because it accounts for opt-in and for having the right contact detail.
The channel is in the path
email, sms and whatsapp each have their own pair of paths, taking the same parameters and returning the same shape.
GET/v1/email/lists
Lists every list with its email-reachable contact count.
Parameters
Name
Type
Required
Description
excludeCount
boolean
No
Set to true to skip counting, which returns faster on large lists.
Responses
Status
Meaning
200
The lists, with counts unless excluded.
401
Missing, expired or invalid bearer token.
curl -X GET "https://crm-dev.jirafix.net/v1/email/lists?excludeCount=false" \
-H "Authorization: Bearer <your-token>"
GET/v1/email/{listId}/contacts/count
Email-reachable contact count for one list.
Parameters
Name
Type
Required
Description
listId
guid
Yes
The list to count.
Responses
Status
Meaning
200
The count and segment breakdown.
401
Missing, expired or invalid bearer token.
curl -X GET https://crm-dev.jirafix.net/v1/email/3fa85f64-5717-4562-b3fc-2c963f66afa6/contacts/count \
-H "Authorization: Bearer <your-token>"
GET/v1/sms/lists
Lists every list with its sms-reachable contact count.
Parameters
Name
Type
Required
Description
excludeCount
boolean
No
Set to true to skip counting, which returns faster on large lists.
Responses
Status
Meaning
200
The lists, with counts unless excluded.
401
Missing, expired or invalid bearer token.
curl -X GET "https://crm-dev.jirafix.net/v1/sms/lists?excludeCount=false" \
-H "Authorization: Bearer <your-token>"
GET/v1/sms/{listId}/contacts/count
Sms-reachable contact count for one list.
Parameters
Name
Type
Required
Description
listId
guid
Yes
The list to count.
Responses
Status
Meaning
200
The count and segment breakdown.
401
Missing, expired or invalid bearer token.
curl -X GET https://crm-dev.jirafix.net/v1/sms/3fa85f64-5717-4562-b3fc-2c963f66afa6/contacts/count \
-H "Authorization: Bearer <your-token>"
GET/v1/whatsapp/lists
Lists every list with its whatsapp-reachable contact count.
Parameters
Name
Type
Required
Description
excludeCount
boolean
No
Set to true to skip counting, which returns faster on large lists.
Responses
Status
Meaning
200
The lists, with counts unless excluded.
401
Missing, expired or invalid bearer token.
curl -X GET "https://crm-dev.jirafix.net/v1/whatsapp/lists?excludeCount=false" \
-H "Authorization: Bearer <your-token>"
GET/v1/whatsapp/{listId}/contacts/count
Whatsapp-reachable contact count for one list.
Parameters
Name
Type
Required
Description
listId
guid
Yes
The list to count.
Responses
Status
Meaning
200
The count and segment breakdown.
401
Missing, expired or invalid bearer token.
curl -X GET https://crm-dev.jirafix.net/v1/whatsapp/3fa85f64-5717-4562-b3fc-2c963f66afa6/contacts/count \
-H "Authorization: Bearer <your-token>"
Errors
See What the status codes mean for the full picture. The short version: 2xx is success, and a rejected request is 406 rather than 400.
Status
What it means
What to do
200
A read succeeded, or a delete completed.
Use the payload.
201
Something was created.
Treat as success. Read the new id from the body.
202
An update, delete or archive was accepted.
Treat as success. For a batch import, poll the status endpoint.
400
The request body was missing or unreadable.
Check you sent a body and that it parses.
401
The token is missing, expired or invalid.
Fetch a new token and retry once.
406
A business rule rejected the request.
Read the message. Retrying unchanged will fail again.