PointOne API Documentation

Last updated: September 10, 2026

The PointOne Public API gives your systems programmatic access to the time your firm records in PointOne. You can read timekeepers, clients and matters, billing codes, billing rules, time entries, totals, and the compliance findings PointOne has already computed — and you can write: log time, edit it, run timers on it, and release it toward a bill.

Every write acts on your own work. There is no way to log or edit time for a colleague through this API.

It is a stable, partner-facing surface, versioned separately from the internal API our own apps use. Everything under /api/public/v1 is covered by the compatibility promise in Versioning and compatibility.

  • Protocol — HTTPS and JSON. Reads are GET; writes are POST, PATCH and DELETE.

  • Retries — every write takes an Idempotency-Key header. Send one; without it a retry writes twice.

  • Auth — a firm-bound API key, sent as Authorization: Bearer <key>.

  • Errors — RFC 9457 application/problem+json documents.

  • Machine-readable spec — OpenAPI 3.0.3 (openapi-public.yaml), suitable for generating a client with openapi-generator, oapi-codegen, or similar.

Building an AI agent instead of an integration? PointOne also runs a Model Context Protocol (MCP) server at https://mcp.pointone.com/mcp, over the same data, the same permission model and the same read and write operations. It authenticates with OAuth rather than an API key, and the client runs the flow.


Before you start

Three things have to be true before your first call succeeds.

  1. Your firm is enabled for the Public API. API access is a licensed feature. A firm that has not been enabled receives 403 surface-not-enabled on every endpoint, including /me. Contact your PointOne representative to switch it on.

  2. You hold an API key issued from your firm's PointOne tenant. See Authentication.


Base URL

Every path in this document is relative to that host and begins with /api/public/v1.

<https://api.pointone.com/api/public/v1/me>

Authentication

Every request carries an API key in the standard bearer form:

GET /api/public/v1/me HTTP/1.1
Host: api.pointone.com
Authorization: Bearer <your-api-key>
Accept: application/json

There is no cookie, no session, and no OAuth flow on this surface.

What a key is

An API key is personal. It is issued to one person at the firm, and every call it makes acts as that timekeeper.

Property

Description

Represents

one person at the firm

Created at

Account → API keys

Acts as

that timekeeper (the key is named in audit records)

Default reach

that person's own time

Unreleased time

that person's own entries only

credential_type in /me

user_key

A key is an opaque string; nothing about its format is meaningful. Call GET /api/public/v1/me to see who it resolves to and what it may read.

Creating a key

Keys are issued from PointOne's identity provider, reachable from PointOne under Settings → Advanced → Developer. The key is bound to you personally.

The key is shown once, at creation. Store it in a secret manager; PointOne cannot recover it.

Key handling

  • Treat a key as a password. It carries firm data and it is bearer-only — anyone holding the string can read what it can read.

  • Never ship a key in a browser, a mobile binary, or a public repo. Call the API from a server you control. (CORS is open on this surface because bearer endpoints see no cookies, but that is not an invitation to expose the key to a page.)

  • Rotate by overlap — create the new key, deploy it, then delete the old. There is no rotation window on the key itself.

  • Disabling a user disables every key they hold. This is deliberate: offboarding an attorney should not leave a live credential behind. It also means an integration built on one person's key stops working the day that person leaves.

What a rejected credential looks like

Status

type

Meaning

Retry?

401

missing-api-key

No Authorization: Bearer header.

No — fix the request

401

invalid-api-key

The key is unknown, expired, archived, or disabled.

No — issue a new key

403

invalid-api-key-binding

Valid key, but it is not bound to a firm this host serves — usually the wrong regional plane, or a user who can no longer sign in.

No

403

surface-not-enabled

Your firm is not licensed for the Public API.

No — contact PointOne

503

directory-unavailable

We could not check the key right now.

Yes, after Retry-After

503

entitlement-unavailable

We could not check your firm's API entitlement right now.

Yes, after Retry-After

429

api-key-rate-limited

Key validation is being throttled upstream.

Yes, after Retry-After

A 401 carries a WWW-Authenticate: Bearer realm="pointone-public-api" challenge. 403s deliberately do not — the credential is genuine, so re-authenticating produces a fresh credential refused identically.


Permissions

Scope selection is not available yet. You cannot choose what an API key may do when you create it, and there is no per-key permission screen. Every key is granted the same access automatically, derived from the person who holds it.

What that means in practice:

  • A key can read everything this API exposes on behalf of the person who holds it — clients and matters, billing codes, billing rules, time entries, totals, and compliance findings.

  • A key can also write that person's own time — log it, edit it, archive it, run timers on it, and release it toward a bill.

  • How far it reads is decided by that person. An ordinary timekeeper's key reads their own work. A firm administrator's key can additionally read across the firm.

  • How far it writes is not. Every write is the holder's own work, and a firm administrator's key writes exactly what an ordinary timekeeper's does. Reach is a reading concept on this surface and nothing more.

GET /me reports what the key in hand can actually do. Read it rather than assuming — it costs one cheap call.

Reading past your own work

Reaching another timekeeper requires the key's holder to be a firm administrator in PointOne. /me reports the answer as is_firm_admin:

{
	...
  "is_firm_admin": true,
  ...
}

Quickstart

Four calls, in the order an integration actually makes them.

1. Confirm the credential and discover who you are

curl -sS <https://api.pointone.com/api/public/v1/me> \
  -H "Authorization: Bearer $POINTONE_API_KEY"
{
  "firm_id": "a03b7c9e-58a1-4c0d-9f21-6d4a2b8e1c77",
  "firm_name": "Example Firm LLP",
  "user_id": "a59c4a6f-0b93-4e2a-b7f5-31a591d820ee",
  "email": "john.doe@examplefirm.com",
  "credential_type": "user_key",
  "is_firm_admin": true,
  "name": "John Doe",
  "title": "Partner",
  "time_zone": "America/New_York",
  "billing_increment_seconds": 360,
  "firm_wide_reach": true
}

Note time_zone — every date you send and every date you receive is interpreted in the timekeeper's own zone, not UTC.

2. Resolve a matter by name

Everything else takes ids. This is the one endpoint that takes a name.

curl -sS -G <https://api.pointone.com/api/public/v1/matters> \
  -H "Authorization: Bearer $POINTONE_API_KEY" \
  --data-urlencode "query=antitrust work for Acme" \
  --data-urlencode "limit=5"

3. Read a week of time

curl -sS -G <https://api.pointone.com/api/public/v1/time-entries> \
  -H "Authorization: Bearer $POINTONE_API_KEY" \
  --data-urlencode "start_date=2026-08-24" \
  --data-urlencode "end_date=2026-08-30" \
  --data-urlencode "response_format=detailed"

4. Check what compliance found on an entry

curl -sS <https://api.pointone.com/api/public/v1/time-entries/$ENTRY_ID/compliance> \
  -H "Authorization: Bearer $POINTONE_API_KEY"

5. Log an hour of work

Your first write. Note the Idempotency-Key — send one on every write.

curl -sS -X POST <https://api.pointone.com/api/public/v1/time-entries> \
  -H "Authorization: Bearer $POINTONE_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
        "date": "2026-09-09",
        "duration_seconds": 3600,
        "narrative": "Drafted response to second request.",
        "matter_id": "'"$MATTER_ID"'"
      }'

The entry comes back with an entry_id and an entry_version. It is not released — nobody but you can see it until you call POST /time-entries/release.


Core concepts

Firms, timekeepers, and ids

A firm is the tenant; a timekeeper is a person who logs time. Every id on this surface is a UUID and is opaque — pass it back exactly as received and do not parse it. Resolve names to ids with GET /matters (clients and matters) and GET /timekeepers (people).

Released vs. unreleased time

A time entry is released when its timekeeper has published it. Until then it is that person's private work in progress.

  • Only its own timekeeper may read an unreleased entry. Not even a firm administrator's key changes that.

  • Firm-wide reach widens which people you can read, never whether you can see their unpublished drafts.

  • is_released is on every entry, so you can tell which you are holding.

If your integration exports to a billing system, filter on is_released unless you have a specific reason not to; unreleased work is not yet the firm's claim.

Anything you create through this API starts unreleased. It is invisible to the firm until you call POST /time-entries/release — which, at most firms, cannot be undone. An integration that logs time and never releases it has quietly built a pile of work nobody can bill.

Staged entries are PointOne's suggestions

review_status is staged or accepted.

  • accepted — a timekeeper has confirmed the entry.

  • staged with a non-manual source — PointOne generated this from captured activity and nobody has confirmed it yet. This is how you answer "what did I work on that isn't logged yet?"

  • staged with a manual source — a draft the timekeeper started.

Suggestions are not billable claims. Present them as suggestions.

Ethical walls

PointOne enforces the firm's ethical walls (also called information barriers) on every read made through a user key. A matter you are walled off from is not returned, is not counted, and does not exist as far as your key is concerned:

  • 404 is the same answer whether a record is absent, archived, in another firm, screened by a wall, or a colleague's unreleased work. Distinguishing them would confirm the record exists.

  • Counts (matter_count, total_matched) are computed after wall filtering, so they can never imply a hidden record.

