Skip to content

Developers

API documentation

A REST API over your own workspace. Read clients, projects, invoices, and your usage against plan limits; draft clients, projects, scopes, revisions, time entries, notes, and invoices. Everything a write creates is a draft with an in-app link — the API never contacts a client or moves money. Enough to sync a spreadsheet, wire up a dashboard, or let the assistant you already talk to do the paperwork.

Machine-readable: /openapi.json (OpenAPI 3.1, generated from the same source as this page) and the MCP server for AI assistants.

Authentication

Every request carries a workspace API key as a bearer token. Create one in Settings → API keys; the full key is shown once, and only its hash is stored. Keys start with crk_ and identify one workspace — there is no user-level key, so a key can never see more than the workspace it belongs to. API keys are available on the plans that include the API; see pricing.

curl https://paloworks.com/api/v1/me \
  -H "Authorization: Bearer crk_your_key_here"

A missing key and an invalid key both return the same 401, so the endpoint cannot be used to test whether a given key is live. Treat a key like a password: it reads every client name and invoice amount in the workspace.

Scopes

Each key carries scopes chosen when it is created. Every key can read; a write scope is needed for the POST endpoints under it, and a key without the right scope gets 403 scope_required. Hand out the narrowest set that does the job: a bookkeeping tool needs write:invoices and nothing else.

read
Read clients, projects, invoices, and plan usage
write:projects
Create clients, projects, scope drafts, and revision rounds
write:invoices
Create draft invoices and estimates
write:time
Log time entries

Read endpoints

All require the read scope, which every key has. Responses are JSON and sent Cache-Control: no-store. A filter that takes one of a fixed set of values (status, stage, kind) is checked: Any other value is 422 validation naming the parameter, never silently ignored.

GET/api/v1/me

The workspace behind the key, its plan, and current usage against plan limits.

The first call an integration makes: confirm the key works, learn the workspace's default currency so amounts can be labelled, and learn which features are unlocked before offering them. Nothing here exposes billing ids, share tokens, member emails, or the owner's identity — a key is a workspace credential, not an account one.

Scope read · MCP tool get_workspace

Response 200

workspaceWorkspace
Id, name, default currency, and the plan actually in force.
limitsLimits
Used and allowed counts for clients, active projects, and seats (limit: null means unlimited), plus which features the plan unlocks.

Errors: 401 invalid_key, 403 scope_required, 429 rate_limited, 500 internal

GET/api/v1/clients

Clients, alphabetical by name, with a project count each.

Every client in the workspace that is not in the trash, ordered by name. search matches the name and company, case-insensitively. Deliberately withheld: email addresses and the freelancer's private notes — an integration matching clients to projects needs names and ids, not a way to reach the freelancer's clients directly. Each client carries its e-invoice country code and Peppol id, read-only (they are set in the app).

Scope read · MCP tool list_clients

Query parameters

searchstring
Substring to match against name and company.
pageinteger
Page number, from 1.
per_pageinteger
Rows per page. Defaults to 25, capped at 100.

Response 200

dataClientSummary[]
The clients on this page.
pagePage
Page number, page size, total rows, and page count.

Errors: 401 invalid_key, 403 scope_required, 429 rate_limited, 500 internal

GET/api/v1/projects

Projects, newest first, with their client, scope price, and invoice counts.

Every project that is not in the trash. Archived projects are left out unless include_archived=true. Invoice totals are reported per currency and never summed across currencies. Deliberately withheld: project notes, intake answers, share tokens, and client email addresses.

Scope read · MCP tool list_projects

Query parameters

pageinteger
Page number, from 1.
per_pageinteger
Rows per page. Defaults to 25, capped at 100.
statusactive | paused | completed | archived
Filter by lifecycle status. One of active, paused, completed, archived. Any other value is 422 validation naming the parameter, never silently ignored.
stagelead | scoping | active | delivered | paid | completed
Filter by pipeline stage. One of lead, scoping, active, delivered, paid, completed. Any other value is 422 validation naming the parameter, never silently ignored.
include_archivedboolean
Set to true to include archived projects. Off by default.

