Developers

The Omnir API

A REST API with JSON in and out, plain record ids and readable field names, plus webhooks when records change. Connect your own tools, or Zapier and Make, in an afternoon.

Quick start

Every Omnir workspace has its own API at its own address. Replace yourcompany with your workspace name:

Base URL
https://yourcompany.omnir.com/api/v1

Create a key in Omnir under My Preferences → API Keys, then list your five most recently changed contacts:

curl
curl https://yourcompany.omnir.com/api/v1/contacts?per_page=5 \
  -H "Authorization: Bearer omn_your_key"
Response
{
  "data": [
    {
      "id": 4821,
      "name": "Jo Rivera",
      "first_name": "Jo",
      "last_name": "Rivera",
      "email": "jo@acme.com",
      "organization_id": 1093,
      "owner_id": 7,
      "do_not_call": false,
      "created_at": "2026-10-29T14:02:11Z",
      "updated_at": "2026-10-30T09:15:40Z"
    }
  ],
  "meta": { "page": 1, "per_page": 5, "total": 1894, "total_pages": 379 }
}

GET /api/v1 returns the key's user and every resource the key can use, which is the quickest way to check a new key works.

Authentication

Send your key in the Authorization header on every request. There is no login step and nothing to refresh: a key works until it is revoked.

HTTP
Authorization: Bearer omn_3f9c1e...
  • A key acts as its user. It sees and changes exactly what that user can in the CRM: the same roles, sharing rules and field permissions.
  • Read-only keys can only make GET requests. Use one for reporting and BI tools.
  • One key per integration, so you can revoke one without breaking the others. Omnir keeps keys encrypted, and you can copy yours again from My Preferences → API Keys at any time.
  • Everyone, admins included, creates their own keys in My Preferences. Admins see and revoke every key in the workspace, add webhooks and check usage in Settings → Developer API.

Keep keys on your server. Never put one in a web page or mobile app.

Requests and responses

  • Requests and responses are JSON. Send Content-Type: application/json with a body.
  • A single record comes back as {"data": {...}}; a list as {"data": [...], "meta": {...}}.
  • Records have plain numeric ids and a read-only name: the name the CRM shows for the record.
  • Links to other records are …_id fields holding that record's id, such as organization_id. owner_id is a user or group id.
  • Dates are YYYY-MM-DD. Date-times are ISO 8601 and always returned in UTC: 2026-11-02T15:30:00Z. You may send any offset, such as 2026-11-02T10:30:00-05:00.
  • Checkboxes are true / false, numbers are numbers, and multi-select fields are arrays of strings. Empty values are null.

Resources

Each CRM module is a resource. Custom modules your workspace adds appear too, under their own names. Which ones a key can use depends on its user's permissions.

ResourceIn the CRM
contactsContacts
leadsLeads
organizationsOrganizations
dealsDeals
ticketsHelp desk tickets
tasksTasks
eventsCalls and meetings
commentsComments on any record
quotesQuotes
sales_ordersSales orders
invoicesInvoices
purchase_ordersPurchase orders
productsProducts
servicesServices
price_booksPrice books
vendorsVendors
projectsProjects
project_tasksProject tasks
project_milestonesProject milestones
documentsDocuments and files
campaignsCampaigns
faqsFAQs
assetsAssets
service_contractsService contracts
Endpoints
GET    /api/v1/{resource}            list, filter and search
POST   /api/v1/{resource}            create
GET    /api/v1/{resource}/{id}       read one
PATCH  /api/v1/{resource}/{id}       update the fields you send
DELETE /api/v1/{resource}/{id}       delete (to the Recycle Bin)
GET    /api/v1/{resource}/fields     fields, allowed values and relations

Fields

Fields have readable names taken from their labels: first_name, lead_source, contract_value. That includes the custom fields your workspace adds. A field's name is fixed the first time the API sees it, so renaming its label in the CRM later never breaks an integration.

GET /api/v1/contacts/fields describes every field: its type, whether it is required or read-only, the allowed values of a picklist, and which resources a link field points to.

