Developers · Reference
The firm API
Base address https://estatedraftfl.com/api/v1. JSON in, JSON out; every answer is uncached. The machine-readable contract is the OpenAPI 3.1 document.
Reviewed 2026-10-01 · contract version 2026-10-01
Authentication
Send the firm’s key as a bearer token. A missing, unknown, revoked or expired key gets the same 401; a key without the operation’s scope gets a 403 that names the scope. Keys are minted by the firm’s admin (Firm settings → API access) and shown once.
curl https://estatedraftfl.com/api/v1/matters?limit=50 \
-H "Authorization: Bearer $EDFL_FIRM_KEY"Scopes
A client-safe key reads only what a client may see; it can never hold the agenda, the deadlines, a proposal or the webhooks.
matters:readattorney or client-safedeadlines:readattorney keys onlyreceipts:readattorney or client-safedocuments:metaattorney or client-safeagenda:readattorney keys onlycapabilities:readattorney or client-saferecord:proposeattorney keys onlywebhooks:manageattorney keys only
Operations
GET
/api/v1/mattersmatters:read · read laneQuery: limit, cursor
Answers 200 · refuses 400, 401, 403, 429, 503
GET
/api/v1/matters/{id}matters:read · read laneAnswers 200 · refuses 401, 403, 404, 429, 503
POST
/api/v1/matters/{id}/proposalsrecord:propose · write lane · Idempotency-Key requiredAnswers 201 · refuses 400, 401, 403, 404, 409, 429, 503
GET
/api/v1/deadlinesdeadlines:read · read laneQuery: limit, cursor
Answers 200 · refuses 400, 401, 403, 429, 503
GET
/api/v1/receiptsreceipts:read · read laneQuery: limit, cursor
Answers 200 · refuses 400, 401, 403, 429, 503
GET
/api/v1/agendaagenda:read · read laneAnswers 200 · refuses 401, 403, 429, 503
GET
/api/v1/capabilitiescapabilities:read · read laneQuery: limit, cursor
Answers 200 · refuses 400, 401, 403, 429, 503
GET
/api/v1/webhookswebhooks:manage · read laneAnswers 200 · refuses 401, 403, 429, 503
POST
/api/v1/webhookswebhooks:manage · write lane · Idempotency-Key requiredAnswers 201 · refuses 400, 401, 403, 409, 429, 503
DELETE
/api/v1/webhooks/{id}webhooks:manage · write lane · Idempotency-Key requiredAnswers 200 · refuses 400, 401, 403, 404, 409, 429, 503
POST
/api/v1/webhooks/{id}/testwebhooks:manage · write lane · Idempotency-Key requiredAnswers 200 · refuses 401, 403, 404, 409, 429, 503
POST
/api/v1/webhooks/{id}/rotatewebhooks:manage · write lane · Idempotency-Key requiredAnswers 200 · refuses 401, 403, 404, 409, 429, 503
GET
/api/v1/openapi.jsonno key neededAnswers 200
The envelope
Every list answers in the same shape; one item carries the same fields around its data.
schema_version- The contract's version (2026-10-01).
generated_at · as_of- When the answer was composed, and as of when its data stands.
data- The rows — an empty list when there are none; null when they could not be read.
page- limit, has_more (true only when a further row exists) and next_cursor (a position only).
verification- status (unknown < ungrounded < grounded < verified — the weakest of the answer's parts), method, counts, notes.
provenance_ids · provenance_count- The ids the answer rests on, and how many.
Pagination
Ask for limit 1–200 (anything else is refused with 400, never clamped). When page.has_more is true, send page.next_cursor back unchanged as cursor. The cursor holds a position only: each page is authorized again, so a matter walled or removed from the key’s allowlist between pages is simply absent from the next one. Exactly 200 matters is one full page with nothing more.
Rate lanes
Each key has four lanes, counted in one-minute windows aligned to the clock and shared by every server — the limit is the same wherever a request lands (X-RateLimit-Enforcement: global). Every answer carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until the window turns over) and RateLimit-Policy; a spent lane answers 429 with Retry-After.
120
REST reads a minute
30
REST writes a minute
120
MCP reads a minute
30
MCP writes a minute
Errors
Each refusal is { "ok": false, "error": "…", "message": "…" }, with the field or the scope named where there is one.
- 400
- Refused: a parameter, the body or the Idempotency-Key could not be read (the field is named).
- 401
- The key is missing, unknown, revoked or expired — the same answer for each.
- 403
- The key does not hold the scope (named), or its sensitivity may never hold it.
- 404
- Not found — the same answer for an unknown id and one the key may not read.
- 409
- A conflict: an Idempotency-Key reused with a different request, a write still running (retry), or the record's state refuses it.
- 429
- The key's rate lane is spent — Retry-After and RateLimit-Reset say when it refills.
- 503
- The read or write could not be completed — retryable; nothing is known about the result (never an empty list).
Writes happen once
Every POST and DELETE carries an Idempotency-Key (1–255 visible characters, kept 24 hours). The same key with the same request returns the first answer again, marked Idempotent-Replayed: true; the same key with a different request is refused (409 idempotency_key_reused); a request still running answers 409 with Retry-After. A write that failed keeps no claim on its key. A signing secret is never kept for a replay — the replay says it was shown once.
Proposals — the one write about a matter
A key holding record:propose files a proposal for the firm’s attorneys to decide: a title, an optional description and a requested object of data. Nothing is executed (executed: false). Anything else sent — a status, a clearance, an approval, an override — is stripped and named on the firm’s card. Three undecided proposals per matter at most; a rotation never carries the scope.
curl -X POST https://estatedraftfl.com/api/v1/matters/$MATTER_ID/proposals \
-H "Authorization: Bearer $EDFL_FIRM_KEY" \
-H "Idempotency-Key: 2026-10-02-inventory-1" \
-H "Content-Type: application/json" \
-d '{"title":"Confirm the inventory date","requested":{"due_on":"2026-10-20"}}'Signed webhooks
Register an https address on the public internet (Firm settings → API access, or POST /api/v1/webhooks with webhooks:manage) and choose its events. Each event carries ids and states only — never a name, a document or a person’s words — and nothing about a matter with a wall, at the moment it happens or the moment it is delivered. A firm keeps ten live endpoints.
matter.status_changeddata: matter_id, status, lifecycle
receipt.minteddata: receipt_id, matter_id, artifact_type, content_hash
deadline.confirmeddata: derivation_id, matter_id, task_key, rule_key, due_on, status
agenda.item_openeddata: request_id, matter_id, action_type, severity
transfer.requesteddata: transfer_id, state, preview_only
consult.receiveddata: consult_id, lane
approved_delivery.completeddata: delivery_id, matter_id, artifact_kind, recipient_kind
source.changeddata: change_set_id, matter_id, trigger_kind, scope
request.status_changeddata: request_id, matter_id, status, closed_as
A delivery
POST /your/endpoint HTTP/1.1
Content-Type: application/json
EstateDraftFL-Signature: t=1759363200,v1=5f0c…
EstateDraftFL-Event-Id: 6a1e7c52-…
EstateDraftFL-Event-Type: deadline.confirmed
{"id":"6a1e7c52-…","type":"deadline.confirmed","created_at":"2026-10-02T00:00:00.000Z","data":{"derivation_id":"…","matter_id":"…","task_key":"…","rule_key":"…","due_on":"2026-11-02","status":"calendared"}}EstateDraftFL-Signatureist=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of<t>.<the raw body>under the endpoint’s whole signing secret. Verify it before trusting the body, and refuse a timestamp more than 5 minutes from your clock.- Delivery is at least once: de-duplicate by
EstateDraftFL-Event-Id; order by the timestamp. - Answer 2xx within ten seconds. Anything else is retried with a growing delay, up to ten attempts; redirects are never followed.
- Answer 410 Gone and the endpoint is disabled. Revoking the key that registered an endpoint disables it too.
- A test event (
webhook.test,data.test: true) is signed like any other and delivered at once. - The secret is shown when the endpoint is registered and when it is rotated — never again; rotation destroys the old one.
Verifying a signature — Node
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received, as text — never JSON that was parsed and serialized again.
export function verifyDelivery(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}Verifying a signature — Python
import hashlib, hmac, time
# raw_body: the request body exactly as received, as bytes.
def verify_delivery(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), str(t).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))The same reads are offered to AI assistants through the MCP server.