Response 200

dataProjectSummary[]
The projects on this page, with a scope and invoice summary each.
pagePage
Page number, page size, total rows, and page count.

Errors: 401 invalid_key, 403 scope_required, 429 rate_limited, 500 internal

GET/api/v1/projects/{id}

One project in full: client, scope, revisions, invoices, and time.

The project with its client, lifecycle status and pipeline stage, the full scope (deliverables, revision rounds, timeline, price, whether approved), revision rounds used against the rounds included, every invoice with its computed totals plus per-currency totals, and time totals. Deliberately withheld: intake answers, private notes, share tokens, and the client's email.

Scope read · MCP tool get_project

Path parameters

idstring
The project id. One not in your workspace is 404 not_found.

Response 200

projectProjectDetail
The project with its related records.
urlstring
In-app path: /projects/{id}.

Errors: 401 invalid_key, 403 scope_required, 404 not_found, 429 rate_limited, 500 internal

GET/api/v1/invoices

Invoices and estimates with their computed totals.

Every invoice and estimate in the workspace, newest first, each with createdVia so an assistant can tell its own drafts from the freelancer's. Totals come from the same calculation the invoice page, the PDF, and the card charge use, so a reconciliation against totalCents cannot disagree with what the client was charged. Deliberately withheld: the share token, invoice notes, and the client's email.

Scope read · MCP tool list_invoices

Query parameters

pageinteger
Page number, from 1.
per_pageinteger
Rows per page. Defaults to 25, capped at 100.
statusDRAFT | SENT | PAID | VOID
Filter by status. One of DRAFT, SENT, PAID, VOID. Any other value is 422 validation naming the parameter, never silently ignored.
kindinvoice | estimate
Invoices or estimates. One of invoice, estimate. Any other value is 422 validation naming the parameter, never silently ignored.
project_idstring
Restrict to one project.
overdueboolean
Set to true for unpaid invoices past their due date.

Response 200

dataInvoice[]
The invoices on this page.
pagePage
Page number, page size, total rows, and page count.

Errors: 401 invalid_key, 403 scope_required, 429 rate_limited, 500 internal

Write endpoints

Every write takes a JSON object body, returns 201 with the record and an in-app url a person opens next, and accepts an Idempotency-Key header. Validation failures are 422 validation with the field named, and bodies are strict: an unknown field is rejected the same way rather than silently dropped, so a client that sends status on an invoice learns that it cannot. A clientId or projectId that is not in your workspace is 404 not_found, identically whether it exists somewhere else or not at all. Records created here are labelled as API-created in the app so your team can see what an integration or an assistant did.

POST/api/v1/clients

Create a client.

Creates a client record and returns it with the in-app link where a person can see it. On the free plan the client cap is enforced and the response is 402 plan_limit with an upgradeUrl, never a bare 403.

Scope write:projects · accepts Idempotency-Key · MCP tool create_client

Request body

name*string, 1–500 characters
The client's name.
emailstring, ≤ 254 characters
Contact email. Stored, never emailed by the API.
companystring, ≤ 500 characters
Company or studio name.
notesstring, ≤ 5000 characters
Private notes for the freelancer. Never shown to the client.

Response 201

clientClient
The record as stored.
urlstring
In-app path where a person can open it: /clients/{id}.

Errors: 400 bad_request, 401 invalid_key, 402 plan_limit, 403 scope_required, 413 payload_too_large, 422 idempotency_mismatch, 422 validation, 429 rate_limited, 500 internal

POST/api/v1/projects

Create a project for an existing client.

Creates a project under a client that already exists in this workspace — create the client first if it does not. As in the app, an intake link and an empty scope are created alongside, so draftScope on the new project fills that scope in. Optional intake answers are stored as a submitted intake response so the project starts with the brief attached. The active-project cap on the plan is enforced and returns 402 plan_limit.

