Proposal API
Send us a request and we build a proposal for your team to review and publish. Then we tell your systems, signed, every time it changes or gets read.
Your CRM or portal posts the deal's facts. We build the proposal.
We post to your endpoint as each proposal changes and gets read.
Target contract — backend adoption is in progress. This reference does not establish deployed support. Existing request/response behavior may differ until qualification.
Base URL https://app.unfairly.ai/api/decks/proposals. The whole API is also an OpenAPI 3.1 document: import it into Postman or Insomnia, generate a client, or hand it to your AI coding tool.
Quickstart
- Get an intake key
An owner or admin opens API & webhooks and chooses New key. It shows once.
- Send a dry run
Save the request beside it as
request.jsonand send it with?dry_run=1. Its inputs are empty; acceptance depends on your kit and inherited role assignments. Fill them in from your schema until it answers 200. - Create and listen
Drop
dry_run, send a uniqueIdempotency-Key, set your endpoint on the same page, and verify each event's signature.
{
"request_id": "portal:deal-123:proposal",
"callback_url": "https://example.com/webhooks/proposals",
"source": "portal",
"recipient": {
"name": "Acme Logistics"
},
"owner": {
"name": "Dana Ruiz",
"email": "dana@example.com"
},
"initial_roles": [
{
"email": "dana@example.com",
"role": "editor"
}
],
"proposal": {
"inputs": {}
}
}curl -X POST "https://app.unfairly.ai/api/decks/proposals/requests?dry_run=1" \
-H "Authorization: Bearer $INTAKE_KEY" \
-H "Content-Type: application/json" \
-d @request.jsonimport { readFile } from "node:fs/promises";
const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/requests?dry_run=1`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Content-Type": "application/json",
},
body: await readFile("request.json", "utf8"),
});
console.log(res.status, await res.json());import json, os, requests
res = requests.post(
"https://app.unfairly.ai/api/decks/proposals/requests?dry_run=1",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
json=json.load(open("request.json")),
)
print(res.status_code, res.json())Try it
Paste your intake key to call the API from this page: read your workspace's schema, dry-run a request against it, or read a job. The key stays in this tab and goes only to https://app.unfairly.ai/api/decks/proposals. Nothing here builds a proposal.
Nothing sent yet.Authentication
Every call carries your intake key: Authorization: Bearer <intake key>. Events we send you are signed with a separate signing secret (unfcb_…).
An owner or admin of your workspace gets both on the API & webhooks page: New key makes an intake key and shows it once (the old one keeps working for 72 hours), and Reveal shows the signing secret. Keep them in your secrets manager, never in code or chat.
Send only de-identified facts. Send aggregate employer facts, not individual employee/claims records. Authorized business contacts in owner and initial_roles are distinct from prohibited employee data.
Authorization: Bearer <intake key>Errors
A refusal contains error_code and error; optional details entries carry a JSON Pointer path, code and message. Each endpoint lists the codes it can answer.
- 200Read result, dry-run result, or completed operation acknowledgment.
- 202Durably accepted operation; follow status_url for its result.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
{
"error_code": "invalid_input",
"error": "The inputs do not match the kit schema.",
"details": [
{
"path": "/inputs/census/eligible",
"code": "required",
"message": "Eligible count is required."
}
]
}Rate limits
Workspace limits: build-triggering writes 60 a minute, other writes 60 a minute, reads 600 a minute. Keys and server instances share these budgets. At most 1,000 unfinished build operations are admitted per workspace; excess returns 503 and Retry-After: 60. Rate rejection is 429 with Retry-After.
A request body can be up to 256 KB; a larger one answers 413 unread.
HTTP/1.1 429 Too Many Requests
Retry-After: 23
{"error_code":"rate_limited","error":"Workspace rate limit exceeded. Retry in 23 seconds."}Send a request
POST/requests
Validate all input and initial roles before grants. A fresh key with an existing active request_id is a completed no-op, not an update. A failed first build with no successful version may restart with corrected inputs/current kit under a fresh key, preserving job_id. Same-key retries replay the original acknowledgment before changed-kit validation. ?dry_run=1 or ?dry_run=true validates with no side effects and needs no Idempotency-Key.
Query
| Field | Type and notes |
|---|---|
dry_run | enum 1 or true validates only: no operation, grant, email or build. A 200 is DryRun, never Ack. Without this parameter, 200 is Ack. one of 1, true |
Body
| Field | Type and notes |
|---|---|
request_idrequired | string Your id for this proposal. Within the workspace it identifies one job; use Idempotency-Key to retry an action. letters, numbers and . _ : -, starting with a letter or number, up to 100 characters |
sourcerequired | enum Where the request came from: crm, portal, inbox, chat, api. one of crm, portal, inbox, chat, api |
recipient | object Optional { name }: who the proposal is for. Omit when the employer is unknown. |
recipient.namerequired | string Who the proposal is for, as it should read on the page. at least 1 characters · up to 200 characters |
ownerrequired | object { name, email, booking_url? }: your person on the deal. Role assignments are separate. |
owner.namerequired | string Your person on the deal. at least 1 characters · up to 120 characters |
owner.emailrequired | string (email) Your person's email. Does not assign an Editor or Commenter role. matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
owner.booking_url | string (url) Fills the book_meeting button when ctas doesn't set one and this is a valid HTTPS link. |
viewers | array of string (email) Omit or send []. Initial Sharer/Viewer grants are not accepted. |
allowed_viewers | object Verified viewers of the approved proposal. API updates add emails and exact domains; omitted entries and empty arrays never remove access. Manage explicit removals in Share. No invitations or staff roles are granted. |
allowed_viewers.emails | array of string (email) up to 1000 items |
allowed_viewers.domains | array of string up to 1000 items |
ctas | object { book_meeting?, accept? }, each { url, label? }, https only. Shown as buttons; clicks come back as proposal.cta_clicked. |
ctas.book_meeting | object A button that books a meeting: { url, label? }, https only. |
ctas.accept | object A button that accepts the proposal: { url, label? }, https only. |
notes | string Optional staff-written context or instructions, up to 8,000 characters. Notes are not content-screened; the sender controls what is included. up to 8,000 characters |
signers | array of object Up to 10 { role, name, email, title?, order? }. When a version is promoted it goes out for signature: each signer confirms their email with a code, fills their fields (the kit's "x-signer" fields for their role) and signs on the page. up to 10 items |
signature | object { expires_in_days?, cc? }: days until the signature request expires (30 by default), and up to 10 emails that get the signed copy and nothing else. |
signature.expires_in_days | integer |
signature.cc | array of string (email) up to 10 items |
callback_urlrequired | string (url) Required HTTPS event endpoint for this proposal. Stored at creation and reused for later events; no workspace default is needed. matches ^https:\/\/ |
proposalrequired | object |
proposal.template | string, or null One of the kit’s template ids, or null to let the kit choose. at least 1 characters · up to 80 characters |
proposal.inputsrequired | object Customer-specific facts validated by the workspace kit. No employee records or identifying health data. |
initial_roles | array of object Workspace-wide add/upgrade assignments. Omission never revokes access. Normalize email; conflicting roles for one email are invalid. Require an inherited or assigned Editor. Invitations are sent for new/upgraded access. up to 50 items |
The full request is in Request fields. Your kit's inputs come from GET /schema.
Responses
- 200Ack for completed creation/no-op; DryRun only when dry_run=1 or dry_run=true.
- 202Durably accepted; work remains. Poll status_url.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
{
"request_id": "portal:deal-123:proposal",
"callback_url": "https://example.com/webhooks/proposals",
"source": "portal",
"recipient": {
"name": "Acme Logistics"
},
"owner": {
"name": "Dana Ruiz",
"email": "dana@example.com"
},
"initial_roles": [
{
"email": "dana@example.com",
"role": "editor"
}
],
"proposal": {
"inputs": {}
}
}curl -X POST "https://app.unfairly.ai/api/decks/proposals/requests?dry_run=1" \
-H "Authorization: Bearer $INTAKE_KEY" \
-H "Content-Type: application/json" \
-d @request.jsonimport { readFile } from "node:fs/promises";
const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/requests?dry_run=1`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Content-Type": "application/json",
},
body: await readFile("request.json", "utf8"),
});
console.log(res.status, await res.json());import json, os, requests
res = requests.post(
"https://app.unfairly.ai/api/decks/proposals/requests?dry_run=1",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
json=json.load(open("request.json")),
)
print(res.status_code, res.json()){
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "succeeded",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001"
}Get current contract manifest
GET/contract
Fetch the current combined OpenAPI at the stable URL GET /api/decks/proposals/contract/openapi.json using Authorization: Bearer <intake_key>. GET /api/decks/proposals/contract returns contract_id and permanent artifact URLs; pin a version with GET /api/decks/proposals/contracts/{contract_id}/openapi.json. The current URL follows contract updates; the permanent URL retains the exact version. GET /api/decks/proposals/schema remains available separately. Fresh enabled execution-context authorization is required on every read, including historical artifacts. IDs bind API version, resolved kit commit, context and exact artifact bytes. Historical artifacts are retained immutable bytes; never regenerate them from current code.
Responses
- 200Get current contract manifest
- 401unauthorized: provision a valid workspace key.
- 404not_found: unknown, foreign-workspace or expired resource.
- 429rate_limited: retry unchanged after Retry-After.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/contract" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/contract`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
"https://app.unfairly.ai/api/decks/proposals/contract",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Download current contract artifact
GET/contract/{artifact}
Fetch the current combined OpenAPI at the stable URL GET /api/decks/proposals/contract/openapi.json using Authorization: Bearer <intake_key>. GET /api/decks/proposals/contract returns contract_id and permanent artifact URLs; pin a version with GET /api/decks/proposals/contracts/{contract_id}/openapi.json. The current URL follows contract updates; the permanent URL retains the exact version. GET /api/decks/proposals/schema remains available separately. Fresh enabled execution-context authorization is required on every read, including historical artifacts. IDs bind API version, resolved kit commit, context and exact artifact bytes. Historical artifacts are retained immutable bytes; never regenerate them from current code.
Responses
- 200Download current contract artifact
- 401unauthorized: provision a valid workspace key.
- 404not_found: unknown, foreign-workspace or expired resource.
- 429rate_limited: retry unchanged after Retry-After.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/contract/{artifact}" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/contract/{artifact}`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/contract/{artifact}",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Download immutable contract artifact
GET/contracts/{contractId}/{artifact}
Fetch the current combined OpenAPI at the stable URL GET /api/decks/proposals/contract/openapi.json using Authorization: Bearer <intake_key>. GET /api/decks/proposals/contract returns contract_id and permanent artifact URLs; pin a version with GET /api/decks/proposals/contracts/{contract_id}/openapi.json. The current URL follows contract updates; the permanent URL retains the exact version. GET /api/decks/proposals/schema remains available separately. Fresh enabled execution-context authorization is required on every read, including historical artifacts. IDs bind API version, resolved kit commit, context and exact artifact bytes. Historical artifacts are retained immutable bytes; never regenerate them from current code.
Responses
- 200Download immutable contract artifact
- 401unauthorized: provision a valid workspace key.
- 404not_found: unknown, foreign-workspace or expired resource.
- 429rate_limited: retry unchanged after Retry-After.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/contracts/{contractId}/{artifact}" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/contracts/{contractId}/{artifact}`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/contracts/{contractId}/{artifact}",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Read safe request diagnostics
GET/diagnostics
One logical diagnostic per authenticated attempt identity, including malformed requests without a job. Replay preserves its ID; later acceptance upgrades a refusal, never the reverse. No payload, notes, raw errors, credentials or caller request IDs are retained in these public rows. Fresh context authorization, signed context-bound pagination and a 30-day read window apply. Unauthenticated correlation IDs are not customer receipts.
Query
| Field | Type and notes |
|---|---|
diagnostic_id | string (uuid) Optional safe diagnostic ID; lookup remains scoped to this authenticated context and retention window. |
page_size | integer Results per page, default 50. |
page_token | string Opaque signed continuation; expires after 15 minutes and is bound to this context. |
Responses
- 200Read safe request diagnostics
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/diagnostics" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/diagnostics`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
"https://app.unfairly.ai/api/decks/proposals/diagnostics",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Download the exact approved PDF
GET/jobs/{job_id}/versions/{version}/pdf
Authenticate with the intake key in the Authorization header, without cookies. Only the current approved version is downloadable. A newer draft leaves these bytes unchanged; replacement revokes the previous version. Withdrawal retains retrieval; deletion acceptance immediately revokes it. Returns private stored bytes after hash/size verification and fresh authorization before and after storage access; no signed redirect or credential-bearing URL.
Responses
- 200Download the exact approved PDF
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID/versions/{version}/pdf" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}/versions/{version}/pdf`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}/versions/{version}/pdf",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Fetch exact successful draft inputs
GET/jobs/{job_id}/versions/{version}/inputs
Read the explicitly requested successful version before publication or after newer drafts exist. Inputs and contract identity are immutable; approval is current at read time. Never substitutes latest working inputs or approved_inputs. Polling works without callback delivery. Missing, failed or unrecorded versions return 404. Withdrawal retains retrieval; deletion acceptance revokes it. Fresh intake-key/workspace/context authority applies; responses are private, no-store. Editors and Commenters request pricing changes through ordinary comments; an Editor approves publication of the resulting draft. No separate pricing authorization is required.
Responses
- 200Fetch exact successful draft inputs
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID/versions/{version}/inputs" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}/versions/{version}/inputs`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}/versions/{version}/inputs",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Get your schema
GET/schema
Exact schema.json bytes from the current authenticated contract bundle; use its manifest and immutable URL to pin a contract.
Responses
- 200Get your schema
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/schema" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/schema`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
"https://app.unfairly.ai/api/decks/proposals/schema",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Get a job
GET/jobs/{job_id}
Without collection, return Job. With collection, return its Page. Analytics failure returns core fields and known PDF URLs with analytics_status=temporarily_unavailable. Published URL allocation is not publication. Review URL follows the latest successful draft.
Query
| Field | Type and notes |
|---|---|
collection | enum Omit for the full object. Otherwise return only the named Page. Invalid selector combinations are invalid_input. one of rounds, publishes, versions, live_viewers, viewer_cta_clicks, anonymous_cta_clicks, engagement_versions, sections |
page_size | integer Default 50, maximum 100. |
page_token | string Opaque continuation token from next_url. Follow next_url verbatim; tokens expire after 24 hours and are bound to workspace/resource/collection/selectors. |
viewer_email | string (email) Required only for viewer_cta_clicks; normalized email. |
rendered_version | integer Required for sections; optional version scope for live_viewers, viewer_cta_clicks and anonymous_cta_clicks. Invalid for other collections. |
Responses
- 200Get a job
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json()){
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"url": "https://proposals.example.com/acme",
"live_url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"version": 2,
"live_version": 1,
"promoted_at": "2026-10-19T14:00:00Z",
"shape": null,
"open_items": [],
"revisions": 1,
"requested_by": null,
"promoted_by": null,
"publishes": {
"items": [
{
"version": 1,
"at": "2026-10-19T14:00:00Z",
"by": null
}
],
"next_url": null
},
"withdrawn_at": null,
"withdrawn_by": null,
"rounds": {
"items": [
{
"round": 1,
"kind": "update",
"at": "2026-10-19T14:00:00Z",
"by": null,
"comments": 1
}
],
"next_url": null
},
"brief": null,
"error": null,
"updated_at": "2026-10-19T14:00:00Z",
"pdf_url": null,
"review_pdf_url": null,
"engagement": null,
"needs_attention": null,
"analytics_status": "not_available",
"versions": {
"items": [
{
"version": 1,
"created_at": "2026-10-19T14:00:00Z",
"review_url": "https://review.pitchunfairly.com/acme?review&v=1",
"review_pdf_url": null
},
{
"version": 2,
"created_at": "2026-10-19T14:00:00Z",
"review_url": "https://review.pitchunfairly.com/acme?review&v=2",
"review_pdf_url": null
}
],
"next_url": null
},
"deletion": null
}Update a job
POST/jobs/{job_id}/update
Accept while busy and validate the merged accepted working state. Preserve custom partial merge: inputs deep-merge, arrays replace, omitted fields remain, notes append; not JSON Merge Patch. Latest accepted API/comment change wins. An unchanged update or {} is a no-op only if a successful draft represents that state; after a failed round it rebuilds retained context without repeating notes. Failed initial builds require creation restart.
Body
| Field | Type and notes |
|---|---|
allowed_viewers | object Verified viewers of the approved proposal. API updates add emails and exact domains; omitted entries and empty arrays never remove access. Manage explicit removals in Share. No invitations or staff roles are granted. |
allowed_viewers.emails | array of string (email) up to 1000 items |
allowed_viewers.domains | array of string up to 1000 items |
source | enum Where the request came from: crm, portal, inbox, chat, api. one of crm, portal, inbox, chat, api |
recipient | object Optional { name }: who the proposal is for. Omit when the employer is unknown. |
recipient.namerequired | string Who the proposal is for, as it should read on the page. at least 1 characters · up to 200 characters |
owner | object Send only the owner fields that changed. Omitted fields remain unchanged. |
owner.name | string Your person on the deal. at least 1 characters · up to 120 characters |
owner.email | string (email) Your person's email. Does not assign an Editor or Commenter role. matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
owner.booking_url | string (url) Fills the book_meeting button when ctas doesn't set one and this is a valid HTTPS link. |
ctas | object { book_meeting?, accept? }, each { url, label? }, https only. Shown as buttons; clicks come back as proposal.cta_clicked. |
ctas.book_meeting | object A button that books a meeting: { url, label? }, https only. |
ctas.accept | object A button that accepts the proposal: { url, label? }, https only. |
notes | string Append call notes, up to 8,000 characters. Omission keeps existing notes; this does not replace them. up to 8,000 characters |
remove | array of string Inputs to take out, by path in the kit's shape ("dates.confirm_by"), when a fact no longer applies. The kit must allow the field to be left out; a path the proposal doesn't have is ignored. up to 20 items |
comment | string Why the update was sent, in plain words. Logged with it and given to the round that rebuilds. at least 1 characters · up to 2,000 characters |
proposal | object Omitted fields remain unchanged. Inputs merge recursively; arrays replace, null remains a value. A null template delegates selection to the kit. |
proposal.template | string, or null One of the kit’s template ids, or null to let the kit choose. at least 1 characters · up to 80 characters |
proposal.inputs | object Customer-specific facts validated by the workspace kit. No employee records or identifying health data. |
Responses
- 200Action already completed, including no-ops. Read status_url for the result.
- 202Durably accepted; work remains. Poll status_url.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl -X POST "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID/update" \
-H "Authorization: Bearer $INTAKE_KEY" -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{"notes":"Use the 2027 plan year."}'const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}/update`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Idempotency-Key": process.env.IDEMPOTENCY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"notes": "Use the 2027 plan year."
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}/update",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"]},
json={
"notes": "Use the 2027 plan year."
},
)
print(res.status_code, res.json()){
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "succeeded",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001"
}Revise a job
POST/jobs/{job_id}/revise
Exactly one of comments or from_review. Accept while busy. Snapshot eligible unapplied comments at acceptance; no eligible comments is a successful no-op. Failure releases reservations but retains original acceptance order; a successful draft marks applied, not human-resolved. Direct comments append; failed direct-comment work can be recovered with an empty update.
Body
| Field | Type and notes |
|---|---|
commentsrequired | array of string One to twenty comments, each up to 2,000 characters. at least 1 · up to 20 items |
or
| Field | Type and notes |
|---|---|
from_reviewrequired | true Apply eligible, unapplied review comments selected when this request is accepted. |
Responses
- 200Action already completed, including no-ops. Read status_url for the result.
- 202Durably accepted; work remains. Poll status_url.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl -X POST "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID/revise" \
-H "Authorization: Bearer $INTAKE_KEY" -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{"comments":["Lead with savings."]}'const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}/revise`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Idempotency-Key": process.env.IDEMPOTENCY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"comments": [
"Lead with savings."
]
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}/revise",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"]},
json={
"comments": [
"Lead with savings."
]
},
)
print(res.status_code, res.json()){
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "succeeded",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001"
}Promote a version
POST/jobs/{job_id}/promote
Version is required; missing or unavailable version returns 422. Publish any available successful version, including older ones with unresolved comments. Preserve newer working data, draft history and the live URL. Pending builds never auto-publish; no-op when already live. Withdrawn/deleted proposals cannot publish.
Body
| Field | Type and notes |
|---|---|
versionrequired | integer The exact available version to publish, including an older version. No implicit newest-version selection. positive |
Responses
- 200Action already completed, including no-ops. Read status_url for the result.
- 202Durably accepted; work remains. Poll status_url.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl -X POST "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID/promote" \
-H "Authorization: Bearer $INTAKE_KEY" -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{"version":1}'const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}/promote`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Idempotency-Key": process.env.IDEMPOTENCY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"version": 1
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}/promote",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"]},
json={
"version": 1
},
)
print(res.status_code, res.json()){
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "succeeded",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001"
}Withdraw a job
POST/jobs/{job_id}/withdraw
Disable draft/live/PDF access before success acknowledgment. Cancel pending work and prevent late restoration. Retain records; repeated withdrawal is successful no-op. Body is an object; reason is optional.
Body
| Field | Type and notes |
|---|---|
reason | string Why the proposal is being withdrawn. at least 1 characters · up to 500 characters |
Responses
- 200Action already completed, including no-ops. Read status_url for the result.
- 202Durably accepted; work remains. Poll status_url.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl -X POST "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID/withdraw" \
-H "Authorization: Bearer $INTAKE_KEY" -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{}'const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}/withdraw`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Idempotency-Key": process.env.IDEMPOTENCY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}/withdraw",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"]},
json={},
)
print(res.status_code, res.json()){
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "succeeded",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001"
}List viewers
GET/jobs/{job_id}/viewers
Without collection return AccessView. Reviewer is named Commenter in this contract. Preserve pending requests plus decided requests from the last 30 days.
Query
| Field | Type and notes |
|---|---|
collection | enum Omit for the full object. Otherwise return only the named Page. Invalid selector combinations are invalid_input. one of viewers, requests |
page_size | integer Default 50, maximum 100. |
page_token | string Opaque continuation token from next_url. Follow next_url verbatim; tokens expire after 24 hours and are bound to workspace/resource/collection/selectors. |
Responses
- 200List viewers
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID/viewers" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}/viewers`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}/viewers",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Change viewers
POST/jobs/{job_id}/viewers
Direct add grants Viewer without emailing. Approving a request grants Viewer and reports its email attempt separately. Validate conflicts before side effects; runtime mixed results terminate failed with partial_failure and every item's result. Successful grants remain. No grants after withdrawal/deletion.
Body
| Field | Type and notes |
|---|---|
add | array of string (email) Emails to grant Viewer access to the live link; no invitation email. up to 50 items |
revoke | array of string (email) Emails who may no longer open the live link. up to 50 items |
revoke_added | boolean With revoke: also remove everyone the revoked people added. |
decide | array of object Approve or decline requests for Viewer access; approve_domain also updates the domain policy. up to 50 items |
audience_domains | array of string Company domains used by the sharing policy. Domains do not grant roles. up to 20 items |
invite_policy | enum colleagues: authorized sharers can invite within audience domains. approval: additions require approval. off: sharing invitations are disabled. Viewers cannot invite. one of colleagues, approval, off |
Responses
- 200Action already completed, including no-ops. Read status_url for the result.
- 202Durably accepted; work remains. Poll status_url.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl -X POST "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID/viewers" \
-H "Authorization: Bearer $INTAKE_KEY" -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{"add":["viewer@example.com"]}'const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}/viewers`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Idempotency-Key": process.env.IDEMPOTENCY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"add": [
"viewer@example.com"
]
}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}/viewers",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"]},
json={
"add": [
"viewer@example.com"
]
},
)
print(res.status_code, res.json()){
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "succeeded",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001"
}Read captured events
GET/callbacks/capture/{workspace}
Ancillary testing surface; not required for integration. Read authenticated captured callbacks. Capture verifies raw sha256 signatures and known event schemas before redacting stored attribution.
Responses
- 200Read captured events
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/callbacks/capture/$WORKSPACE" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/callbacks/capture/${workspace}`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/callbacks/capture/{workspace}",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Look up proposal by request ID
GET/requests/{request_id}
Read-only lookup without current-kit validation. URL-encode the request_id segment. 410 while a deletion receipt is retained; otherwise 404.
Responses
- 200Look up proposal by request ID
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/requests/$REQUEST_ID" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/requests/${requestId}`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/requests/{request_id}",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json()){
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"url": "https://proposals.example.com/acme",
"live_url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"version": 2,
"live_version": 1,
"promoted_at": "2026-10-19T14:00:00Z",
"shape": null,
"open_items": [],
"revisions": 1,
"requested_by": null,
"promoted_by": null,
"publishes": {
"items": [
{
"version": 1,
"at": "2026-10-19T14:00:00Z",
"by": null
}
],
"next_url": null
},
"withdrawn_at": null,
"withdrawn_by": null,
"rounds": {
"items": [
{
"round": 1,
"kind": "update",
"at": "2026-10-19T14:00:00Z",
"by": null,
"comments": 1
}
],
"next_url": null
},
"brief": null,
"error": null,
"updated_at": "2026-10-19T14:00:00Z",
"pdf_url": null,
"review_pdf_url": null,
"engagement": null,
"needs_attention": null,
"analytics_status": "not_available",
"versions": {
"items": [
{
"version": 1,
"created_at": "2026-10-19T14:00:00Z",
"review_url": "https://review.pitchunfairly.com/acme?review&v=1",
"review_pdf_url": null
},
{
"version": 2,
"created_at": "2026-10-19T14:00:00Z",
"review_url": "https://review.pitchunfairly.com/acme?review&v=2",
"review_pdf_url": null
}
],
"next_url": null
},
"deletion": null
}Delete proposal
DELETE/jobs/{job_id}
No body. DELETE is machine confirmation. A proposal whose site has an agreement with any signature (partly or fully signed) is refused with 409 signed_agreement: the signing record is kept for at least 7 years, so withdraw it instead; an agreement out for signature with no signature yet is voided and the delete proceeds. Disable hosted access/new work before acceptance; cancel pending work. Operation runs until active-system deletion completes. Poll the accepted operation for completion and its category receipt; deletion erases callback destinations and payloads, so no deletion-completion callback is sent. Keep only minimal redacted receipts for the retention window; return 410 for retained deleted identities, then 404. Cleanup durations are operational goals, not guarantees.
Responses
- 200Action already completed, including no-ops. Read status_url for the result.
- 202Durably accepted; work remains. Poll status_url.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl -X DELETE "https://app.unfairly.ai/api/decks/proposals/jobs/$JOB_ID" \
-H "Authorization: Bearer $INTAKE_KEY" -H "Idempotency-Key: $IDEMPOTENCY_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/jobs/${jobId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Idempotency-Key": process.env.IDEMPOTENCY_KEY,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.delete(
f"https://app.unfairly.ai/api/decks/proposals/jobs/{job_id}",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"]},
)
print(res.status_code, res.json()){
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "succeeded",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001"
}Read operation outcome
GET/operations/{operation_id}
Durable result for one action; no list endpoint. Unknown/foreign/expired receipts return 404. Queued/running result is null; terminal result contains outcome or is null with result_redacted=true after deletion. Invitation resend has null job_id/request_id.
Responses
- 200Read operation outcome
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl "https://app.unfairly.ai/api/decks/proposals/operations/$OPERATION_ID" \
-H "Authorization: Bearer $INTAKE_KEY"const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/operations/${operationId}`, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
},
});
console.log(res.status, await res.json());import os, requests
res = requests.get(
f"https://app.unfairly.ai/api/decks/proposals/operations/{operation_id}",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}"},
)
print(res.status_code, res.json())Resend workspace invitation
POST/invitations/{invitation_id}/resend
Existing active workspace invitation only; no role change or rebuild. Unknown/foreign invitation: 404; consumed/revoked: 409. Return the current invitation ID in result.invitation, including a replacement ID when credentials rotate. Provider acceptance means sent, not delivered.
Body
| Field | Type and notes |
|---|
Responses
- 200Action already completed, including no-ops. Read status_url for the result.
- 202Durably accepted; work remains. Poll status_url.
- 400invalid_input, invalid_page_token or page_token_expired: correct the request. Validation before acceptance does not consume a new key.
- 401unauthorized: provision a valid workspace key.
- 403forbidden: unavailable to this credential/workspace.
- 404not_found: unknown, foreign-workspace or expired resource.
- 409idempotency_conflict, idempotency_in_progress, invalid_state, or (DELETE only) signed_agreement. An in-progress reservation includes Retry-After: 1.
- 410proposal_deleted: stop proposal actions; retained operation receipts remain readable.
- 413payload_too_large: maximum 262144 UTF-8 bytes.
- 415unsupported_media_type: send application/json.
- 422invalid_input or prohibited_data: correct semantic inputs; no work accepted.
- 429rate_limited: retry unchanged after Retry-After.
- 500internal_error: outcome may be uncertain; retry unchanged with the same key or look it up.
- 503temporarily_unavailable: retry unchanged after Retry-After; backlog admission uses 60 seconds.
Counts toward the rate limit.
curl -X POST "https://app.unfairly.ai/api/decks/proposals/invitations/$INVITATION_ID/resend" \
-H "Authorization: Bearer $INTAKE_KEY" -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{}'const res = await fetch(`https://app.unfairly.ai/api/decks/proposals/invitations/${invitationId}/resend`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INTAKE_KEY}`,
"Idempotency-Key": process.env.IDEMPOTENCY_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
console.log(res.status, await res.json());import os, requests
res = requests.post(
f"https://app.unfairly.ai/api/decks/proposals/invitations/{invitation_id}/resend",
headers={"Authorization": f"Bearer {os.environ['INTAKE_KEY']}", "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"]},
json={},
)
print(res.status_code, res.json()){
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "succeeded",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001"
}The request
One JSON object per proposal. The recipient may be omitted when the employer is unknown. Optional allowed_viewers grants verified viewing access only; it never assigns workspace roles or sends invitations. API updates add supplied emails and domains to the current audience. Omission and empty arrays never remove access. Explicit removals are managed in Share, alongside existing people and roles. The access receipt reports the committed audience independently of content build status; content failure does not undo that receipt. Before approval no audience update exposes a draft. Unknown keys are refused. inputs is your kit's: GET /schema has every field, with its limits.
| Field | Type and notes |
|---|---|
request_idrequired | string Your id for this proposal. Within the workspace it identifies one job; use Idempotency-Key to retry an action. letters, numbers and . _ : -, starting with a letter or number, up to 100 characters |
sourcerequired | enum Where the request came from: crm, portal, inbox, chat, api. one of crm, portal, inbox, chat, api |
recipient | object Optional { name }: who the proposal is for. Omit when the employer is unknown. |
recipient.namerequired | string Who the proposal is for, as it should read on the page. at least 1 characters · up to 200 characters |
ownerrequired | object { name, email, booking_url? }: your person on the deal. Role assignments are separate. |
owner.namerequired | string Your person on the deal. at least 1 characters · up to 120 characters |
owner.emailrequired | string (email) Your person's email. Does not assign an Editor or Commenter role. matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
owner.booking_url | string (url) Fills the book_meeting button when ctas doesn't set one and this is a valid HTTPS link. |
viewers | array of string (email) Omit or send []. Initial Sharer/Viewer grants are not accepted. |
allowed_viewers | object Verified viewers of the approved proposal. API updates add emails and exact domains; omitted entries and empty arrays never remove access. Manage explicit removals in Share. No invitations or staff roles are granted. |
allowed_viewers.emails | array of string (email) up to 1000 items |
allowed_viewers.domains | array of string up to 1000 items |
ctas | object { book_meeting?, accept? }, each { url, label? }, https only. Shown as buttons; clicks come back as proposal.cta_clicked. |
ctas.book_meeting | object A button that books a meeting: { url, label? }, https only. |
ctas.accept | object A button that accepts the proposal: { url, label? }, https only. |
notes | string Optional staff-written context or instructions, up to 8,000 characters. Notes are not content-screened; the sender controls what is included. up to 8,000 characters |
signers | array of object Up to 10 { role, name, email, title?, order? }. When a version is promoted it goes out for signature: each signer confirms their email with a code, fills their fields (the kit's "x-signer" fields for their role) and signs on the page. up to 10 items |
signature | object { expires_in_days?, cc? }: days until the signature request expires (30 by default), and up to 10 emails that get the signed copy and nothing else. |
signature.expires_in_days | integer |
signature.cc | array of string (email) up to 10 items |
callback_urlrequired | string (url) Required HTTPS event endpoint for this proposal. Stored at creation and reused for later events; no workspace default is needed. matches ^https:\/\/ |
proposalrequired | object |
proposal.template | string, or null One of the kit’s template ids, or null to let the kit choose. at least 1 characters · up to 80 characters |
proposal.inputsrequired | object Customer-specific facts validated by the workspace kit. No employee records or identifying health data. |
initial_roles | array of object Workspace-wide add/upgrade assignments. Omission never revokes access. Normalize email; conflicting roles for one email are invalid. Require an inherited or assigned Editor. Invitations are sent for new/upgraded access. up to 50 items |
{
"request_id": "portal:deal-123:proposal",
"callback_url": "https://example.com/webhooks/proposals",
"source": "portal",
"recipient": {
"name": "Acme Logistics"
},
"owner": {
"name": "Dana Ruiz",
"email": "dana@example.com"
},
"initial_roles": [
{
"email": "dana@example.com",
"role": "editor"
}
],
"proposal": {
"inputs": {}
}
}The job
What GET /jobs/{job_id} returns. Writes return an operation acknowledgment; follow its status_url for the result.
Context-bound jobs include environment and test. In dev and staging, url, live_url and pdf_url are null. After private approval, private_test_url requires authentication and stays pinned to live_version while a newer draft is reviewed. Before approval and in production it is null. Legacy workspaces without an execution context omit these three context fields.
| Field | Type and notes |
|---|---|
environment | enum The operator-bound customer execution environment. Absent for legacy workspaces without a context. one of dev, staging, production |
test | boolean True for dev and staging private test proposals; false for production. |
private_test_url | string (url), or null Authenticated private test link pinned to the approved live_version. Null before approval and in production; public URLs remain null in test mode. matches ^https:\/\/ |
job_idrequired | string (uuid) The job. |
request_idrequired | string Your id for the proposal, from the request. |
statusrequired | enum queued, building, in_review (a successful draft is available), failed (the first build failed), or withdrawn. one of queued, building, in_review, failed, withdrawn |
urlrequired | string (url), or null The public link: what the customer opens. It shows live_version once one is promoted. matches ^https:\/\/ |
live_urlrequired | string (url), or null The same value as url. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link: always the newest successful version, for your team. matches ^https:\/\/ |
versionrequired | integer, or null The newest version built. positive |
live_versionrequired | integer, or null The version on the public link, or null before the first promote. positive |
promoted_atrequired | string (timestamp), or null When a version was last promoted. matches Z$ |
approved_pdf | object, or null Authenticated version-bound current approved PDF, with no credential in its URL. Withdrawal retains it until replacement or deletion. |
approved_inputs | object, or null Immutable effective inputs of live_version; null before approval. A newer draft never changes this snapshot. |
approved_contract | object, or null |
approved_pricing_authorization | object, or null Historical pricing authorization, if recorded before the separate authorization step was retired. New approvals use the existing Editor publication flow. |
shaperequired | string, or null The proposal's shape, as the kit's build chose it. |
open_itemsrequired | array of string What your team should confirm before it goes live: a section dropped for missing data, a term assumed. Advisory; does not block publication. |
revisionsrequired | integer How many rounds have run since the first build. |
requested_byrequired | string, or null The member who asked for it, or null when your systems did. |
promoted_byrequired | string, or null The member who last promoted it, or null when your systems did. |
publishesrequired | object Publication history, oldest first, returned as a page. |
withdrawn_atrequired | string (timestamp), or null When it was withdrawn. matches Z$ |
withdrawn_byrequired | string, or null The member who withdrew it, or null when your systems did. |
roundsrequired | object Revision rounds, oldest first, returned as a page. |
briefrequired | object, or null The deal brief your team reads and edits, once the first build writes it. |
errorrequired | string, or null Why the last build or round failed. |
updated_atrequired | string (timestamp) When the job last changed. matches Z$ |
pdf_urlrequired | string (url), or null The live version's PDF, behind the live link's own gate. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
engagementrequired | object, or null Null before measurable history exists or when analytics are temporarily unavailable; see analytics_status. |
needs_attentionrequired | object, or null |
analytics_statusrequired | enum one of available, not_available, temporarily_unavailable |
versionsrequired | object |
deletionrequired | object, or null |
{
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"url": "https://proposals.example.com/acme",
"live_url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"version": 2,
"live_version": 1,
"promoted_at": "2026-10-19T14:00:00Z",
"shape": null,
"open_items": [],
"revisions": 1,
"requested_by": null,
"promoted_by": null,
"publishes": {
"items": [
{
"version": 1,
"at": "2026-10-19T14:00:00Z",
"by": null
}
],
"next_url": null
},
"withdrawn_at": null,
"withdrawn_by": null,
"rounds": {
"items": [
{
"round": 1,
"kind": "update",
"at": "2026-10-19T14:00:00Z",
"by": null,
"comments": 1
}
],
"next_url": null
},
"brief": null,
"error": null,
"updated_at": "2026-10-19T14:00:00Z",
"pdf_url": null,
"review_pdf_url": null,
"engagement": null,
"needs_attention": null,
"analytics_status": "not_available",
"versions": {
"items": [
{
"version": 1,
"created_at": "2026-10-19T14:00:00Z",
"review_url": "https://review.pitchunfairly.com/acme?review&v=1",
"review_pdf_url": null
},
{
"version": 2,
"created_at": "2026-10-19T14:00:00Z",
"review_url": "https://review.pitchunfairly.com/acme?review&v=2",
"review_pdf_url": null
}
],
"next_url": null
},
"deletion": null
}Event types
A JSON POST to your endpoint whenever a proposal changes or is read, for every proposal in your workspace however it was started: your API, your team in chat, or your CRM. Every API creation request must include callback_url; subsequent events for that proposal go there. The API & webhooks page can set a default for proposals created through other workflows.
State events omit business content; access and engagement events can contain email addresses. Verify signatures before acknowledging unknown types. Open one to see its body.
proposal.in_reviewin_reviewSee the event schema in OpenAPI for its exact fields.
proposal.in_reviewSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kindrequired | enum, or null What started the round that just built; null for the initial build. one of comments, update |
typerequired | "proposal.in_review" The event type. |
pricing_change | object, or null |
{
"type": "proposal.in_review",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"revision_kind": "update",
"pricing_change": {
"changed": true,
"comparison_version": 1,
"before": {
"current_price_pepm": 20,
"discount_note": "Supplied display note"
},
"after": {
"current_price_pepm": 21,
"discount_note": "Supplied display note"
}
}
}proposal.round_failedround_failedSee the event schema in OpenAPI for its exact fields.
proposal.round_failedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.round_failed" The event type. |
{
"type": "proposal.round_failed",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z"
}proposal.failedfailedSee the event schema in OpenAPI for its exact fields.
proposal.failedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.failed" The event type. |
{
"type": "proposal.failed",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "failed",
"version": null,
"live_version": null,
"revisions": 0,
"url": null,
"review_url": null,
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z"
}proposal.promotedpromotedSee the event schema in OpenAPI for its exact fields.
proposal.promotedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.promoted" The event type. |
{
"type": "proposal.promoted",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z"
}proposal.withdrawnwithdrawnSee the event schema in OpenAPI for its exact fields.
proposal.withdrawnSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.withdrawn" The event type. |
{
"type": "proposal.withdrawn",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "withdrawn",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z"
}proposal.viewedviewedSee the event schema in OpenAPI for its exact fields.
proposal.viewedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.viewed" The event type. |
viewer_emailrequired | string (email), or null The viewer it's about. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
email_verifiedrequired | boolean The viewer proved their email with a one-time code. |
ctarequired | string, or null Which call to action was clicked: book_meeting or accept. |
{
"type": "proposal.viewed",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"viewer_email": null,
"email_verified": false,
"cta": null
}proposal.cta_clickedcta_clickedSee the event schema in OpenAPI for its exact fields.
proposal.cta_clickedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.cta_clicked" The event type. |
viewer_emailrequired | string (email), or null The viewer it's about. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
email_verifiedrequired | boolean The viewer proved their email with a one-time code. |
ctarequired | string, or null Which call to action was clicked: book_meeting or accept. |
{
"type": "proposal.cta_clicked",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"viewer_email": null,
"email_verified": false,
"cta": "book_meeting"
}proposal.viewer_addedviewer_addedSee the event schema in OpenAPI for its exact fields.
proposal.viewer_addedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.viewer_added" The event type. |
viewer_emailrequired | string (email) The viewer it's about. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
added_byrequired | string, or null Who added them, or asked to. |
viarequired | string, or null How access originated, such as owner, request, invite or approval. Does not grant a role. |
access_request_idrequired | string (uuid), or null The access-request UUID, not the proposal request_id. |
decisionrequired | enum, or null What became of the access request; null for unrelated access events. one of approved, declined, expired, cancelled |
expires_atrequired | string (timestamp), or null When the request lapses if nobody decides. matches Z$ |
byrequired | string, or null Who decided or removed. |
{
"type": "proposal.viewer_added",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"viewer_email": "viewer@example.com",
"added_by": null,
"via": null,
"access_request_id": null,
"decision": null,
"expires_at": null,
"by": null
}proposal.access_requestedaccess_requestedSee the event schema in OpenAPI for its exact fields.
proposal.access_requestedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.access_requested" The event type. |
viewer_emailrequired | string (email) The viewer it's about. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
added_byrequired | string, or null Who added them, or asked to. |
viarequired | string, or null How access originated, such as owner, request, invite or approval. Does not grant a role. |
access_request_idrequired | string (uuid), or null The access-request UUID, not the proposal request_id. |
decisionrequired | enum, or null What became of the access request; null for unrelated access events. one of approved, declined, expired, cancelled |
expires_atrequired | string (timestamp), or null When the request lapses if nobody decides. matches Z$ |
byrequired | string, or null Who decided or removed. |
{
"type": "proposal.access_requested",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"viewer_email": "viewer@example.com",
"added_by": null,
"via": null,
"access_request_id": null,
"decision": null,
"expires_at": null,
"by": null
}proposal.access_decidedaccess_decidedSee the event schema in OpenAPI for its exact fields.
proposal.access_decidedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.access_decided" The event type. |
viewer_emailrequired | string (email) The viewer it's about. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
added_byrequired | string, or null Who added them, or asked to. |
viarequired | string, or null How access originated, such as owner, request, invite or approval. Does not grant a role. |
access_request_idrequired | string (uuid), or null The access-request UUID, not the proposal request_id. |
decisionrequired | enum, or null What became of the access request; null for unrelated access events. one of approved, declined, expired, cancelled |
expires_atrequired | string (timestamp), or null When the request lapses if nobody decides. matches Z$ |
byrequired | string, or null Who decided or removed. |
{
"type": "proposal.access_decided",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"viewer_email": "viewer@example.com",
"added_by": null,
"via": null,
"access_request_id": null,
"decision": null,
"expires_at": null,
"by": null
}proposal.viewer_removedviewer_removedSee the event schema in OpenAPI for its exact fields.
proposal.viewer_removedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.viewer_removed" The event type. |
viewer_emailrequired | string (email) The viewer it's about. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
added_byrequired | string, or null Who added them, or asked to. |
viarequired | string, or null How access originated, such as owner, request, invite or approval. Does not grant a role. |
access_request_idrequired | string (uuid), or null The access-request UUID, not the proposal request_id. |
decisionrequired | enum, or null What became of the access request; null for unrelated access events. one of approved, declined, expired, cancelled |
expires_atrequired | string (timestamp), or null When the request lapses if nobody decides. matches Z$ |
byrequired | string, or null Who decided or removed. |
{
"type": "proposal.viewer_removed",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"viewer_email": "viewer@example.com",
"added_by": null,
"via": null,
"access_request_id": null,
"decision": null,
"expires_at": null,
"by": null
}proposal.sent_for_signaturesent_for_signatureSee the event schema in OpenAPI for its exact fields.
proposal.sent_for_signatureSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.sent_for_signature" The event type. |
agreement_idrequired | string (uuid) The signature request. |
signed_versionrequired | integer The version being signed. positive |
signer_rolerequired | string, or null The signer's role, as the request named it (proposal.signed and proposal.signature_declined); otherwise null. |
signer_emailrequired | string (email), or null The signer (proposal.signed and proposal.signature_declined); otherwise null. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
signed_countrequired | integer How many have signed so far. |
signer_countrequired | integer How many sign in all. |
signed_pdf_urlrequired | string (url), or null The signed PDF with its certificate, behind the live link's gate (proposal.signature_completed); otherwise null. matches ^https:\/\/ |
{
"type": "proposal.sent_for_signature",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 2,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"agreement_id": "6d1c2f0e-0000-4000-8000-000000000001",
"signed_version": 2,
"signer_role": null,
"signer_email": null,
"signed_count": 0,
"signer_count": 2,
"signed_pdf_url": null
}proposal.signedsignedSee the event schema in OpenAPI for its exact fields.
proposal.signedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.signed" The event type. |
agreement_idrequired | string (uuid) The signature request. |
signed_versionrequired | integer The version being signed. positive |
signer_rolerequired | string, or null The signer's role, as the request named it (proposal.signed and proposal.signature_declined); otherwise null. |
signer_emailrequired | string (email), or null The signer (proposal.signed and proposal.signature_declined); otherwise null. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
signed_countrequired | integer How many have signed so far. |
signer_countrequired | integer How many sign in all. |
signed_pdf_urlrequired | string (url), or null The signed PDF with its certificate, behind the live link's gate (proposal.signature_completed); otherwise null. matches ^https:\/\/ |
{
"type": "proposal.signed",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 2,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"agreement_id": "6d1c2f0e-0000-4000-8000-000000000001",
"signed_version": 2,
"signer_role": "customer",
"signer_email": "cfo@acmelogistics.com",
"signed_count": 1,
"signer_count": 2,
"signed_pdf_url": null
}proposal.signature_completedsignature_completedSee the event schema in OpenAPI for its exact fields.
proposal.signature_completedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.signature_completed" The event type. |
agreement_idrequired | string (uuid) The signature request. |
signed_versionrequired | integer The version being signed. positive |
signer_rolerequired | string, or null The signer's role, as the request named it (proposal.signed and proposal.signature_declined); otherwise null. |
signer_emailrequired | string (email), or null The signer (proposal.signed and proposal.signature_declined); otherwise null. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
signed_countrequired | integer How many have signed so far. |
signer_countrequired | integer How many sign in all. |
signed_pdf_urlrequired | string (url), or null The signed PDF with its certificate, behind the live link's gate (proposal.signature_completed); otherwise null. matches ^https:\/\/ |
{
"type": "proposal.signature_completed",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 2,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"agreement_id": "6d1c2f0e-0000-4000-8000-000000000001",
"signed_version": 2,
"signer_role": null,
"signer_email": null,
"signed_count": 2,
"signer_count": 2,
"signed_pdf_url": "https://pitchunfairly.com/acme/acme-logistics-1a2b3c/signed.pdf"
}proposal.signature_declinedsignature_declinedSee the event schema in OpenAPI for its exact fields.
proposal.signature_declinedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.signature_declined" The event type. |
agreement_idrequired | string (uuid) The signature request. |
signed_versionrequired | integer The version being signed. positive |
signer_rolerequired | string, or null The signer's role, as the request named it (proposal.signed and proposal.signature_declined); otherwise null. |
signer_emailrequired | string (email), or null The signer (proposal.signed and proposal.signature_declined); otherwise null. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
signed_countrequired | integer How many have signed so far. |
signer_countrequired | integer How many sign in all. |
signed_pdf_urlrequired | string (url), or null The signed PDF with its certificate, behind the live link's gate (proposal.signature_completed); otherwise null. matches ^https:\/\/ |
{
"type": "proposal.signature_declined",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 2,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"agreement_id": "6d1c2f0e-0000-4000-8000-000000000001",
"signed_version": 2,
"signer_role": "customer",
"signer_email": "cfo@acmelogistics.com",
"signed_count": 1,
"signer_count": 2,
"signed_pdf_url": null
}proposal.signature_expiredsignature_expiredSee the event schema in OpenAPI for its exact fields.
proposal.signature_expiredSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | string (uuid), or null |
operation_statusrequired | enum, or null one of queued, running, succeeded, failed, cancelled |
job_idrequired | string (uuid) The job. Null on proposal.ping. |
request_idrequired | string Your id for the proposal. Null on proposal.ping. |
statusrequired | enum The job status at event creation. Null on proposal.ping. one of queued, building, in_review, failed, withdrawn |
versionrequired | integer, or null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. positive |
live_versionrequired | integer, or null The version on the public link. positive |
revisionsrequired | integer How many rounds have run. |
urlrequired | string (url), or null The public link. matches ^https:\/\/ |
review_urlrequired | string (url), or null The review link. matches ^https:\/\/ |
pdf_urlrequired | string (url), or null The live version's PDF. matches ^https:\/\/ |
review_pdf_urlrequired | string (url), or null The newest review version's PDF. matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.signature_expired" The event type. |
agreement_idrequired | string (uuid) The signature request. |
signed_versionrequired | integer The version being signed. positive |
signer_rolerequired | string, or null The signer's role, as the request named it (proposal.signed and proposal.signature_declined); otherwise null. |
signer_emailrequired | string (email), or null The signer (proposal.signed and proposal.signature_declined); otherwise null. up to 254 characters · matches ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ |
signed_countrequired | integer How many have signed so far. |
signer_countrequired | integer How many sign in all. |
signed_pdf_urlrequired | string (url), or null The signed PDF with its certificate, behind the live link's gate (proposal.signature_completed); otherwise null. matches ^https:\/\/ |
{
"type": "proposal.signature_expired",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 2,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z",
"agreement_id": "6d1c2f0e-0000-4000-8000-000000000001",
"signed_version": 2,
"signer_role": null,
"signer_email": null,
"signed_count": 1,
"signer_count": 2,
"signed_pdf_url": null
}proposal.pingpingSee the event schema in OpenAPI for its exact fields.
proposal.pingSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
event_idrequired | string (uuid) |
operation_idrequired | null |
operation_statusrequired | null |
job_idrequired | null The job. Null on proposal.ping. |
request_idrequired | null Your id for the proposal. Null on proposal.ping. |
statusrequired | null The job status at event creation. Null on proposal.ping. |
versionrequired | null For engagement, the version observed. Otherwise the newest successfully built version. Null on proposal.ping. |
live_versionrequired | null The version on the public link. |
revisionsrequired | 0 How many rounds have run. |
urlrequired | null The public link. |
review_urlrequired | null The review link. |
pdf_urlrequired | null The live version's PDF. |
review_pdf_urlrequired | null The newest review version's PDF. |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
revision_kind | enum, or null What started the round that just built. one of comments, update |
typerequired | "proposal.ping" The event type. |
{
"type": "proposal.ping",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": null,
"request_id": null,
"status": null,
"version": null,
"live_version": null,
"revisions": 0,
"url": null,
"review_url": null,
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z"
}proposal.operation_completedoperation_completedSee the event schema in OpenAPI for its exact fields.
proposal.operation_completedSee the event schema in OpenAPI for its exact fields.
| Field | Type and notes |
|---|---|
typerequired | "proposal.operation_completed" The event type. |
event_idrequired | string (uuid) |
operation_idrequired | string (uuid) |
operation_statusrequired | enum one of succeeded, failed, cancelled |
job_idrequired | string (uuid), or null The job. Null on proposal.ping. |
request_idrequired | string, or null Your id for the proposal. Null on proposal.ping. |
status_urlrequired | string (url) matches ^https:\/\/ |
occurred_atrequired | string (timestamp) When the event happened. Retries and resends preserve this timestamp. matches Z$ |
{
"type": "proposal.operation_completed",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": "7f3e9c2a-0000-4000-8000-000000000001",
"operation_status": "succeeded",
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status_url": "https://app.unfairly.ai/api/decks/proposals/operations/7f3e9c2a-0000-4000-8000-000000000001",
"occurred_at": "2026-10-19T14:00:00Z"
}Event fields
Fields vary by event type. The compact operation-completed event has its own shape. Use the exact OpenAPI event schemas.
Open an event above for its field table and example. Required fields and nullability are specific to that event.
{
"type": "proposal.promoted",
"event_id": "7f3e9c2a-0000-4000-8000-000000000002",
"operation_id": null,
"operation_status": null,
"job_id": "4db5a295-7c1e-4f8a-9d2b-3e6f0a1b2c3d",
"request_id": "portal:deal-123:proposal",
"status": "in_review",
"version": 2,
"live_version": 1,
"revisions": 1,
"url": "https://proposals.example.com/acme",
"review_url": "https://review.pitchunfairly.com/acme",
"pdf_url": null,
"review_pdf_url": null,
"occurred_at": "2026-10-19T14:00:00Z"
}Verifying signatures
Header Unfairly-Signature: t=<unix seconds>,sha256=<hex>. Verify sha256: HMAC-SHA256 of <t>.<raw body>, lowercase hex, keyed with your signing secret as-is.
- Split the header on commas into name=value pairs and ignore names you don't know.
- Accept if any
sha256matches (there can be more than one). - Refuse a
tmore than five minutes from now. - Never compare the header as a whole string.
No legacy v1/v2 fallback. Rotation is manually initiated with a 72-hour overlap.
Test vector
Your verifier should accept this.
import { createHmac, timingSafeEqual } from "node:crypto";
// header: the Unfairly-Signature request header. rawBody: the body exactly as received.
export function verify(header, rawBody, secret /* unfcb_… */) {
const pairs = header.split(",").map((p) => p.split("="));
const t = Number(pairs.find(([k]) => k === "t")?.[1]);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return pairs.some(([k, v]) => k === "sha256" && /^[0-9a-f]{64}$/.test(v ?? "") && timingSafeEqual(want, Buffer.from(v, "hex")));
}import hmac, hashlib, time
# header: the Unfairly-Signature request header. raw_body: the body bytes exactly as received.
def verify(header: str, raw_body: bytes, secret: str) -> bool: # secret: unfcb_…
pairs = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in pairs if k == "t"), "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(k == "sha256" and len(v) == 64 and hmac.compare_digest(want, v) for k, v in pairs)secret: unfcb_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
t: 1792152000
body: {"type":"proposal.promoted","job_id":"00000000-0000-0000-0000-000000000001"}
sha256: 53f566149296ee847b9ec6826b27fd20c40751f2958f4eda6518288f8df6c26fDelivery and retries
- Any 2xx acknowledges receipt. Verify the signature and durably record the event first. Answer within 10 seconds; process the event after you answer.
- A network error, timeout, 408, 429 or 5xx is tried again with backoff. Respect valid Retry-After within the 72-hour retry horizon. This horizon is an operational target, not a deployed guarantee. Any other answer is final.
- Redirects aren't followed. We only call public https hosts.
- A retry sends the same body with a fresh signature, so the same event can arrive twice: deduplicate by
event_id. - Retries and resends preserve event_id, occurred_at and the body. Reconcile missed outcomes through operation reads.
Testing
?dry_run=1or?dry_run=trueonPOST /requestsruns every check and builds nothing. Try it runs one from this page.- The API & webhooks page lists every request we received from your workspace, accepted or refused, with why. Send test event there posts a signed
proposal.pingto your URL and shows what it answered. - Before your endpoint exists, set a test request's
callback_urlto the capture URL, then read what arrived.
https://app.unfairly.ai/api/decks/proposals/callbacks/capture/<workspace>Changelog
The API is not versioned in its paths. This target contract includes the breaking changes listed below; coordinate adoption before switching clients. Events and objects may gain fields and new event types, so ignore what you don't know. Anything that would break an integration is announced here first, with a date.
- Operation acknowledgments, initial Editor/Commenter roles, exact-version publication, paginated reads and expanded analytics.
- Stable event identity and sha256 signatures; generation costs removed from partner responses.
- Entries below describe historical behavior, not the target contract.
- An OpenAPI 3.1 document of the whole API, at /openapi.json.
- GET /schema: your workspace's whole request as JSON Schema.
- Viewer access events: proposal.viewer_added, proposal.access_requested, proposal.access_decided and proposal.viewer_removed.
- POST /jobs/{job_id}/viewers takes decide, audience_domains and invite_policy.
- Events are signed with their own secret, as v2. v1 is deprecated and stops on 2027-01-15.
- POST /jobs/{job_id}/withdraw, and proposal.withdrawn.
- POST /jobs/{job_id}/update.
- The job carries engagement; proposal.viewed and proposal.cta_clicked.
- ?dry_run=1 on POST /requests, and the capture URL for testing events.
- A 256 KB body cap and a per-key rate limit.
- The job carries requested_by, promoted_by and cost.
- POST /requests, GET /jobs/{job_id} and POST /jobs/{job_id}/revise.
- Review and live links: POST /jobs/{job_id}/promote and GET and POST /jobs/{job_id}/viewers.
- Signed status events.
- A failed first build retries when its request_id is sent again.