Esqase

Search documentation

Search all Esqase documentation pages

Authentication and scopes

Every request to the Esqase API has to prove who it is and is allowed to touch only the data you granted. This page explains how a request authenticates with your API key, how scopes limit what each key can do, the rate limit that protects your firm, and how the API reports errors.

If you have not made a key yet, see Managing API keys first.

The base address

All API requests go to:

https://api.esqase.com

There is a public root, GET /, that returns a short description of the service (its name, the current version, where to find the docs, and a link to the machine-readable spec). It needs no key and is handy for confirming you can reach the API at all. Every request that touches your firm's data lives under /v1, for example https://api.esqase.com/v1/contacts.

The machine-readable spec

The API also publishes a full OpenAPI 3.1 description of every endpoint at GET /v1/openapi.json (the openapi field in the GET / root links to it). Like the root, it needs no key. You can load this file into tools such as Postman, Swagger, or a code generator to explore the endpoints and scaffold an integration without copying anything by hand.

Quickstart: your first request

Once you have a key, every call is a normal HTTPS request with your key in the Authorization header. The examples below list your contacts (a GET, which needs contacts:read) and then create one (a POST, which needs contacts:write). Each example reads the key from an environment variable, so you never paste the secret into your code.

cURL

# List contacts
curl https://api.esqase.com/v1/contacts \
  -H "Authorization: Bearer $ESQASE_API_KEY"

# Create a contact
curl -X POST https://api.esqase.com/v1/contacts \
  -H "Authorization: Bearer $ESQASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "PERSON",
    "firstName": "Jane",
    "lastName": "Doe",
    "emails": ["jane@example.com"]
  }'

JavaScript (fetch)

const BASE = "https://api.esqase.com/v1";
const headers = {
  Authorization: `Bearer ${process.env.ESQASE_API_KEY}`,
  "Content-Type": "application/json",
};

// List contacts
const listResponse = await fetch(`${BASE}/contacts`, { headers });
const { data, pagination } = await listResponse.json();

// Create a contact
const createResponse = await fetch(`${BASE}/contacts`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    type: "PERSON",
    firstName: "Jane",
    lastName: "Doe",
    emails: ["jane@example.com"],
  }),
});
const { data: newContact } = await createResponse.json();

Python (requests)

import os
import requests

BASE = "https://api.esqase.com/v1"
headers = {"Authorization": f"Bearer {os.environ['ESQASE_API_KEY']}"}

# List contacts
list_response = requests.get(f"{BASE}/contacts", headers=headers)
payload = list_response.json()

# Create a contact
create_response = requests.post(
    f"{BASE}/contacts",
    headers=headers,
    json={
        "type": "PERSON",
        "firstName": "Jane",
        "lastName": "Doe",
        "emails": ["jane@example.com"],
    },
)
new_contact = create_response.json()["data"]

What comes back

The list call returns your records in data, plus a pagination block that tells you the page size and how many records exist. Use ?limit= (default 20, max 100) and ?offset= to page through larger sets.

{
  "data": [
    {
      "id": "a1b2c3d4-0000-4a2b-8c1d-1234567890ab",
      "type": "PERSON",
      "status": "ACTIVE",
      "name": "Jane Doe",
      "firstName": "Jane",
      "lastName": "Doe",
      "companyName": null,
      "emails": ["jane@example.com"],
      "phones": [],
      "createdAt": "2026-07-01T12:00:00.000Z",
      "updatedAt": "2026-07-01T12:00:00.000Z"
    }
  ],
  "pagination": { "limit": 20, "offset": 0, "total": 1 }
}

The create call returns 201 Created with the new record wrapped in data:

{
  "data": {
    "id": "b2c3d4e5-1111-4b3c-9d2e-234567890abc",
    "type": "PERSON",
    "status": "ACTIVE",
    "name": "Jane Doe",
    "firstName": "Jane",
    "lastName": "Doe",
    "companyName": null,
    "emails": ["jane@example.com"],
    "phones": [],
    "createdAt": "2026-07-01T12:00:00.000Z",
    "updatedAt": "2026-07-01T12:00:00.000Z"
  }
}

Using Postman

You do not have to build requests by hand. Because the API publishes an OpenAPI spec, Postman can import every endpoint for you.

  1. In Postman, choose Import, then Link, and paste the spec URL:

    https://api.esqase.com/v1/openapi.json
    
  2. Postman creates a collection with all the endpoints and their parameters already filled in.

  3. Open the collection's Authorization tab, set the type to Bearer Token, and paste your esq_ key as the token. If you prefer, add a header named X-API-Key with the key value instead; either works.

Every request in the collection now sends your key automatically.

Tip: The same https://api.esqase.com/v1/openapi.json URL works in Swagger UI, Insomnia, and OpenAPI code generators, so you can scaffold a typed client in the language of your choice.

Authenticating a request

Send your API key with every request. There are two accepted ways, and you only use one:

  • Authorization header (recommended):

    Authorization: Bearer esq_your_key_here
    
  • X-API-Key header:

    X-API-Key: esq_your_key_here
    

Esqase keys always start with esq_. The two methods are equivalent; the Authorization: Bearer form is the common convention and the one to prefer.

If the key is missing, malformed, revoked, expired, or simply wrong, the API replies with 401 and a generic "invalid or missing API key" message. The message is deliberately vague (it never tells an attacker which part was wrong), so when you hit a 401, check the key value, that it is still Active in the dashboard, and that it has not passed its expiry date.