Scope write:projects · accepts Idempotency-Key · MCP tool create_project

Request body

clientId*string
Id of a client in this workspace. Unknown ids are 404 not_found.
name*string, 1–500 characters
Project name.
stagestring, lead | scoping | active | delivered | paid | completed
Pipeline stage. Defaults to active. Any other value is 422 validation.
notesstring, ≤ 5000 characters
Private project notes.
hourlyRateCentsinteger, 0–100000000
Hourly rate in minor units, for time tracking.
budgetMinutesinteger, 0–600000
Time budget in minutes, for overrun warnings.
intakeobject, string values only; ≤ 40 answers, questions ≤ 200 and answers ≤ 5000 characters
Question → answer pairs from the brief, stored as the project's intake response with submittedAt set to now.

Response 201

projectProject
The record as stored.
urlstring
In-app path: /projects/{id}.

Errors: 400 bad_request, 401 invalid_key, 402 plan_limit, 403 scope_required, 404 not_found, 413 payload_too_large, 422 idempotency_mismatch, 422 validation, 429 rate_limited, 500 internal

POST/api/v1/projects/{id}/scope

Draft or update the project's scope.

Every project has a scope from the moment it is created; this fills it in or updates the draft. The first draft on a new project is version 1, and each later update while the scope is still unapproved bumps version; on an update, fields left out keep their current value. A scope the client has already approved is fixed: the request is 409 conflict, and changes to agreed work go through a change order in the app. Deliverables are synced to the project's deliverable list. The result is a draft; the freelancer reviews it in the app and decides whether to share it.

Scope write:projects · accepts Idempotency-Key · MCP tool draft_scope

Path parameters

idstring
The project id. One not in your workspace is 404 not_found.

Request body

deliverables*string[], 1–50 items, each ≤ 500 characters
What is included, one line each. Blank lines are dropped.
revisionRoundsinteger, 0–20
Revision rounds included in the price. Defaults to 2 on a new scope.
timelinestring, ≤ 500 characters
Free-text timeline, e.g. "3 weeks from approval".
priceCentsinteger, 0–100000000
Fixed price in minor units. Omit for hourly work.
currencystring, 3 uppercase letters
ISO 4217 code. Defaults to the workspace currency.

Response 201

scopeScope
The record, including version and approvedAt (null for a draft).
urlstring
In-app path: /projects/{id}?tab=scope.

Errors: 400 bad_request, 401 invalid_key, 402 plan_limit, 403 scope_required, 404 not_found, 409 conflict, 413 payload_too_large, 422 idempotency_mismatch, 422 validation, 429 rate_limited, 500 internal

POST/api/v1/projects/{id}/revisions

Log a revision request against the project.

Records a revision round. roundNumber is the count so far plus one, and the response says how many rounds the scope includes and whether this one goes over. With classify: true, and when AI is configured for the workspace, the request is classified as in-scope, new work, or needing a question to the client, and the verdict is returned; otherwise classification is null and classificationUnavailable says why.

Scope write:projects · accepts Idempotency-Key · MCP tool log_revision

Path parameters

idstring
The project id. One not in your workspace is 404 not_found.

Request body

description*string, 1–5000 characters
What the client asked for, in their words where possible.
classifyboolean
Run the revision classifier and include its verdict. Defaults to false.

Response 201

revisionRevision
The record as stored.
roundsUsedinteger
Revision rounds logged so far, including this one.
roundsIncludedinteger|null
Rounds the scope includes, or null when the project has no scope yet.
overBudgetboolean
True when roundsUsed exceeds roundsIncluded. False when there is no scope.
classificationRevisionClassification|null
The classifier's verdict, or null when not requested or unavailable.
classificationUnavailablestring|null
Why no classification was produced, when classify was requested.
urlstring
In-app path: /projects/{id}?tab=revisions.

Errors: 400 bad_request, 401 invalid_key, 402 plan_limit, 403 scope_required, 404 not_found, 413 payload_too_large, 422 idempotency_mismatch, 422 validation, 429 rate_limited, 500 internal

