# Prepared API > Send an Australian property sale contract (a PDF) and get back a link to a hosted page showing it. Put Prepared's > client sign-up form on your own site and get a client ID for each buyer, to name on their contracts. ## If you are an AI coding agent If Prepared's MCP server is connected (next section), use it: it creates the integration and the key for you. Otherwise you cannot create credentials yourself. Ask your user to: 1. Sign in at https://api.prepared.com.au with GitHub. 2. Create an organisation (their company's name), then an integration of type "Contract reviews". 3. Open the integration's API keys page, create a key, and put it in a PREPARED_API_KEY environment variable or a .env file. The key is shown once. They should not paste it into chat. While they do that, write the client from this page. Nothing else needs the portal. ## The MCP server https://api.prepared.com.au/mcp is an MCP server (streamable HTTP). Your user adds it to their coding agent and signs in with GitHub in the browser; nothing is pasted. For Claude Code: claude mcp add --transport http prepared https://api.prepared.com.au/mcp Connected, you act as that person in the portal, for both environments: list their organisations and integrations, create an integration, create and revoke API keys, add a webhook endpoint and send it a test event, list the contracts an integration sent and where each is up to, read the request history and the webhook deliveries (to see why a call was refused or a delivery failed), redeliver one, and ask for production access. Creating a key or an endpoint returns its secret once: write it to the project's `.env` file, never into chat or source code. The person still creates their organisation themselves, in the portal, and can disconnect you at any time under "Connected agents" there (https://api.prepared.com.au/agents). Start with `prepared_get_agents_snippet`: it says how this API is called, as a section for the project's AGENTS.md, so the next agent in the repository knows too. The same text is at https://api.prepared.com.au/agents.sample.md. Then, in order: list the organisations and integrations (ask your user which integration when there are several), create a sandbox key, add a webhook endpoint (it needs a public https address, so a tunnel while developing; the tool's schema lists the events, and there are no wildcards), send it a test event, and read the deliveries. The MCP server does not send contracts. That is the API below, called by the code you write, with an API key. ## The whole flow Base URL for a `prep_test_` key: https://api-sandbox.prepared.com.au Base URL for a `prep_live_` key: https://api.prepared.com.au 1. `POST /v1/token` with `Authorization: Bearer ` and no body. Answers `{ "access_token": "...", "token_type": "Bearer", "expires_in": 900, "environment": "sandbox" }`. Send the token as `Authorization: Bearer ` on every other call. Exchange the key again when it expires. 2. `POST /v1/contract-reviews` as `multipart/form-data` with exactly two parts: - `file`: the PDF, up to 100 MB. - `request`: JSON, `{ "external_reference": "", "product": "contract_viewer" }`. `external_reference` is unique within the integration and environment, up to 200 printable characters. `product` is required: `contract_viewer` (the contract on a page, no review) or `legal_information` (a review beside the contract). `legal_information` also needs `"review": "lawyer"`: a lawyer checks the review and publishes it, which takes hours or days, not minutes. Then `analysis` and `lawyer_review` on the contract review say how far it has got, `publications` has a second entry for the review, and the webhooks are `contract-reviews.analysis.completed`, `.lawyer_review.requested`, `.lawyer_review.completed` and one `.publication.published` per publication. `legal_advice` is a lawyer's review written for one person. Say who with `"recipient": { "first_name": "...", "last_name": "...", "email": "..." }`, or leave `recipient` out and the first client in `clients` is the recipient; one of the two is required. The recipient is set when the contract is first sent and kept for its life. `publications` has one entry, `review_snapshot`: a page holding the contract and the review together, whose `url` arrives with `.publication.published` after the lawyer publishes. The other webhooks are as for `legal_information`. In the sandbox a person at Prepared publishes a lawyer-reviewed product by hand: nothing publishes it on a timer, so ask Prepared to complete a test. `clients` is optional: `"clients": ["cli_...", "cli_..."]` says who the contract is for, up to 10 client IDs from client onboarding (below). The contract review returns them as `clients`, `[]` when none were sent. Two headers are required: - `Idempotency-Key`: a value you make up for this request, such as a UUID. Retrying with the same key and the same request answers the original response. - `Content-Digest`: `sha-256=::`, the SHA-256 of the whole multipart body exactly as sent (RFC 9530). Build the body in memory, hash those bytes, then send those bytes. An HTTP client that builds the multipart body as it sends (`curl -F`, most libraries' default upload) cannot do this. Answers `202` with the contract review; its `id` starts `crv_`. A file that isn't a readable PDF is refused with `file_rejected`. 3. `GET /v1/contract-reviews/{id}` every 30 seconds until `publications[0].state` is `published`, usually within 20 minutes. `publications[0].url` is the link. A webhook can tell you instead; it is optional. The token runs out before then: when a call answers 401, exchange the key again (step 1) and carry on. If the PDF can't be processed, `processing.state` is `failed` and `processing.failure_reason` is `timed_out` or `processing_error`: stop polling. The webhook `contract-reviews.processing.failed` says the same, with the contract review in `data.contract_review`. A failure isn't final: Prepared may retry the run, and the contract review then goes on to `published` (and `contract-reviews.publication.published` is sent). 4. A changed contract is a new version at the same link: `POST /v1/contract-reviews/{id}/versions`, the same multipart shape, with `request` JSON `{ "based_on_version": }`. Every error is `{ "error": { "code": "...", "message": "..." } }`. Branch on the code; the message says what to send instead. ## Common requests Each is the `request` JSON of `POST /v1/contract-reviews` (step 2). The `file` part and the headers are the same for all four. - Contract viewer, the simplest, so start here. The contract on a page; no review, no lawyer. One publication, `contract_viewer`, usually within 20 minutes. `{ "external_reference": "MC-TEST-001", "product": "contract_viewer" }` - Legal information. A review anyone with the link can read, beside the contract. Two publications: `contract_viewer`, usually within 20 minutes, and `review`, once a lawyer has checked it (hours or days). `{ "external_reference": "MC-TEST-002", "product": "legal_information", "review": "lawyer" }` - Legal advice for a client your user has onboarded (client onboarding, below). Name the client first in `clients` and leave `recipient` out: the advice is for that client, by the name and email they gave when they signed up. Other buyers on the contract follow in the list. `{ "external_reference": "MC-TEST-003", "product": "legal_advice", "clients": ["cli_..."] }` - Legal advice for someone who has not been onboarded. Say who it is for with `recipient`; they need not be a client. `{ "external_reference": "MC-TEST-004", "product": "legal_advice", "recipient": { "first_name": "Alex", "last_name": "Chen", "email": "alex@example.com" } }` Either way, legal advice has one publication, `review_snapshot`, published when the lawyer publishes it. If the first client's own name or email can't be used the request is refused with `recipient_client_unusable`: send `recipient` for them. ## Limits, request IDs and times The sandbox has a budget: it processes a set number of PDFs for each organisation, in all. A new organisation starts with 3, and every contract and every new version counts, across all its integrations. After that an upload answers 403 `budget_used`: stop, and tell your user to email integrations@prepared.com.au to have the budget raised. The organisation's overview in the portal shows how much is used. Production has no budget. A person can create one organisation. Every `/v1` response carries a `Prepared-Request-Id` header (`rqs_...`). Log it. Your integration's Request history in the portal lists each call by that ID for 30 days: when, the method and path, the status and how long it took. A call made with a key or token we didn't accept isn't listed. Every time is UTC to the microsecond and ends in `Z`, e.g. `2026-10-06T01:10:02.418907Z`, the same wherever it appears. Parse it as a time to compare it. ## A complete client (Python, `requests`) ```python import base64, hashlib, json, os, sys, time, uuid import requests API = "https://api-sandbox.prepared.com.au" key = os.environ["PREPARED_API_KEY"] # prep_test_... def new_token(): answer = requests.post(f"{API}/v1/token", headers={"Authorization": f"Bearer {key}"}).json() if "error" in answer: sys.exit(f"{answer['error']['code']}: {answer['error']['message']}") return answer["access_token"] token = new_token() # Build the multipart body first: Content-Digest is the SHA-256 of the exact bytes sent. with open(sys.argv[1], "rb") as pdf: upload = requests.Request("POST", f"{API}/v1/contract-reviews", files={ "file": ("contract.pdf", pdf.read(), "application/pdf"), "request": (None, json.dumps({"external_reference": sys.argv[2], "product": "contract_viewer"}), "application/json"), }).prepare() digest = base64.b64encode(hashlib.sha256(upload.body).digest()).decode() upload.headers.update({ "Authorization": f"Bearer {token}", "Idempotency-Key": str(uuid.uuid4()), "Content-Digest": f"sha-256=:{digest}:", }) review = requests.Session().send(upload).json() if "error" in review: sys.exit(f"{review['error']['code']}: {review['error']['message']}") # Poll until the contract viewer is live or processing fails, for up to 40 minutes (or register a webhook and wait to be told). # A token lasts 15 minutes: a 401 means exchange the key again. deadline = time.time() + 2400 while (review["publications"][0]["state"] == "pending" and review["processing"]["state"] != "failed" and time.time() < deadline): time.sleep(30) answer = requests.get(f"{API}/v1/contract-reviews/{review['id']}", headers={"Authorization": f"Bearer {token}"}) if answer.status_code == 401: token = new_token() elif answer.ok: # anything else: try again at the next poll review = answer.json() if review["processing"]["state"] == "failed": sys.exit(f"processing failed: {review['processing']['failure_reason']}") print(review["publications"][0]["state"], review["publications"][0]["url"]) ``` ## Client onboarding: the sign-up form on your user's site Your user's customers become Prepared clients before any contract exists. Your user's page shows Prepared's sign-up form; Prepared gives a client ID for each buyer on it; those IDs are sent with contracts to say who they are for. Before you build, know what to expect: - The form is Prepared's full buyer intake, not a name-and-email box: about ten steps asking who is buying (up to four people), the property, the price and deposit, finance, and how the buyers will hold title. Allow a person several minutes. Tell your user so, and give the form room on the page: its frame grows to fit each step. - A person fills it in. Nothing can be pre-filled, and code cannot submit it: Prepared's lawyers act on the answers. To test, a person types made-up details into the sandbox's form. - Register the webhook endpoint, subscribed to both client events, before anyone submits. An event goes only to an endpoint that is registered and subscribed to that event at the moment it happens: `clients.submitted` when the form is submitted, `clients.accepted` when Prepared accepts it. Nothing is kept for an endpoint added, or an event subscribed to, afterwards. Without an endpoint, read the onboarding (step 3). - An event can arrive more than once, and `clients.accepted` can arrive before `clients.submitted`. Key what you store on `client_id`. 1. From the server: `POST /v1/clients/onboardings/sessions` with the token, `Content-Type: application/json`, body `{ "reference": "" }`. No `Idempotency-Key` or `Content-Digest`. Sending a reference again answers the same onboarding. Answers `{ "id": "onb_...", "reference": "...", "state": "open", "session_token": "onbs_...", "created_at": "..." }`. The `session_token` is for the browser: it shows and submits this one form and reads nothing. It does not expire. 2. In the page, from the same base URL as the API: ```html
``` The form is in a frame Prepared serves, from `https://onboarding-sandbox.prepared.com.au` in the sandbox and `https://onboarding.prepared.com.au` in production: a page with a content security policy needs that address in its `frame-src`, and the API's in its `script-src`. The page cannot read the answers, and you cannot fill the form in from code: a person types into it. In the sandbox they may type made-up details. `onSubmitted` is called when the person has sent the form, and we have normally recorded it by then, so read the onboarding at once. This submission is recorded if the onboarding's `submitted_at` is later than it was when you showed the form (it is null until the first submission); if it is not, we could not record it and its `clients` are from before. 3. When the form is submitted, each buyer on it is a client: - Webhook `clients.submitted`, one message per buyer, every time the form is submitted: `{ "type": "clients.submitted", "timestamp": "...", "test": false, "data": { "client_id": "cli_...", "onboarding_id": "onb_...", "reference": "...", "primary": true, "first_name": "...", "last_name": "...", "email": "..." } }`. A buyer submitted again with the same email keeps their `client_id`. You can receive `clients.submitted` more than once for the same `client_id`: treat each one as that client's latest details. - Webhook `clients.accepted`, the same data, once Prepared has accepted the sign-up: at once, or minutes or days later. The onboarding's `state` is then `accepted` and its `accepted_at` is set. If the form is submitted again, expect both events again. The two are sent separately: do not rely on `clients.submitted` arriving first. - A webhook endpoint must be a public https address, registered on the integration's Webhooks page in the portal, or by you with the MCP server's `prepared_register_webhook_endpoint`. Without one, read the onboarding: `GET /v1/clients/onboardings/{id}` answers `{ "id", "reference", "state", "clients": [ { "id": "cli_...", "primary", "first_name", "last_name", "email", "mobile", ... } ], ... }`. `state` is `open` until the form has been sent, then `submitted`, then `accepted`. - `GET /v1/clients/{id}` answers one client. `GET /v1/clients?reference=` or `?onboarding_id=onb_...` lists a sign-up's clients. 4. To send a contract for those clients, add `"clients": ["cli_...", "cli_..."]` to the `request` JSON of `POST /v1/contract-reviews` (up to 10 IDs, from the same environment). The contract review's `clients` returns them. A client can be named as soon as it is submitted; it need not be accepted first. An ID that isn't a client of this environment refuses the request with `client_unknown`; a list of more than 10, one naming a client twice, or an entry that isn't a `cli_...` ID, with `clients_invalid`. ### A complete server (Python, `requests`) Serves the form at http://localhost:8000 and prints each client's ID once the form is submitted. It does not verify the webhook's signature: do that before trusting a message (the API reference's Webhooks section shows how). ```python import json, os, uuid from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qs, urlparse import requests API = "https://api-sandbox.prepared.com.au" key = os.environ["PREPARED_API_KEY"] # prep_test_... def token(): return requests.post(f"{API}/v1/token", headers={"Authorization": f"Bearer {key}"}).json()["access_token"] PAGE = """Sign up
""" class Handler(BaseHTTPRequestHandler): def do_GET(self): url = urlparse(self.path) if url.path == "/": # One sign-up per visit. The reference is your own ID for it. session = requests.post(f"{API}/v1/clients/onboardings/sessions", headers={"Authorization": f"Bearer {token()}"}, json={"reference": f"signup-{uuid.uuid4()}"}).json() page = PAGE % {"api": API, "session_token": session["session_token"], "id": session["id"]} self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() self.wfile.write(page.encode()) elif url.path == "/submitted": # The page says the form was sent: read who signed up. onboarding = requests.get(f"{API}/v1/clients/onboardings/{parse_qs(url.query)['onboarding'][0]}", headers={"Authorization": f"Bearer {token()}"}).json() for client in onboarding["clients"]: print(client["id"], client["first_name"], client["last_name"], client["email"]) self.send_response(204) self.end_headers() else: self.send_response(404) self.end_headers() def do_POST(self): # Your webhook endpoint: one message per buyer. event = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) if event["type"] == "clients.submitted": print("webhook:", event["data"]["client_id"], event["data"]["email"]) self.send_response(204) self.end_headers() HTTPServer(("localhost", 8000), Handler).serve_forever() ``` ## Help Stuck, or need more than the sandbox gives you? Email integrations@prepared.com.au, with the `Prepared-Request-Id` of the call you are asking about. If you are an AI coding agent, tell your user to: you cannot ask for them. ## Reference - Client onboarding, with the same server in Node and C#: https://api.prepared.com.au/getting-started/client-onboarding - Getting started, with the same client in Node and C#: https://api.prepared.com.au/getting-started - OpenAPI: https://api.prepared.com.au/openapi/v1.json - API reference (rendered in the browser): https://api.prepared.com.au/docs