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.

SurfaceAddressHow it is authenticated
REST API/api/v1/…An API key sent as Authorization: Bearer mf_live_…
Webhooks MatterFirst sends to youAn https address your firm registersEach 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}/submissionsA 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 scopeWrite scopePaths under /api/v1
audit:readnone (read only)/audit-log
automations:readautomations:write/automations
billing:readbilling:write/disbursements, /invoices, /time-entries
calendar:readcalendar:write/calendar-events
contacts:readcontacts:write/contact-types, /contacts
documents:readdocuments:write/document-request-templates, /document-requests, /documents, /folders
emails:reademails:write/email-drafts, /email-templates, /emails
integrations:readnone (read only)/integrations
leads:readleads:write/lead-pipelines, /leads
matter_types:readmatter_types:write/field-groups, /key-date-types, /matter-types, /role-templates
matters:readmatters:write/key-dates, /matters
organization:readnone (read only)/organization
referrals:readnone (read only)/referral-contracts
reports:readreports:write/reports
tasks:readtasks:write/task-templates, /tasks
users:readnone (read only)/teams, /users
webhooks:readwebhooks:write/webhook-deliveries, /webhook-events, /webhooks
workflows:readworkflows: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

LimitRequests per 60 seconds
Each API key300
The whole firm, all keys together1000
Each API key, for POSTs ending in /send, /run, /convert, /uploads, /test, /merge30
Each automation's incoming webhook address120

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):

CategoryEvents
calendar_eventcalendar_event.bulk_deleted, calendar_event.bulk_updated, calendar_event.created, calendar_event.deleted, calendar_event.updated
contactcontact.bulk_deleted, contact.bulk_updated, contact.created, contact.deleted, contact.updated
contact_typecontact_type.bulk_deleted, contact_type.bulk_updated, contact_type.created, contact_type.deleted, contact_type.updated
disbursementdisbursement.bulk_deleted, disbursement.bulk_updated, disbursement.created, disbursement.deleted, disbursement.updated
documentdocument.bulk_deleted, document.bulk_updated, document.created, document.deleted, document.updated
email_draftemail_draft.bulk_deleted, email_draft.bulk_updated, email_draft.created, email_draft.deleted, email_draft.updated
email_templateemail_template.bulk_deleted, email_template.bulk_updated, email_template.created, email_template.deleted, email_template.updated
field_definitionfield_definition.bulk_deleted, field_definition.bulk_updated, field_definition.created, field_definition.deleted, field_definition.updated
field_groupfield_group.bulk_deleted, field_group.bulk_updated, field_group.created, field_group.deleted, field_group.updated
folderfolder.bulk_deleted, folder.bulk_updated, folder.created, folder.deleted, folder.updated
intelligenceintelligence.rollup_completed
invoiceinvoice.bulk_deleted, invoice.bulk_updated, invoice.created, invoice.deleted, invoice.updated
invoice_line_iteminvoice_line_item.bulk_deleted, invoice_line_item.bulk_updated, invoice_line_item.created, invoice_line_item.deleted, invoice_line_item.updated
key_datekey_date.bulk_deleted, key_date.bulk_updated, key_date.created, key_date.deleted, key_date.updated
key_date_typekey_date_type.bulk_deleted, key_date_type.bulk_updated, key_date_type.created, key_date_type.deleted, key_date_type.updated
leadlead.bulk_deleted, lead.bulk_updated, lead.created, lead.deleted, lead.updated
lead_notelead_note.bulk_deleted, lead_note.bulk_updated, lead_note.created, lead_note.deleted, lead_note.updated
mattermatter.bulk_deleted, matter.bulk_updated, matter.created, matter.deleted, matter.updated
matter_factmatter_fact.asserted, matter_fact.disputed, matter_fact.rejected, matter_fact.superseded, matter_fact.verified
matter_rolematter_role.bulk_deleted, matter_role.bulk_updated, matter_role.created, matter_role.deleted, matter_role.updated
matter_typematter_type.bulk_deleted, matter_type.bulk_updated, matter_type.created, matter_type.deleted, matter_type.updated
migrationmigration.cut_over, migration.divergence_detected, migration.imported, migration.parallel_run_started, migration.rolled_back
notenote.bulk_deleted, note.bulk_updated, note.created, note.deleted, note.updated
obligationobligation.confirmed, obligation.detected, obligation.dismissed, obligation.missed, obligation.satisfied
paymentpayment.bulk_deleted, payment.bulk_updated, payment.created, payment.deleted, payment.updated
reportreport.bulk_deleted, report.bulk_updated, report.created, report.deleted, report.updated
role_templaterole_template.bulk_deleted, role_template.bulk_updated, role_template.created, role_template.deleted, role_template.updated
stepstep.bulk_deleted, step.bulk_updated, step.created, step.deleted, step.updated
tasktask.bulk_deleted, task.bulk_updated, task.created, task.deleted, task.updated
task_commenttask_comment.bulk_deleted, task_comment.bulk_updated, task_comment.created, task_comment.deleted, task_comment.updated
task_templatetask_template.bulk_deleted, task_template.bulk_updated, task_template.created, task_template.deleted, task_template.updated
time_entrytime_entry.bulk_deleted, time_entry.bulk_updated, time_entry.created, time_entry.deleted, time_entry.updated
workflowworkflow.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.

RequestWhat it does
GET /scim/v2/ServiceProviderConfigSays what this service supports
GET /scim/v2/UsersLists the firm's people and pending invitations; filter=userName eq "address" finds one
POST /scim/v2/UsersInvites 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.

SwitchWhat switching it off does
Other software using an API keyEvery API key stops working straight away. The keys stay saved.
Automations and web addressesThose 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.

Download the sample (a zip of 3 plain files)
README.md : What it does, and how to run it
Open README.md on its own
# 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
Open run.exs on its own
# 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
Open matterfirst_sample.exs on its own
# 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.