POST/api/v1/projects/{id}/time

Log a time entry.

Adds a time entry to the project. Minutes are integers; there are no fractional hours anywhere in the API. Entries longer than one working day (16 hours) are rejected so a typo cannot become an invoice line.

Scope write:time · accepts Idempotency-Key · MCP tool log_time

Path parameters

idstring
The project id. One not in your workspace is 404 not_found.

Request body

minutes*integer, 1–960
Duration in whole minutes.
datestring
Day the work happened, YYYY-MM-DD. Defaults to today (UTC).
descriptionstring, ≤ 500 characters
What was done.
billableboolean
Whether the time can be invoiced. Defaults to true.

Response 201

timeEntryTimeEntry
The record as stored.
urlstring
In-app path: /projects/{id}?tab=time.

Errors: 400 bad_request, 401 invalid_key, 402 plan_limit, 403 scope_required, 404 not_found, 413 payload_too_large, 422 idempotency_mismatch, 422 validation, 429 rate_limited, 500 internal

POST/api/v1/projects/{id}/notes

Leave a note on the project.

Adds an internal note the team sees on the project. The author is recorded as the key's label followed by "(API)", so a note left by an integration or an assistant is never mistaken for one a person wrote. Notes are never visible to the client.

Scope write:projects · accepts Idempotency-Key · MCP tool add_note

Path parameters

idstring
The project id. One not in your workspace is 404 not_found.

Request body

body*string, 1–5000 characters
The note.

Response 201

noteNote
The record as stored.
urlstring
In-app path to the project.

Errors: 400 bad_request, 401 invalid_key, 402 plan_limit, 403 scope_required, 404 not_found, 413 payload_too_large, 422 idempotency_mismatch, 422 validation, 429 rate_limited, 500 internal

POST/api/v1/invoices

Draft an invoice or estimate.

Creates a DRAFT invoice or estimate on a project. The amount is given exactly one way: a flat amountCents subtotal, or 1–50 lineItems — not both, and a body with neither is 422 validation on amountCents. The response carries the computed taxCents and totalCents. There is no status field on input and no way to change one from the API — a body carrying status, paidAt, or sentAt is 422 validation naming the field — the freelancer opens url and clicks Send when they are ready. The number and share link are generated on creation but the link is not returned — it is private to the client it is eventually shared with.

Scope write:invoices · accepts Idempotency-Key · MCP tool draft_invoice

Request body

projectId*string
Id of a project in this workspace. Unknown ids are 404 not_found.
kindstring, invoice | estimate
Defaults to invoice. Any other value is 422 validation.
amountCentsinteger, 0–100000000
Subtotal in minor units. One of amountCents or lineItems is required; give one, not both.
lineItemsLineItemInput[], 1–50 items; descriptions ≤ 500 characters, quantityHundredths 0–1000000, unitPriceCents 0–100000000, taxRateBps 0–10000 or null
Line items, each { description*, quantityHundredths? (default 100 = 1.00), unitPriceCents*, taxRateBps? } — a line's taxRateBps overrides the invoice's for that line. One of amountCents or lineItems is required; give one, not both. An empty array is 422 validation on lineItems.
currencystring, 3 uppercase letters
ISO 4217 code. Defaults to the scope's currency, then the workspace currency.
dueDatestring
YYYY-MM-DD.
notesstring, ≤ 5000 characters
Notes printed on the invoice.
taxRateBpsinteger, 0–10000
Tax rate in basis points (2000 = 20%). Defaults to the workspace's default rate.
taxInclusiveboolean
The line prices (or amountCents) already include tax: the tax is worked out of each line at its rate — round(price × rate / (10000 + rate)), per line — instead of added on top, the client pays the prices as given, and taxCents is the part of the total that is tax. Defaults to the workspace's "Prices include tax" setting.
discountCentsinteger, 0–100000000
Discount in minor units, applied before tax. With taxInclusive it is a tax-inclusive amount off the prices, and carries its share of the tax.
poNumberstring, ≤ 64 characters
The client's purchase-order reference, printed on the invoice and its PDF. Whitespace runs fold to one space.
supplyDatestring
Date of supply, YYYY-MM-DD, where it differs from the issue date (EU and UK VAT invoices state it). Printed on the invoice and its PDF.