Response
{
  "data": {
    "resource": "contacts",
    "fields": [
      { "name": "last_name", "label": "Last Name", "type": "string", "required": true, "read_only": false },
      { "name": "lead_source", "label": "Lead Source", "type": "picklist", "required": false,
        "read_only": false, "values": ["Cold Call", "Partner", "Web Site"] },
      { "name": "organization_id", "label": "Organization Name", "type": "reference",
        "required": false, "read_only": false, "refers_to": ["organizations"] }
    ],
    "relations": [
      { "name": "deals", "label": "Deals", "can_link": false },
      { "name": "activities", "label": "Activities", "can_link": false },
      { "name": "documents", "label": "Documents", "can_link": true }
    ]
  }
}

Tasks and events have starts_at (and events ends_at) as one date-time instead of separate date and time fields. An event's contacts are a list, contact_ids.

Lists, filters and search

ParameterWhat it does
page, per_pagePaging. per_page defaults to 20, at most 100.
sortComma-separated fields; a leading - sorts descending. Default -updated_at.
qSearches the record name, email and phone fields.
fieldsReturn only these fields, e.g. fields=id,name,email.
updated_sinceOnly records changed at or after this UTC time. Use it to sync.
{field}=valueFilter on any field: email=jo@acme.com.
{field}[op]=valueFilter with an operator: amount[gte]=1000.
OperatorMeaning
eqEquals (the default: field=value)
neDoes not equal
gt, gteGreater than, or equal to
lt, lteLess than, or equal to
containsContains the text
starts_with, ends_withStarts or ends with the text
in, not_inOne of a comma-separated list, or none of it
emptyempty=true for blank values, empty=false for filled ones
curl
# Open deals worth 10,000 or more, biggest first
curl -g "https://yourcompany.omnir.com/api/v1/deals?amount[gte]=10000&sales_stage[not_in]=Closed%20Won,Closed%20Lost&sort=-amount" \
  -H "Authorization: Bearer omn_your_key"

# Everything that changed since your last sync
curl "https://yourcompany.omnir.com/api/v1/contacts?updated_since=2026-10-30T00:00:00Z&per_page=100&page=1" \
  -H "Authorization: Bearer omn_your_key"

With curl, add -g so the square brackets reach the API instead of being read as a curl pattern. HTTP libraries send them as they are.

Filters take the same values you would write: ids for link fields, true/false for checkboxes, UTC date-times for date-time fields. An unknown field, operator or sort is answered with a 400 that names it.

GET /api/v1/search?q=acme searches contacts, leads, organizations, deals, tickets, projects, quotes and invoices at once; name others with resources=products,vendors.

Creating and updating

curl
curl -X POST https://yourcompany.omnir.com/api/v1/contacts \
  -H "Authorization: Bearer omn_your_key" \
  -H "Content-Type: application/json" \
  -d '{"first_name": "Jo", "last_name": "Rivera", "email": "jo@acme.com",
       "organization_id": 1093, "lead_source": "Partner"}'
  • POST answers 201 with the new record, including its id and the values Omnir filled in.
  • PATCH changes only the fields you send and answers with the whole updated record.
  • A new record is owned by the key's user unless you send owner_id.
  • An event without ends_at gets the user's default call or meeting length.
  • Unknown fields are rejected, so a typo never fails silently. Read-only fields are ignored, so you can send back a record you read.
  • Saves run the CRM's workflows, assignment rules and history, exactly as a save in the app does.

Every problem in a request is reported at once:

422 Unprocessable Entity
{
  "error": {
    "code": "validation_failed",
    "message": "Some fields are not valid.",
    "details": [
      { "field": "email", "message": "Expected an email address." },
      { "field": "lead_source", "message": "\"Web\" is not one of the allowed values: Cold Call, Partner, Web Site." },
      { "field": "last_name", "message": "This field is required." }
    ]
  }
}

Files

Upload a file as multipart/form-data with the file in a field called file. Posted to a record's documents, it is stored and linked in one step:

curl
curl -X POST https://yourcompany.omnir.com/api/v1/contacts/4821/documents \
  -H "Authorization: Bearer omn_your_key" \
  -F "file=@proposal.pdf" \
  -F "title=Proposal, November"

curl https://yourcompany.omnir.com/api/v1/documents/77/file \
  -H "Authorization: Bearer omn_your_key" -o proposal.pdf

Users and groups