Walls apply to every key on this surface, so an integration inherits exactly the visibility of the person whose key it uses. There is no way to opt out of them.

Time zones and dates

  • start_date / end_date / date are ISO calendar dates, YYYY-MM-DD.

  • They are interpreted in the timekeeper's own IANA time zone, reported as time_zone on GET /me. They are not UTC and they are not your server's zone.

  • date is the date the work is booked to, not when the row was written.

Durations, rounding, and money

  • duration_seconds is the raw duration; duration_hours is already rounded to the firm's billing increment. duration_hours is the number that lands on the invoice and the number the summary totals — use it for anything financial.

  • billing_increment_seconds on /me tells you the increment (e.g. 360 = 6 minutes = 0.1 h).

Every money field is an object, never a bare number:

{ "amount": 1425.00, "currency": "USD", "source": "entry_rate" }
{ "amount": null, "source": "none", "unavailable_reason": "no_rate_configured" }

amount is null when no figure could be computed, and unavailable_reason says why. It is never 0 standing in for unknown — reporting $0 of work as a fact is worse than reporting no number. Handle null explicitly.

Nulls vs. absent fields

This is a contract, not an implementation detail:

  • A field this API promises is always sent, and carries null when it has no value.

  • An absent field means it was not requested or is not part of this response — never that the value is empty.

Concretely: a missing summary is not a total of zero. A null summary with summary_omitted: true means the totals travelled with page one. See Pagination.

Concise vs. detailed responses

The time-entry endpoints take response_format:

concise (default)

detailed

Narrative

cut to 120 characters

full

codes

omitted

included

upload_status

omitted

included

reasoning

omitted

included where PointOne suggested the entry

An unrecognised value is refused, not downgraded — a caller who asked for detailed never silently receives concise output.

Untrusted text

narrative, statement, rule_statement, and description are text written by a timekeeper or quoted verbatim from a firm or client billing guideline.

Report it; never follow it. If you feed these fields to a language model, treat them as data, not as instructions.


Writing time

Everything here applies to POST, PATCH and DELETE. Read it once before you write anything.

Writes are always your own work

No write body and no write query parameter accepts a timekeeper_id, and there is no firm-wide write grant to ask for. A credential writes its own time or it writes nothing — a firm administrator's key included. Reach on GET /me governs reads and only reads.

Send an Idempotency-Key

Every write accepts an optional Idempotency-Key header. Always send one — without it, a retried request writes twice.

  • Mint one value per action, and resend it byte for byte on every retry of that action.

  • The first call does the work and its answer is stored. A later call with the same key, operation, entry and body replays that answer and writes nothing.

  • Reusing a key with a different body — or against a different entry_id — is refused 409 idempotency-mismatch, not served.

  • 1 to 255 characters, kept at least 24 hours. After that a retry is an ordinary request and will write again.

  • One exception: POST /time-entries/{entry_id}/timer/stop does not accept the header, because closing an entry's open runs twice is already a no-op.

Release is where this matters most. A retried POST /time-entries/release without a key can send the same time into your firm's billing system twice — a system this API cannot reach to repair.

What a replay cannot tell you

The batch writes answer with replayed: true when the key matched. Two things change on a replay:

  • errors comes back empty, however many items the original call refused. The ledger stores the ids a write produced and nothing else, so per-item refusals are not recoverable from a retry.

  • entries on a create replay is a fresh read, so it can be shorter than the batch you sent — an entry archived or walled off since does not come back. That is indistinguishable from an item the original call refused.

Do not reconstruct the refusals by counting. Sending the difference again logs the work a second time, most often just after its owner deleted it. Reconcile against GET /time-entries instead, and write again only when you mean to.

Release is the exception: released_entry_ids is the ledger's own list rather than a fresh read, so an id you sent and do not see back is one that never released. Resending just those ids under a new key is safe there — an id that never released has no released time to duplicate.

If-Match and entry_version

Every entry carries an entry_version. PATCH and DELETE accept an optional If-Match header holding it.

  • Send it to make the write conditional on nobody having edited the entry since you read it. If somebody has, you get 412, nothing is written, and the repair is to re-read and resend with the version you get back.

  • Omit it and the last writer wins.

  • It is opaque and short-lived — hold one for the length of a read-modify-write, not for a day. It is not a quoted ETag and not usable as a cache validator.

In a PATCH, absent and null are different

This is the only place on the surface where leaving a field out carries meaning.

You send

What happens

the field, with a value

it is set

the field, as null

it is cleared

nothing

it is left alone

So sending a whole entry back is not a safe way to change one field of it. Two fields refuse a null outright: date and duration_seconds cannot be cleared, and neither can be zero.

Billability is three-valued

On create and patch the field is is_non_billable; on a read it comes back as is_billable. They are named and polarised differently because they are different facts — one is what you asked for, the other is what the matter and your ask resolved to.

  • Omit it — billability follows the matter. This is what you want unless the entry differs from its matter.

  • false — pins the entry billable for good, even if it is later moved to a non-billable matter.

  • true — non-billable. Reads back as is_billable: false.

  • On a PATCH, null returns the entry to following its matter, which is a different request from false.

Changing matter_id resets billability, so an is_non_billable you set earlier is cleared unless you send it again in the same request.

Classification codes cannot be written

Entries read back a codes array, but neither write body accepts one. There is no way to set an entry's codes through this API today. An entry whose firm requires a code will be refused at release until somebody sets it in the app.


Pagination

Listing endpoints are keyset-paginated with an opaque cursor. Keyset rather than offset because time entries are written constantly, and an offset page silently skips rows that shifted past the boundary while you were reading.

The loop:

cursor = None
while True:
    params = {"start_date": "2026-08-01", "end_date": "2026-08-31", "limit": 200}
    if cursor:
        params["cursor"] = cursor
    page = get("/api/public/v1/time-entries", params)
    handle(page["entries"])
    cursor = page["next_cursor"]
    if not cursor:
        break

Rules:

  • next_cursor is null on the last page. That is the only signal the listing is complete. Do not infer completeness from a short page.

  • A cursor is opaque. Pass it back byte for byte. An altered token is refused 400 invalid-argument rather than read as "start again", because starting again silently is how a client loops forever over page one.

  • Cursors are per-query. Do not reuse a cursor with different filters.

  • limit is clamped, never rejected. A value above the maximum is served at the maximum; a value below the floor is raised to it. A short page therefore always means the data ran out, never that your limit was reinterpreted.

  • truncated means more matched than this page holds. On GET /matters, truncated: true with next_cursor: null means the semantic matching found more but there is no page to ask for — narrow the query instead.

  • GET /codes does not paginate. Code sets are firm-configured metadata, roughly a dozen per firm. Each set's options are capped at 200 and any set that hit the cap is named in truncated_sets.

Summaries and pagination

GET /time-entries returns a summary over the whole filtered set, not the page — but only once, on page one:

Call

summary

summary_omitted

Date range, page 1

totals

false

Date range, page 2+

null

true

entry_ids fetch

totals

false

Keep page one's summary. If you find summary: null with summary_omitted: true, re-read page one — do not report zero.


Rate limits

Requests are charged against every limit that names them: your credential's own budget and, above it, your organization's ceiling.

Tier

Default rate

Burst

Per credential

10 req/s

20

Per organization

25 req/s

50

Per client IP (anti-abuse ceiling)

100 req/s

200

These are token buckets: the burst is how many requests you may make back-to-back before the sustained rate binds.

Headers

Every response carries the state of the one bucket that binds it — which may be your credential's or your organization's, whichever is tighter, and which can change between responses.

Header

Meaning

X-RateLimit-Limit

Bucket capacity

X-RateLimit-Remaining

Whole tokens left

X-RateLimit-Reset

Unix timestamp at which that bucket is full again

Retry-After

On a 429: whole seconds to wait

Handling 429

HTTP/1.1 429 Too Many Requests
Retry-After: 3
Content-Type: application/problem+json

{"type":"urn:pointone:api:problem:rate-limited","title":"Rate limit exceeded","status":429,
 "detail":"Retry after the number of seconds in Retry-After.","request_id":"01J…"}
  • Wait Retry-After, then retry. Add jitter if you run several workers.

  • A 429 is not necessarily yours. A key comfortably inside its own budget is refused once other keys in the same organization have spent the ceiling. Slowing that one client down will not clear it — coordinate across your workers, or spread the load.

  • A refusal raised before your credential is read (the IP ceiling) carries Retry-After alone and no X-RateLimit-*.

  • 400 limit-exceeded is not a rate limit and must not be retried. It means you asked past a bound in the request itself — a date range too wide, too many entry_ids. Change the request.

Designing for the limits

  • Page wide, not deep. One request with limit=500 beats fifty with limit=10; page one carries a full-range aggregate whatever the page size, so small pages cost the same aggregate over and over. That is why the floor exists.

  • Cache what does not move. GET /codes, GET /rules, and GET /timekeepers change on the order of days. Cache them.

  • Prefer one call per matter for rules. GET /rules?matter_id=… already returns the matter's own rules, its client's, and the firm-wide ones, unioned and deduplicated. Do not call once per category.