Response 201

invoiceInvoice
The record, status DRAFT, with amountCents, discountCents, taxRateBps, taxCents, lateFeeCents, and totalCents.
urlstring
In-app path: /projects/{projectId}?tab=invoices.
nextstring
What a person does next: "Open the link and click Send when you are ready."

Errors: 400 bad_request, 401 invalid_key, 402 plan_limit, 403 scope_required, 404 not_found, 413 payload_too_large, 422 idempotency_mismatch, 422 validation, 429 rate_limited, 500 internal

Idempotency

Integrations retry — a timeout, a dropped connection, an assistant that calls the same tool twice. Without protection every retried POST is a duplicate client or invoice somebody then has to notice and remove. So every write accepts an Idempotency-Key header: any unique string up to 255 characters of letters, digits, _, -, :, or .. Use one on every write.

curl -X POST https://paloworks.com/api/v1/clients \
  -H "Authorization: Bearer crk_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-client-2026-09-10-acme" \
  -d '{ "name": "Acme Studio", "email": "hello@example.com" }'
  • Repeating the same key with the same method, path, and body within 24 hours replays the first response byte-for-byte with Idempotency-Replayed: true and creates nothing.
  • The same key with a different body is 422 idempotency_mismatch — never a silent replay of the wrong thing. Use a new key for a new request.
  • Keys are scoped to your workspace, so two workspaces can use the same key text.
  • A 5xx response is not stored: our fault, and you may retry with the same key.

Pagination

List endpoints return a data array and a page object. Each list says how it is ordered — projects and invoices newest first, clients by name — and every order is tie-broken by id, so a row cannot be repeated or skipped across a page boundary when two records share a timestamp or a name.

{
  "data": [ /* … */ ],
  "page": { "page": 1, "perPage": 25, "total": 63, "pageCount": 3 }
}

Money

Every amount is an integer number of minor units — cents for USD, pence for GBP — alongside an ISO 4217 currency. There are no floats anywhere in the API, because there are none in the product. Send priceCents, amountCents, and unitPriceCents the same way.

For creation, amountCents is a flat subtotal. On returned invoices, it is the issued amount after discount and tax, before late fees. totalCents is what the client owes, and it is the same figure the invoice page shows, the PDF prints, and the card is charged. Reconcile against totalCents, never against the subtotal.

"amountCents":   108000,
"discountCents":  10000,
"taxRateBps":      2000,   // 20.00%
"taxCents":       18000,
"lateFeeCents":       0,
"totalCents":    108000,
"currency":      "USD"

Amounts are never summed across currencies. If you total them yourself, group by currency first — project totals in the API already come back one row per currency.

Rate limits

Reads: 120 requests per minute. Writes: 60 per minute. Both are counted per workspace rather than per IP — an integration behind one address cannot throttle an unrelated one. Over the limit returns 429 rate_limited with a Retry-After header in seconds.

Writes also have a monthly allowance per workspace, across every key, that resets on the 1st (UTC). Reaching it returns 402 plan_limit with how many were used. The allowance on each plan is on the pricing page.

Errors

Errors are JSON with a human error string and a machine-readable code, so an integration can branch without parsing prose. The status always follows the code. Responses are sent no-store — they contain account data, and a shared proxy caching one workspace’s response would serve it to the next key.