GET /api/v1/me is the key's user. GET /api/v1/users lists active users (status=all includes inactive ones) and GET /api/v1/groups the groups, so you can resolve and choose an owner_id. Both are read-only.

Errors

Errors use HTTP status codes and always have the same body: {"error": {"code", "message", "details"}}. Branch on code; the message is written for people and may change.

StatusWhen
200The request worked.
201A record was created or linked.
204Deleted or unlinked; no body.
400The request is malformed: bad JSON, an unknown parameter or operator.
401The key is missing, wrong or revoked.
403The key may not do this: a read-only key, or a record its user may not change.
404No such resource or record, or one its user cannot see.
405That method is not supported on this URL.
422The body has invalid or missing fields; details lists each one.
429Over the rate limit; wait for Retry-After seconds.
500Our fault. Retry later, and tell us if it persists.
CodeMeaning
unauthenticatedNo API key was sent.
invalid_keyThe key does not exist or was revoked.
read_only_keyA read-only key tried to change data.
forbiddenThe key's user is not allowed to do this.
not_foundThe resource or record does not exist, or is hidden from this user.
method_not_allowedUse one of the methods named in the message.
invalid_jsonThe body is not a JSON object.
invalid_parametersA query parameter, filter or sort is not valid; see details.
validation_failedFields in the body are not valid; see details.
not_linkableThis relation is a field on the other record; PATCH that record.
rate_limitedToo many requests this minute.
server_errorSomething went wrong on our side.

Rate limits

Each key may make 120 requests a minute. Every response carries the limit, what is left and when the window resets:

HTTP
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1793340300

Over the limit the API answers 429 with a Retry-After header in seconds. For bulk syncs, ask for 100 records a page and use updated_since rather than reading everything again.

Admins can see the last 30 days of requests, per key, under Settings → Developer API → Usage.

Webhooks

Instead of asking Omnir for changes, let Omnir tell you. An admin adds a webhook in Settings → Developer API → Webhooks with a URL and the changes to send: created, updated or deleted, for all records or chosen ones. Omnir then POSTs JSON to your URL as they happen.

POST to your URL
{
  "id": "evt_d21f716df6b1f99e",
  "event": "contacts.updated",
  "created_at": "2026-10-30T05:42:45Z",
  "data": { "id": 4821, "name": "Jo Rivera", "title": "Head of Ops", "...": "..." },
  "changed_fields": ["title"]
}
  • Events are {resource}.created, .updated and .deleted, such as deals.created.
  • data is the whole record, in the same shape the API returns. A deleted record sends only its id.
  • Updates list the changed_fields; a save that changes nothing sends nothing.
  • Headers: X-Omnir-Event, X-Omnir-Delivery (unique per delivery) and X-Omnir-Signature.
  • The id is the same for every retry of an event, so you can ignore one you have already handled.

Check the signature

Every webhook has a signing secret, shown in its settings. The signature header is t=<unix time>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> with that secret. Compare it before trusting the body, and reject old timestamps:

Node.js
import crypto from 'node:crypto'

export function verifyOmnir(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSeconds
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
PHP
function verify_omnir(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return abs(time() - (int) $parts['t']) < $tolerance && hash_equals($expected, $parts['v1']);
}

Answer quickly; Omnir retries

Answer with any 2xx status within 10 seconds and do slow work afterwards. Anything else, or no answer, is retried:

AttemptSent
1At once
21 minute later
35 minutes later
430 minutes later
52 hours later
612 hours later
724 hours later, then the delivery is marked failed

From the same Webhooks tab, admins can see each webhook's recent deliveries, send a test, pause it or rotate its secret. Webhooks are only sent to public https:// or http:// addresses.

Your workspace's reference

Because every workspace has its own fields and custom modules, each one has its own complete reference too, as an OpenAPI 3 file. Fetch it with your key and open it in Postman, Insomnia or a code generator:

curl
curl https://yourcompany.omnir.com/api/v1/openapi.json \
  -H "Authorization: Bearer omn_your_key" -o omnir-openapi.json

This is version 1. Changes within /v1 only add things: new resources, fields or optional parameters. Anything that would break an integration gets a new version.

Building something?

Tell us what you are connecting and we will help you get it right, or build the connector if it is something many customers need.