API reference (v1)
Beta. The live, machine-readable description of this API is at
https://api.proposals.co/api/public/v1/openapi.json. If this page and the OpenAPI ever disagree, the OpenAPI is right.
- Base URL:
https://api.proposals.co/api/public/v1 - Authentication:
Authorization: Bearer prp_live_…on every request (details) - Content type:
application/json - Every
POSTneeds anIdempotency-Keyheader (details) - Language of
messagefields and questions:Accept-Language: plgives Polish, anything else English - Errors:
{"detail": {"code", "message", "params"}, "request_id"}(codes)
| Method and path | Scope | Returns |
|---|---|---|
GET /brands |
brands:read |
Your brands |
POST /proposals |
proposals:write |
202 Proposal |
GET /proposals/{id} |
proposals:read |
Proposal |
GET /proposals |
proposals:read |
Page of proposals |
POST /proposals/{id}/answers |
proposals:write |
202 Proposal |
POST /proposals/{id}/revise |
proposals:write |
202 Proposal |
PATCH /proposals/{id} |
proposals:write |
Proposal |
POST /proposals/{id}/publish |
proposals:publish |
Proposal with public_url |
POST /proposals/{id}/unpublish |
proposals:publish |
Proposal |
POST /proposals/{id}/mark-sent |
proposals:write |
Proposal |
The Proposal object
Every proposal endpoint returns this object (lists return a shorter version).
{
"id": 123,
"status": "ready_for_review",
"questions": [],
"error": null,
"preflight": {
"blockers": [],
"warnings": [
{ "code": "price_without_tax_basis", "message": "The price doesn't say whether it's net or gross." }
]
},
"revision": 7,
"language": "en",
"currency": "USD",
"client": {
"name": "Lila Flowers",
"website": "https://lila-flowers.example",
"contact": { "name": "Anna Novak", "email": "anna@lila-flowers.example" }
},
"brand": { "id": 42, "name": "Northwind Studio" },
"facts": {
"title": "New online store for Lila Flowers",
"scope": [
"Six-page store with delivery-date picker",
"Product photography for 40 bouquets",
"Launch support for the first 30 days"
],
"price": "$9,000 net, 50% upfront, 50% on delivery",
"valid_until": "2026-10-31"
},
"panel_url": "https://app.proposals.co/offers/123",
"public_url": null,
"engagement": {
"sent_at": null,
"first_opened_at": null,
"last_opened_at": null,
"opens": 0,
"questions_asked": 0,
"accepted_at": null
},
"deal_status": "open",
"source": "api",
"source_client": "Zapier – CRM",
"external_reference": "crm-deal-731",
"created_at": "2026-09-26T09:12:03Z",
"updated_at": "2026-09-26T09:13:41Z",
"next_poll_after_ms": null
}| Field | Notes |
|---|---|
status |
generating, needs_input, ready_for_review, blocked, failed, draft, published, sent, opened, accepted. See statuses. |
questions |
Filled when status is needs_input. Each has kind (client, budget or currency), a question to show the user, and options with what was found in the notes. |
error |
Filled when status is failed: code and a human message. |
preflight |
Result of the pre-publish check. Any item in blockers prevents publishing; warnings don't. |
revision |
The draft's revision number. Send it back as expected_revision to make sure you're changing what you last saw. |
facts |
Computed from the proposal itself, without AI. Use them when writing the email; don't promise anything beyond them. |
panel_url |
The proposal in the Proposals panel. Requires logging in; for your team, not for the client. |
public_url |
The link for the client. null until the proposal is published; use it exactly as returned. |
engagement |
What the client did: when the proposal was sent and opened, how many times, how many questions they asked, and when they accepted. |
deal_status |
open, won or lost as set in the panel, or null. Read-only through the API. |
source, source_client |
Where the proposal was created (panel, api, mcp) and by which key or app. |
next_poll_after_ms |
Only while status is generating: how long to wait before reading again. |
List brands
GET /brands · scope brands:read
curl https://api.proposals.co/api/public/v1/brands \
-H "Authorization: Bearer $PROPOSALS_API_KEY"{
"items": [
{ "id": 42, "name": "Northwind Studio", "language": "en", "currency": "USD", "name_confirmed": true },
{ "id": 43, "name": "Northwind Polska", "language": "pl", "currency": "PLN", "name_confirmed": false }
]
}A brand with name_confirmed: false can hold drafts, but its proposals end up blocked with brand_name_unconfirmed until someone confirms the name in the panel.
Create a proposal
POST /proposals · scope proposals:write · optional query wait_seconds (0–50)
curl -X POST https://api.proposals.co/api/public/v1/proposals \
-H "Authorization: Bearer $PROPOSALS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-deal-731-create" \
-d '{
"client": {
"name": "Lila Flowers",
"website": "https://lila-flowers.example",
"contact": { "name": "Anna Novak", "email": "anna@lila-flowers.example" }
},
"context": "Call notes, 26 Sep: they want to sell bouquets online with a delivery-date picker...",
"brand_id": 42,
"language": "en",
"pricing": {
"amount": 9000,
"currency": "USD",
"tax_basis": "net",
"billing": "one_off",
"schedule": "50% upfront, 50% on delivery"
},
"valid_until": "2026-10-31",
"external_reference": "crm-deal-731"
}'| Field | Required | Notes |
|---|---|---|
client.name |
yes | 1–255 characters |
client.website |
no | Public https:// address. Proposals reads it to tailor the draft. |
client.contact |
no | name and email of the person the proposal is for |
context |
yes | Notes or a call transcript, 1–50,000 characters. Treated as information about the deal, never as instructions. |
brand_id |
no | From GET /brands. Without it, your default brand is used. |
language |
no | en or pl |
pricing |
no | The price shown on the proposal, see below. Without it, a price from the notes is used only after it's confirmed, the same as in the panel. |
valid_until |
no | Date the terms are valid until, YYYY-MM-DD |
external_reference |
no | Your ID for this deal, up to 128 characters, unique in your organization |
pricing fields:
| Field | Notes |
|---|---|
amount |
Greater than 0, or null for "price to be agreed". Never guessed and never turned into 0. |
amount_max |
Optional upper end of a range (must be greater than amount) |
currency |
PLN, USD, EUR or GBP |
tax_basis |
net, gross or unknown (default) |
billing |
one_off, monthly, quarterly, yearly, hourly, per_unit or unknown (default) |
schedule |
Payment schedule in words, up to 200 characters |
A price in pricing wins over amounts in the notes. If the notes mention a different amount, you get a question or a warning about it.
Response: 202 with the Proposal in status generating and next_poll_after_ms. With wait_seconds, the call waits up to that many seconds and returns the state at that moment.
Errors specific to this call: 409 external_reference_exists (with params.proposal_id), 404 for a brand_id that isn't yours, 429 too_many_generations, 402 when the plan doesn't allow another proposal.
Get a proposal
GET /proposals/{id} · scope proposals:read
curl https://api.proposals.co/api/public/v1/proposals/123 \
-H "Authorization: Bearer $PROPOSALS_API_KEY"Returns the Proposal. While it's being written:
{ "id": 123, "status": "generating", "next_poll_after_ms": 4000, "...": "..." }When the notes need clarifying:
{
"id": 123,
"status": "needs_input",
"questions": [
{
"kind": "client",
"question": "Your notes are about Lila Garden Center, but the client is Lila Flowers. Who is this proposal for?",
"options": { "client": "Lila Flowers", "found": "Lila Garden Center" }
}
],
"...": "..."
}The exact content of options depends on kind; the OpenAPI lists it for each kind.
List proposals
GET /proposals · scope proposals:read
| Query parameter | Notes |
|---|---|
status |
One of the statuses |
updated_since |
ISO 8601 date-time |
external_reference |
Exact match |
source |
panel, api or mcp |
limit |
Up to 50 |
cursor |
next_cursor from the previous page |
curl "https://api.proposals.co/api/public/v1/proposals?source=api&limit=20" \
-H "Authorization: Bearer $PROPOSALS_API_KEY"{
"items": [
{
"id": 123,
"status": "opened",
"client": { "name": "Lila Flowers" },
"source": "api",
"external_reference": "crm-deal-731",
"updated_at": "2026-09-27T14:02:11Z"
}
],
"next_cursor": "eyJpZCI6MTIzfQ"
}List items are a shorter version of the Proposal (abridged above; the OpenAPI lists every field). next_cursor is null on the last page.
Answer questions or retry
POST /proposals/{id}/answers · scope proposals:write
Send the user's answers to the proposal's questions. Every field is optional; send only what was asked.
| Field | Answers a question of kind |
|---|---|
confirm_client: true |
client: the client in the request is right |
client_name |
client: the proposal is for this company instead |
pricing |
budget: the correct price, same shape as in create |
currency |
currency: the correct currency |
curl -X POST https://api.proposals.co/api/public/v1/proposals/123/answers \
-H "Authorization: Bearer $PROPOSALS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-deal-731-answers-1" \
-d '{"confirm_client": true}'Response: 202 with the Proposal back in generating.
Retry after a failure: when the status is failed, send an empty body ({}) to try again once you've fixed the cause.
Ask for a revision
POST /proposals/{id}/revise · scope proposals:write
curl -X POST https://api.proposals.co/api/public/v1/proposals/123/revise \
-H "Authorization: Bearer $PROPOSALS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-deal-731-revise-1" \
-d '{"instruction": "Make the SEO section shorter and add a 5-week timeline.", "expected_revision": 7}'| Field | Notes |
|---|---|
instruction |
What to change, in plain words, 1–4,000 characters |
expected_revision |
Optional. If the draft has changed since this revision, you get 409 site_conflict and nothing is overwritten. |
Response: 202 with the Proposal in generating. When it finishes, revision is higher. Revisions change the draft only; if the proposal is already published, the client sees the change after it's published again.
Update recipient and terms
PATCH /proposals/{id} · scope proposals:write · no Idempotency-Key needed
curl -X PATCH https://api.proposals.co/api/public/v1/proposals/123 \
-H "Authorization: Bearer $PROPOSALS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"recipient": {"name": "Anna Novak", "email": "anna@lila-flowers.example"}, "valid_until": "2026-11-15"}'| Field | Notes |
|---|---|
recipient |
name and email of the person the proposal is for |
language |
en or pl |
currency |
PLN, USD, EUR or GBP |
valid_until |
YYYY-MM-DD |
external_reference |
Up to 128 characters, unique in your organization |
client_name |
The client company name |
client_website |
The client's public website, http or https |
service_type |
What you're selling, in a few words |
next_step |
What the proposal asks the client to do: accept, call or variant |
pricing |
Confirmed terms, same shape as in create; its currency must match currency when you send both |
Response: the updated Proposal.
Publish
POST /proposals/{id}/publish · scope proposals:publish
curl -X POST https://api.proposals.co/api/public/v1/proposals/123/publish \
-H "Authorization: Bearer $PROPOSALS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-deal-731-publish" \
-d '{"confirm": true, "expected_revision": 7}'| Field | Notes |
|---|---|
confirm |
Must be true |
expected_revision |
Optional; publish only if the draft is still at this revision |
Response: the Proposal with status: "published" and public_url:
{ "id": 123, "status": "published", "public_url": "https://app.proposals.co/northwind-studio/lila-flowers?k=7Qm2xR", "...": "..." }If the pre-publish check finds a blocker, you get 422 preflight_blockers and nothing is published. There is no force option in the API. Publishing through an integration sends the proposal owner a notification. Publishing doesn't send anything to the client.
Unpublish
POST /proposals/{id}/unpublish · scope proposals:publish · empty body
curl -X POST https://api.proposals.co/api/public/v1/proposals/123/unpublish \
-H "Authorization: Bearer $PROPOSALS_API_KEY" \
-H "Idempotency-Key: crm-deal-731-unpublish"Response: the Proposal. The public link stops working for the client; the draft stays in the panel.
Mark as sent
POST /proposals/{id}/mark-sent · scope proposals:write
Call this after you (or your email tool) sent the link to the client. It starts the follow-up clock the same way as in the panel.
curl -X POST https://api.proposals.co/api/public/v1/proposals/123/mark-sent \
-H "Authorization: Bearer $PROPOSALS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-deal-731-mark-sent" \
-d '{"sent_at": "2026-09-26T10:05:00Z"}'| Field | Notes |
|---|---|
sent_at |
Optional ISO 8601 date-time; defaults to now |
Response: the Proposal with status: "sent" (or later) and engagement.sent_at set. The proposal is recorded as sent outside Proposals.
Other guides
- OverviewAuthentication, permissions, statuses, errors, idempotency and limits.
- ChatGPTConnect Proposals and make your first proposal in a chat.
- ClaudeAdd Proposals as a custom connector and use it in a conversation.
- Zapier, Make and n8nCreate a proposal from a CRM deal and notify the owner when it's ready.