Developers
Connect to MatterFirst
What you can connect to, how access is granted and taken away, and how to build an integration that survives retries. The full reference is in the API reference and the OpenAPI document.
What you can connect to
MatterFirst offers a REST API under /api/v1, described by an OpenAPI document at /api/v1/openapi and browsable at /docs, and signed webhooks that tell your system when something changes. API access is available on Practice and above. Sending updates to another app (webhooks) is available on Practice and above.
| Surface | Address | How it is authenticated |
|---|---|---|
| REST API | /api/v1/… | An API key sent as Authorization: Bearer mf_live_… |
| Webhooks MatterFirst sends to you | An https address your firm registers | Each delivery is signed with that address's own secret |
| Webhooks you send to start an automation | /api/webhooks/automations/{automation id} | An HMAC-SHA256 signature of the body, using the automation's own secret |
| Referral partner submissions | /api/v1/referral-contracts/{contract}/submissions | A partner key (mf_ref_…) issued for that partner only |
Nothing else is supported. In particular, connections from outside AI tools are paused and MatterFirst accepts no model of your own; see “What is switched off”.
Keys and authentication
A firm admin or owner creates keys in Settings → API keys. The full key is shown once, when it is created; MatterFirst keeps only a hash of it. Send it on every request:
curl https://matterfirst.com/api/v1/me \
-H "Authorization: Bearer mf_live_…"
- Every request acts as the person who created the key. It can do no more than that person could in MatterFirst, and no more than the key's scopes allow.
- A key stops working when it is revoked, when the person who created it leaves or is deactivated, or when the firm switches off “Other software using an API key” in Settings › Security & data › Where data can go.
- An unusable key is refused with 401. A key without the scope an endpoint needs is refused with 403 and names the missing scope, for example “Missing scope: matters:read”. A key refused by the firm's switch gets 403 with a sentence saying so.
- GET /api/v1/me says who the key acts for, and GET /api/v1/capabilities lists the scopes, page size and status values the API uses. Neither needs a scope.
Scopes
A scope is chosen when the key is created. GET and HEAD need the resource's read scope; POST, PUT, PATCH and DELETE need its write scope. Two POSTs are reads in substance and need only the read scope: POST /documents/{id}/download and POST /invoices/preview. An endpoint with no scope listed here is refused to every key.
| Read scope | Write scope | Paths under /api/v1 |
|---|---|---|
| audit:read | none (read only) | /audit-log |
| automations:read | automations:write | /automations |
| billing:read | billing:write | /disbursements, /invoices, /time-entries |
| calendar:read | calendar:write | /calendar-events |
| contacts:read | contacts:write | /contact-types, /contacts |
| documents:read | documents:write | /document-request-templates, /document-requests, /documents, /folders |
| emails:read | emails:write | /email-drafts, /email-templates, /emails |
| integrations:read | none (read only) | /integrations |
| leads:read | leads:write | /lead-pipelines, /leads |
| matter_types:read | matter_types:write | /field-groups, /key-date-types, /matter-types, /role-templates |
| matters:read | matters:write | /key-dates, /matters |
| organization:read | none (read only) | /organization |
| referrals:read | none (read only) | /referral-contracts |
| reports:read | reports:write | /reports |
| tasks:read | tasks:write | /task-templates, /tasks |
| users:read | none (read only) | /teams, /users |
| webhooks:read | webhooks:write | /webhook-deliveries, /webhook-events, /webhooks |
| workflows:read | workflows:write | /workflows |
Exempt from scopes: /me and /capabilities, because they describe the key itself.
Payloads
Requests and responses are JSON in a JSON:API-like envelope. Writes send the fields inside data.attributes:
POST /api/v1/matters
Content-Type: application/json
Idempotency-Key: 7f6d2c9e-create-harper-purchase
{"data": {"attributes": {
"name": "Harper purchase",
"matter_type_id": "<id from GET /api/v1/matter-types>",
"jurisdiction_region": "VIC"
}}}
- Lists take page_number and page_size and answer with meta (total, page, page_size, total_pages) and links (self, first, last, prev, next).
- Filter with filter[field]=value, sort with sort=field or sort=-field, choose fields with fields[type]=a,b and embed related records with include=.
- Every error has the same shape: {"errors": [{"status", "code", "title", "detail", "source"}]}. Branch on code; show detail to a person.
- Matters, invoices, tasks and leads return an ETag. Send it back as If-Match on an update and a stale write is refused with 412 before anything changes.
- The OpenAPI document at /api/v1/openapi has the exact fields of every resource.
Retrying safely
- Send an Idempotency-Key header (up to 255 characters) on every POST you might retry. Repeating the same key and body replays the first answer exactly, with Idempotency-Replayed: true, and does the work once.
- The same key with a different body is refused with 409 idempotency_key_reuse. The same key while the first request is still running gets 409 idempotency_key_in_flight with Retry-After.
- An answer is kept for 24 hours. A 5xx answer is never kept, so retrying it runs the request again.
- Retry a 429 after the number of seconds in Retry-After. Retry a 5xx or a dropped connection with a growing wait, reusing the same Idempotency-Key. Do not retry any other 4xx: fix the request instead.
Rate limits
| Limit | Requests per 60 seconds |
|---|---|
| Each API key | 300 |
| The whole firm, all keys together | 1000 |
| Each API key, for POSTs ending in /send, /run, /convert, /uploads, /test, /merge | 30 |
| Each automation's incoming webhook address | 120 |
Every API answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Going over answers 429 with the code rate_limited and a Retry-After header.
Webhooks MatterFirst sends to you
Register an https address in Settings → Send updates, or with POST /api/v1/webhooks and the events you want (an exact name, category.* or *). The signing secret is returned once, in meta.secret; rotate it with POST /api/v1/webhooks/{id}/rotate-secret and send a test with POST /api/v1/webhooks/{id}/test.
Each delivery is a POST with this body and these headers:
x-matterfirst-event: matter.created
x-matterfirst-delivery: <delivery id>
x-matterfirst-timestamp: 1790000000
x-matterfirst-signature: v1=<hex>
{"event": "matter.created",
"organization_id": "<your firm>",
"resource_id": "<the matter>",
"payload": {"id": "<the matter>", "name": "Harper purchase", "...": "..."},
"fired_at": "2026-09-23T01:02:03Z"}
- Verify every delivery: compute HMAC-SHA256 of "v1.<timestamp>.<raw body>" with the secret, compare it in constant time with the hex after v1=, and refuse a timestamp more than 300 seconds from now.
- Answer with any 2xx within 30 seconds. Anything else, or no answer, is a failure. Redirects are not followed.
- A failed delivery is retried 6 more times, after 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 1 day.
- After 10 failures in a row the address is switched off; switch it back on in Settings → Send updates. The delivery log is at GET /api/v1/webhooks/{id}/deliveries, and POST /api/v1/webhook-deliveries/{id}/retry sends one again.
- The address is checked again before every attempt, and an address that resolves to a private network is refused and not retried.
- Deliveries can repeat. Use x-matterfirst-delivery to ignore one you have already handled.
Events you can subscribe to, by category (GET /api/v1/webhook-events lists them):
| Category | Events |
|---|---|
| calendar_event | calendar_event.bulk_deleted, calendar_event.bulk_updated, calendar_event.created, calendar_event.deleted, calendar_event.updated |
| contact | contact.bulk_deleted, contact.bulk_updated, contact.created, contact.deleted, contact.updated |
| contact_type | contact_type.bulk_deleted, contact_type.bulk_updated, contact_type.created, contact_type.deleted, contact_type.updated |
| disbursement | disbursement.bulk_deleted, disbursement.bulk_updated, disbursement.created, disbursement.deleted, disbursement.updated |
| document | document.bulk_deleted, document.bulk_updated, document.created, document.deleted, document.updated |
| email_draft | email_draft.bulk_deleted, email_draft.bulk_updated, email_draft.created, email_draft.deleted, email_draft.updated |
| email_template | email_template.bulk_deleted, email_template.bulk_updated, email_template.created, email_template.deleted, email_template.updated |
| field_definition | field_definition.bulk_deleted, field_definition.bulk_updated, field_definition.created, field_definition.deleted, field_definition.updated |
| field_group | field_group.bulk_deleted, field_group.bulk_updated, field_group.created, field_group.deleted, field_group.updated |
| folder | folder.bulk_deleted, folder.bulk_updated, folder.created, folder.deleted, folder.updated |
| intelligence | intelligence.rollup_completed |
| invoice | invoice.bulk_deleted, invoice.bulk_updated, invoice.created, invoice.deleted, invoice.updated |
| invoice_line_item | invoice_line_item.bulk_deleted, invoice_line_item.bulk_updated, invoice_line_item.created, invoice_line_item.deleted, invoice_line_item.updated |
| key_date | key_date.bulk_deleted, key_date.bulk_updated, key_date.created, key_date.deleted, key_date.updated |
| key_date_type | key_date_type.bulk_deleted, key_date_type.bulk_updated, key_date_type.created, key_date_type.deleted, key_date_type.updated |
| lead | lead.bulk_deleted, lead.bulk_updated, lead.created, lead.deleted, lead.updated |
| lead_note | lead_note.bulk_deleted, lead_note.bulk_updated, lead_note.created, lead_note.deleted, lead_note.updated |
| matter | matter.bulk_deleted, matter.bulk_updated, matter.created, matter.deleted, matter.updated |
| matter_fact | matter_fact.asserted, matter_fact.disputed, matter_fact.rejected, matter_fact.superseded, matter_fact.verified |
| matter_role | matter_role.bulk_deleted, matter_role.bulk_updated, matter_role.created, matter_role.deleted, matter_role.updated |
| matter_type | matter_type.bulk_deleted, matter_type.bulk_updated, matter_type.created, matter_type.deleted, matter_type.updated |
| migration | migration.cut_over, migration.divergence_detected, migration.imported, migration.parallel_run_started, migration.rolled_back |
| note | note.bulk_deleted, note.bulk_updated, note.created, note.deleted, note.updated |
| obligation | obligation.confirmed, obligation.detected, obligation.dismissed, obligation.missed, obligation.satisfied |
| payment | payment.bulk_deleted, payment.bulk_updated, payment.created, payment.deleted, payment.updated |
| report | report.bulk_deleted, report.bulk_updated, report.created, report.deleted, report.updated |
| role_template | role_template.bulk_deleted, role_template.bulk_updated, role_template.created, role_template.deleted, role_template.updated |
| step | step.bulk_deleted, step.bulk_updated, step.created, step.deleted, step.updated |
| task | task.bulk_deleted, task.bulk_updated, task.created, task.deleted, task.updated |
| task_comment | task_comment.bulk_deleted, task_comment.bulk_updated, task_comment.created, task_comment.deleted, task_comment.updated |
| task_template | task_template.bulk_deleted, task_template.bulk_updated, task_template.created, task_template.deleted, task_template.updated |
| time_entry | time_entry.bulk_deleted, time_entry.bulk_updated, time_entry.created, time_entry.deleted, time_entry.updated |
| workflow | workflow.bulk_deleted, workflow.bulk_updated, workflow.created, workflow.deleted, workflow.updated |
Webhooks you send to start an automation
An automation that starts “When a webhook is received” has its own address and signing secret, shown in the automation's trigger panel. POST JSON to it with the lowercase hex HMAC-SHA256 of the exact body in one of these headers: x-webhook-signature or x-hub-signature-256 (the second as sha256=<hex>).
A missing secret, a missing signature and a wrong signature are each refused with 401 and a different code, so you can tell which to fix. Only that one automation is started.
Adding and removing people from your directory
A firm's identity provider (for example Microsoft Entra ID or Okta) can add, change and switch off the firm's people over SCIM 2.0 at /scim/v2. A firm admin or owner creates a key in Settings → API keys with the “Directory sync” permission (directory_sync) and gives it to the identity provider. The key stops working if the person who made it can no longer manage the firm's people.
| Request | What it does |
|---|---|
| GET /scim/v2/ServiceProviderConfig | Says what this service supports |
| GET /scim/v2/Users | Lists the firm's people and pending invitations; filter=userName eq "address" finds one |
| POST /scim/v2/Users | Invites a person by email address (userName), as Staff unless roles says otherwise |
| PUT or PATCH /scim/v2/Users/{id} | Changes a person's role (admin, manager, staff or readonly) or switches them on or off |
| DELETE /scim/v2/Users/{id} | Switches a person off; their record and work stay |
- Directory sync never makes, changes, switches off or switches back on an owner, and never leaves the firm without an active owner.
- Switching someone off has the same effect as on the Team page: their open approvals move to the person accountable for each matter and their paid seat stops.
- Refusals are SCIM error documents that say what was wrong in a sentence.
What the firm controls
The firm decides, in Settings › Security & data › Where data can go, whether information may leave through these doors. The switch is checked at the moment information would leave, so switching one off also stops work that is already queued.
| Switch | What switching it off does |
|---|---|
| Other software using an API key | Every API key stops working straight away. The keys stay saved. |
| Automations and web addresses | Those updates stop, including any that were already waiting to be sent. Your automations stay saved. |
Information already sent can't be recalled. Revoking one key in Settings → API keys stops only that key.
What is switched off
- Connections to and from outside AI tools. Connections to outside AI tools are paused. No data was shared. MatterFirst does not serve an outside-AI connection address, and saved connections or keys with outside-AI permission grant no access. The firm cannot switch this on.
- Bringing your own AI model. MatterFirst's assistant uses only the model service MatterFirst runs. No firm setting, key or API field accepts a model address or a model key.
- Reopening either needs a separate decision and a full review of the protocol, who may act, and how information is handled. It is not on a release schedule.
Sample integration
A small working integration in Elixir, in three plain files. It checks who the key acts for, lists open matters page by page, creates a matter with an Idempotency-Key so a retry cannot make two, registers a webhook address, and verifies each delivery's signature and timestamp before trusting it. It waits for Retry-After on 429 and retries 5xx answers with a growing wait.
README.md : What it does, and how to run it
# MatterFirst sample integration
A small, complete integration against the MatterFirst REST API and webhooks.
It:
1. checks who the API key acts for (`GET /api/v1/me`);
2. lists every open matter, following `links.next` page by page;
3. creates a matter with an `Idempotency-Key`, so a retried request replays the
first answer instead of creating a second matter;
4. waits for `Retry-After` on a 429 and retries 5xx answers with a growing wait,
keeping the same `Idempotency-Key`;
5. registers a webhook address and verifies each delivery's
`x-matterfirst-signature` and `x-matterfirst-timestamp` before trusting it.
## Run it
```
MATTERFIRST_URL=https://matterfirst.com \
MATTERFIRST_API_KEY=mf_live_… \
elixir run.exs
```
The key needs `matters:read`, `matters:write`, `matter_types:read`,
`webhooks:read` and `webhooks:write`. Set `MATTERFIRST_WEBHOOK_URL` to an https
address you control to register it for `matter.created`.
You need Elixir 1.15 or later; `run.exs` fetches its two libraries (Req and
Jason) the first time it runs.
## Kept honest
MatterFirst's own test suite loads `matterfirst_sample.exs` and runs it against
the real API on every build: listing, creating twice with one key, a missing
scope, retrying a 429, receiving and verifying a real signed delivery, refusing
a tampered or stale one, and the firm switching API keys and outgoing updates
off.
The full guide is at https://matterfirst.com/docs/developers.
run.exs : Runs it against your firm with your API key
# Runs the MatterFirst sample integration against your firm.
#
# MATTERFIRST_URL=https://matterfirst.com \
# MATTERFIRST_API_KEY=mf_live_… \
# elixir run.exs
#
# Optionally set MATTERFIRST_WEBHOOK_URL to an https address you control to
# register it for "matter.created". The key needs matters:read, matters:write,
# matter_types:read, webhooks:read and webhooks:write.
Mix.install([{:req, "~> 0.5"}, {:jason, "~> 1.4"}])
Code.require_file("matterfirst_sample.exs", __DIR__)
MatterFirstSample.run(
base_url: System.fetch_env!("MATTERFIRST_URL"),
api_key: System.fetch_env!("MATTERFIRST_API_KEY"),
webhook_url: System.get_env("MATTERFIRST_WEBHOOK_URL")
)
matterfirst_sample.exs : The integration itself
# A small, complete MatterFirst integration.
#
# It shows the four things every integration has to get right:
#
# 1. authenticate with an API key and find out who it acts for;
# 2. read a list page by page, following `links.next`;
# 3. create something with an `Idempotency-Key`, so a retry cannot make two,
# waiting for `Retry-After` on 429 and backing off on 5xx;
# 4. receive webhooks and verify each one's signature and timestamp before
# trusting it.
#
# Run it against your firm with `run.exs` in this folder. MatterFirst's own test
# suite loads this same file and runs it against the real API on every build, so
# it cannot fall behind the API it describes.
#
# Only `Req` and `Jason` are needed.
defmodule MatterFirstSample do
@moduledoc false
@tolerance_seconds 300
@max_attempts 4
@doc """
A client for one firm. `opts`:
* `:base_url`: e.g. "https://matterfirst.com"
* `:api_key`: an `mf_live_…` key
* `:req_options`: extra `Req` options (the test suite passes its router here)
* `:sleep`: how to wait between retries; defaults to `Process.sleep/1`
"""
def client(opts) do
req =
Req.new(
base_url: String.trim_trailing(Keyword.fetch!(opts, :base_url), "/") <> "/api/v1",
auth: {:bearer, Keyword.fetch!(opts, :api_key)},
headers: [{"accept", "application/json"}],
# This client does its own retrying, so it can honour Retry-After and
# keep the same Idempotency-Key across attempts.
retry: false,
receive_timeout: 30_000
)
|> Req.merge(Keyword.get(opts, :req_options, []))
%{req: req, sleep: Keyword.get(opts, :sleep, &Process.sleep/1)}
end
@doc "Who the key acts for: `{:ok, %{\"email\" => ..., ...}}`."
def whoami(client) do
with {:ok, body} <- request(client, :get, "/me") do
{:ok, get_in(body, ["data", "attributes"])}
end
end
@doc "Every open matter, following `links.next` until there is none."
def list_open_matters(client, page_size \\ 50) do
collect(client, "/matters?filter[status]=open&page_size=#{page_size}", [])
end
defp collect(_client, nil, acc), do: {:ok, acc}
defp collect(client, path, acc) do
with {:ok, body} <- request(client, :get, path) do
next = get_in(body, ["links", "next"])
next_path = if next && next != path, do: relative(next), else: nil
collect(client, next_path, acc ++ Map.get(body, "data", []))
end
end
# `links.next` is a path under /api/v1 (or a full URL); the client's base URL
# already ends in /api/v1.
defp relative(link) do
%URI{path: path, query: query} = URI.parse(link)
path = String.replace_prefix(path || "", "/api/v1", "")
if query, do: path <> "?" <> query, else: path
end
@doc """
Creates a matter. The same `idempotency_key` always means the same matter:
retrying after a timeout replays the first answer instead of creating a
second one.
"""
def create_matter(client, attributes, idempotency_key) do
with {:ok, body} <-
request(client, :post, "/matters",
json: %{"data" => %{"attributes" => attributes}},
idempotency_key: idempotency_key
) do
{:ok, body["data"]}
end
end
@doc """
Registers an https address for the given events. Returns the endpoint and its
signing secret, which MatterFirst shows only this once: store it.
"""
def register_webhook(client, url, events, idempotency_key) do
with {:ok, body} <-
request(client, :post, "/webhooks",
json: %{"data" => %{"attributes" => %{"url" => url, "events" => events}}},
idempotency_key: idempotency_key
) do
{:ok, %{endpoint: body["data"], secret: get_in(body, ["meta", "secret"])}}
end
end
@doc """
Verifies one delivery exactly as MatterFirst signs it, before anything in it
is trusted. `headers` are the request's headers (lower-case names).
Returns `{:ok, event}`, `{:error, :missing_headers}`, `{:error, :stale}` or
`{:error, :bad_signature}`. `now` is unix seconds, for tests.
"""
def verify_delivery(raw_body, headers, secret, now \\ System.os_time(:second)) do
headers = Map.new(headers, fn {name, value} -> {String.downcase(name), value} end)
with {:ok, timestamp} <- fetch_integer(headers, "x-matterfirst-timestamp"),
{:ok, signature} <- fetch(headers, "x-matterfirst-signature"),
:ok <- fresh(timestamp, now),
:ok <- signed?(raw_body, secret, timestamp, signature) do
{:ok, Jason.decode!(raw_body)}
end
end
defp signed?(raw_body, secret, timestamp, "v1=" <> given) do
expected =
:crypto.mac(:hmac, :sha256, secret, "v1.#{timestamp}.#{raw_body}")
|> Base.encode16(case: :lower)
# Constant time, so a receiver does not leak how much of a guess was right.
if byte_size(expected) == byte_size(given) and :crypto.hash_equals(expected, given),
do: :ok,
else: {:error, :bad_signature}
end
defp signed?(_raw_body, _secret, _timestamp, _other_scheme), do: {:error, :bad_signature}
defp fetch(headers, name) do
case Map.fetch(headers, name) do
{:ok, value} -> {:ok, value}
:error -> {:error, :missing_headers}
end
end
defp fetch_integer(headers, name) do
with {:ok, value} <- fetch(headers, name) do
case Integer.parse(value) do
{int, ""} -> {:ok, int}
_other -> {:error, :missing_headers}
end
end
end
defp fresh(timestamp, now) do
if abs(now - timestamp) <= @tolerance_seconds, do: :ok, else: {:error, :stale}
end
@doc """
One request with safe retries: 429 waits for `Retry-After`; a 5xx or a
dropped connection waits 1, 2 then 4 seconds. POSTs carry the same
`Idempotency-Key` on every attempt. Any other 4xx is returned at once as
`{:error, %{status:, code:, detail:}}`.
"""
def request(client, method, path, opts \\ []) do
{key, opts} = Keyword.pop(opts, :idempotency_key)
headers = if key, do: [{"idempotency-key", key}], else: []
attempt(client, [method: method, url: path, headers: headers] ++ opts, 1)
end
defp attempt(client, options, n) do
case Req.request(client.req, options) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, body}
{:ok, %Req.Response{status: 429} = response} when n < @max_attempts ->
client.sleep.(retry_after_ms(response))
attempt(client, options, n + 1)
{:ok, %Req.Response{status: status}} when status >= 500 and n < @max_attempts ->
client.sleep.(backoff_ms(n))
attempt(client, options, n + 1)
{:ok, %Req.Response{status: status, body: body}} ->
{:error, error(status, body)}
{:error, _transport} when n < @max_attempts ->
client.sleep.(backoff_ms(n))
attempt(client, options, n + 1)
{:error, exception} ->
{:error, %{status: nil, code: "transport", detail: Exception.message(exception)}}
end
end
defp retry_after_ms(response) do
case Req.Response.get_header(response, "retry-after") do
[seconds | _] ->
case Integer.parse(seconds) do
{s, _} when s >= 0 -> s * 1_000
_other -> 1_000
end
[] ->
1_000
end
end
defp backoff_ms(n), do: 1_000 * Integer.pow(2, n - 1)
defp error(status, %{"errors" => [first | _]}) do
%{status: status, code: first["code"], detail: first["detail"]}
end
defp error(status, body), do: %{status: status, code: nil, detail: inspect(body)}
@doc """
The whole walk-through: who am I, what is open, and (with `:webhook_url`)
register an address for new matters. Prints what it does.
"""
def run(opts) do
client = client(opts)
with {:ok, me} <- whoami(client),
{:ok, matters} <- list_open_matters(client) do
IO.puts("This key acts for #{me["full_name"] || me["email"]}.")
IO.puts("#{length(matters)} open matters.")
case Keyword.get(opts, :webhook_url) do
nil ->
:ok
url ->
{:ok, %{secret: secret}} =
register_webhook(client, url, ["matter.created"], "sample-webhook-#{url}")
IO.puts("Registered #{url}. Store this signing secret now: #{secret}")
end
end
end
end
Unzip it, then run it from the folder it makes:
cd matterfirst-sample-integration
MATTERFIRST_URL=https://matterfirst.com \
MATTERFIRST_API_KEY=mf_live_… \
elixir run.exs
MatterFirst's own tests run these same files against the real API on every build, so they cannot fall behind the API they describe. The key it needs: matters:read, matters:write, matter_types:read, webhooks:read and webhooks:write.