Errors

Every non-2xx response is an RFC 9457 problem document:

{
  "type": "urn:pointone:api:problem:invalid-argument",
  "title": "Invalid argument",
  "status": 400,
  "detail": "start_date and end_date must be given together",
  "instance": "/api/public/v1/time-entries",
  "request_id": "01JBK7X4H2Q9WQ3M6C0N5Y8ZT4"
}

Content-Type is application/problem+json.

Member

Notes

type

Stable URI. Switch on this, never on title or detail.

title

Short human summary. Wording may change.

status

Mirrors the HTTP status.

detail

Written for a person, and often names the exact field to fix. Wording may change.

instance

The request path.

request_id

Quote this when contacting support.

The document is open — RFC 9457 extension members may be added. A client that rejects unknown members will break on an additive change.

Problem types

Status

type (after urn:pointone:api:problem:)

What it means

Retry?

400

invalid-argument

The request is malformed; detail names the field.

No

400

limit-exceeded

You asked past a declared bound (date range, id count). detail names the cap.

No — narrow the request

401

missing-api-key

No bearer credential.

No

401

invalid-api-key

Unknown, expired, archived, or disabled key.

No

401

unauthenticated

The credential resolved to no firm.

No

403

insufficient-scope

This key is not permitted the read it attempted — most often a firm-wide read from a key that is not a firm administrator's.

No

403

credential-not-permitted

The credential cannot perform this read. Its holder is not permitted the breadth the request asked for.

No

403

invalid-api-key-binding

Wrong regional plane, or a disabled user.

No

403

surface-not-enabled

Firm not licensed for the Public API.

No

404

not-found

Nothing this credential can read matches.

No

409

immutable-entry

Write. The entry is already in the firm's billing system, or sits on an open bill. Not repairable from here.

No

409

period-closed

Write. The entry's date is inside a closed billing period — either the date it carries now, or the one you are moving it to.

No — pick a date outside it

409

idempotency-mismatch

Write. That Idempotency-Key was already used for a different request.

No — new key, or resend the original body

409

conflict

Write. Another writer is holding the row.

Yes — the only 409 worth retrying unchanged

410

gone

Write. Your Idempotency-Key already made this write, and what it produced can no longer be read — deleted in the app, or behind a wall raised since.

No — do not resend

412

precondition-failed

Write. The If-Match you sent names a version the entry has moved past. Nothing was written.

No — re-read and resend

422

unresolved-reference

Well-formed request; a reference in it named nothing — most often a name passed where an id belongs.

No — resolve the id first

429

rate-limited

A rate limit fired.

Yes, after Retry-After

429

api-key-rate-limited

Key validation throttled upstream.

Yes, after Retry-After

500

internal-error

A fault on our side.

Yes

502

upstream-unavailable

A dependency this read needs was unavailable.

Yes

503

directory-unavailable

Key could not be checked right now.

Yes, after Retry-After

503

entitlement-unavailable

Firm entitlement could not be read right now.

Yes, after Retry-After

503

unavailable

A prerequisite this one operation needs is not configured on this deployment. Every other operation keeps answering. Nothing was written.

Yes, but slowly — see below

410 gone is the one to handle carefully. It means the write happened — the ledger holds the id it produced — and that id is no longer readable. It is deliberately not a 404 and not a fifth 409, because both of those invite a resend, and here the resend is the one thing that does damage: it would log the work a second time, most often the moment after its owner deleted it. Do not resend. Reconcile against GET /time-entries, and mint a new key only when you mean to write again.

503 unavailable does not clear on its own. It clears when an operator configures what is missing, and it carries no Retry-After. Back off far longer than you would for a 500, and tell us. It is deliberately not a 502 — nothing upstream was reached and failed.

The retry rule, stated once

Retry 5xx and 429. Do not retry 4xx — with one exception, 409 conflict, which means another writer is holding the row. Every other 4xx describes something the caller has to change first; retrying it unchanged fails identically for exactly as long. Three deliberate design choices follow from this:

  • A too-wide date range is 400 limit-exceeded, not 429. Backing off would not help.

  • A query that timed out is 400 limit-exceeded, with a detail telling you to narrow it — a shorter range, one timekeeper, a more specific search. It is not reported as a transient fault, because each retry would hold a shared read connection for the full timeout again.

  • A 4xx on a write means nothing was written. Every refusal above is raised before the write rather than partway through it, so you never have to reason about a half-applied change.

404 is deliberately uninformative

404 not-found is the same answer whether the record does not exist, belongs to another firm, is archived, is screened from you by an ethical wall, or is a colleague's unreleased work. Telling those apart would confirm the record exists. Do not build logic that tries to distinguish them.

422 vs. 400

422 unresolved-reference means the request was well formed and something it referenced named nothing — a matter_id that resolves to nothing you can read, for instance. That is a fact about the firm's data, not about your syntax, which is why it is not a 400. The fix is to resolve the id via GET /matters first.


Endpoint reference

Eighteen operations. Every one accepts the bearer key and returns application/json on success, except DELETE, which returns no body.

Reads

Endpoint

Purpose

GET /me

Identity, reach, time zone

GET /timekeepers

The firm's roster of people

GET /matters

Resolve a name to client/matter ids

GET /codes

Classification codes an entry may carry

GET /rules

Billing rules for a matter, client, or the firm

GET /time-entries

List by date range, or fetch by id

GET /time-entries/{entry_id}

One entry

GET /time-entries/summary

Totals by matter, client, timekeeper or period — including work in progress

GET /time-entries/compliance

Compliance findings across a date range

GET /time-entries/{entry_id}/compliance

Compliance findings for one entry

GET /timers/active

Timers currently running

Writes — all of them act on your own entries only.

Endpoint

Purpose

POST /time-entries

Log one entry

POST /time-entries/batch

Log up to 100 entries, answered per item

PATCH /time-entries/{entry_id}

Change fields on one of your entries

DELETE /time-entries/{entry_id}

Archive one of your entries

POST /time-entries/release

Release your entries toward a bill — usually irreversible

POST /time-entries/{entry_id}/timer

Start a timer on one of your entries

POST /time-entries/{entry_id}/timer/stop

Stop the timers running on one of your entries

Every endpoint is available to every key. How far a given endpoint reads depends on the key holder — see Permissions.


GET /me

Return the identity resolved from the API key. A cheap credential, tenant-binding, and wiring check — call it first, and call it whenever you need to know what this key can do.

The fields below describe the person this key acts as, and what that person's key is permitted to read.

Parameters — none.

ResponseMeResponse

Field

Type

Notes

firm_id

uuid

firm_name

string

user_id

uuid

The timekeeper this key acts as.

email

string

credential_type

string

Always user_key.

is_firm_admin

boolean

The person's directory role in the firm.

scopes

string[]

Reserved. Scope selection is not configurable today — see Permissions.

name

string

title

string

Empty if the person has not set one.

time_zone

string

IANA zone every date on this surface is interpreted in.

billing_increment_seconds

integer

The increment duration_hours is rounded to.

firm_wide_reach

boolean

Whether this key may read past its own timekeeper. Read this before offering a firm-wide view.

Statuses — 200, 401, 403, 404, 429.

404 on /me means the key's user has no PointOne profile. This is answered rather than returning a half-filled identity.

  • A key without firm-wide reach

    {
      "firm_id": "2f3b7c9e-58a1-4c0d-9f21-6d4a2b8e1c77",
      "firm_name": "Acme Legal LLP",
      "user_id": "b0a91c37-4d2e-4f88-9a03-7e5c1f6b2d40",
      "email": "sam.okafor@acmelegal.example",
      "credential_type": "user_key",
      "is_firm_admin": false,
      "name": "Sam Okafor",
      "title": "Associate",
      "time_zone": "America/New_York",
      "billing_increment_seconds": 360,
      "firm_wide_reach": false
    }

    This key reads Sam's own time and nothing else. timekeeper_id=* is refused, and GET /timekeepers answers with Sam alone.


GET /timekeepers

The roster to resolve a timekeeper_id from before passing one anywhere else. It is the only firm-level read that returns people rather than work.

A credential with firm_wide_reach is answered the whole firm. One without it is answered itself alone, not refused — so a client may call this unconditionally to discover its own id without branching on what the key can reach.

Parameters

Name

In

Type

Default

Notes

include_disabled

query

boolean

false

Include people who can no longer sign in.

limit

query

integer 10–500

50

Clamped, never rejected.

cursor

query

string

The previous page's next_cursor.

ResponseTimekeeperList

Field

Type

Notes

timekeepers[].timekeeper_id

uuid

The id every entry reports and every timekeeper filter accepts.

timekeepers[].name

string

Empty when the directory holds no name.

timekeepers[].email

string

Empty when the directory holds no address.

timekeepers[].title

string

Empty when the directory holds no title.

timekeepers[].is_disabled

boolean

Can no longer sign in — but still owns their entries.

next_cursor

string or null

Null on the last page.

Statuses — 200, 400, 401, 403, 429.