Note: Keys are stored only in a scrambled (hashed) form, so the dashboard cannot show you a key after creation. Keep the value your integration uses in a secrets store, not in the dashboard.

Scopes: least privilege

A scope is a single permission attached to a key. Each scope names one kind of record and one action. The ten scopes are:

ScopeWhat it allows
contacts:readRead contacts
contacts:writeCreate, update, and delete contacts
matters:readRead matters
matters:writeCreate, update, and delete matters
leads:readRead leads
leads:writeCreate, update, and delete leads
practice-areas:readRead practice areas
practice-areas:writeCreate, update, and delete practice areas
matter-types:readRead matter types
matter-types:writeCreate, update, and delete matter types

You choose these as the Contacts / Matters / Leads / Practice areas / Matter types checkboxes (each with Read and Write) when you create the key. See Managing API keys.

The rule is simple: every request needs the matching scope. A GET (reading) needs the resource's :read scope; a POST, PATCH, or DELETE (writing) needs its :write scope. If a key tries something it does not have the scope for, the request is rejected with 403 and a "missing required scope" message.

Give each key the fewest scopes that let it do its job. A key that only imports contacts needs contacts:write and nothing else; a key that mirrors your matters into a reporting tool needs only matters:read.

A key can never exceed its creator

Scopes are only the first of two gates. A key always acts as the member who created it, so on top of the scopes you ticked, every request is also checked against that member's own permissions in the firm.

That means a key can do something only when both are true:

  1. The key has the matching scope, and
  2. The member who created the key is allowed to do that thing in the app.

For example, a key with matters:write whose creator only has view access to matters still cannot create matters. The request is refused. This is why it matters who creates a key: a key is never a way around a person's normal permissions. To learn how a member's permissions are set, see Roles and permissions.

Restricting a key to certain domains

Scopes and the creator's permissions control what a key can do. A key can also be locked to where it is used from in a browser. Each key carries an optional allowed domains list (a hostname allowlist) that acts as a browser, or CORS, lock.

Here is exactly how it behaves:

  • Empty list (the default) means any origin may use the key. No restriction is applied.
  • The list is checked only for browser requests, against the request's Origin header (falling back to the Referer host). A browser request from a host that is not on the list is rejected with 403 and the code origin_not_allowed.
  • Requests with no Origin or Referer header are never blocked by the allowlist. That covers curl, Postman, and backend or server-side SDK calls, where the API key itself is the credential. So the allowlist protects a key that runs on a web page; it does not block server-to-server calls, and the curl or Postman examples above keep working even against a domain-restricted key.
  • A *.example.com entry matches subdomains such as app.example.com but not the bare apex example.com. To allow the apex as well, add example.com as its own entry.
  • CORS is supported. For an allowed browser origin the API returns the matching Access-Control-Allow-Origin header and answers OPTIONS preflight requests, so a key with an allowlist can still be called from your permitted web pages.

You set this in the Allowed domains (optional) field of the Create API key dialog (one hostname per line, or comma-separated; leave it empty to allow any origin), and you can change it later from the key's Domains button. See Managing API keys.

Rate limiting

To keep one busy or runaway integration from overwhelming your firm's data, each key has a fixed limit of about 600 requests per minute. If a key goes over, further requests for the rest of that minute are rejected with 429 (too many requests).

A 429 response includes a Retry-After header telling you how many seconds to wait before trying again. A well-behaved integration reads that header and pauses, then resumes. If you regularly hit the limit, spread your requests out over time or batch your work into fewer calls.

Errors

When something goes wrong, the API replies with a standard HTTP status code and a small JSON body in this shape:

{
  "error": {
    "code": "forbidden",
    "message": "This API key is missing the required scope."
  }
}

The code is a short, stable label you can branch on in your integration; the message is a plain-English sentence. Messages are intentionally generic and never reveal internal details about your firm.

The status codes you will see:

StatusMeaning
400Invalid request. Something in your request body or parameters is wrong (for example a missing required field).
401Invalid or missing API key. Check the key, its status, and its expiry.
403Missing scope (forbidden). The key does not have the scope this request needs (or its creator lacks the permission).
403Blocked origin (origin_not_allowed). A browser request came from a domain that this key's allowed-domains list does not permit. Read the code field to tell this apart from a missing-scope 403.
404Not found. The record you asked for does not exist (or is not in your firm).
409Conflict. The request could not be completed in the record's current state.
429Rate limited. You sent too many requests; wait the Retry-After seconds.
500Something went wrong on Esqase's side. Try again shortly.

Tip: Build your integration to read both the status code and the code field. A 400 means fix the request; a 401 or a forbidden 403 means fix the key or its scopes; an origin_not_allowed 403 means fix the key's allowed domains (or the page it is called from); a 429 means slow down; a 500 means retry later.

Common questions

  • Which header should I use? Either works. Prefer Authorization: Bearer <key>.
  • My request returns 403 even though the key has the scope. Why? The key also has to pass the permissions of the member who created it. If that member cannot do the action in the app, the key cannot either. See a key can never exceed its creator.
  • How do I avoid 429s? Stay under roughly 600 requests a minute per key, honor the Retry-After header, and batch work where you can.
  • Are error messages safe to log? Yes. They are generic by design and never include sensitive firm data.