{ "error": "Give the client a name.", "code": "validation", "field": "name" }
401 invalid_key
Missing, malformed, revoked, or unknown key. Identical in every case so the endpoint cannot be used to test whether a key is live.
403 scope_required
The key is real but lacks the scope this endpoint needs. The body names the scope.
402 plan_limit
The workspace's plan does not include this, or a plan limit is reached. The body may carry upgradeUrl.
429 rate_limited
Too many requests this minute for the workspace. Retry-After says how long to wait, in seconds.
422 validation
A field failed validation. field names it; error says what is wrong.
404 not_found
The addressed record is not in this workspace. Identical whether the id exists elsewhere or not at all.
409 conflict
The write conflicts with current state — for example the scope is already approved.
422 idempotency_mismatch
The same Idempotency-Key was already used for a different request. Use a new key for a new request.
413 payload_too_large
The request body is over 64 KB.
400 bad_request
The body is not the JSON object the endpoint expects, or a header is malformed.
500 internal
Our fault. Safe to retry with the same Idempotency-Key; a failed write stores nothing.

Webhooks

Webhooks push instead of waiting to be asked: when one of the events below happens in your workspace, a POST goes to every endpoint subscribed to it. Add up to 5 endpoints in Settings → Integrations, each with its own events, and press Send test for a ping delivery before a real one arrives. An endpoint is an https:// URL that resolves to a public IPv4 address: one on a private network, an IPv6 literal, or a URL with a username and password in it is refused, and a redirect is not followed — point the webhook at the final URL.

JSON-format endpoints receive the signed envelope described here. Slack and Discord endpoints receive a message in that service’s incoming-webhook shape instead, and it is not signed: the URL is the credential, so keep it private.

Events

intake.submitted
Intake submitted. data: projectId, projectName, clientName, submittedAt
scope.approved
Scope approved. data: projectId, projectName, clientName, approvedName, priceCents, currency, approvedAt
contract.signed
Contract signed. data: projectId, projectName, clientName, signedAt
invoice.sent
Invoice sent. data: invoiceId, invoiceNumber, projectId, projectName, clientName, totalCents, currency, dueDate, sentAt
invoice.paid
Invoice paid. data: invoiceId, invoiceNumber, projectId, projectName, clientName, totalCents, currency, paidAt
change_order.approved
Change order approved. data: changeOrderId, projectId, projectName, amountCents, currency, approvedAt
invoice.viewed
Invoice opened by the client. The client opens an invoice or estimate for the first time. Once per document; your own previews do not count. data: invoiceId, invoiceNumber, projectId, projectName, clientName, totalCents, currency, dueDate, viewedAt
invoice.overdue
Invoice overdue. The daily check finds a sent invoice past its due date. Once per invoice, on the first check after it falls due. data: invoiceId, invoiceNumber, projectId, projectName, clientName, totalCents, currency, dueDate, daysOverdue, detectedAt
revision.logged
Revision logged. A revision round is logged on a project's Revisions tab or through the API. data: revisionId, projectId, projectName, clientName, roundNumber, roundsAllowed, description, loggedAt
client.created
Client added. A client is added from the Clients page, the quick panel or the API. A client created by converting an inquiry, a CSV import or sample data does not fire it. data: clientId, clientName, contactName, company, createdAt
ping
Sent only by Send test, to the one endpoint tested; it cannot be subscribed to. data: message

Timestamps are ISO-8601 strings and dueDate is a calendar day, YYYY-MM-DD, or null. Money is integer cents with its currency beside it, as in the rest of the API; totalCents is what the client owes, not the subtotal, and priceCents is null for a scope with no price. On invoice.overdue, dueDate is always set and daysOverdue counts whole days past it; on revision.logged, roundsAllowed is the scope’s rounds plus any an approved change order added, or null with no scope. Share links, client email addresses and Stripe ids are left out on purpose — open the record in the app by its id.

The delivery

A JSON delivery’s body is an envelope around the event’s data:

{
  "id": "3f2b8c1e-5d4a-4e6b-9c7d-2a1b0e9f8d7c",
  "event": "invoice.paid",
  "createdAt": "2026-09-15T12:00:00.000Z",
  "workspaceId": "workspace-id",
  "data": {
    "invoiceId": "invoice-id",
    "invoiceNumber": "INV-202609-001",
    "projectId": "project-id",
    "projectName": "Spring campaign",
    "clientName": "Acme Studio",
    "totalCents": 576000,
    "currency": "USD",
    "paidAt": "2026-09-15T12:00:00.000Z"
  }
}
Content-Type
application/json
User-Agent
PaloWorks-Webhooks/1
X-PaloWorks-Event
The event name, for every format.
X-PaloWorks-Delivery
The delivery id: the body's id, and the same on every retry and every replay of that delivery. Delivery is at least once, so dedupe on it.
X-PaloWorks-Timestamp
Unix seconds when this attempt was signed, new on every retry and replay. JSON format only.
X-PaloWorks-Signature
v1=<hex>, always exactly one entry. JSON format only.
X-PaloWorks-Signatures
v1=<hex>, or v1=<hex>,v1=<hex> while a rotated secret is still signing. JSON format only.
X-ClientReady-Event
The same value as X-PaloWorks-Event, under the product's former name, sent so a receiver built against it keeps working.
X-ClientReady-Delivery
The same value as X-PaloWorks-Delivery, under the product's former name, sent so a receiver built against it keeps working.
X-ClientReady-Timestamp
The same value as X-PaloWorks-Timestamp, under the product's former name, sent so a receiver built against it keeps working.
X-ClientReady-Signature
The same value as X-PaloWorks-Signature, under the product's former name, sent so a receiver built against it keeps working.
X-ClientReady-Signatures
The same value as X-PaloWorks-Signatures, under the product's former name, sent so a receiver built against it keeps working.

The X-ClientReady- headers carry byte-for-byte the values of their X-PaloWorks- twins, so a receiver that reads either name verifies the same delivery. New receivers should read X-PaloWorks-.

Retries, replays and turning off

Every delivery is recorded before it is sent, and the first attempt is made as soon as the event happens. A 2xx answer is success. Anything else — any other status, a redirect included, no answer within 10 seconds, or a failed connection — is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours: 7 attempts over about 21 hours. Retries run every 5 minutes, so each can arrive up to 5 minutes after its time. Every attempt sends the same body and the same X-PaloWorks-Delivery id, with a fresh timestamp and signature. After the last one fails the delivery is marked failed and not tried again.

Settings → Integrations lists each endpoint’s recent deliveries with their status and response code, and Replay sends one again: the same body and delivery id, signed now, with the retry schedule starting over. If you already processed that id, a replay of it is yours to ignore. Send test makes a single attempt and is not recorded.

When 20 deliveries in a row fail — each counted once, however many times it is retried, and any success resets the count — the endpoint is turned off. Its waiting retries stop, nothing new is sent to it, and the workspace owner gets one email. Turn it back on from Settings → Integrations, then replay what it missed.

Verifying a delivery

The signature is HMAC-SHA256 of {X-PaloWorks-Timestamp}.{raw body}, keyed with the endpoint’s whole signing secret (it starts whsec_), written as lowercase hex after v1=. Compute it over the body exactly as it arrived: parsing the JSON and serialising it again changes the bytes, and the signature will not match. Refuse a timestamp more than 300 seconds from your clock, so a captured delivery cannot be replayed later, and compare in constant time.

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as it arrived, as a string, before any
// JSON parsing. headers: the request headers, names lower-cased.
// secret: the endpoint's signing secret, whsec_ prefix included.
export function verifyPaloWorksWebhook(rawBody, headers, secret) {
  const timestamp = headers["x-paloworks-timestamp"];
  const signatures =
    headers["x-paloworks-signatures"] ?? headers["x-paloworks-signature"];
  if (!timestamp || !signatures) return false;

  // The timestamp is part of what was signed; refuse an old delivery replayed.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= 300)) return false;

  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")
  );
  // One v1= entry per live secret; during a rotation, either may match.
  let matched = false;
  for (const entry of signatures.split(",")) {
    const [scheme, hex] = entry.trim().split("=");
    if (scheme !== "v1" || !hex) continue;
    const provided = Buffer.from(hex.toLowerCase());
    if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
      matched = true;
    }
  }
  return matched;
}