Disabled people still own time. They are excluded from this roster by default but their entries still appear on a firm-wide time listing. If you are joining entries to people, fetch with include_disabled=true or you will find timekeeper_ids the roster does not explain.

org_role is deliberately not returned. /me already reports it for the caller; a roster publishing every colleague's admin status is an escalation-target list for a credential that only asked who logs time.

{
  "timekeepers": [
    {
      "timekeeper_id": "8c1d4a6f-0b93-4e2a-b7f5-31c9a6d820ee",
      "name": "Dana Reyes",
      "email": "dana.reyes@acmelegal.example",
      "title": "Partner",
      "is_disabled": false
    },
    {
      "timekeeper_id": "b0a91c37-4d2e-4f88-9a03-7e5c1f6b2d40",
      "name": "Sam Okafor",
      "email": "sam.okafor@acmelegal.example",
      "title": "Associate",
      "is_disabled": false
    }
  ],
  "next_cursor": null
}

GET /matters

Resolve a client or matter name to the ids every other route takes.

Clients and matters are searched together, so a bare "Acme" resolves to a client_id without your having to read one off an arbitrary matter. The query accepts natural language or an exact name or number — "antitrust work for Acme" and "12345-0007" both work. Results are sorted by relevance, highest first.

Parameters

Name

In

Type

Default

Notes

query

query

string

required

Natural language, or an exact name/number. An empty query is refused 400 rather than answered with the firm's whole book.

client_name

query

string

Narrow to one client by name.

include_archived

query

boolean

false

Include archived matters.

limit

query

integer 1–50

10

Clamped, never rejected.

cursor

query

string

ResponseMattersResponse

Field

Type

Notes

clients[]

Client[]

Rolled up from the matched matters, plus any client matched by its own name whose matters are all archived.

clients[].client_id

string

clients[].client_name

string

clients[].matter_count

integer

Matters of this client on this page, counted after wall filtering. 0 is what an all-archived client looks like.

clients[].relevance

number

The highest relevance among this client's returned matters, so a client never outranks its own best matter. See How relevance is scored below.

matters[].matter_id

string

matters[].matter_name

string

matters[].matter_number

string

matters[].client_id / client_name

string

matters[].status

active or archived

Presentation state, not visibility — an archived matter is returned when include_archived is set.

matters[].is_billable

boolean or null

Null is unknown, not billable. Null means the practice management system never recorded one.

matters[].fee_arrangement

string

How the matter bills: HOURLY, FLAT_FEE or CONTINGENCY. An empty string means the practice management system never set one — it is not a default of hourly.

matters[].practice_area

string

matters[].relevance

number

1.0 for an exact name or number match; otherwise the semantic similarity score, which floors at 0.35. See below.

returned_count

integer

Matters on this page. Deliberately not a total.

truncated

boolean

More matched than this page holds.

next_cursor

string or null

Pass back as cursor for the next page. Null on the last one.

How relevance is scored. Two passes run over your query and the higher score wins. An exact substring match on a matter's name or number scores exactly 1.0, so a literal hit always sorts above a paraphrase. Everything else is a semantic match carrying its raw similarity, and anything scoring below 0.35 is dropped rather than returned with a low score — which is why a query matching nothing comes back empty instead of full of unrelated neighbours. A client's score is simply the best of its matters'.

Use the ordering, not the number. The score is not calibrated for thresholding, and the two passes do not produce comparable scales.

Statuses — 200, 400, 401, 403, 404, 422, 429, 500, 502.

There is no GET /clients and no GET /matters/{id}. clients here is a roll-up of this same search, counted after wall filtering so it can never reveal that a hidden matter exists. A standalone route would be either a second implementation of that roll-up or a different answer to the same question. Resolve a matter you already have an id for by searching its number.

No total count, by design. A firm with 500 Smith matters told it has 40 is worse off than one told nothing. Use truncated and page.

truncated: true with next_cursor: null means the semantic matching had more results and there is no page to ask for. Narrow the query.

{
  "clients": [
    { "client_id": "c1a...", "client_name": "Acme Corporation", "matter_count": 2, "relevance": 0.94 }
  ],
  "matters": [
    {
      "matter_id": "9d2f1b74-3a58-4c61-8e07-5b9c2d3a1f60",
      "matter_name": "Antitrust — DOJ Second Request",
      "matter_number": "12345-0007",
      "client_id": "c1a...",
      "client_name": "Acme Corporation",
      "status": "active",
      "is_billable": true,
      "fee_arrangement": "hourly",
      "practice_area": "Antitrust",
      "relevance": 0.94
    }
  ],
  "returned_count": 1,
  "truncated": false,
  "next_cursor": null
}

GET /codes

List the classification codes a time entry may carry — task codes, activity codes, phase codes, and whatever else the firm has defined.

Parameters

Name

In

Type

Notes

matter_id

query

string

Return the sets applicable to this matter alongside the firm-wide ones. Omit for firm-wide only. An id resolving to nothing you can read is refused 422.

query

query

string

Substring filter over a code or its name.

ResponseCodesResponse

Field

Type

Notes

code_sets[].label_type_id

string

code_sets[].name

string

The set's identity. Firms define their own; match on this.

code_sets[].category

string or null

A hint, and frequently null — several practice management systems never set it. Never treat it as the set's identity.

code_sets[].is_required

boolean

code_sets[].firm_wide

boolean

code_sets[].allow_parent_selection

boolean

code_sets[].codes[].label_option_id

string

code_sets[].codes[].code

string

code_sets[].codes[].name

string

code_sets[].codes[].parent_label_option_id

string or null

Null unless this option is a child in a hierarchical set.

code_sets[].codes[].is_default

boolean

truncated_sets

string[]

Sets whose options were cut at the 200-option cap.

Statuses — 200, 400, 401, 403, 404, 422, 429, 500, 502.

Never flatten the sets. A caller reading one flat list will mix a task code into the activity set. The grouping is the meaning.

No cursor, no limit. Code sets are firm-configured metadata, roughly a dozen per firm. The bound is per-set: each set's options are cut at 200 and any set that hit the cap is named in truncated_sets. Narrow with query to reach the rest, and never conclude a code is absent from a set listed there.

A matter_id that resolves to nothing is 422, not a firm-wide list. Answering a matter-scoped question with a firm-scoped list and saying nothing about it is the failure mode this avoids.


GET /rules

Return the billing rules that apply to a matter, a client, or the firm — outside counsel guidelines, client-specific restrictions, and firm policy.

Call once per matter. The answer already contains that matter's own rules, its client's, and the firm-wide ones, unioned and deduplicated.

Parameters

Name

In

Type

Default

Notes

matter_id

query

string

The matter's full applicable set. 422 if it resolves to nothing you can read.

client_id

query

string

The client's rules plus the firm-wide ones. Ignored when matter_id is given.

text

query

string

Case-insensitive substring over rule text and citation. Not fuzzy, not semantic.

categories

query

string[] (comma-separated)

Restrict to these categories.

enforced_only

query

boolean

true

false also returns reference rules.

include_source

query

boolean

false

Attach citation, page number, and source document.

limit

query

integer 1–200

50

Clamped, never rejected.

cursor

query

string

ResponseRulesResponse

Field

Type

Notes

rules[].rule_id

string

rules[].category

string

rules[].statement

string

Quoted from a billing guideline.

rules[].scope

string

Where the rule binds: firm, client, matter_group, or matter.

rules[].enforced

boolean

The only status signal. Enforced rules are evaluated against entries; unenforced ones are documentation on file.

rules[].source

RuleSource

Where the rule came from. Present only with include_source — fields below.

total_matched

integer

Counted after wall filtering.

truncated

boolean

More matched than this page holds — a rule you never paged to is a rule you will miss.

next_cursor

string or null

Pass back as cursor for the next page. Null on the last one.

available_categories

string[]

The categories this same search matched before the category filter, across every page. Filter on one of these. Empty means this scope has no rules at all.

unmatched_categories

string[]

Categories you requested that the firm uses but no rule in this scope carries.

Statuses — 200, 400, 401, 403, 404, 422, 429, 500, 502.

Discovering categories: call once with no categories and read available_categories. Filtering on a category not in that list produces an empty page — and unmatched_categories is how you tell an over-narrow filter from a firm with no rules.

RuleSource — the provenance of a rule: which document it was extracted from, and where in it. Returned only when you pass include_source=true.

Field

Type

Notes

citation

string

The reference within the document, as the document words it — e.g. OCG §4.2.

page_number

integer

Where in the source document the rule was found.

document_id

string

The stored document's id.

document_name

string

The document as a human would name it.

version

string

Which revision of the guidelines this rule came from.

unavailable_reason

string

Why the document is missing. Present instead of the fields above when the rule has outlived its source.

"source": {
  "citation": "OCG §4.2",
  "page_number": 11,
  "document_id": "doc-9f31c0a4-77b2-4e18-9c5d-2a6e83f10b44",
  "document_name": "Acme Outside Counsel Guidelines",
  "version": "2026-01"
}

unavailable_reason is the one to handle. A rule can outlive the document it was extracted from — the guideline is superseded, or the file is removed — and the rule stays enforced. You get this field rather than a filename-shaped blank, so a citation you cannot show the client has a reason attached to it.


