Developers
Unifix API
Connect your product to a shop's Unifix account. Your app works on behalf of whoever connected it: it sees what they can see, and everything it does carries their name.
Say a shop creates a user login called WhatsApp Assistant and connects Acme Chat with it. Acme Chat can see whatever WhatsApp Assistant can, and when it opens a ticket, the shop sees "WhatsApp Assistant via Acme Chat opened a ticket."
Connecting
Unifix uses OAuth 2.1, with the authorization code flow and PKCE. Two things happen at the shop before your app can connect.
- An admin at the shop registers your app in Unifix, under Manage people and account › Apps, with your redirect URL. They send you a client ID and a client secret.
- The shop creates a login for your app, like WhatsApp Assistant. Your app acts as that login, so it keeps working when staff come and go.
Then send the shop to the authorization URL. Someone signs in as your app's login and approves it.
https://app.unifix.io/oauth/authorize?response_type=code
&client_id=$CLIENT_ID
&redirect_uri=https://acme.example/unifix/callback
&code_challenge=$CODE_CHALLENGE
&code_challenge_method=S256
&scope=read+write
&state=$STATE
Unifix sends them back to your redirect URL with a code.
https://acme.example/unifix/callback?code=$CODE&state=$STATE&iss=https%3A%2F%2Fapp.unifix.io
Trade the code for tokens within a minute.
curl https://app.unifix.io/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d code_verifier="$CODE_VERIFIER" \
-d redirect_uri="https://acme.example/unifix/callback"
{
"access_token": "ufx_at_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "ufx_rt_…",
"scope": "read write"
}
Access tokens last an hour, so refresh them before they run out. The response looks like the one above.
curl https://app.unifix.io/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"
- The redirect URL has to match the registered one exactly.
- If they decline, the redirect carries
error=access_deniedinstead of a code. -
If they don't allow changes,
scopecomes back asread, even when you asked forread write. - Send your client secret with HTTP Basic auth. Unifix rejects it in the form body.
- Each code works once. Using one twice disconnects your app.
- Every refresh gives you a new refresh token. The old one keeps working for 30 seconds, so two of your workers can refresh at once, but using it after that disconnects your app.
- If you go 30 days without refreshing, the refresh token expires.
-
If a refresh returns
invalid_grant, the connection is gone (the shop removed your app, for example). Send the shop through the authorization URL again. -
The token URL returns 400 for most errors, and 401
invalid_clientfor a wrong secret, including one the shop has replaced. It allows 60 requests a minute per IP address, and past that it returns 429 withRetry-After. -
OAuth libraries can read the server's settings from
https://app.unifix.io/..well-known/ oauth-authorization-server
Making requests
API calls go to https://api.unifix.io, and every path below starts with the
account's id. Send these headers with each one.
Authorization: Bearer $ACCESS_TOKEN
Accept: application/json
Content-Type: application/json
GEThttps://api.unifix.io/
curl https://api.unifix.io/me \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"
{
"email_address": "whatsapp@polarkitchen.example",
"scope": "read write",
"accounts": [
{
"id": 24817,
"name": "Polar Kitchen Service",
"url": "https://app.unifix.io/24817/",
"person": {
"id": 386102,
"name": "WhatsApp Assistant",
"time_zone": "America/Mexico_City"
}
}
]
}
-
Each account's
idstarts the paths below, and itsurlopens the account in Unifix. - Times are in ISO 8601, in that person's
time_zone. -
A staff member's own login can reach several accounts, the ones they picked when they
connected your app. They can change that later, so check
/meagain if an account starts returning 404. - We add fields and endpoints without notice, so ignore fields you don't know. Before we change or remove anything, we tell you by email.
Errors and limits
When the token is the problem, the error is in the WWW-Authenticate header, and there's no body.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer scope="read write", error="invalid_token"
When the account or the connection is the problem, a JSON body carries an error code.
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"error": "rate_limited",
"error_description": "This connection made too many requests. Wait the seconds in Retry-After, then try again."
}
When the request is the problem, you get the status alone.
HTTP/1.1 422 Unprocessable Content
400- A parameter is missing, or a reporter's phone matches no contact.
400error="invalid_request"- A token went to
app.unifix.io. Send API calls toapi.unifix.io. 401- The request has no token.
401error="invalid_token"- The token expired, was revoked, or isn't for this API. Refresh it.
402subscription_required- The shop's subscription isn't active.
/mestill works. 403error="insufficient_scope"- The connection is read-only.
404- The API has no such endpoint, the token doesn't reach that account, or its login can't see that record.
415- A file came as a form instead of as the request body.
422- A ticket is missing its title or site, an email address isn't one, or a file is a type Unifix doesn't take.
429rate_limited-
More than 60 requests in a minute, or 1,000 in an hour, on one connection.
Retry-Aftersays how many seconds to wait: 60 after the minute's limit, 3,600 after the hour's. 503account_not_ready- The account is still being set up. Try again in a minute.
Contacts
Search contacts
GET/{account_id}/
Search the shop's contacts by phone number or email address. The whole number or address has to match. Several contacts can have the same one, so you get back a list, and it's empty when nobody matches.
Parameters (send one)
phone_number-
In international format, like
+525551234567. Mexican mobile numbers match with or without the 1 after+52. In a query string, encode the+as%2B. email_address- Case doesn't matter.
curl https://api.unifix.io/24817/contacts \
--get --data-urlencode "phone_number=+525551234567" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"
[
{
"id": 391457,
"name": "María García",
"email_address": "maria@laesquina.example",
"phone_number": "+525551234567",
"client": { "id": 60412, "bucket_id": 51920, "name": "La Esquina" },
"sites": [
{ "id": 73384, "name": "Centro", "address": "Av. Juárez 10, Centro, CDMX" }
]
}
]
client is null when the contact works for a company that isn't one of the shop's clients.
Clients
Search clients
GET/{account_id}/
Search the shop's clients. Leave out q to get all of them.
Parameters
q- The search query. It matches any part of a client's name or short code. Case and accents don't matter.
curl "https://api.unifix.io/24817/clients?q=esquina" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"
[
{ "id": 60412, "bucket_id": 51920, "name": "La Esquina" }
]
A client's tickets live in its bucket, so ticket calls take its bucket_id. Its sites take its id.
Results come 50 at a time. The Link header points to the next page, and
X-Total-Count has the total.
Sites
Search a client's sites
GET/{account_id}/
Search one client's sites, with the id that Search clients gives you. Leave out q to get all of them.
Parameters
q- The search query. It matches any part of a site's name or short code. Case and accents don't matter.
curl "https://api.unifix.io/24817/clients/60412/sites?q=centro" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"
[
{ "id": 73384, "name": "Centro", "address": "Av. Juárez 10, Centro, CDMX" }
]
Results are paged like clients.
Tickets
Open a ticket
POST/{account_id}/
Parameters
ticket.titlerequired- A short summary of the problem.
ticket.site_idrequired- One of the client's sites.
ticket.description- The details, as HTML. Wrap each paragraph in
<p>, since plain line breaks don't show. reporter.reporter_id- The contact who asked for the work.
reporter.name,reporter.email_address,reporter.phone_number- For someone you don't have an id for. Unifix looks for a contact of this client with that email, phone, or name, and saves a new contact if it finds none. A new contact needs a name or an email, so a phone that matches nobody gets a 400.
curl https://api.unifix.io/24817/buckets/51920/tickets \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"ticket": {
"title": "Walk-in cooler is warm",
"description": "<p>Reads 12°C since this morning.</p>",
"site_id": 73384
},
"reporter": { "reporter_id": 391457 }
}'
{
"id": 9420117,
"number": 214,
"title": "Walk-in cooler is warm",
"bucket_id": 51920,
"client": { "id": 60412, "bucket_id": 51920, "name": "La Esquina" },
"reporter": "María García",
"site": "Centro",
"stage": "Received",
"completed": false,
"canceled": false,
"status": "active",
"created_at": "2026-10-03T12:02:11.000-06:00",
"url": "https://app.unifix.io/24817/buckets/51920/tickets/9420117"
}
numberis the ticket number people at the shop see. Useidin API calls.urlopens the ticket in Unifix, for a person. API calls useapi.unifix.io.- Each shop names its own stages, so show
stageas it comes. -
statusisactivewhile the shop works on the ticket. It'sdraftedbefore the shop has set the ticket up,archivedonce the shop puts it away, andtrashedordeletedonce the shop throws it out. A ticket in the trash still answers, so checkstatusbefore you act on one.
Update a ticket
PATCH/{account_id}/
Send any of ticket.title, ticket.description, and
ticket.site_id. Unifix changes only the fields you send. An update makes a
drafted ticket active, so it needs the title and the site too.
curl -X PATCH https://api.unifix.io/24817/buckets/51920/tickets/9420117 \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{ "ticket": { "description": "<p>The compressor hums but never starts.</p>" } }'
The response is the updated ticket.
Check on a ticket
GET/{account_id}/
The response has the same fields as when you open a ticket.
Equipment
Search the shop's equipment, pick the result that fits, and add it to the ticket by its id. The API never creates equipment, so you can only add what the shop already has on file. In URLs and JSON, a unit is a serial number and a model is a SKU.
Search units
GET/{account_id}/
Parameters
q- The search query. It matches any part of a serial number. It also finds the units of any model that the query matches.
curl "https://api.unifix.io/24817/serial_numbers?q=abc" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"
[
{
"id": 208815,
"value": "ABC-123",
"sku": { "id": 12693, "make": "True", "model": "T-49" }
}
]
Results are paged like clients.
Search models
GET/{account_id}/
Parameters
q- The search query. It matches any part of a make, a model, its description, or its short code. Case and accents don't matter.
curl "https://api.unifix.io/24817/skus?q=t-49" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"
[
{ "id": 12693, "make": "True", "model": "T-49" }
]
Results are paged like clients.
Add equipment to a ticket
POST/{account_id}/
Each piece of equipment on a ticket is a line item.
Parameters
parent_idrequired- The ticket's
id. line_item.serial_number_id- A unit from the search.
line_item.sku_id- A model from the search, when the unit itself isn't on file. Send this or
serial_number_id.
curl https://api.unifix.io/24817/buckets/51920/line_items \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{ "parent_id": 9420117, "line_item": { "serial_number_id": 208815 } }'
{
"id": 9420163,
"sku": { "id": 12693, "make": "True", "model": "T-49" },
"serial_number": { "id": 208815, "value": "ABC-123" }
}
Files
Adding a file to a ticket takes two requests: first you send the file, then you add it to the ticket.
Send the file
POST/{account_id}/
curl "https://api.unifix.io/24817/attachments?name=gauge.jpg" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: image/jpeg" \
--data-binary @gauge.jpg
{
"attachable_sgid": "BAh7CEkiCGdpZAY6BkVU…",
"filename": "gauge.jpg",
"content_type": "image/jpeg",
"byte_size": 184392
}
Send the file's bytes as the request body, not as a form. If you don't add the file to a ticket within two days, Unifix deletes it.
Add it to a ticket
POST/{account_id}/
curl https://api.unifix.io/24817/buckets/51920/uploads \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{ "parent_id": 9420117, "attachable_sgid": "BAh7CEkiCGdpZAY6BkVU…" }'
{
"id": 9420164,
"title": "gauge.jpg",
"content_type": "image/jpeg",
"byte_size": 184392
}
Unifix accepts images, video, audio, PDFs, plain text, CSV, and Office files. Anything else, including SVG, gets a 422.