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.

  1. 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.
  2. 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.

Authorize
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.

Redirect
https://acme.example/unifix/callback?code=$CODE&state=$STATE&iss=https%3A%2F%2Fapp.unifix.io

Trade the code for tokens within a minute.

Request
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"
Response
{
  "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.

Refresh
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_denied instead of a code.
  • If they don't allow changes, scope comes back as read, even when you asked for read 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_client for a wrong secret, including one the shop has replaced. It allows 60 requests a minute per IP address, and past that it returns 429 with Retry-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.

Headers
Authorization: Bearer $ACCESS_TOKEN
Accept: application/json
Content-Type: application/json

GEThttps://api.unifix.io/me

Request
curl https://api.unifix.io/me \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Accept: application/json"
Response
{
  "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 id starts the paths below, and its url opens 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 /me again 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.

Expired token
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.

Too many requests
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.

Missing title
HTTP/1.1 422 Unprocessable Content
400
A parameter is missing, or a reporter's phone matches no contact.
400 error="invalid_request"
A token went to app.unifix.io. Send API calls to api.unifix.io.
401
The request has no token.
401 error="invalid_token"
The token expired, was revoked, or isn't for this API. Refresh it.
402 subscription_required
The shop's subscription isn't active. /me still works.
403 error="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.
429 rate_limited
More than 60 requests in a minute, or 1,000 in an hour, on one connection. Retry-After says how many seconds to wait: 60 after the minute's limit, 3,600 after the hour's.
503 account_not_ready
The account is still being set up. Try again in a minute.

Contacts

Search contacts

GET/{account_id}/contacts

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.
Request
curl https://api.unifix.io/24817/contacts \
  --get --data-urlencode "phone_number=+525551234567" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Accept: application/json"
Response
[
  {
    "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}/clients

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.
Request
curl "https://api.unifix.io/24817/clients?q=esquina" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Accept: application/json"
Response
[
  { "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}/clients/{id}/sites

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.
Request
curl "https://api.unifix.io/24817/clients/60412/sites?q=centro" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Accept: application/json"
Response
[
  { "id": 73384, "name": "Centro", "address": "Av. Juárez 10, Centro, CDMX" }
]

Results are paged like clients.

Tickets

Open a ticket

POST/{account_id}/buckets/{bucket_id}/tickets

Parameters

ticket.title required
A short summary of the problem.
ticket.site_id required
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.
Request
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 }
  }'
Response · 201 Created
{
  "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"
}
  • number is the ticket number people at the shop see. Use id in API calls.
  • url opens the ticket in Unifix, for a person. API calls use api.unifix.io.
  • Each shop names its own stages, so show stage as it comes.
  • status is active while the shop works on the ticket. It's drafted before the shop has set the ticket up, archived once the shop puts it away, and trashed or deleted once the shop throws it out. A ticket in the trash still answers, so check status before you act on one.

Update a ticket

PATCH/{account_id}/buckets/{bucket_id}/tickets/{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.

Request
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}/buckets/{bucket_id}/tickets/{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}/serial_numbers

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.
Request
curl "https://api.unifix.io/24817/serial_numbers?q=abc" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Accept: application/json"
Response
[
  {
    "id": 208815,
    "value": "ABC-123",
    "sku": { "id": 12693, "make": "True", "model": "T-49" }
  }
]

Results are paged like clients.

Search models

GET/{account_id}/skus

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.
Request
curl "https://api.unifix.io/24817/skus?q=t-49" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Accept: application/json"
Response
[
  { "id": 12693, "make": "True", "model": "T-49" }
]

Results are paged like clients.

Add equipment to a ticket

POST/{account_id}/buckets/{bucket_id}/line_items

Each piece of equipment on a ticket is a line item.

Parameters

parent_id required
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.
Request
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 } }'
Response · 201 Created
{
  "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}/attachments?name={file_name}

Request
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
Response · 201 Created
{
  "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}/buckets/{bucket_id}/uploads

Request
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…" }'
Response · 201 Created
{
  "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.