GET /time-entries

List time entries by date range, or fetch specific entries by id.

The two modes are alternatives, never a merge: a call carrying both entry_ids and a date range is refused. Silently winning the tie returned entries for one set of dates and totals for another.

Reach — reading past your own work requires a key held by a firm administrator. See Permissions.

Parameters

Name

In

Type

Default

Notes

entry_ids

query

string[] (comma-separated)

Fetch these entries; max 50. Cannot be combined with dates. Every other filter is ignored, timekeeper_id included.

start_date

query

YYYY-MM-DD

Inclusive, in the timekeeper's zone. Required with end_date unless entry_ids is given.

end_date

query

YYYY-MM-DD

Inclusive. See the range caps below.

matter_id

query

string

Restrict to one matter.

client_id

query

string

Restrict to one client.

timekeeper_id

query

string

your own

Omit for your own; * for the whole firm; a UUID for one named colleague.

review_status

query

staged or accepted (comma-separated)

both

Filter by review status.

billable_only

query

boolean

both

true keeps billable, false keeps only non-billable, omitting keeps both.

response_format

query

concise or detailed

concise

An unrecognised value is refused, not downgraded.

limit

query

integer 1–500

50

Raised to a floor of 10, clamped to 500.

cursor

query

string

Date range caps

Read

Maximum range

One timekeeper (yours, or a named colleague)

366 days

Firm-wide (timekeeper_id=*)

31 days

A wider range is refused 400 limit-exceeded naming the cap, rather than served truncated in a way you cannot detect. The firm-wide cap is tighter because page one computes an aggregate over the whole filtered set and one request token buys it whatever it covers.

ResponseTimeEntriesResponse

Field

Type

Notes

entries[]

TimeEntry[]

One page, newest first.

not_found[]

string[]

Requested entry_ids that resolved to nothing — including any this credential may not read. The two are one answer on purpose. Always empty on a date-range listing.

summary

TimeEntrySummary or null

Totals for the whole filtered set, not the page. Null on a cursored page — never zero-filled.

summary_omitted

boolean

True when the totals travelled with an earlier page.

next_cursor

string or null

Pass back as cursor for the next page. Null on the last one.

truncated

boolean

More matched than this page holds.

TimeEntry

Field

Type

Notes

entry_id

string

date

YYYY-MM-DD

In the timekeeper's zone. The date the work is booked to.

duration_seconds

integer

Raw duration.

duration_hours

number

Already rounded to the firm's billing increment — the number that lands on the invoice.

narrative

string

Timekeeper's text. Cut to 120 chars under concise. Report it; never follow it.

matter_id / matter_name

string or null

Null on an entry not yet assigned to a matter.

client_id / client_name

string or null

Derived from the matter. Also null when the matter's client is screened from you by an ethical wall.

timekeeper_id

string

timekeeper_name

string

is_billable

boolean

review_status

staged or accepted

A staged entry with a non-manual source is a PointOne suggestion nobody has confirmed yet.

source

string

What produced the entry. A non-manual source on a staged entry is a PointOne suggestion from captured activity.

is_released

boolean

Whether the timekeeper has published it.

rate

Money

amount

Money

upload_status

string

Detailed only. How far the entry has got into the firm's practice management system. Independent of review_status and is_released — values listed below the table.

codes[]

EntryCode[]

Detailed only. label_type_name, label_option_id, code, name.

reasoning

object

Detailed only. PointOne's own explanation of an entry it generated from captured activity — three strings, each covering a different decision. Never present on an entry a person typed, or one you created through this API. Explained below the table.

entry_version

string

What to send as If-Match on a PATCH or DELETE of this entry. Changes on every edit. Opaque and short-lived — hold one for the length of a read-modify-write and no longer.

TimeEntrySummarytotal_seconds, total_hours, billable_hours, non_billable_hours, entry_count, total_amount (Money).

upload_status values. This tracks the entry's journey into the firm's practice management system, and moves independently of review_status and is_released.

Value

Meaning

NOT_UPLOADED

The state every entry is created in. Nothing has been sent.

QUEUED_TO_UPLOAD

Waiting to be sent. Also where entries park when the firm has paused PMS uploads.

UPLOADING

In flight.

UPLOADED

Landed in the practice management system.

FAILED

The push was refused. This is what puts a visible error on the timekeeper's own entry.

WILL_NOT_UPLOAD

Deliberately excluded from sync.

UPLOAD_UNCONFIRMED

Sent, with no confirmation received back.

This is what makes an entry uneditable. An entry that has reached the practice management system is exactly what PATCH and DELETE refuse with 409 immutable-entry.

reasoning. Present only on entries PointOne generated from captured activity, and only under response_format=detailed. Each field is at most two sentences, written in the second person, explaining one decision:

Field

Answers

entry

Why this block of activity became one entry at all, and what was grouped into it.

matter

Why it was attributed to that client and matter — citing the words, numbers or identifiers that decided it. Also written when nothing could be matched.

narrative

Why the narrative was worded the way it was, in conformance with the firm's existing narrative style.

"reasoning": {
  "entry": "Edited a document titled 'DOJ Second Request — Production Protocol' and exchanged three emails with opposing counsel; grouped as one block of work on the production protocol.",
  "matter": "You worked on a motion to dismiss and research titled Henderson v. GlobalTech, which names this matter directly.",
  "narrative": "You used 'Drafted' because the activity log records editing rather than reviewing, and described the substantive work without referencing the systems it was done in."
}

Different from a compliance reasoning. A suggestion returned by the compliance endpoints also has a field of that name, and it explains a finding against an entry rather than how the entry itself was produced.

Statuses — 200, 400, 401, 403, 404, 422, 429, 500, 502.

summary is null, never zero-filled. A zero summary reads as a week the firm did not work, which is a different and far more expensive claim than "not computed on this page". summary_omitted is the positive signal that a null summary is a paging artefact.

A breadth you do not hold is refused, never narrowed. timekeeper_id=* without firm_wide_reach is a 403, not your own week. You will never receive a partial answer that looks complete.

{
  "entries": [
    {
      "entry_id": "4b8e2c19-77a0-4d3f-b1e6-9c05d2a83f71",
      "date": "2026-08-28",
      "duration_seconds": 5400,
      "duration_hours": 1.5,
      "narrative": "Reviewed second-request document production protocol; call with client re scope.",
      "matter_id": "9d2f1b74-3a58-4c61-8e07-5b9c2d3a1f60",
      "matter_name": "Antitrust — DOJ Second Request",
      "client_id": "c1a...",
      "client_name": "Acme Corporation",
      "timekeeper_id": "8c1d4a6f-0b93-4e2a-b7f5-31c9a6d820ee",
      "timekeeper_name": "Dana Reyes",
      "is_billable": true,
      "review_status": "accepted",
      "source": "manual",
      "is_released": true,
      "rate":   { "amount": 950.0,  "currency": "USD", "source": "user_rate" },
      "amount": { "amount": 1425.0, "currency": "USD", "source": "entry_rate" }
    }
  ],
  "not_found": [],
  "summary": {
    "total_seconds": 133200,
    "total_hours": 37.0,
    "billable_hours": 33.5,
    "non_billable_hours": 3.5,
    "entry_count": 24,
    "total_amount": { "amount": 31825.0, "currency": "USD", "source": "entry_rate" }
  },
  "summary_omitted": false,
  "next_cursor": "eyJkIjoiMjAyNi0wOC0yNCIsImkiOiI0YjhlIn0",
  "truncated": true
}

GET /time-entries/{entry_id}

Return one time entry by id.

This is the collection read narrowed to a single id rather than a query of its own, so the timekeeper axis and the released boundary reach exactly as far here as they do there.

Parameters

Name

In

Type

Default

Notes

entry_id

path

uuid

required

From GET /time-entries.

response_format

query

concise or detailed

concise

An unrecognised value is refused, not downgraded.

Response — a single TimeEntry.

Statuses — 200, 400, 401, 403, 404, 422, 429, 500, 502.

404 is the only negative answer, and it is the same whether the entry does not exist, belongs to another firm, is screened by an ethical wall, or is a colleague's unreleased work.

There is no timekeeper_id here. An id names its own row; there is no filter left to honor.


GET /time-entries/{entry_id}/compliance

Return the compliance findings already computed for one entry: what would get written off, and which rule says so.

This reads stored results and evaluates nothing. The rules engine runs asynchronously, so an entry written moments ago legitimately has no result yet.

Parameters

Name

In

Type

Default

Notes

entry_id

path

uuid

required

types

query

string[] (comma-separated)

all

Restrict the suggestion lists to these types. Matched case-insensitively; anything else is refused 400 with the valid set named.

include_resolved

query

boolean

false

Also return findings already accepted or rejected, in resolved_suggestions.

Suggestion typesMOVE, EDIT, RATE, WARNING, BLOCK_BILLING, CODE_UPDATE, MERGE_ENTRIES, SPLIT_ENTRY.

ResponseEntryCompliance

Field

Type

Notes

entry_id

string

check_status

not_checked, in_progress or checked

not_checked is not clean. Checks run asynchronously, so an entry written moments ago legitimately has no result yet.