Rotating the secret

Rotate secret, beside a JSON endpoint in Settings → Integrations, issues a new secret (Slack and Discord endpoints have none to rotate). For 24 hours after that every delivery is signed with both, so the new secret can be deployed whenever suits you inside that window:

  • X-PaloWorks-Signatures carries one v1= entry per live secret, the new one first: two during the overlap, one otherwise. Accept a delivery when any entry matches your secret, as the example above does, and switching secrets costs no rejected deliveries.
  • X-PaloWorks-Signature always carries exactly one entry. Outside a rotation it is the current secret’s; during the overlap it stays signed with the previous secret, so a receiver that reads only this header keeps verifying until the window closes, and needs the new secret from then on.
  • Rotating again before the window closes retires the oldest secret at once: the overlap is always the two most recent secrets, the one you were just given and the one before it, for 24 hours from the latest rotation.
  • Adding or removing another endpoint leaves a rotation in progress alone; its window runs its full length.

The headers for the delivery above during a rotation, signed over its compact JSON (no whitespace, as deliveries are sent) with whsec_example_new as the new secret and whsec_example_old as the previous one — a way to check the signature half of a verifier before a live delivery. The timestamp is fixed, so a tolerance check will rightly refuse it.

X-PaloWorks-Timestamp: 1789473600
X-PaloWorks-Signature: v1=26f6342d43365fdc48bd36c63498c4b25bdc3eae317f5786af79ba793a7fbc78
X-PaloWorks-Signatures: v1=b82a1b8e43552f38ec8fa363ba931aaec349c64b2099c54fe60f3ef6e0ef6796,v1=26f6342d43365fdc48bd36c63498c4b25bdc3eae317f5786af79ba793a7fbc78
X-ClientReady-Timestamp: 1789473600
X-ClientReady-Signature: v1=26f6342d43365fdc48bd36c63498c4b25bdc3eae317f5786af79ba793a7fbc78
X-ClientReady-Signatures: v1=b82a1b8e43552f38ec8fa363ba931aaec349c64b2099c54fe60f3ef6e0ef6796,v1=26f6342d43365fdc48bd36c63498c4b25bdc3eae317f5786af79ba793a7fbc78

What the API will not do

These are not missing endpoints; they are decisions. Anything a client sees, and anything that moves money, stays a click by a signed-in person in the app.

  • send an invoice, estimate, contract, or any email to a client
  • sign or countersign a contract
  • charge a card, record a payment, or mark an invoice paid
  • delete or trash anything
  • invite, remove, or change the role of a member
  • change the plan, billing, or Stripe settings
  • return a share-link URL, a client's email address, or a Stripe id

There is also no OAuth: a key belongs to a workspace, not to a user, so it is not suitable for a multi-tenant integration acting on other people’s behalf. For push rather than pull, use outbound webhooks from Settings → Integrations. JSON-format endpoints receive a signed, retried delivery; the Slack and Discord formats post a message to that service’s incoming-webhook URL and are not signed, because those services have no way to check a signature.

For AI assistants

The same API is exposed as an MCP server at https://paloworks.com/api/mcp, so Claude, Cursor, and any other MCP client can draft a scope or an invoice into your workspace and hand you the link to review. Setup and the tool list are on the MCP page. Agents that prefer REST can read /openapi.json; every operation carries its scope and, where one exists, the MCP tool name. One rule an assistant reading this should carry: Never fetch, quote, or cite a share-link URL (/reset-password, /file, /intake, /inquiry, /kickoff, /scope, /invoice, /contract, /agreement, /delivery, /status, /portal, /statement, /change-order, /invite, /unsubscribe, /year): each is private to the one person it was sent to, and no endpoint or tool returns one.

Building something and blocked on a missing endpoint? Tell us what you need — the roadmap here is short and driven by what people ask for.