Getting started
Send us a contract and we publish it to the contract viewer: a page anyone with the link can open. Everything here runs against the sandbox, https://api-sandbox.prepared.com.au.
Products
- contract_viewer
- The contract on a page anyone with the link can open. No review. Available now.
- legal_information
- A review anyone with the link can read, beside the contract, checked by a lawyer before it is published. Send "review": "lawyer". Available now.
- legal_advice
- A lawyer's review written for one person and published as a snapshot. Send "recipient" (first_name, last_name, email), or name them first in "clients". Available now.
Common requests
Each of these is the request part of POST /v1/contract-reviews (step 3). The file part and the headers are the same for all four, and external_reference is your own ID for the contract.
Contract viewer: the simplest, so start here. The contract on a page anyone with the link can open. No review and no lawyer. publications has one entry, contract_viewer, published 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. publications has two entries: contract_viewer, published usually within 20 minutes, and review, published once a lawyer has checked it, which takes hours or days.
{ "external_reference": "MC-TEST-002", "product": "legal_information", "review": "lawyer" }
Legal advice for a client you have onboarded. A lawyer's review written for one person. Name the client first in clients and leave recipient out: the advice is for that client, by the name and email they gave in client onboarding. Other buyers on the contract follow in the list.
{ "external_reference": "MC-TEST-003", "product": "legal_advice",
"clients": ["cli_01j9z4c1x2y3z4a5b6c7d8e9f0"] }
Legal advice for someone you have not onboarded. Say who the advice 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: a page holding the contract and the review together, published when the lawyer publishes it. Who it is for is set when the contract is first sent and kept for its life. In the sandbox a person at Prepared publishes a lawyer's review by hand: email integrations@prepared.com.au to have a test completed.
1. Create a key
In the sandbox. The key is shown once: keep it in an environment variable such as PREPARED_API_KEY, never in code. A webhook is optional: you can poll instead (step 4).
The sandbox has a budget: it processes 3 PDFs for a new organisation, in all, counting every contract and every new version. After that an upload is refused with budget_used. Email integrations@prepared.com.au to have it raised.
A coding agent can do this step for you. Connect it to our MCP server, sign in with GitHub in the browser, and it creates the key and the webhook endpoint as you: claude mcp add --transport http prepared https://api.prepared.com.au/mcp
Sign in with GitHub, create an organisation, then an integration of type Contract reviews. Its API keys page creates the key.
2. Exchange the key for a token
Tokens last 15 minutes; exchange the key again for a new one.
POST https://api-sandbox.prepared.com.au/v1/token
Authorization: Bearer prep_test_…
200 OK
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "environment": "sandbox" }
3. Send a contract
Multipart: a file part (the PDF, up to 100 MB) and a request part (JSON). Two headers are required. Idempotency-Key is a value you make up, such as a UUID: it makes a retry safe. Content-Digest is the SHA-256 of the whole request body (RFC 9530), so we know it arrived intact: build the body, hash those bytes, then send them. Tools that build the body for you as they send it (curl -F, Postman) can't make this call; the programs below can.
clients says who the contract is for: up to 10 client IDs from client onboarding. Leave it out when you have none.
POST https://api-sandbox.prepared.com.au/v1/contract-reviews
Authorization: Bearer eyJ…
Idempotency-Key: 4f1c2b9e-0d7a-4e55-9c1e-6b2f7a8d3e10
Content-Digest: sha-256=:<base64 SHA-256 of the body>:
Content-Type: multipart/form-data; boundary=…
part "file": contract.pdf
part "request": { "external_reference": "MC-TEST-001", "product": "contract_viewer",
"clients": ["cli_01j9z4c1x2y3z4a5b6c7d8e9f0"] }
202 Accepted
{
"id": "crv_01j9z4a7k2m3n4p5q6r7s8t9v0",
"external_reference": "MC-TEST-001",
"product": "contract_viewer",
"version": 1,
"processing": { "state": "running", "completed_at": null, "failure_reason": null, "failed_at": null },
"publications": [ { "channel": "contract_viewer", "state": "pending", "url": null, "published_at": null } ],
"clients": ["cli_01j9z4c1x2y3z4a5b6c7d8e9f0"],
"created_at": "2026-10-06T01:10:02.418907Z"
}
Each of these is a whole program: it exchanges the key, sends the PDF and prints the link. Run it with a PDF and a reference of your own, for example python send.py contract.pdf MC-TEST-001.
Python (requests)
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"])
Node 18 or later (no packages)
import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
const API = "https://api-sandbox.prepared.com.au";
const key = process.env.PREPARED_API_KEY; // prep_test_...
const [pdfPath, externalReference] = process.argv.slice(2);
const newToken = async () => {
const answer = await (await fetch(`${API}/v1/token`, {
method: "POST", headers: { Authorization: `Bearer ${key}` },
})).json();
if (answer.error) throw new Error(`${answer.error.code}: ${answer.error.message}`);
return answer.access_token;
};
let token = await newToken();
// Build the multipart body first: Content-Digest is the SHA-256 of the exact bytes sent.
const form = new FormData();
form.append("file", new Blob([await readFile(pdfPath)], { type: "application/pdf" }), "contract.pdf");
form.append("request", new Blob(
[JSON.stringify({ external_reference: externalReference, product: "contract_viewer" })], { type: "application/json" }));
const encoded = new Response(form);
const body = Buffer.from(await encoded.arrayBuffer());
let review = await (await fetch(`${API}/v1/contract-reviews`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": encoded.headers.get("content-type"),
"Idempotency-Key": randomUUID(),
"Content-Digest": `sha-256=:${createHash("sha256").update(body).digest("base64")}:`,
},
body,
})).json();
if (review.error) throw new Error(`${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.
const deadline = Date.now() + 2400000;
while (review.publications[0].state === "pending" && review.processing.state !== "failed" && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 30000));
const answer = await fetch(`${API}/v1/contract-reviews/${review.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (answer.status === 401) token = await newToken();
else if (answer.ok) review = await answer.json(); // anything else: try again at the next poll
}
if (review.processing.state === "failed") throw new Error(`processing failed: ${review.processing.failure_reason}`);
console.log(review.publications[0].state, review.publications[0].url);
C# (.NET 8 or later, as a console project's Program.cs)
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var api = new HttpClient { BaseAddress = new Uri("https://api-sandbox.prepared.com.au") };
var key = Environment.GetEnvironmentVariable("PREPARED_API_KEY"); // prep_test_...
async Task UseNewToken()
{
using var exchange = new HttpRequestMessage(HttpMethod.Post, "/v1/token");
exchange.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
var exchanged = await (await api.SendAsync(exchange)).Content.ReadFromJsonAsync<JsonElement>();
if (exchanged.TryGetProperty("error", out var refused))
throw new InvalidOperationException(refused.ToString());
api.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", exchanged.GetProperty("access_token").GetString());
}
await UseNewToken();
// Build the multipart body first: Content-Digest is the SHA-256 of the exact bytes sent.
using var multipart = new MultipartFormDataContent();
var file = new ByteArrayContent(await File.ReadAllBytesAsync(args[0]));
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
multipart.Add(file, "file", "contract.pdf");
multipart.Add(new StringContent(
JsonSerializer.Serialize(new { external_reference = args[1], product = "contract_viewer" }), Encoding.UTF8, "application/json"), "request");
var body = await multipart.ReadAsByteArrayAsync();
using var upload = new HttpRequestMessage(HttpMethod.Post, "/v1/contract-reviews") { Content = new ByteArrayContent(body) };
upload.Content.Headers.ContentType = multipart.Headers.ContentType;
upload.Content.Headers.Add("Content-Digest", $"sha-256=:{Convert.ToBase64String(SHA256.HashData(body))}:");
upload.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
var review = await (await api.SendAsync(upload)).Content.ReadFromJsonAsync<JsonElement>();
if (review.TryGetProperty("error", out var error))
throw new InvalidOperationException(error.ToString());
// 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.
var deadline = DateTimeOffset.UtcNow.AddMinutes(40);
while (review.GetProperty("publications")[0].GetProperty("state").GetString() == "pending"
&& review.GetProperty("processing").GetProperty("state").GetString() != "failed" && DateTimeOffset.UtcNow < deadline)
{
await Task.Delay(TimeSpan.FromSeconds(30));
using var answer = await api.GetAsync($"/v1/contract-reviews/{review.GetProperty("id").GetString()}");
if (answer.StatusCode == System.Net.HttpStatusCode.Unauthorized)
await UseNewToken();
else if (answer.IsSuccessStatusCode) // anything else: try again at the next poll
review = await answer.Content.ReadFromJsonAsync<JsonElement>();
}
if (review.GetProperty("processing").GetProperty("state").GetString() == "failed")
throw new InvalidOperationException($"processing failed: {review.GetProperty("processing").GetProperty("failure_reason")}");
Console.WriteLine($"{review.GetProperty("publications")[0].GetProperty("state")} {review.GetProperty("publications")[0].GetProperty("url")}");
4. Hear when it's live
Usually within 20 minutes. Either poll GET /v1/contract-reviews/{id} every 30 seconds until the publication's state is published (its url is the link), or register a webhook and be told. A token lasts 15 minutes: exchange the key again when a call answers 401. Every message is signed; the API reference shows how to verify it.
If the PDF can't be processed, processing.state is failed and processing.failure_reason is timed_out or processing_error; the webhook is contract-reviews.processing.failed. A failure isn't final: we may retry the run, and the publication can still follow.
POST https://your-endpoint…
{
"type": "contract-reviews.publication.published",
"timestamp": "2026-10-06T01:11:52.903381Z",
"test": false,
"data": {
"channel": "contract_viewer",
"url": "https://contracts-sandbox.prepared.com.au/…",
"contract_review": { "id": "crv_01j9z4a7…", "version": 1, "processing": { "state": "completed", … }, … }
}
}
5. Read it, or send a new version
GET /v1/contract-reviews/{id} answers the same shape at any time. A changed contract is a new version of the same one, at the same link: name the version it replaces. While the new version is being read its publication is pending with no url; the link you already hold is the one it comes back with.
POST https://api-sandbox.prepared.com.au/v1/contract-reviews/crv_01j9z4a7…/versions
part "file": contract-amended.pdf
part "request": { "based_on_version": 1 }
202 Accepted (409 if a newer version exists: the error names it)
6. Say who a contract is for
Your customers can become Prepared clients on your own site: you put our sign-up form on a page and get a client ID for each buyer. Send those IDs as clients in the request part to name them on a contract.
Errors
Every error has one shape: { "error": { "code": "…", "message": "…" } }. Branch on the code; show the message, which says what to send instead.
- content_digest_required
- Send Content-Digest: the SHA-256 of the body, as the programs above do.
- idempotency_key_required
- Send an Idempotency-Key header: any value you make up for this request.
- product_required
- Send product in the request part; it is never assumed.
- recipient_required
- legal_advice is for one person. Send recipient, or name them first in clients.
- recipient_invalid
- recipient needs first_name, last_name and an email address.
- recipient_client_unusable
- The first client's own name or email can't be used. Send recipient for them.
- recipient_not_applicable
- recipient applies only to legal_advice. Leave it out.
- review_required
- Send "review": "lawyer" with legal_information.
- review_not_available
- That review isn't available yet. Send "lawyer".
- review_not_applicable
- review applies only to legal_information. Leave it out.
- review_invalid
- review isn't a value we know. The message lists them.
- external_reference_exists
- That reference is taken. Send a changed contract as a new version.
- idempotency_key_reused
- The key was used for a different request. Use a new key for a new request.
- wrong_environment
- A key was sent to the other environment's URL. The message names the right one.
- budget_used
- Your organisation's sandbox budget is used. Email integrations@prepared.com.au to have it raised.
Need 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: every response carries one, and your integration's Request history lists each call by it.