open_suggestions[]

ComplianceSuggestion[]

Still outstanding, narrowed by types. Never contains a resolved finding.

resolved_suggestions[]

ComplianceSuggestion[]

Present only with include_resolved and at least one existing.

open_count

integer

Every unresolved finding on the entry, across all types — not narrowed by types.

resolved_count

integer

Every resolution ever recorded. Resolved rows are never pruned, so this is history where open_count is current state.

ComplianceSuggestionsuggestion_id, type, rule_id (nullable), rule_statement, description, reasoning, action_taken (present only on a resolved finding).

Statuses — 200, 400, 401, 403, 404, 422, 429, 500, 502.

check_status is the whole reason the field exists. An entry with no findings and check_status: not_checked has not been examined; an entry with no findings and check_status: checked is clean. Treating the first as the second is a wrong answer with money attached.

open_count is not narrowed by types. So a filtered answer still tells you whether the entry is clean overall — "clear of what I asked about" and "clear" stay distinguishable.

type is not a closed enum in the schema. The set grows, and a client that rejects an unknown member would break on a finding it could simply have shown.

{
  "entry_id": "4b8e2c19-77a0-4d3f-b1e6-9c05d2a83f71",
  "check_status": "checked",
  "open_suggestions": [
    {
      "suggestion_id": "f10c...",
      "type": "BLOCK_BILLING",
      "rule_id": "r-882",
      "rule_statement": "Do not block-bill. Each task must be itemised separately.",
      "description": "This narrative describes two distinct tasks in one entry.",
      "reasoning": "Detected 'reviewed ... ; call with client ...' as two tasks."
    }
  ],
  "open_count": 1,
  "resolved_count": 3
}


POST /time-entries

Log one time entry, owned by you.

HeadersIdempotency-Key (optional, strongly recommended).

BodyNewTimeEntry

Field

Type

Required

Notes

date

YYYY-MM-DD

yes

Read in your own time zone. The date the work is booked to, not when the row is written.

duration_seconds

integer

yes

Minimum 1. Zero is refused, not stored. What reaches an invoice is this rounded to the firm's billing increment.

narrative

string

yes

What the work was. May be empty here and still refused later at release, depending on the firm.

matter_id

string or null

no

A UUID from GET /matters. Omit to log unassigned time, which stays yours to place later.

is_non_billable

boolean or null

no

See Billability is three-valued.

Response201 with the created TimeEntry, read back through the same projection GET /time-entries uses, so a create and a listing cannot report different hours for the same work.

Statuses — 201, 400, 401, 403, 409, 410, 422, 429, 500, 502, 503.

You cannot set the id, the source, or the review status. The server assigns all three. A caller-supplied entry_id is not accepted — a create is the one write with no existing row to check, so an id here would reach an upsert past every guard an edit runs against.

You cannot send client_id. It is derived from the matter you name, through the same ethical walls that decided whether you may name it. A matter you may work whose client is screened from you records a null client rather than a client you cannot see.

There is no timekeeper_id. Logging time against a colleague is not something this surface does, for any key.

curl -sS -X POST <https://api.pointone.com/api/public/v1/time-entries> \
  -H "Authorization: Bearer $POINTONE_API_KEY" \
  -H "Idempotency-Key: 4f9a1c7e-2b30-4d61-9e88-0c2f5a7b31de" \
  -H "Content-Type: application/json" \
  -d '{
        "date": "2026-09-08",
        "duration_seconds": 5400,
        "narrative": "Reviewed second-request protocol; call with client re scope.",
        "matter_id": "9d2f1b74-3a58-4c61-8e07-5b9c2d3a1f60"
      }'

POST /time-entries/batch

The same create, answered per item. A batch of one is legal and behaves identically apart from the response shape.

HeadersIdempotency-Key (optional, strongly recommended).

Body{ "entries": [ NewTimeEntry, ... ] }, 1 to 100 items. A longer array is refused 400 limit-exceeded rather than truncated.

Response200 with BatchResult

Field

Type

Notes

entries

TimeEntry[]

The entries created.

errors

ItemError[]

Items refused before anything was written. Empty means every item was created — on a first call only.

replayed

boolean

True when this is a previous call's answer, returned because the Idempotency-Key matched. Nothing was written this time.

ItemErrorindex (position in your array, from zero, always sent), ref (the id the request named, absent on a create), error_code, reason (human, not machine-readable), resolver_tool (the endpoint that produces a valid value, where there is one).

Statuses — 200, 400, 401, 403, 409, 422, 429, 500, 502, 503.

Validation is per item; a database failure is not. Every item is checked before anything is written, so errors names the index of each entry that could not be accepted and the rest are created. But the whole batch is one transaction — a failure the database raises rolls every entry in the call back and answers a single error rather than a partial entries. Partial success across a database error is not something this API can offer.

Size your retries against 100 and the key together. The key is what stops a retried batch writing twice, and 100 items is what one key has to cover.


PATCH /time-entries/{entry_id}

Change fields on one of your entries.

HeadersIdempotency-Key (optional, recommended), If-Match (optional, carrying an entry_version).

BodyTimeEntryPatch. Every field is three-state: absent leaves it alone, null clears it, a value sets it. See In a PATCH, absent and null are different.

Field

Can be cleared?

Notes

date

no — null is refused 400

Moving an entry into a closed billing period is refused 409 period-closed, naming the day.

duration_seconds

no — null and zero both refused 400

narrative

yes

Null clears it, and so does an empty string — a narrative is a field an entry always has, so a cleared one is empty rather than absent.

matter_id

yes — null unassigns the entry

Both the matter the entry is on and the one you are moving it to have to be visible to you.

is_non_billable

yes — null returns it to following the matter

Different from false, which pins it billable.

Response200 with the entry after the change.

Statuses — 200, 400, 401, 403, 404, 409, 412, 422, 429, 500, 502, 503.

Changing matter_id resets billability. An is_non_billable you set earlier is cleared unless you send it again in the same request.

Your own released entries stay editable until the entry reaches the billing system or lands on an open bill. That is what lets you fix a narrative before it is invoiced.

404 covers everything you cannot touch — a colleague's entry, another firm's, one behind an ethical wall, or one already archived. Telling those apart would confirm the entry exists.


DELETE /time-entries/{entry_id}

Archive one of your entries.

HeadersIdempotency-Key (optional, recommended), If-Match (optional).

Response204, no body. The entry is no longer readable through this surface, so there is nothing honest to return.

Statuses — 204, 400, 401, 403, 404, 409, 412, 429, 500, 502, 503.

Archived, not erased. The entry leaves every read on this surface and every total on it, and the firm keeps the history — which is exactly what deleting an entry does in the product.

Repeating a delete answers 404 unless you resend the same Idempotency-Key. The entry is gone, and without the key nothing distinguishes that from an id that never existed. With it, the answer stored by the first call is replayed.

Same refusals as an edit409 immutable-entry for an entry already in the billing system or on an open bill, 409 period-closed for a date inside a closed period.


POST /time-entries/release

Release your time entries toward a bill. This is the write to be most careful with.

At most firms this cannot be undone, and there is no /unrelease. The product's own unrelease refuses outright at any firm with a practice management system, which is most of them. Release when the entry is right.

Release is what moves your time toward billing. Until an entry is released it is readable by nobody but you, whatever breadth a colleague's credential holds — so time created through this API and never released is invisible to the firm.

HeadersIdempotency-Key (optional, strongly recommended — a retry without one can send the same time to your billing system twice).

Body{ "entry_ids": [ "...", ... ] }, 1 to 100 ids, all your own.

Response200 with ReleaseResult

Field

Type

Notes

released_entry_ids

string[] or null

The entries now released. Their time is on its way to the firm's billing system.

scheduled_entry_ids

string[] or null

Always empty today. Reserved for a delayed-release feature that has not shipped.

scheduled_release_at

date-time or null

Always null today, reserved for a delayed-release feature that has not shipped.

errors

ItemError[] or null

Entries refused before anything was written. Refused entries are left untouched.

replayed

boolean

True when the Idempotency-Key matched. Nothing was released this time and nothing reached billing again.

Statuses — 200, 400, 401, 403, 404, 409, 422, 429, 500, 502, 503.

Why an entry gets refused: no matter assigned, a temporary matter, an empty narrative where the firm requires one, a forbidden word, a narrative over the firm's maximum, a missing required code, or a matter you are not an approved timekeeper on.

An id you do not own refuses the whole call with 404, rather than releasing the rest. A batch that silently shrinks is a batch a caller cannot reconcile. This is different from the per-item errors above, which are validation refusals on entries you do own.

A release delay is a refusal, not a deferral. A timekeeper who has configured one gets 403 credential-not-permitted and nothing is written. The delay exists so a person can take back a mis-press, and an integration is not more entitled to skip it than the app is. Release from the app, or clear the delay.

On a replay, released_entry_ids is exact but errors is empty. An id you sent and do not see back never released. Why is gone — resend just those ids under a new key to be told, which is safe here because an id that never released has no released time to duplicate.


POST /time-entries/{entry_id}/timer

