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 arePOST,PATCHandDELETE.Retries — every write takes an
Idempotency-Keyheader. Send one; without it a retry writes twice.Auth — a firm-bound API key, sent as
Authorization: Bearer <key>.Errors — RFC 9457
application/problem+jsondocuments.Machine-readable spec — OpenAPI 3.0.3 (
openapi-public.yaml), suitable for generating a client withopenapi-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.
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-enabledon every endpoint, including/me. Contact your PointOne representative to switch it on.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/jsonThere 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 |
|
|
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 |
| Meaning | Retry? |
|---|---|---|---|
401 |
| No | No — fix the request |
401 |
| The key is unknown, expired, archived, or disabled. | No — issue a new key |
403 |
| 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 |
| Your firm is not licensed for the Public API. | No — contact PointOne |
503 |
| We could not check the key right now. | Yes, after |
503 |
| We could not check your firm's API entitlement right now. | Yes, after |
429 |
| Key validation is being throttled upstream. | Yes, 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_releasedis 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.stagedwith a non-manualsource— 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?"stagedwith 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:
404is 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/dateare ISO calendar dates,YYYY-MM-DD.They are interpreted in the timekeeper's own IANA time zone, reported as
time_zoneonGET /me. They are not UTC and they are not your server's zone.dateis the date the work is booked to, not when the row was written.
Durations, rounding, and money
duration_secondsis the raw duration;duration_hoursis already rounded to the firm's billing increment.duration_hoursis the number that lands on the invoice and the number the summary totals — use it for anything financial.billing_increment_secondson/metells 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
nullwhen 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:
|
| |
|---|---|---|
Narrative | cut to 120 characters | full |
| omitted | included |
| omitted | included |
| 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 refused409 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/stopdoes 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:
errorscomes 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.entrieson 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 | 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 asis_billable: false.On a
PATCH,nullreturns the entry to following its matter, which is a different request fromfalse.
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:
breakRules:
next_cursorisnullon 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-argumentrather 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.
limitis 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 yourlimitwas reinterpreted.truncatedmeans more matched than this page holds. OnGET /matters,truncated: truewithnext_cursor: nullmeans the semantic matching found more but there is no page to ask for — narrow the query instead.GET /codesdoes 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 intruncated_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 |
|
|
|---|---|---|
Date range, page 1 | totals |
|
Date range, page 2+ |
|
|
| totals |
|
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 |
|---|---|
| Bucket capacity |
| Whole tokens left |
| Unix timestamp at which that bucket is full again |
| 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-Afteralone and noX-RateLimit-*.400 limit-exceededis 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 manyentry_ids. Change the request.
Designing for the limits
Page wide, not deep. One request with
limit=500beats fifty withlimit=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, andGET /timekeeperschange 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 |
|---|---|
| Stable URI. Switch on this, never on |
| Short human summary. Wording may change. |
| Mirrors the HTTP status. |
| Written for a person, and often names the exact field to fix. Wording may change. |
| The request path. |
| 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 |
| What it means | Retry? |
|---|---|---|---|
400 |
| The request is malformed; | No |
400 |
| You asked past a declared bound (date range, id count). | No — narrow the request |
401 |
| No bearer credential. | No |
401 |
| Unknown, expired, archived, or disabled key. | No |
401 |
| The credential resolved to no firm. | No |
403 |
| 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 |
| The credential cannot perform this read. Its holder is not permitted the breadth the request asked for. | No |
403 |
| Wrong regional plane, or a disabled user. | No |
403 |
| Firm not licensed for the Public API. | No |
404 |
| Nothing this credential can read matches. | No |
409 |
| Write. The entry is already in the firm's billing system, or sits on an open bill. Not repairable from here. | No |
409 |
| 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 |
| Write. That | No — new key, or resend the original body |
409 |
| Write. Another writer is holding the row. | Yes — the only 409 worth retrying unchanged |
410 |
| Write. Your | No — do not resend |
412 |
| Write. The | No — re-read and resend |
422 |
| Well-formed request; a reference in it named nothing — most often a name passed where an id belongs. | No — resolve the id first |
429 |
| A rate limit fired. | Yes, after |
429 |
| Key validation throttled upstream. | Yes, after |
500 |
| A fault on our side. | Yes |
502 |
| A dependency this read needs was unavailable. | Yes |
503 |
| Key could not be checked right now. | Yes, after |
503 |
| Firm entitlement could not be read right now. | Yes, after |
503 |
| 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 adetailtelling 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 |
|---|---|
| Identity, reach, time zone |
| The firm's roster of people |
| Resolve a name to client/matter ids |
| Classification codes an entry may carry |
| Billing rules for a matter, client, or the firm |
| List by date range, or fetch by id |
| One entry |
| Totals by matter, client, timekeeper or period — including work in progress |
| Compliance findings across a date range |
| Compliance findings for one entry |
| Timers currently running |
Writes — all of them act on your own entries only.
Endpoint | Purpose |
|---|---|
| Log one entry |
| Log up to 100 entries, answered per item |
| Change fields on one of your entries |
| Archive one of your entries |
| Release your entries toward a bill — usually irreversible |
| Start a timer on one of your entries |
| 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.
Response — MeResponse
Field | Type | Notes |
|---|---|---|
| uuid | |
| string | |
| uuid | The timekeeper this key acts as. |
| string | |
| string | Always |
| boolean | The person's directory role in the firm. |
| string[] | Reserved. Scope selection is not configurable today — see Permissions. |
| string | |
| string | Empty if the person has not set one. |
| string | IANA zone every date on this surface is interpreted in. |
| integer | The increment |
| 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, andGET /timekeepersanswers 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 |
|---|---|---|---|---|
| query | boolean |
| Include people who can no longer sign in. |
| query | integer 10–500 |
| Clamped, never rejected. |
| query | string | — | The previous page's |
Response — TimekeeperList
Field | Type | Notes |
|---|---|---|
| uuid | The id every entry reports and every timekeeper filter accepts. |
| string | Empty when the directory holds no name. |
| string | Empty when the directory holds no address. |
| string | Empty when the directory holds no title. |
| boolean | Can no longer sign in — but still owns their entries. |
| 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 | string | required | Natural language, or an exact name/number. An empty query is refused 400 rather than answered with the firm's whole book. |
| query | string | — | Narrow to one client by name. |
| query | boolean |
| Include archived matters. |
| query | integer 1–50 |
| Clamped, never rejected. |
| query | string | — |
Response — MattersResponse
Field | Type | Notes | |
|---|---|---|---|
| Client[] | Rolled up from the matched matters, plus any client matched by its own name whose matters are all archived. | |
| string | ||
| string | ||
| integer | Matters of this client on this page, counted after wall filtering. | |
| 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. | |
| string | ||
| string | ||
| string | ||
| string | ||
|
| Presentation state, not visibility — an archived matter is returned when | |
| boolean or null | Null is unknown, not billable. Null means the practice management system never recorded one. | |
| string | How the matter bills: | |
| string | ||
| number |
| |
| integer | Matters on this page. Deliberately not a total. | |
| boolean | More matched than this page holds. | |
| string or null | Pass back as |
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 |
|---|---|---|---|
| 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 |
| query | string | Substring filter over a code or its name. |
Response — CodesResponse
Field | Type | Notes |
|---|---|---|
| string | |
| string | The set's identity. Firms define their own; match on this. |
| string or null | A hint, and frequently null — several practice management systems never set it. Never treat it as the set's identity. |
| boolean | |
| boolean | |
| boolean | |
| string | |
| string | |
| string | |
| string or null | Null unless this option is a child in a hierarchical set. |
| boolean | |
| 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 |
|---|---|---|---|---|
| query | string | — | The matter's full applicable set. |
| query | string | — | The client's rules plus the firm-wide ones. Ignored when |
| query | string | — | Case-insensitive substring over rule text and citation. Not fuzzy, not semantic. |
| query | string[] (comma-separated) | — | Restrict to these categories. |
| query | boolean |
|
|
| query | boolean |
| Attach citation, page number, and source document. |
| query | integer 1–200 |
| Clamped, never rejected. |
| query | string | — |
Response — RulesResponse
Field | Type | Notes |
|---|---|---|
| string | |
| string | |
| string | Quoted from a billing guideline. |
| string | Where the rule binds: |
| boolean | The only status signal. Enforced rules are evaluated against entries; unenforced ones are documentation on file. |
| RuleSource | Where the rule came from. Present only with |
| integer | Counted after wall filtering. |
| boolean | More matched than this page holds — a rule you never paged to is a rule you will miss. |
| string or null | Pass back as |
| 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. |
| 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 |
|---|---|---|
| string | The reference within the document, as the document words it — e.g. |
| integer | Where in the source document the rule was found. |
| string | The stored document's id. |
| string | The document as a human would name it. |
| string | Which revision of the guidelines this rule came from. |
| 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 |
|---|---|---|---|---|
| query | string[] (comma-separated) | — | Fetch these entries; max 50. Cannot be combined with dates. Every other filter is ignored, |
| query |
| — | Inclusive, in the timekeeper's zone. Required with |
| query |
| — | Inclusive. See the range caps below. |
| query | string | — | Restrict to one matter. |
| query | string | — | Restrict to one client. |
| query | string | your own | Omit for your own; |
| query |
| both | Filter by review status. |
| query | boolean | both |
|
| query |
|
| An unrecognised value is refused, not downgraded. |
| query | integer 1–500 |
| Raised to a floor of 10, clamped to 500. |
| query | string | — |
Date range caps
Read | Maximum range |
|---|---|
One timekeeper (yours, or a named colleague) | 366 days |
Firm-wide ( | 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.
Response — TimeEntriesResponse
Field | Type | Notes |
|---|---|---|
| TimeEntry[] | One page, newest first. |
| string[] | Requested |
| TimeEntrySummary or null | Totals for the whole filtered set, not the page. Null on a cursored page — never zero-filled. |
| boolean | True when the totals travelled with an earlier page. |
| string or null | Pass back as |
| boolean | More matched than this page holds. |
TimeEntry
Field | Type | Notes |
|---|---|---|
| string | |
|
| In the timekeeper's zone. The date the work is booked to. |
| integer | Raw duration. |
| number | Already rounded to the firm's billing increment — the number that lands on the invoice. |
| string | Timekeeper's text. Cut to 120 chars under |
| string or null | Null on an entry not yet assigned to a matter. |
| string or null | Derived from the matter. Also null when the matter's client is screened from you by an ethical wall. |
| string | |
| string | |
| boolean | |
|
| A |
| string | What produced the entry. A non-manual source on a |
| boolean | Whether the timekeeper has published it. |
| Money | |
| Money | |
| string | Detailed only. How far the entry has got into the firm's practice management system. Independent of |
| EntryCode[] | Detailed only. |
| 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. |
| string | What to send as |
TimeEntrySummary — total_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 |
|---|---|
| The state every entry is created in. Nothing has been sent. |
| Waiting to be sent. Also where entries park when the firm has paused PMS uploads. |
| In flight. |
| Landed in the practice management system. |
| The push was refused. This is what puts a visible error on the timekeeper's own entry. |
| Deliberately excluded from sync. |
| 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 |
|---|---|
| Why this block of activity became one entry at all, and what was grouped into it. |
| 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. |
| 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 |
|---|---|---|---|---|
| path | uuid | required | From |
| query |
|
| 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 |
|---|---|---|---|---|
| path | uuid | required | |
| 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. |
| query | boolean |
| Also return findings already accepted or rejected, in |
Suggestion types — MOVE, EDIT, RATE, WARNING, BLOCK_BILLING, CODE_UPDATE, MERGE_ENTRIES, SPLIT_ENTRY.
Response — EntryCompliance
Field | Type | Notes |
|---|---|---|
| string | |
|
|
|
| ComplianceSuggestion[] | Still outstanding, narrowed by |
| ComplianceSuggestion[] | Present only with |
| integer | Every unresolved finding on the entry, across all types — not narrowed by |
| integer | Every resolution ever recorded. Resolved rows are never pruned, so this is history where |
ComplianceSuggestion — suggestion_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.
Headers — Idempotency-Key (optional, strongly recommended).
Body — NewTimeEntry
Field | Type | Required | Notes |
|---|---|---|---|
|
| yes | Read in your own time zone. The date the work is booked to, not when the row is written. |
| integer | yes | Minimum 1. Zero is refused, not stored. What reaches an invoice is this rounded to the firm's billing increment. |
| string | yes | What the work was. May be empty here and still refused later at release, depending on the firm. |
| string or null | no | A UUID from |
| boolean or null | no | See Billability is three-valued. |
Response — 201 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.
Headers — Idempotency-Key (optional, strongly recommended).
Body — { "entries": [ NewTimeEntry, ... ] }, 1 to 100 items. A longer array is refused 400 limit-exceeded rather than truncated.
Response — 200 with BatchResult
Field | Type | Notes |
|---|---|---|
| TimeEntry[] | The entries created. |
| ItemError[] | Items refused before anything was written. Empty means every item was created — on a first call only. |
| boolean | True when this is a previous call's answer, returned because the |
ItemError — index (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.
Headers — Idempotency-Key (optional, recommended), If-Match (optional, carrying an entry_version).
Body — TimeEntryPatch. 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 |
|---|---|---|
| no — null is refused 400 | Moving an entry into a closed billing period is refused |
| no — null and zero both refused 400 | |
| 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. |
| 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. |
| yes — null returns it to following the matter | Different from |
Response — 200 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.
Headers — Idempotency-Key (optional, recommended), If-Match (optional).
Response — 204, 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 edit — 409 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.
Headers — Idempotency-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.
Response — 200 with ReleaseResult
Field | Type | Notes |
|---|---|---|
| string[] or null | The entries now released. Their time is on its way to the firm's billing system. |
| string[] or null | Always empty today. Reserved for a delayed-release feature that has not shipped. |
| date-time or null | Always null today, reserved for a delayed-release feature that has not shipped. |
| ItemError[] or null | Entries refused before anything was written. Refused entries are left untouched. |
| boolean | True when the |
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.
Headers — Idempotency-Key (optional, recommended — without one, a retry opens a second run).
Response — 201 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.
Response — 200 with StoppedTimerList
Field | Type | Notes |
|---|---|---|
| Timer[] | The runs that were closed. Empty means nothing was running, which is not an error. |
| 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.
Parameters — timekeeper_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.
Response — 200 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 |
|---|---|---|---|
| enum | yes | How to group: |
|
| yes | A range that matched nothing and a range never applied would both answer zero. |
|
| yes | Same caps as |
| enum | no |
|
| string | no | Restrict to one. |
| string | no | Omit for your own; |
| boolean | no | Omit to keep both. |
| integer 1–500 | no | Default 50, floor 10. |
| string | no | Bound to the axis that produced it. |
billing_state — what each one means
Value | Meaning |
|---|---|
| Work in progress. Released or not, not yet on a bill. This is what a firm means by WIP, and what most callers want. |
| Time a timekeeper has not yet submitted. |
| Submitted time not yet invoiced. |
| Populates both buckets. The default. |
Response — 200 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.
Parameters — start_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.
Response — 200 with ComplianceResultsResponse
Field | Notes |
|---|---|
| One page of entries with their compliance state. |
| Entries on this page that were checked and had nothing open. |
| Entries on this page with no result yet. Never fold these into |
| Counts for the whole filtered set. Page one only; null on a cursored page. |
| True when the counts travelled with an earlier page. |
| 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 |
| default 50, min 10, max 500 |
| default 10, max 50 |
| default 50, max 200 |
| default 50, min 10, max 500 |
| 200 (no paging) |
| default 50, min 10, max 500 |
| default 25, min 10, max 100 |
| 50 |
| 100 |
| 100 |
| 1–255 characters |
| at least 24 hours |
| 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:
breakLonger 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=stagedThen 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}/complianceStep 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_hoursUse 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 againReport 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_reachfrom/mebefore offering a firm-wide view.Treat
amount: nullas unknown, never as0.Treat
is_billable: nullon a matter as unknown, never as billable.Page until
next_cursorisnull; never infer completeness from a short page.Keep page one's
summary; onsummary_omitted: true, do not report zero.Retry only 5xx and 429, honouring
Retry-After.Switch on
type, never ontitleordetail.Ignore unknown JSON fields and unknown problem members.
Log
request_idon every failure.Never pass
narrative/statementtext to a model as instructions.
If you write:
Send an
Idempotency-Keyon every write, minted per action and reused across retries of that action.Never reconstruct a batch's refusals from a replay —
errorsis empty on a replay. Reconcile againstGET /time-entries.Treat
410 goneas terminal. Do not resend.Send
If-Matchwithentry_versionon any read-modify-write, and handle412by re-reading.In a
PATCH, send only the fields you mean to change —nullclears, absent leaves alone.Read
time_bookedon 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 |
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. |
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. |
Idempotency key | A value you mint per write action so a retry replays the first answer instead of writing twice. |
| An opaque token that changes on every edit. Send it as |