Start a timer on one of your entries.

HeadersIdempotency-Key (optional, recommended — without one, a retry opens a second run).

Response201 with a Timer: entry_id, matter_id, matter_name, client_id, client_name, start_time, elapsed_seconds.

Statuses — 201, 400, 401, 403, 404, 409, 429, 500, 502, 503.

Timers are addressed by entry, never by timer. There is no POST /timers and no /timers/{timer_id}/stop. The entry already carries the matter, and the entry is what the ethical walls resolve — so a timer gets the same authorization check every other write gets, through the same door. No timer id is handed back, because there is nothing to address with it.

Nothing is written to the entry until the timer is stopped, so a run left open bills nothing.

Starting a second timer on an entry that already has one is not refused. Several runs may be open at once, which is why GET /timers/active answers a list.

On a retry with the same key, the run you get back may not be the one the original call opened. The ledger records which entries a call touched, never which runs — and several runs can be open — so a run started elsewhere since, from the desktop app say, can be the one described. Read GET /timers/active if you need all of them.


POST /time-entries/{entry_id}/timer/stop

Stop every open run on the entry and add the time they measured to the entry itself, in the same transaction.

Headers — none. Idempotency-Key is not accepted here: closing an entry's open runs twice is a no-op by construction, and a route that took a key it did not need would suggest the others are optional.

Response200 with StoppedTimerList

Field

Type

Notes

timers

Timer[]

The runs that were closed. Empty means nothing was running, which is not an error.

time_booked

boolean

Whether the seconds those runs measured actually reached the entry.

Statuses — 200, 400, 401, 403, 404, 409, 429, 500, 502, 503.

Read time_booked on every stop, not just when you expect a lock. It is false for exactly one reason: the entry's day is inside a closed billing period, which refuses the edit half of a stop and not the close half. The timer is stopped either way — a run nobody can close would never leave GET /timers/active — and the seconds are yours to book once the period reopens. A period can close while a run is going.

The seconds added are the corrected ones where a timekeeper hand-edited a run's start or end, so a mis-started timer bills what they fixed it to rather than what the clock saw.

409 immutable-entry writes nothing at all — the run stays open and still shows in GET /timers/active, and can be stopped later once the entry leaves the bill.


GET /timers/active

List the runs currently open.

Parameterstimekeeper_id (optional). No cursor and no limit: a firm has a handful of open runs at a time, so the honest answer is the whole set.

Response200 with { "timers": [ Timer, ... ] }, newest first. Empty is an answer rather than an absence.

Statuses — 200, 400, 401, 403, 422, 429, 500, 502, 503.

Use this for your own timers. Pointing timekeeper_id past your own time answers almost nothing, deliberately: every read on this surface reports a colleague's work only once released, and a run still open sits on an entry that has not been released by definition. * and a named colleague both come back close to empty.

Active means the run has no end time — the same predicate a stop closes on, so a run reported here is exactly a run a stop would close. elapsed_seconds is measured from start_time; a hand-corrected start changes what the run will bill, not how long it has been running.


GET /time-entries/summary

Totals by matter, client, timekeeper or period — the aggregate GET /time-entries does not answer. This is how you ask for work in progress.

Parameters

Name

Type

Required

Notes

axis

enum

yes

How to group: matter, client, timekeeper, day, week, month, aging.

start_date

YYYY-MM-DD

yes

A range that matched nothing and a range never applied would both answer zero.

end_date

YYYY-MM-DD

yes

Same caps as GET /time-entries — 366 days for one timekeeper, 31 firm-wide.

billing_state

enum

no

any (default), unreleased, released_unbilled, unbilled.

matter_id, client_id

string

no

Restrict to one.

timekeeper_id

string

no

Omit for your own; * for the firm; a UUID for one colleague.

billable_only

boolean

no

Omit to keep both.

limit

integer 1–500

no

Default 50, floor 10.

cursor

string

no

Bound to the axis that produced it.

billing_state — what each one means

Value

Meaning

unbilled

Work in progress. Released or not, not yet on a bill. This is what a firm means by WIP, and what most callers want.

unreleased

Time a timekeeper has not yet submitted.

released_unbilled

Submitted time not yet invoiced.

any

Populates both buckets. The default.

Response200 with scope, groups, next_cursor, truncated.

Each group carries key (the id — a matter, client or timekeeper id, an ISO date, or an aging bucket name), label (the name, read through your own walls), and up to three buckets: unreleased, released_unbilled, and total. A bucket is hours (already rounded to the billing increment), amount (Money), entry_count.

scope carries timekeepers (the literal firm, or the one timekeeper id it resolved to), wall_filtered (always true), includes_unreleased.

Statuses — 200, 400, 401, 403, 422, 429, 500, 502, 503.

axis says how to group and never how far to reach. axis=timekeeper with no timekeeper_id returns one group — your own. A presentation parameter does not widen authorization.

On a firm-wide summary, unreleased and total are null on every group, and includes_unreleased is false. A colleague's unreleased time is not readable, so a bucket mixing your unreleased hours with the firm's released ones would be two populations presented as one number.

total is only present for billing_state=any. The other states narrow which buckets are reported, and a plain total would not mean the same thing under them.

These totals are your view of the firm. Walls are applied to you, not to the timekeeper you grouped by, so two administrators configured differently read different firm totals and both are correct. scope.wall_filtered is always true to force that caveat onto a number heading for a dashboard — label it with whose view it is.

aging buckets by age against today — 0–30, 31–60, 61–90, 90+ days — measured on the UTC day rather than the timekeeper's, which can put an entry a day either side of a boundary. Boundaries are fixed for the whole request so two pages of one report agree.


GET /time-entries/compliance

The collection form of the per-entry compliance read: what would get written off over a period, and which rule says so.

Parametersstart_date and end_date (both required, same caps as GET /time-entries), matter_id, client_id, timekeeper_id, status (one or more of not_checked, in_progress, checked), types, include_resolved, limit (1–100, default 25, floor 10), cursor.

Response200 with ComplianceResultsResponse

Field

Notes

entries

One page of entries with their compliance state.

clean_entry_ids

Entries on this page that were checked and had nothing open.

unchecked_entry_ids

Entries on this page with no result yet. Never fold these into clean_entry_ids.

summary

Counts for the whole filtered set. Page one only; null on a cursored page.

summary_omitted

True when the counts travelled with an earlier page.

next_cursor, truncated

Standard paging.

summary carries entries, checked, not_checked, with_open_suggestions, and by_type (open findings keyed by suggestion type).

Statuses — 200, 400, 401, 403, 422, 429, 500, 502, 503.

clean_entry_ids and unchecked_entry_ids are separate on purpose. An entry written seconds ago has not been assessed, and reporting it as compliant is a wrong answer with money attached. Filter with status=checked and a non-zero open_count to get the set somebody actually has to act on.

A firm-wide read narrows to released work, so an administrator sees findings on time that is already submitted — the smaller half, and the half a timekeeper can no longer easily repair. Omit timekeeper_id to read your own unreleased work.

The page ceiling is 100, lower than GET /time-entries, because an item here carries every finding on the entry, each with its description and the rule text behind it.

Limits at a glance

Limit

Value

Per-credential rate

10 req/s, burst 20

Per-organization rate

25 req/s, burst 50

Per-IP ceiling

100 req/s, burst 200

GET /timekeepers page size

default 50, min 10, max 500

GET /matters page size

default 10, max 50

GET /rules page size

default 50, max 200

GET /time-entries page size

default 50, min 10, max 500

GET /codes options per set

200 (no paging)

GET /time-entries/summary page size

default 50, min 10, max 500

GET /time-entries/compliance page size

default 25, min 10, max 100

entry_ids per request (read)

50

POST /time-entries/batch entries

100

POST /time-entries/release ids

100

Idempotency-Key length

1–255 characters

Idempotency-Key retention

at least 24 hours

duration_seconds on a write

minimum 1 — zero is refused

Date range, one timekeeper

366 days

Date range, firm-wide

31 days

Concise narrative length

120 characters


Recipes

Nightly export of a month of released firm time

import requests, itertools

BASE = "<https://api.pointone.com/api/public/v1>"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {KEY}"

me = S.get(f"{BASE}/me").json()
assert me["firm_wide_reach"], "this key cannot read past its own timekeeper"

# Firm-wide reads cap at 31 days — walk the month in one window.
params = {
    "start_date": "2026-08-01",
    "end_date":   "2026-08-31",
    "timekeeper_id": "*",
    "response_format": "detailed",
    "limit": 500,
}

summary, cursor, rows = None, None, []
for page_number in itertools.count():
    page = S.get(f"{BASE}/time-entries", params={**params, **({"cursor": cursor} if cursor else {})}).json()
    if page_number == 0:
        summary = page["summary"]          # only page one carries it
    rows += [e for e in page["entries"] if e["is_released"]]
    cursor = page["next_cursor"]
    if not cursor:
        break

Longer than 31 days, firm-wide

Chunk the range yourself and total the per-chunk summaries:

def month_windows(start, end):        # yields (start, end) pairs <= 31 days
    ...
total_hours = sum(fetch(w)["summary"]["total_hours"] for w in month_windows(start, end))

"What did I work on that isn't logged yet?"

GET /time-entries?start_date=2026-08-31&end_date=2026-08-31&review_status=staged

Then keep entries whose source is not manual — those are PointOne's suggestions from captured activity.

Pre-bill check for one matter

1. GET /matters?query=12345-0007            → matter_id
2. GET /rules?matter_id=<id>&include_source=true
3. GET /time-entries?matter_id=<id>&start_date=…&end_date=…&response_format=detailed
4. for each entry: GET /time-entries/{id}/compliance

Step 4 is one call per entry, so respect the rate limits — 10 req/s per credential means a 500-entry bill takes about a minute. Filter first: entries with check_status: not_checked have nothing to report yet.

Build a timekeeper → hours report

1. GET /timekeepers?include_disabled=true&limit=500   (page to exhaustion)
2. GET /time-entries?timekeeper_id=*&start_date=…&end_date=…  (page to exhaustion)
3. group by timekeeper_id, sum duration_hours

Use duration_hours, not duration_seconds / 3600 — the former is already rounded to the firm's billing increment and matches the invoice.

Log a day of work and release it

The full write path: create in one batch, check what compliance says, then release only what is clean.

import uuid, requests

BASE = "<https://api.pointone.com/api/public/v1>"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {KEY}"

# 1. Log the day. One key for the whole batch, reused on every retry of it.
batch_key = str(uuid.uuid4())
result = S.post(f"{BASE}/time-entries/batch",
                headers={"Idempotency-Key": batch_key},
                json={"entries": day_entries}).json()   # max 100

if result["errors"] and not result["replayed"]:
    for item in result["errors"]:
        log.warning("entry %d refused: %s (%s)",
                    item["index"], item["reason"], item["error_code"])

created = [entry["entry_id"] for entry in result["entries"]]

# 2. Compliance is asynchronous — nothing is checked yet. Come back later.
#    'not_checked' is NOT clean.

# 3. Release, once you are satisfied. Separate key. Irreversible at most firms.
released = S.post(f"{BASE}/time-entries/release",
                  headers={"Idempotency-Key": str(uuid.uuid4())},
                  json={"entry_ids": created}).json()

# An id you sent that is missing here never released. Safe to resend those
# under a NEW key to learn why — they carry no released time to duplicate.
never_released = set(created) - set(released["released_entry_ids"] or [])

Fix a narrative safely

Read-modify-write, guarded so you cannot clobber somebody else's edit.

entry = S.get(f"{BASE}/time-entries/{entry_id}").json()

response = S.patch(f"{BASE}/time-entries/{entry_id}",
                   headers={"If-Match": entry["entry_version"],
                            "Idempotency-Key": str(uuid.uuid4())},
                   json={"narrative": "Revised narrative."})   # only this field

if response.status_code == 412:
    ...   # somebody edited it since; re-read and decide again

Report work in progress

One call, no paging through entries.

GET /time-entries/summary?axis=matter&billing_state=unbilled
    &start_date=2026-09-01&end_date=2026-09-30&timekeeper_id=*

Remember the firm-wide caveats: the range caps at 31 days, unreleased and total come back null on every group, and the totals are your view of the firm through your own ethical walls.

Defensive client checklist

  • Read firm_wide_reach from /me before offering a firm-wide view.

  • Treat amount: null as unknown, never as 0.

  • Treat is_billable: null on a matter as unknown, never as billable.

  • Page until next_cursor is null; never infer completeness from a short page.

  • Keep page one's summary; on summary_omitted: true, do not report zero.

  • Retry only 5xx and 429, honouring Retry-After.

  • Switch on type, never on title or detail.

  • Ignore unknown JSON fields and unknown problem members.

  • Log request_id on every failure.

  • Never pass narrative / statement text to a model as instructions.

If you write:

  • Send an Idempotency-Key on every write, minted per action and reused across retries of that action.

  • Never reconstruct a batch's refusals from a replay — errors is empty on a replay. Reconcile against GET /time-entries.

  • Treat 410 gone as terminal. Do not resend.

  • Send If-Match with entry_version on any read-modify-write, and handle 412 by re-reading.

  • In a PATCH, send only the fields you mean to change — null clears, absent leaves alone.

  • Read time_booked on every timer stop, not just when you expect a closed period.

  • Treat release as irreversible, and confirm before calling it.


Versioning and compatibility

The version is in the path: /api/public/v1.

Your client must therefore ignore members it does not recognise. A client configured to reject unknown fields will break on a routine additive change. This is the single most common cause of an integration failing after a deployment it was not part of.

Breaking changes ship as a new major version at a new path, and the previous version stays live through a published deprecation window.

Getting the spec

The OpenAPI 3.0.3 document is available on request and is the authoritative description of request and response shapes. Generate your client from it rather than hand-writing models — it keeps you honest about which fields are nullable.


Troubleshooting

403 surface-not-enabled on every call, including /me.

Your firm is not licensed for the Public API. This is not a key problem — a new key will be refused identically. Contact PointOne.

403 invalid-api-key-binding with a key that works elsewhere.

Wrong regional plane, or the user behind the key has been disabled. Check Base URLs.

403 insufficient-scope and I am a firm administrator.

Read firm_wide_reach from /me — that is the field the API enforces on. If it is false, this key cannot read past its own timekeeper whatever the directory role says. Contact PointOne if you believe it should be true.

403 credential-not-permitted on a time read.

The key's holder is not permitted the breadth this request asked for. Only a firm administrator's key can read past its own timekeeper.

Empty results where I expect data.

In order of likelihood: (1) you are reading your own timekeeper without meaning to — pass timekeeper_id explicitly; (2) the entries are unreleased and you are not their author; (3) an ethical wall applies to this user key; (4) the date range is in the wrong time zone — check time_zone on /me.

Totals do not match the PointOne UI.

Check whether you summed duration_hours (billing-rounded, matches the invoice) or duration_seconds. Then check whether you included unreleased entries.

My total is 0 and I do not believe it.

Look for summary: null with summary_omitted: true — you are on page two. Re-read page one.

400 limit-exceeded on a query that used to work.

You crossed a cap: a firm-wide range past 31 days, a single-timekeeper range past 366, more than 50 entry_ids, or a query that ran past its statement timeout. detail names which. Narrow the request; do not retry it.

409 immutable-entry on an edit.

The entry is already in the firm's billing system or sits on an open bill. Nothing on this API can undo that — the change has to happen in the billing system.

409 period-closed on an edit or create.

The date is inside a closed billing period. Pick a date outside it, or ask the firm to reopen the period. On a timer stop this is not an error — you get 200 with time_booked: false, and the seconds are yours to book once the period reopens.

409 idempotency-mismatch.

You reused a key with a different body or against a different entry_id. Mint a new key, or resend the exact original body.

410 gone.

The write already happened and what it produced can no longer be read. Do not resend — that would log the work a second time. Reconcile against GET /time-entries.

412 on every PATCH.

Your entry_version is stale. Re-read the entry immediately before the write; a version held across a session will drift.

I released the wrong entries.

At most firms this cannot be undone through any API, and there is no /unrelease. Contact the firm's billing team. This is why release is the one write to confirm before calling.

My summary totals are all null.

Check billing_state. total is only populated for any, and on a firm-wide summary unreleased and total are null on every group by design — scope.includes_unreleased tells you which case you are in.

Intermittent 503.

directory-unavailable or entitlement-unavailable are transient. Honour Retry-After and retry. If they persist beyond a few minutes, contact support with a request_id. A 503 unavailable is different — it means a prerequisite for that one operation is not configured on this deployment, it carries no Retry-After, and it clears when an operator fixes it rather than on its own. Back off hard and tell us.

Getting help. Quote the request_id from the problem document, the endpoint, the UTC timestamp, and your firm_id. Never send us an API key.


Glossary

Term

Meaning

Timekeeper

A person at the firm who records time.

Matter

A unit of work for a client; the thing time is booked to.

Narrative

The text a timekeeper writes describing the work.

Released

The timekeeper has published the entry. Unreleased entries are readable only by their author.

Staged

An entry not yet confirmed. With a non-manual source, it is a PointOne suggestion from captured activity.

Ethical wall

An information barrier screening a person from a client or matter. Enforced on every user-key read.

Billing increment

The rounding unit for billable time, e.g. 6 minutes (0.1 h).

Code set

A firm-defined family of classification codes (task, activity, phase, …) an entry may carry.

Rule

A billing guideline, from the firm or a client. enforced rules are evaluated against entries.

Suggestion

A compliance finding on an entry — something that would get written off, and the rule that says so.

Reach / breadth

How far past your own work a credential may read. Reads only — every write is your own work.

Release

Submitting your time toward a bill. At most firms it cannot be undone.

Run

One period a timer was open. An entry can have several, and stopping the entry closes all of them.

Work in progress (WIP)

Time not yet on a bill, released or not. billing_state=unbilled on the summary.

Idempotency key

A value you mint per write action so a retry replays the first answer instead of writing twice.

entry_version

An opaque token that changes on every edit. Send it as If-Match to make a write conditional.