customers.archive
ChatGPTArchives a client, along with their Projul portal login if they have one. An archived client disappears from customers.list and from customers.get — this result is the one place the API returns an archived client's representation, because you just archived it. The action is state-idempotent: archiving an already-archived client is a no-op that changes nothing. No idempotency_key is needed or accepted. version is optional and honoured: send the version you read and the archive is refused with 412 stale_version if the client changed in between.
customers.create
ChatGPTCreates a client — or returns the existing one. This tool never manufactures a duplicate. Before creating, Projul looks for an existing client matching on both names plus an email or both names plus a phone number: - A match returns `200` with the existing client's full representation and matched: true. Contact details you submitted that the matched client does not already have are appended to it, and the response reflects that post-append state. - No match returns `201` with the newly created client and matched: false. An ambiguous match — two or more equally good candidates — is deliberately treated as no match, so a new client is created rather than merging two real people. A client with only one name (a caller whose phone system knows one name) is accepted and matched on its email: it links only when exactly one client has that email AND the name given is that client's first or last name, returns 200 with matched: true, and appends nothing to that client. Any other name creates a new client. When two or more clients have the email the create is refused with 409 ambiguous_match, because Projul cannot tell which of them is meant. A company-only client (a business with no person to name) sends company_name and no first_name or last_name. Projul stores the names as empty strings, as the app does, and shows the company name when display_company_name is true. It is matched on the company name (trimmed, any case) plus the email or the phone, by the same rules as two names, and only ever to another client that has no first or last name. A person at that company, or another company with the same email, is never merged with it. A client with no name and no company_name is 422 validation_failed. matched appears only on this operation's response; it is not a field of the client. Only the first listed email and the first listed phone number are used for matching and for appending. If you submit three emails and two phone numbers, emails[0] and phones[0] drive the match. On a 201 all submitted contact rows are persisted; on a 200 only that first-listed pair is an append candidate — the remaining submitted values are not added to the matched client. If the match is an archived client the create is refused with `409 resource_archived`. Nothing is created and nothing is appended. The error detail carries the archived client's cus_ id: call customers.unarchive on it and retry the same call with the same idempotency_key — a failed create never burns a key. Agents: matched: true means the client already existed and you have it. Do not re-create, do not "retry until 201", and never perturb contact details to dodge the match.
customers.delete
ChatGPTDeletes a client. This cascades. Deleting a client also deletes that client's invoices, their projects and those projects' tasks, and their estimates, and it clears the client's file associations. An agent deleting a client is deleting their jobs. Refusal: if any invoice belonging to the client has a recorded payment, the delete is refused with 409 deletion_blocked and nothing is changed — the client, their projects, their estimates, and their invoices are all left exactly as they were. `version` is required, for the same reason it is required on projects.delete and more so: this is the widest destructive call on the API, and a stale delete here destroys a whole client's history. Send the version from your most recent read of the client. Missing is 428 precondition_required; no longer current is 412 stale_version. On success nothing is returned, and customers.get on the id answers 404 not_found from then on.
customers.get
ChatGPTReturns one active client. An archived, deleted, unknown, or other-tenant id is an indistinguishable 404 not_found.
customers.list
ChatGPTReturns the tenant's active clients, newest first, using cursor pagination. Archived clients are listed only when you pass include_archived=true, and carry status: Archived; deleted clients are never listed. There is deliberately no status filter. An archived client is not readable by id but can be restored with the customers.unarchive tool. Use updated_since to sync incrementally: it is the intended workhorse for keeping an external system in step with Projul.
customers.unarchive
ChatGPTRestores an archived client — and their Projul portal login — to active. This operation resolves the id whether the client is active or archived, which is what makes the 409 resource_archived recovery loop work: create returns the archived client's id, you unarchive it, then you retry the create. The action is state-idempotent: unarchiving an already-active client is a no-op. No idempotency_key is needed or accepted. version is optional and honoured: send the version you read and the unarchive is refused with 412 stale_version if the client changed in between.
customers.update
ChatGPTPartially updates a client using JSON Merge Patch semantics (RFC 7396): a field you omit is left untouched, and a field you send as null is cleared. The same rule applies to the collections (emails, phones, websites, contacts, additional_addresses): omit one and it is untouched; send one and it replaces that collection wholesale. Contact rows carry no ids on the wire, so there is no way to edit a single row — read the client, change the array, send the whole array back. status is read-only; archive and unarchive are the action posts below. version is optional and honoured: send the version you read and the update is refused with 412 stale_version if someone else changed the client in between. Omit it and the last write wins, as it always has.
daily_logs.create
ChatGPTOpens the log for one project and one company day, with any entries written in the same call. One log per project per day. A day the project already has a log for is 409 daily_log_exists, and existing_id names that log: add to it with an update instead. log_date defaults to today in the company's time zone. A day after the company's tomorrow is 422 validation_failed; a day one ahead of the company's today (a caller in a time zone ahead of the company) is filed on the company's today, and the response's log_date says so. Entries are one flat object per entry. Set type and that type's fields; a field another type takes is 422 validation_failed. Every entry must carry content: | type | takes | needs at least one of | |---|---|---| | manpower | person_user_id, trade_id, company_name, headcount, hours | person_user_id, trade_id, company_name | | work_performed | text or text_html, task_ids | text, text_html | | notes | text or text_html | text, text_html | | delivery | vendor_name, description, quantity, received_by, po_reference | any of them | | equipment | name, hours, idle_hours | any of them | | visitors | name, company_name, reason, time_in, time_out | name, company_name, reason, time_in | | delay | delay_type, description, impact_hours, linked_task_id | description, impact_hours, linked_task_id | | safety | description, severity, reportable | description | | photos | file_ids | file_ids | Hours are 0 to 24 with at most 2 decimal places, and headcount is a whole number from 1 to 999, the same bounds the app's editor applies. Tasks and photos must be on the log's project; upload a photo to the project through the files API first and pass its file_ id, and Projul attaches it to the log as the app does. A photo must be an image, as in the app's picker. Manpower here is a field record, not job costing. Crew hours are what was written down for the day and are never summed into payroll or job cost; record paid hours as time logs. on_behalf_of_user_id names the foreman the log and its entries are written for: they become the owner and the entries' author, and the API key is kept as the recorder. They must be an active employee with access to the project's daily logs in Projul; a subcontractor cannot be named. Their own access governs the write, as if they wrote it in the app: someone with contributor access can be named only for a log of today or yesterday, with no narrative, custom fields or external_ref, and their Manpower entries must be about themselves. Custom fields are written by label in custom_field_values. A label that is unknown, shared by two fields, or retired is 422 validation_failed; read any log to see the fields, their types and their options. This never signs, locks or sends the log. Needs the Daily Logs Pro feature. Full reference for this API: https://connect.projul.com/docs
daily_logs.get
ChatGPTReturns one daily log with its live entries in the order they were recorded. An unknown, other-account or deleted id, and a log on a project this API does not serve, are one indistinguishable 404 not_found. Needs the Daily Logs Pro feature.
daily_logs.list
ChatGPTReturns the account's daily logs, newest first, using cursor pagination. Each log carries its live entries in the order they were recorded. Filter to one project with project_id (a lead's id is a project id), to a range of days with from and to (calendar days, both inclusive), and to a lifecycle stage with status. Deleted logs, and logs on a project this API does not serve, are not returned. Needs the Daily Logs Pro feature: without it the call is 403 pro_feature_required.
daily_logs.update
ChatGPTApplies an RFC 7396 merge patch to a draft log's header: log_date, narrative or narrative_html, custom_field_values (by label; null for one label clears it) and external_ref. Send only what you are changing. The weather reading, the owner and the status are not written here. `entries` adds and merges; it never removes. An element without id adds an entry (it needs a type); an element with an entry's id merges into that entry, leaving its other fields as stored. An entry's type and author cannot change. Entries the array does not name are untouched, so you never need to resend the whole log. Entries cannot be removed with this tool; remove them in Projul. Manpower here is a field record, not job costing. Crew hours are never summed into payroll or job cost; record paid hours as time logs. Moving log_date onto a day the project already has a log for is 409 daily_log_exists, and a day after the company's tomorrow is 422 validation_failed. version is required: send the version you read. Adding or editing an entry changes the log's version too. A log that is locked or amended is 409 not_draft; this tool never signs, amends or sends a log. Needs the Daily Logs Pro feature. Full reference for this API: https://connect.projul.com/docs
deleted_items.get
ChatGPTReturns who deleted one record, when, and through which API key, by the record's own id. A record that is not deleted, belongs to another account, or is of a kind this lookup does not cover is 404 not_found. A purchase order or daily log id on an account whose plan does not include them is 403 pro_feature_required.
deleted_items.list
ChatGPTReturns records deleted in Projul, newest deletion first, using cursor pagination: who deleted each one, when, and through which API key when the deletion came through this API. Each row names the record by its own id (prj_…, est_…) and its kind in object. Only records this API would have returned before they were deleted appear: clients, projects and leads, estimates, tasks, messages, files, invoices, vendors, purchase orders and daily logs. Templates, and messages or files outside this API's surface, never do. Purchase orders and daily logs appear only when the account's plan includes them: naming one in type without it is 403 pro_feature_required, and a list of every kind leaves them out. label is the title of a project, estimate, task or purchase order, or an invoice's number. Clients, messages, files, vendors and daily logs carry no label, so no personal detail is kept here; q still matches client and vendor names, titles, project, estimate and purchase order numbers, and file names without returning them. deleted_by_source says how sure the deleter is. Narrow with type, customer_id (records belonging to that client, and the client itself), project_id (the same for a project), deleted_by and since.
discounts.get
ChatGPTReturns one discount definition. Disabled discounts still resolve, so the discount a version was applied at always has a name and a value — which is the whole point of publishing discount_id on a version's applied discount. Read is_percentage before you read value: it says whether the amount is money or a percent.
discounts.list
ChatGPTReturns the tenant's discount library — the definitions an estimate version's discount points at, and the value space of the discounts you apply on a version write. Disabled discounts are still returned: a document already using one keeps pointing at it, so the row a version's discount_id names always resolves. A disabled discount can be kept on a version but not newly applied. Filter on is_disabled yourself to build a picker. Read-only. The discount library is curated in the Projul app; this API applies an existing discount to a version, it does not create or edit the library.
estimate_templates.add_line_items
ChatGPTAdds one line item to the section named by section_id. The section must belong to this template; one that does not — including one that belongs to a real estimate — is 422 validation_failed rather than a distinguishable answer. Pick a category_id from tags.list that references a tag of type labor or materials matching this line's cost_line_type, and a trade_id from trades.list where the tenant uses trades. Tags of any other type are document templates, worker groups and the like — not cost categories — and the server refuses one with 422. An uncategorized line is invisible to the tenant's job-costing reports.
estimate_templates.add_sections
ChatGPTAdds a section — with its line items, if you send them — to the template. There is no version_id here: a template has one implicit document and no change orders. A body that names one is 422 validation_failed, which is how you can tell an esttmpl_ from an est_ if you are unsure which you are holding. Consult the section library first: section_templates.list returns the tenant's reusable sections and assemblies, already priced. Compose from scratch only when none fits. On a line item, pick a category_id from tags.list that references a tag of type labor or materials matching the line's cost_line_type, and a trade_id from trades.list where the tenant uses trades. tags.list also returns tags of other types — document templates, worker groups, on-hold reasons — and those are NOT cost categories: the server refuses one with 422. An uncategorized line is invisible to the tenant's job-costing reports, so leave these null only when nothing fits.
estimate_templates.create
ChatGPTCreates an empty estimate template — its document settings and title. Add sections and lines afterwards. The template is born with no client, no project, no document number and no totals: those belong to a real estimate, and are produced when one is created from this template.
estimate_templates.delete
ChatGPTDeletes an estimate template and its tree. Estimates previously created from it are NOT affected — they are independent documents. Requires the version argument with the template's current version.
estimate_templates.delete_line_items
ChatGPTDeletes one line item of the template. Requires the version argument with the template's current version.
estimate_templates.delete_sections
ChatGPTDeletes one section of the template and its line items. Estimates previously created from the template are NOT affected — they are independent documents. Requires the version argument with the template's current version.
estimate_templates.get
ChatGPTReturns one estimate template with its whole section tree, in persisted display order. Preserve that order verbatim when you copy a template: it is the order the document prints in. Every section carries its line items, so this call returns all of the template's sections and line items. No tool reads them on their own, and none is needed: the section and line-item tools under estimate_templates.* only write.
estimate_templates.list
ChatGPTEvery row carries the template's whole section and line-item tree, the same tree estimate_templates.get returns for one template, so no second call is needed to see a template's sections and line items. Returns the tenant's estimate templates, newest first, using cursor pagination. Templates and real estimates are disjoint: nothing returned here appears in estimates.list, and no estimate appears here.
estimate_templates.update
ChatGPTApplies a JSON Merge Patch (RFC 7396) to the template's own fields: omitted fields are left untouched, an explicit null clears. The section tree is NOT written here — add and edit sections and lines with the estimate_templates.add_sections, estimate_templates.update_sections and estimate_templates.delete_sections tools and their *_line_items counterparts, exactly as you would on an estimate. Naming sections in this call is rejected. A template has no client, no job, no document number and no totals you can send: those belong to a real estimate and are produced when one is created from this template. Requires the version argument with the template's current version. Templates are the tenant's shared library and the Projul app edits the same rows, so a stale token is 412 rather than a silent overwrite of someone's afternoon.
estimate_templates.update_line_items
ChatGPTApplies a JSON Merge Patch (RFC 7396) to one line item of the template. Omitted fields keep their stored values; an explicit null clears. A line cannot be moved between sections here — delete it and add it where you want it. Requires the version argument with the template's current version.
estimate_templates.update_sections
ChatGPTApplies a JSON Merge Patch (RFC 7396) to one section of the template. `line_items` is wholesale. If you send the array it is the DESIRED set — an element with an id updates that line, an element without one adds a line, and a line you omit is DELETED. Omit the array entirely to leave the lines alone. The section is addressed by its estsec_ id: identity is per TABLE, so a section inside a template is the same kind of row, addressed the same way, as a section inside an estimate. Requires the version argument with the TEMPLATE's current version — the token covers the template and its document row together, so any concurrent edit anywhere in the template moves it.
estimates.add_line_items
ChatGPTAdds one line item to the section named by section_id. Consult the cost-item library first: line_item_templates.list returns the tenant's own priced items, and reusing one keeps this estimate consistent with everything else they quote. Categorize the line. Set category_id to a tag_ id from tags.list whose type matches the line's cost_line_type — a labor line takes a labor category, anything else takes a materials one — and set trade_id where the tenant uses trades. A line left uncategorized is invisible to their job-costing reports, and a category whose type does not match the line's cost_line_type is rejected with 422 validation_failed, pointing at category_id. Tags of every other type exist for DOCUMENT templates and are never cost categories: assigning one is rejected. The response is the WHOLE estimate (every version, section and line), not just the created line item. This is intended: read the new estline_ id out of the returned tree. Guarded by the owning version's draft class.
estimates.add_sections
ChatGPTAdds a section — with its line items, if you send them — to the version named by version_id. Consult the section templates first: section_templates.list returns the tenant's reusable sections and assemblies, already priced. Compose a section from scratch only when none fits. The response is the WHOLE estimate (every version, section and line), not just the created section. This is intended: read the new estsec_ id out of the returned tree. Guarded by the owning version's draft class.
estimates.create
ChatGPTCreates an estimate whose head version is a draft, optionally from an estimate template and optionally with its whole section/line tree in one request. Consult the templates first. Call estimate_templates.list and pass the one you want as template_id before composing a document from scratch: the tenant's templates carry their pricing, their section structure and their discounts, and a document built from a template is the one the estimator expects to see. Compose from scratch only when no template fits. The status is set by the server and cannot be sent — there is no status field on any write body, and one that carries it is rejected. Totals are computed by Projul from the tree you send: every money and percent field is read-only, and a body naming one is rejected too. A project may hold only one real estimate; creating a second is 409 estimate_exists.
estimates.create_version
ChatGPTAdds a version to an estimate. A CHANGE ORDER IS A VERSION — type: change_order on this operation is the whole change-order surface, and change-order ids carry the same estv_ prefix as the head document, because in Projul both are rows of the same table. The new version is created as a draft, so a change order can be added to an estimate whose head is already approved — that is the real change-order workflow. Editing that approved head is still refused. The response is the WHOLE estimate (every version, section and line), not just the created change order. This is intended: read the new estv_ id out of the returned tree.
estimates.delete
ChatGPTDeletes the whole document — its versions, change orders, sections and lines. `version` is required. Send the version from your most recent read of the estimate. A missing version is 428 precondition_required and one that is no longer current is 412 stale_version, which means someone edited the estimate in Projul since you read it — re-read it and retry. Requires EVERY live version to be draft, pending_review, revision_requested or review_revision_requested AND unlocked; one approved change order refuses the whole delete with 409 not_draft. Linked invoices are UNLINKED and survive. This API never destroys an invoice.
estimates.delete_line_items
ChatGPTDeletes one line item. Send the owning version's version.
estimates.delete_sections
ChatGPTDeletes one section and its line items. Send the owning version's version.
estimates.delete_version
ChatGPTDeletes one CHANGE-ORDER version and its tree. Sibling versions are untouched. The head version (type: normal) is not deletable on its own — that would orphan the document and its change orders — and returns 422; delete the whole estimate instead.
estimates.get
ChatGPTReturns one estimate with its complete ordered tree: every live version (the head document and each change order), every section and every line item, at any status. This is the read that always carries the tree, so it is how you fill in the sections a list row leaves as null. No tool reads sections or line items on their own, and none is needed: the section and line-item tools under estimates.* only write. A deleted, template, ghost, unknown, malformed, or other-tenant id is an indistinguishable 404 not_found. This API does not answer "exists, but not yours". The version on the estimate and on each version row are the concurrency tokens for writing (§11.6). One token per document version guards that version AND its sections and line items, so hold the version's token, not a per-row one. Sections and line items come back in their PERSISTED order — the order the estimator arranged them in — and REST, MCP and webhook payloads all agree on it. Preserve that order in any export, summary, or rewrite you produce: never re-sort or renumber the lines yourself.
estimates.list
ChatGPTBy default list rows do not include sections or line items (each version's sections is null), so to read an estimate's sections and line items, change orders included, call estimates.get with the row's id as estimate_id. The section and line-item tools under estimates.* only write. Returns the tenant's estimates, newest first, using cursor pagination. Rows are SUMMARIES by default. Every row carries the estimate header, its company, customer and project snapshots, its document-level totals, and every version with that version's own status, totals and concurrency token. What a summary row does NOT carry is the section and line-item tree: each version's sections comes back as null, alongside a section_count and a line_item_count that tell you how much is there. That is enough to answer most questions outright, including what a job was quoted at and what state a document is in, without reading a single line item. Two ways to get the tree when you do need it: - estimates.get always returns ONE document in full. This is the right call once you have picked a document out of a list, and it is what you should reach for by default. - include: "versions.sections" returns the tree for every row on the page. It is one to two orders of magnitude larger, so narrow the page with a filter and a small limit first. Nothing is hidden by status: drafts, sent estimates, approved work and closed documents are all listed, because an integration or agent needs the whole picture. Filter with status when you want a slice of it. Two kinds of row never appear here, and they are not errors: - Estimate templates are their own resource, reached with the estimate_templates.* tools. The two surfaces are disjoint. - Auto-generated "ghost" estimates — scaffolding Projul creates behind some projects for cost tracking, which the app itself hides until someone edits them. Use updated_since to sync incrementally. It matches when the estimate row or any of its versions changed at or after the instant you pass, so an edit that only touched a change order is not missed. It does not see every edit made deep inside the tree in the Projul app; for a reliable change feed, subscribe to the estimate.* webhook events. Sections and line items come back in their PERSISTED order — the order the estimator arranged them in — and REST, MCP and webhook payloads all agree on it. Preserve that order in any export, summary, or rewrite you produce: never re-sort or renumber the lines yourself.
estimates.update
ChatGPTUpdates the document-level fields. Merge-patch semantics: omitted fields are untouched. Guarded by the HEAD version — send its version, which is the same token the estimate itself reports. The write is refused 409 not_draft unless that version is draft, pending_review, revision_requested or review_revision_requested AND unlocked. Moving the estimate to another project_id also re-links that job's invoices; it is a real reassignment, not a label change.
estimates.update_line_items
ChatGPTUpdates one line item. Merge-patch semantics. Categorize the line if it is not already: set category_id to a labor or materials tag matching the line's cost_line_type, and trade_id where the tenant uses trades. Tags of other types are DOCUMENT templates and are rejected here. A line carries no concurrency token of its own: send the OWNING VERSION's version.
estimates.update_sections
ChatGPTUpdates one section. Merge-patch semantics on the section's own fields; line_items, when you send it, is the DESIRED SET — a line with an id is updated, one without is added, and a stored line you omit is DELETED. A section carries no concurrency token of its own: send the OWNING VERSION's version. That is deliberate — it means two agents editing different lines of the same version serialize instead of silently interleaving.
estimates.update_version
ChatGPTUpdates one version's own fields. Merge-patch semantics. Guarded by THIS version: send its version, and the write is refused 409 not_draft unless this version is draft, pending_review, revision_requested or review_revision_requested AND unlocked. Sibling versions are untouched.
files.complete_upload
ChatGPTPublishes an uploaded file, after Projul verifies the bytes itself. Projul reads the blob you uploaded and checks what is ACTUALLY there, against both the size limits and the size_bytes you declared: Nothing uploaded → `422 validation_failed`. The file stays `pending_upload`; upload and call this again. Larger than the `size_bytes` you declared → 422 validation_failed (field code size_mismatch), and the uploaded blob is deleted. A blob SMALLER than you declared is accepted, and its real length is recorded. Over your account's size limit → `422 file_too_large`, and the uploaded blob is deleted. A content type that is off the allow-list or disagrees with the one you declared → 422 validation_failed, and the uploaded blob is deleted. Bytes changed while we were verifying them* → 422 validation_failed, and nothing is published. On success the file becomes available, its verified size_bytes is filled in, and images are queued for thumbnail generation. No idempotency_key: this call is idempotent by state. Calling it again on a file that is already available returns the same representation and changes nothing.
files.create_upload
ChatGPTCreates a file record and returns a short-lived URL to upload the bytes to. File bytes never pass through this API. Uploading is three steps: 1. files.create_upload — this call. It validates the file type, the declared size and every association, creates the record in pending_upload, and returns an upload_url. 2. PUT the raw bytes to upload_url with the header x-ms-blob-type: BlockBlob and x-ms-blob-content-type set to the same content_type you declared here. That request goes to Azure Blob Storage, not to Projul, and you make it yourself. 3. files.complete_upload — Projul reads the uploaded blob, checks its ACTUAL size and type against the limits, and publishes it. Only then is the file available. Every file must be attached to a project — and a lead IS a project, so a lead's prj_ id is attached exactly like any other project's. Optionally attach it to an estimate version (estimate_version_id — a change order is a version) or to a single line item (cost_line_id); both are allowed only while that version is a draft. It can also go on a draft invoice (invoice_id); once the invoice is published its attachments are fixed and attaching is 409 invoice_published. Files are attached to messages from the message side, not here. Limits. The account's own file size setting, 50 MB when it never set one. Accepted types: jpg, jpeg, png, gif, bmp, pdf, txt, csv, doc, docx, xls, xlsx — the extension and the content_type must agree. Uploads are additionally rate-limited (10/min per key, 30/min per account) and an account may hold at most 25 uploads in pending_upload at once; exceeding either is 429 rate_limited. The upload_url expires in about 15 minutes. If it expires before you upload, call files.refresh_upload_url for a fresh one — do not start over. If you are an agent: step 2 is an ordinary HTTP PUT you must perform yourself. Projul never accepts file bytes inline — not here and not through any tool — so a runtime that cannot make an arbitrary HTTP PUT cannot upload a file.
files.delete
ChatGPTDeletes a file. It disappears from lists, from files.get and from the Projul app, and its download links stop being issued. Deleting does not free any allowance: uploads are limited by RATE, not by stored bytes, so deleting a file does not let you upload another one sooner. Repeating the call on an already-deleted file is 404 not_found.
files.get
ChatGPTReturns one file's record. A deleted, user-removed, settings-image, unknown, or other-account id is an indistinguishable 404 not_found. The record carries no URL — request a time-limited link from files.get_download_url when you need the bytes. created_by is the user_ id of the person who added the file; look them up with users.get. It can also name a client who signs in to Projul, a Projul support user, or a person who has left the account, and users.get does not return those.
files.get_download_url
ChatGPTReturns a link to the file's bytes that works for about 15 minutes. Fetch the link with an ordinary HTTP GET. It grants read on ONE blob and nothing else — it cannot list, write, or reach any other file. Request a fresh link each time you need the bytes rather than storing this one, and treat it as a credential: do not log it or embed it in a page that outlives it. The original file is returned. Thumbnails are not exposed by this API. A file that has not completed its upload has no bytes to serve and is 422 validation_failed.
files.list
ChatGPTReturns the tenant's files, newest first, using cursor pagination. This is scoped file storage for project records, not a file system. Every file belongs to a project — and a lead IS a project, so a lead's files are found with its prj_ id under project_id. The filters address attachment points: project_id, estimate_version_id (a version's files, including the photos on its line items; a change order is a version), cost_line_id (one line item's photos) and message_id. To list one person's files, find them with users.list and pass their user_ id as created_by. It combines with every other filter, so created_by with file_type=image returns that person's photos, including the photos they edited in the app and saved as new files. An id that names no person of the account returns an empty page. Attachment order is preserved. When you filter to a single attachment point and do not pass sort, files come back in the order the Projul app renders them — so the photos on a line item arrive in the sequence a person put them in. Passing sort explicitly switches to that timestamp order instead. Only available files are returned unless you pass status=pending_upload. Deleted files, files a user has removed in the app, and company-settings images are never returned. Two further kinds of file exist in Projul that this endpoint does not list, so a count here can legitimately come out lower than the count in the app. A file attached ONLY to something other than a project, a task photo being the common case, has no project_id it could report and is left out. So is a file stored in a format outside the value space of content_type — a .heic photo straight off a phone, a .webp, or a .zip the app built for a bulk download. Nothing is deleted in either case and both remain visible in the Projul app; they simply have no representation this contract can make good on. A file is only returned while the project it belongs to is. project_id is required and never null, so a file whose project has been deleted has nothing it can honestly report. Such a file stops appearing here and answers 404 by id, exactly as its project does. Nothing is destroyed: the file is still in the Projul app, and it becomes reachable again if the project is restored. There is no URL on a file. Call files.get_download_url when you need the bytes.
files.refresh_upload_url
ChatGPTIssues a new ~15-minute upload URL for a file that is still pending_upload, without creating anything. Use it when the original URL expired — including after an idempotent replay of files.create_upload, which returns the ORIGINAL result and therefore the original, possibly expired, URL. Once the file is available its bytes are fixed and this returns 422 validation_failed: a completed file's blob is never overwritten. Upload different bytes by creating a new file.
files.update
ChatGPTUpdates a file's label, note and attachment points with JSON Merge Patch semantics: fields you omit are untouched, and a field sent as null is cleared. The bytes are immutable. A completed file's content is never replaced — the size and type limits were enforced against the bytes that are already stored. Upload different bytes by creating a new file. Attachments are edited incrementally. Setting associations.estimate_version_id adds that attachment; setting it to null removes it. Files attached to the same estimate or line item by other means are left alone — this never rebuilds an item's file list. associations.project_id may be changed to a different project but never removed. Attaching to, or detaching from, an estimate version or line item is allowed only while that version is a draft and unlocked; otherwise 409 not_draft and nothing changes. The same goes for associations.invoice_id: an invoice's files change only while it is a draft, otherwise 409 invoice_published and nothing changes. No version argument: these are metadata writes, so there is no version to carry.
guides.get
ChatGPTReturns one guide, its content as Markdown. A slug that is unknown, or that names a guide still awaiting review, is 404 not_found.
guides.list
ChatGPTReturns the guides Projul publishes for AI assistants: plain-language explanations of how Projul works, the words contractors use, and what this connection cannot do. Read the ones that fit the task before acting for a contractor, and use their words when talking to the person. Only guides a person at Projul has reviewed are listed, and the full set comes back in one response. Fetch a guide's content with the guides.get tool. The same guides are also offered as MCP resources named projul://guides/{slug}.
history.get
ChatGPTReturns one record's change history, newest first: what each change did, who it is recorded against (actor, with actor_source saying a person, Projul itself, or unknown), the API key it came through (via) and which fields it set or moved, by name only. object is customer, project, estimate, task or file, and id is that record's id. An estimate's history includes its versions, sections and line items, each entry naming the part it changed in record; complete: false means its least recently changed parts were left out. Use it to answer who changed something and when. Page with after set to the previous page's next_cursor. 503 history_unavailable means history could not be read right now, never that the record has no history.
invoices.create
ChatGPTCreates a draft invoice that is not raised against an estimate: its lines are the line items you send, in the same shape as an estimate's line items and priced the same way (a price you send wins; without one the price comes from the cost and markup). To bill an estimate, use invoices.create_from_estimate instead. The invoice is created as a draft, numbered by Projul, and priced by Projul: subtotal, tax, discount and total are computed on the server from the lines. The client and project are optional; tax_id names the invoice-level rate on an account that taxes at the invoice level. Nothing is sent to the customer, and a draft created here has no customer link until a person sends it from the Projul app. external_ref is unique among the account's live invoices. `idempotency_key` is required. Retrying with the same key returns the invoice the first call created.
invoices.create_from_estimate
ChatGPTCreates a draft invoice that bills part of an estimate: a lump_sum bills an amount, a percentage bills a percent of the estimate's approved documents, both measured before the estimate's discounts and tax. Projul builds the invoice's rows from the approved versions (one per top-level section, or per cost line on an itemized estimate) and spreads the amount over them the way the Projul app does: each row in proportion to what it has left to bill, a credit row billed in full, the rounding on the last row. A percent that finishes the estimate bills exactly what is left. An itemized invoice names the rows it bills in line_items, as the Projul app's line-by-line invoice does: each row by its estimate_section_id (by its estimate_line_item_id on an estimate billed line by line) with an amount, or a percent of the row. Every line item bills by amount or every one by percent, a credit row bills a negative amount, and the rows not named bill nothing. Each row bills at most what it has left after the estimate's other invoices. You can bill at most what the approved documents have left after the estimate's other invoices; more is 422 validation_failed and the error says how much is left. An estimate with nothing approved is 422 validation_failed too. Billing past the estimate, or a credit, is done in the app. The invoice is a draft, numbered by Projul and priced on the server (its tax and total follow the estimate's tax mode). It carries none of the estimate's discounts; a person applies those in the Projul app. It takes the estimate's client, project and company details and the account's payment settings. Nothing is sent to the customer, and the draft has no customer link until a person sends it from the Projul app. `idempotency_key` is required. Retrying with the same key returns the invoice the first call created.
invoices.delete
ChatGPTDeletes an invoice that is still a draft (draft, pending_review, revision_requested or review_revision_requested). Any other status is 409 not_draft: a sent invoice is closed or voided in the Projul app, never deleted here; that is checked first. A draft with any payment recorded against it is 409 deletion_blocked. `version` is required. Send the version from your latest read. Missing is 428 precondition_required; no longer current is 412 stale_version.
invoices.get
ChatGPTReturns one invoice with its lines, discounts, and what has been paid. A deleted, unknown, or other-account id is an indistinguishable 404 not_found. The customer share link is never returned.
invoices.list
ChatGPTReturns the tenant's invoices, newest first, using cursor pagination. Find one invoice by its number (number) or by the reference you tagged it with (external_ref). Filter to a client, project or estimate with their ids. status filters on the stored status; is_overdue=true returns invoices with a balance still owed past their due date on the company's calendar day, whatever status they were last saved with. Money: subtotal, tax, discount and total are the stored amounts; amount_paid and balance_due are recomputed from the payments.
invoices.list_payments
ChatGPTReturns the invoice's payments, newest first, whoever recorded them: a person in Projul, a card or bank payment, a QuickBooks sync, or this API. counts_toward_balance says whether a payment is part of amount_paid. Processor records are never returned.
invoices.record_payment
ChatGPTRecords a payment that reached you outside Projul, such as a financing provider's payout or a check, and settles the invoice with it exactly as recording it in the Projul app would: the balance, the status (partial, overdue or paid) and the estimate's invoiced totals all follow. Nothing is sent to the client. A partial payment leaves the client's due-date reminder in place; paying the balance withdraws it. amount is what the invoice receives. fee_amount is what the provider kept, recorded but never deducted. reference_number (at most 20 characters) is what QuickBooks matches the payment by, so use the number the provider printed on the payout. external_ref is the provider's own id for the payout: recording the same one twice is 409 duplicate_external_ref, and existing_id names the payment already recorded. Accepted on invoices that are pending_send, pending_payment, partial or overdue. An invoice that is not published yet, closed, void, or mid-checkout is 409 invoice_not_published, and the detail says what to do in the app. A paid invoice is 409 invoice_already_paid. More than balance_due is 422 overpayment. One difference from the app: a payment recorded here on a pending_send invoice moves it on to partial or paid, where recording one in the app leaves it at pending_send. Payments are append-only: a payment is never edited or deleted through this tool. Full reference for this API: https://connect.projul.com/docs
invoices.update
ChatGPTA merge patch. external_ref can be set or cleared on any invoice, so a later list call can find it with the external_ref filter. null clears it; a blank value is 422 validation_failed. On a draft (draft, pending_review, revision_requested or review_revision_requested) you can also change title, and on a draft raised against an estimate the amount (lump sum) or percent (percentage) it bills: the estimate's rows are spread again and the invoice priced again on the server. Naming a draft field on an invoice that is no longer a draft is 409 not_draft. An itemized draft bills the rows it names, so its amount or percent is 422 validation_failed: change its lines in the Projul app. Status, payments and the lines of an itemized or standalone invoice are never changed here; naming any other field is 400 invalid_request. An external reference names one invoice: one already used by another live invoice is 409 duplicate_external_ref, and existing_id names that invoice. `version` is required. Send the version from your most recent read. Missing is 428 precondition_required; stale is 412 stale_version. Recording a payment changes the version, so read the invoice again after one.
lead_sources.get
ChatGPTReturns one lead source. Disabled sources still resolve, so a project already attributed to one always has a name.
lead_sources.list
ChatGPTReturns the account's lead sources — the "where did this work come from" library a project is attributed to. Disabled sources are still returned, so a project already attributed to one always resolves to a name. Call this before attributing a project by name, and reuse what you find: that is what keeps "Google", "Google Ads" and "google_ads_campaign" from becoming three separate sources that split the account's reporting.
line_item_templates.create
ChatGPTAdds an entry to the tenant's cost-item library — a line they estimate often, with their own name, costing and categorisation. Create-or-match on `sku`. If you send a sku an entry already has, this returns that existing entry unchanged with matched: true instead of creating a duplicate, so re-running a price-list load is safe to repeat. A new sku, or none at all, creates a fresh entry with matched: false. matched: true means the entry already existed and you have it. Do not retry to force a create, and do not perturb the sku to dodge the match. To change a matched entry, call line_item_templates.update; look one up first with line_item_templates.list and a sku filter. Categorise it. category_id must reference a labor or materials tag — labor for a Labor line, materials otherwise — and trade_id the trade that performs the work. Tags of other types exist for DOCUMENT templates and are never cost categories; sending one is refused 422 validation_failed, as is a tag belonging to another tenant. Call tags.list with type: "labor,materials" to pick from the valid subset rather than guessing. `cost_line_type` accepts only `Labor`, `Material` and `Other`. The enum's other four members are read-only: they can appear on rows written before v1 and are returned verbatim, but sending one here is refused 422 validation_failed with field code unsupported_cost_line_type. Send Material in place of MaterialInventory, and Other in place of Note, Discount or Rebate. There is no discount or rebate line type; to reduce a total, add a line with a negative price. See CostLineType.
line_item_templates.delete
ChatGPTDeletes a library entry. Estimate lines already created from it are NOT affected. Requires the version argument.
line_item_templates.get
ChatGPTReturns one cost-item-library entry — the tenant's own name, costing and categorisation for a line they estimate repeatedly. Prefer copying an entry's values onto a new line item over inventing a name and a price: the library is what keeps a tenant's estimates costed and categorised consistently.
line_item_templates.list
ChatGPTReturns the tenant's cost-item library — the catalogue their estimators pick from. Prefer picking from this library over inventing a name and a price: a line created from a library item carries the tenant's own costing and categorisation.
line_item_templates.update
ChatGPTApplies a JSON Merge Patch (RFC 7396). Requires the version argument with the entry's current version. Updating a library entry does NOT retro-apply to estimates that already used it.
me.get
ChatGPTCall this first. estimate_label is the company's own word for an estimate (a Bid, a Proposal, a Quote): use it whenever you talk to the person, even though the tools say "estimate". A Read key cannot use any write tool, so tell the person before you plan a change. Returns what the key presented on this request is: its non-secret id, the name an administrator gave it, its access level, the name of the Projul company it belongs to, and the word that company uses for an estimate. The intended use is a connection test. It lets an integration refuse a Read-only key at connect time instead of failing later: an integration that needs to write can check access_level when the key is first entered and say what to do about it, rather than surfacing 403 insufficient_access on the first write hours or days afterwards. It also gives the integration a label for the connection (company_name, key_name) so a person with several Projul accounts can tell them apart, and estimate_label so it can call an estimate what the company calls it (a Bid, a Proposal, a Quote). This describes the key and nothing else. It never returns the secret half of the token, it is answered for a Read key and a ReadWrite key alike, and it counts as one read against the key's allowance. A missing, unknown, revoked, or non-Connect credential is the same undifferentiated 401 invalid_api_key as on every other operation.
messages.create
ChatGPTAdds a message to a project, task, client, or estimate. parent_type and parent_id say what it is about; the id must carry that record type's own prefix (prj_, tsk_, cus_, est_). A parent that does not exist, belongs to another account, or has been deleted is an indistinguishable 404 not_found and NOTHING is written. To add a message on a lead, use `parent_type: "project"` with the lead's project id — leads are projects, and there is no lead channel. This tool cannot message a client. customer_message is a read-only channel: client-facing messages are sent from the Projul app, because sending one emails the client. parent_type accepts only the four values above. Supply body (plain text) or body_html; whichever you omit is derived from the other, so the message reads correctly both in the app and in search. Messages are allowed on records in ANY status — including estimates that are no longer drafts — because a message is commentary, not a change to the document. Attach files with file_ids, using ids of files already uploaded through the files.* tools. A file that is not yours, is unknown, or has not finished uploading is 422 validation_failed. File bytes are never accepted inline here. Messages have no version and no precondition: concurrent edits are last-write-wins. Full reference for this API: https://connect.projul.com/docs
messages.delete
ChatGPTDeletes a message exactly as the Projul app does: this API stops returning it, and in the app's thread its text is replaced by a "message deleted" line with the date. The record it was attached to is untouched. Deleting a client message is 422 validation_failed — that channel is read-only. Deleting an already-deleted or unknown message is 404 not_found.
messages.get
ChatGPTReturns one message, including a read-only client message. A deleted, unknown, other-account, or unsupported-channel id is an indistinguishable 404 not_found, as is a message whose parent record has been deleted.
messages.list
ChatGPTReturns the tenant's messages, newest first, using cursor pagination. A message hangs off exactly ONE record, given by parent_type + parent_id. Filter to one record's messages with both (?parent_type=project&parent_id=prj_…); parent_id alone is 400 invalid_request, because an id means nothing without the kind of record it belongs to. Leads are projects. Messages "on a lead" are messages on the lead-status project, under parent_type=project. Client messages appear here, read-only. A project's client conversation is returned under parent_type: "project" with channel: "customer_message". There is no customer_message parent type, and those messages cannot be created, changed, or deleted through this API. Messages deleted in Projul are not returned, and messages on record types this API does not expose (contractor messages, follow-ups) are outside this surface entirely. A message whose parent record (its project, task, client, or estimate) has been deleted is not returned either.
messages.update
ChatGPTApplies an RFC 7396 merge patch to the message's own content. Send only the fields you are changing; null clears a field. A message cannot be moved to a different record: parent_type and parent_id are not editable here — move it in the Projul app. Attachments are set when the message is created, so file_ids is not editable either. Naming any of them is 400 invalid_request. Editing a client message is 422 validation_failed: that channel is read-only. There is no version on messages — concurrent edits are last-write-wins. Full reference for this API: https://connect.projul.com/docs
payroll_items.list
ChatGPTReturns the account's payroll items: the pay types time is recorded under, with each item's default rate and overtime multiplier. Use these ids for payroll_item_id on a time log, and to map your own pay codes. Retired items are listed with active: false because stored time still names them; only an active item can be set on a write. Payroll items are managed in the Projul app.
project_templates.list
ChatGPTReturns the tenant's project and schedule templates, so you can discover the template_id values the projects.create tool accepts. Templates are not projects and never appear in projects.list. This surface is unpaged: the full set is returned in one response.
projects.apply_schedule_template
ChatGPTClones a schedule template's tasks onto an existing project or lead: each task with its steps, tags, dependencies, tentative flags and recurrence. The template's earliest scheduled task lands on schedule_anchor_date and every other task keeps its spacing from it, in the account's time zone. Weekends and holidays are not skipped. Find a template_id with the project_templates.list tool; only a schedule_template can be applied here. Workers. With assign_template_workers: true each cloned window carries the template's workers. A worker who is no longer an active employee or subcontractor is left off and listed in skipped_assignees. The project's own assigned people are never changed by this call. Applying twice. A project that already holds live tasks from this template is refused with 409 schedule_template_already_applied and nothing is written. Send allow_reapply: true to add a second copy, or call projects.reschedule_schedule_template to move the copy already there. Side effects (the app's own, so use them deliberately): - A lead in new_lead, lead_pending_contact or lead_pending_schedule advances to lead_pending_estimate when a cloned EstimateMeeting task is scheduled, the same rule as scheduling one with tasks.create. The result lists tasks, so re-read the project with projects.get to see its stage. - Task reminders are queued for the assigned workers, as the account's notification settings say. - A cloned estimate meeting queues the customer's reminder emails only when notify_customer is true, and then only as the account's client reminder settings allow. Otherwise nothing from this call reaches a customer. Each created task emits task.created. idempotency_key is required: a retry after an ambiguous failure returns the original result rather than cloning the template a second time.
projects.create
ChatGPTCreates a project. Leads are projects. To create a lead, call this tool with a lead-stage `status` — one of new_lead, lead_pending_contact, lead_pending_schedule, lead_pending_estimate, lead_pending_estimate_creation, lead_pending_estimate_to_be_sent, lead_pending_estimate_revision, lead_pending_sale. There is no leads resource and no leads tool. status is required — there is no default. Decide deliberately whether you are creating a lead or an active project. Attach the client in exactly one of two ways: - `customer_id` — links an existing client. The client row is not modified. - `customer` — an inline client. This runs the same create-or-match rule as customers.create: an existing client matching on both names plus an email, or both names plus a phone, is linked instead of duplicated, and divergent contact details are appended to them. An ambiguous match creates a new client. A client with only one name is accepted and matched on its email: it links only when exactly one client has that email and the name given is that client's first or last name, and is never appended to; two or more clients with the email is 409 ambiguous_match. A company-only client (company_name and no names) is matched on the company name plus the email or phone, and only to another client with no first or last name. Supplying both, or neither, is 422 validation_failed. If the inline client matches an archived client the whole request is refused with `409 resource_archived` — no project row and no client row is created, and nothing is appended to the archived client. Unarchive the cus_ id in the error detail, then retry. project_number is generated by Projul unless the account has turned project auto-numbering off. Those accounts may supply it here and it is stored as given; with auto-numbering on, supplying it is 422 validation_failed (project_number_not_allowed), because project and estimate numbers share one counter. Accounts with auto-numbering off who send none get a project with no number. start_date and completion_date are calendar days (YYYY-MM-DD) in the company's time zone. Setting start_date schedules the client's project start reminder emails (a week before, the day before and on the day) if the account has those reminders turned on in Projul; reminders whose time has already passed are not sent, so a start date in the past sends nothing.
projects.delete
ChatGPTDeletes a project, and decides what happens to the work attached to it. The optional parameters tasks, estimates, and invoices each take delete or unlink: delete removes those records along with the project; unlink keeps them and detaches them from the project. If you specify none of them the default is a guarded cascade: the project's tasks, its estimate, and its invoices are deleted along with the project — but the request is refused with `409 deletion_blocked` if any invoice on the project has a recorded payment, and in that case nothing at all is changed. The same refusal applies to an explicit invoices=delete: an invoice with a recorded payment is never deletable through this API. invoices=unlink never refuses. An agent must know that an unparameterized delete takes the job's tasks, estimate, and unpaid invoices with it. The project's time logs are never deleted — in every variant they survive, detached from the project. `version` is required (a stale delete destroys concurrent human work at maximum blast radius): missing is 428 precondition_required, out-of-date is 412 stale_version.
projects.get
ChatGPTReturns one project — including leads, and including archived-stage projects. A deleted, template, unknown, or other-tenant id is an indistinguishable 404 not_found. Reading a project through this API does not mark it as accessed in Projul.
projects.list
ChatGPTReturns the tenant's projects, newest first, using cursor pagination. Leads are included — a lead is a project in a lead-stage status. Nothing is hidden by default: won, lost, completed, and terminated projects are all listed, so an integration or agent sees the whole pipeline. Partition the results with stage (lead / active / archived) rather than hardcoding a list of statuses. Project and schedule templates are not projects and never appear here.
projects.reschedule_schedule_template
ChatGPTRe-stamps a schedule template already applied to this project on a new date. Only the template's open, one-off, scheduled tasks move, and the earliest of them is the reference point: each moves by the whole days between that task's date and schedule_anchor_date, keeping its time of day in the account's time zone. Tasks added to the project any other way are never moved. Some of the template's tasks stay where they are, listed in skipped_tasks with a reason: completed (finished, failed, cancelled, rejected or expired work keeps its dates), recurring (a repeating task's series is set by its recurring_rule; move it with tasks.update), or unscheduled (no dated window to move). The move is the app's own calendar move: assigned workers are told their task changed, and a reminder already queued, including one for a customer, moves with its task. Moving an EstimateMeeting makes its assigned people the project's estimators, as moving it in the app does. Each moved task emits task.updated. The call is state-idempotent: sending the same date again moves nothing and returns days_shifted: 0. No idempotency_key is needed or accepted. version is optional and honoured: send the project version you read and the call is refused with 412 stale_version if the project changed in between.
projects.update
ChatGPTPartially updates a project using JSON Merge Patch semantics (RFC 7396): an omitted field is untouched, an explicit null clears it. `version` is required. Send the version from your most recent read of the project. A missing version is 428 precondition_required; a version that is no longer current is 412 stale_version, which means someone edited the project in Projul since you read it — re-read it and re-apply your change rather than overwriting their work. Your OWN prior writes can bump the version too: creating or completing an EstimateMeeting task advances a lead-stage project and changes its version, so re-read the project after such a call before your next write to it. Changing `status` is how you move a project through the pipeline, including converting a lead: status: "project_sold" on a lead-stage project converts it exactly as dragging it in the app would, with the same status history, dates, and notifications. Any exposed status may be set from any other; Projul does not enforce a transition order. Writable fields: name, status, address, sale_probability, project_number, start_date, completion_date, external_ref, description, the lead source and lead source type fields, the project type fields, tag_ids and assigned_users. Everything else in the representation is read-only. customer_id is create-only — naming it here is 400 invalid_request; re-parenting a project is done in the Projul app. project_number can be changed only by accounts that turned project auto-numbering off. With auto-numbering on, a value different from the current one is 422 validation_failed (project_number_not_allowed). start_date and completion_date are calendar days (YYYY-MM-DD) in the company's time zone: null clears one, and completion_date may not be before start_date. Setting or changing start_date schedules the client's project start reminder emails (a week before, the day before and on the day) if the account has those reminders turned on in Projul; reminders whose time has already passed are not sent, so a start date in the past sends nothing.
purchase_orders.create
ChatGPTCreates a Draft purchase order. Needs the purchase_orders Pro feature. Projul numbers it by the account's own rules and prices it on the server from the lines: send quantities and unit prices, never totals. project_id charges every line that names no project of its own to that project, the common "a purchase order for this job" case. Tax follows the account's setting, reported back as per_line_tax_required: when it is false the order's tax_id taxes the lines marked is_taxable, and a line's own tax_id is 422 tax_level_mismatch; when it is true each line carries its own tax_id and the order's is refused the same way. A rate that is disabled cannot be chosen (422 tax_rate_retired). order_number is accepted only when the account numbers purchase orders by hand; otherwise it is 422 not_applicable, and Projul assigns the number. As in the Projul app, a number you send need not be unique, so it never conflicts with another order. The payee is an active vendor or an active subcontractor (user_ id). external_ref is unique among the account's live purchase orders: one another order holds is 409 duplicate_external_ref with its id in existing_id. When Projul assigns the number and other purchase orders take the next numbers at the same moment, so none can be claimed, the create is 409 concurrency_conflict; retry it. Nothing is sent to the vendor. A new order takes the account's payment terms, photo size and document layout, and a vendor payee's shipping defaults; it does not take the account's instructions or terms text.
purchase_orders.get
ChatGPTReturns one purchase order with its lines. A deleted, unknown, or other-tenant id is the same 404 not_found. Needs the purchase_orders Pro feature.
purchase_orders.list
ChatGPTReturns the tenant's purchase orders that are not deleted, every status, newest first, with their lines, using cursor pagination. Needs the purchase_orders Pro feature. Filter by vendor_id, by project_id (a purchase order with at least one line charged to that project), by one or more status values, and by updated_since for incremental sync.
purchase_orders.update
ChatGPTPartially updates a Draft purchase order with JSON Merge Patch semantics (RFC 7396): a field you omit is left untouched and a field you send as null is cleared. Needs the purchase_orders Pro feature. Any other status is 409 not_draft. line_items is replaced wholesale when sent: keep a line by sending its id, change it by sending new values with that id, add one by leaving id out (Projul assigns it), and drop one by leaving it out of the list. project_id charges every line that names no project to that project. The server reprices the order on every save; totals are never sent. The tax rules, payee rules and external_ref rule are the create's. order_number can be changed and need not be unique, as in the Projul app; order_date and terms sent as null are left as they were. `version` is required. Send the version from your latest read; one that is no longer current is 412 stale_version, so read the purchase order again and reapply your change.
reports.export
ChatGPTRenders one whole report to a CSV file and returns a link to download it. type names the report and parameters takes that report's own filters and sort, written as they are on its route; there is no paging, because the file holds every row. The file has one line per row with the same column names as the report's JSON, and a client ledger has one line per entry. Cells a spreadsheet would run as a formula start with an apostrophe. A report covering more than 25,000 records is 422 report_too_large: narrow the dates or filters. The link works for about 15 minutes. Hand it to the person to open now and never store it; if it has expired, export again.
reports.leads
ChatGPTProjects created from from through to with their estimate, lead source, project type and the people on them, as the app's Leads Report lists them. Narrow with customer_id. approval_overdue is true for an estimate still waiting on the client after its expiry date, and estimate_total is the estimate's total as Projul saved it. Returns one page of rows (limit, default 25, at most 100); pass next_cursor back as after for the next page. summary totals the whole report, not only the page. For the whole report as a spreadsheet, use the reports.export tool.
reports.profitability
ChatGPTJobs completed from from through to, as the app's Profitability Analysis report shows them: a job counts when its planned end date falls in the period, or, when it has none, the day it was completed. Each row sets the estimate's labor, material and total costs against the actual ones and shows what the job earned. A job with no approved estimate version (has_contract false) earns what it billed. Narrow with customer_id or project_id. For jobs still in progress, use the wip report. Returns one page of rows (limit, default 25, at most 100); pass next_cursor back as after for the next page. summary totals the whole report, not only the page. For the whole report as a spreadsheet, use the reports.export tool.
reports.project_costs
ChatGPTEach job's budget line by line, as the app's Project Budget costs it: what each cost line was budgeted (company cost and price), what has been spent on it (expenses, purchase-order actuals and labor), what is projected and what is left, with cost_type saying whether it is labor, material or other. Costs the job carries outside any budgeted line are rows of their own (unbudgeted_labor, unbudgeted_expense, unbudgeted_purchase_order). It covers the jobs in progress, or with job_status completed the jobs completed from from through to by the profitability report's rule. Narrow with customer_id or project_id. Returns one page of rows (limit, default 25, at most 100); pass next_cursor back as after for the next page. summary totals the whole report, not only the page. For the whole report as a spreadsheet, use the reports.export tool.
reports.sales_revenue
ChatGPTEvery project with its estimate figures, as the app's Sales Revenue report shows them: the projected, published and approved totals Projul saved on the project's estimate, and the pre-tax, tax and rejected figures summed over its versions. It is revenue only: it carries no costs and no profit, so it cannot say which jobs made money. A lead with no estimate shows its own estimated value as estimate_total. search matches text in the project number, name, client name, status, lead source, project type, people and document number. sold_from and sold_to keep only projects sold in that range. Returns one page of rows (limit, default 25, at most 100); pass next_cursor back as after for the next page. summary totals the whole report, not only the page. For the whole report as a spreadsheet, use the reports.export tool.
reports.time_logs
ChatGPTTime logs that started from from through to, as the app's Time Log report shows them, with regular, overtime and other hours, the log's pay rate and its pay. Pay is the log's own rate, times its overtime multiplier (1.5 when it has none) on overtime only, so it can differ from the labor cost the job reports charge. Hours are the clock in whole minutes, as the app shows them: seconds are dropped and a log still clocked in counts up to now. Narrow with project_id, user_id (the worker) and task_type (repeat it for several). Rows carry no location data. Returns one page of rows (limit, default 25, at most 100); pass next_cursor back as after for the next page. summary totals the whole report, not only the page. For the whole report as a spreadsheet, use the reports.export tool.
reports.unpaid_invoices
ChatGPTInvoices with money owed on as_of (default today in the company's time zone), aged by due date the way the app's Unpaid Invoices report ages them: due on or after as_of is current, then 1 to 30, 31 to 60, 61 to 90 and more than 90 days past due. For today or a later day the invoices and amounts are exactly the app's. For a past day the amount owed is the invoice total less the settled payments dated on or before it, so an invoice paid by its payments or closed since then still shows what was owed that day; an invoice dated after it is left out. Projul keeps no dated history of voiding or sending, so an invoice voided since a past day is not listed for it. An invoice with no due date is owed but in no bucket; summary.no_due_date holds it. Narrow with customer_id. Returns one page of rows (limit, default 25, at most 100); pass next_cursor back as after for the next page. summary totals the whole report, not only the page. For the whole report as a spreadsheet, use the reports.export tool.
reports.wip
ChatGPTSold jobs still in progress, as the app's Work in Progress report shows them, each with its estimate, its costs to date (labor, expenses and purchase-order actuals), percent complete, earned revenue, what has been billed and what is left to bill. Projul figures every number the way the app does, so repeat them rather than recomputing them. A job with no approved estimate version (has_contract false) earns what it billed. income_wip is earned revenue less billed to date: positive means the job is under-billed. Narrow to one job with project_id. For jobs already completed, use the profitability report. Returns one page of rows (limit, default 25, at most 100); pass next_cursor back as after for the next page. summary totals the whole report, not only the page. For the whole report as a spreadsheet, use the reports.export tool.
search
ChatGPTUse this first when the person names a client, project, estimate, task or file and you do not have its id, then open the match with that resource's get tool. Searches the account's clients, projects and leads, estimates, tasks and files for q and returns one list of matches. Each row says what kind of record it is (object), carries that record's own id, and a title and subtitle to recognise it by. Open a match with its resource's own read: customers.get, projects.get, estimates.get, tasks.get or files.get. Order. Results are grouped by type, in the order clients, projects, estimates, tasks, files, and ranked by relevance within each type. Relevance from one type is not comparable with another, so types are never interleaved. Narrow the search with types to reach the type you want sooner. What is matched. Clients, projects, estimates and tasks match the way the Projul app's own search matches them: names, numbers, addresses, phone numbers, emails and notes. Files match on their display name, file name and note, where every word of q must appear (the first eight words count), and a file also matches when its project or client matched. So "Johnson kitchen photos" finds the photos on the Johnson project. What is never returned. Every match is checked against the same rules the resource's own read applies, so search returns nothing that read would refuse: no project or schedule templates, no deleted records, no archived clients, no estimate templates and no files that are deleted, removed in the app, or still uploading. An estimate matched through several of its versions is listed once, as the estimate, on its page and on the pages after it; a search paged past 100 estimates may list an older one again. Paging. A page holds at most limit results across all types. Pass next_cursor back as after with the same q and types. A page can hold fewer than limit results while has_more is still true. Only the first 10,000 matches of each type can be reached, so narrow q rather than paging that far. When search itself cannot run, the request fails with 503 search_unavailable; it is never answered with an empty list.
section_templates.create
ChatGPTCreates a section template with its line items in one request. Line elements must NOT carry an id on a create — ids are assigned by Projul.
section_templates.delete
ChatGPTDeletes a section template and its lines. Requires the version argument.
section_templates.get
ChatGPTReturns one section template with its line items, in persisted display order. Preserve that order verbatim when you copy the section into an estimate: it is the order the document prints in.
section_templates.list
ChatGPTReturns the tenant's section library, newest first, using cursor pagination. Each section carries its line items in persisted display order. Assemblies are section templates: a section with is_assembly_on true. The units and unit_type are DISPLAY metadata; the section total is still the sum of its line prices, not units × a unit rate.
section_templates.update
ChatGPTApplies a JSON Merge Patch (RFC 7396): omitted fields are left untouched, an explicit null clears. `line_items` is wholesale. If you send the array it is the DESIRED set — an element with an id updates that line, an element without one adds a line, and a line you omit is DELETED from the template. Omit the array entirely to leave the lines alone. Requires the version argument with the section template's current version. The version covers the section AND its lines, so editing any line moves it.
tags.get
ChatGPTReturns one tag. Read its type before you use it. Projul stores several unrelated taxonomies in this one table, and only a labor or materials tag is a valid line-item category_id — the rest are document templates, whose description holds the template BODY rather than a label. Disabled tags still resolve, so a category already assigned to a line always has a name.
tags.list
ChatGPTReturns the tenant's whole tag table — every tag type, unfiltered. Projul stores several unrelated taxonomies in one table and tells them apart with type. Narrow with ?type= rather than assuming: ?type=labor,materials is the cost-category subset, which is the only subset a line item's category_id accepts. On the document-template types (*_intro_templates, *_contract_templates, purchase_order_*) the description field holds the template BODY — the tenant's own stored prose, usually rich text. Disabled tags are still returned, so a category already assigned to a line always resolves to a name.
tasks.complete
ChatGPTSets a task's completion state. This is declarative, not a toggle: completed states the state you want, so sending the same request twice leaves the task in the same state rather than flipping it back. A repeating task completes ONE occurrence at a time. recurrence_date is required there (the day of the occurrence, YYYY-MM-DD) and is refused on a non-repeating task. Completing a non-repeating task also completes ALL of its steps, and un-completing it un-completes them — that is Projul's own behaviour and it is stated here so it is never a surprise. Completing an occurrence of a repeating task leaves its steps alone. Side effect, gated by the project's current stage: completing an EstimateMeeting advances a lead at new_lead / lead_pending_contact / lead_pending_schedule to lead_pending_estimate, and one at lead_pending_estimate to lead_pending_estimate_creation. Completing an EstimateToWrite advances a lead ONLY from lead_pending_estimate_creation to lead_pending_estimate_to_be_sent; at any other stage the task still completes but the stage is unchanged. This mirrors the Projul app. The completion response returns the task, not the project, so read the project's stage/status back to confirm a transition. Three account settings can refuse the completion outright, with 422 validation_failed and a code on /completed. They are the same rules the Projul app applies, where they remove the mark-complete control rather than reporting an error, so a refusal here means a Projul user could not have done it either: - follow_up_action_required — the account requires an action on a follow-up reminder instead of a completion ("Require action to complete follow-ups"). - steps_required — the account requires a task that HAS steps to be finished step by step ("Require steps for task completion"). A task with no steps is unaffected. - time_off_completion_disabled — the account does not allow TimeOff or AbsentWorker events to be completed ("Enable time off events completion"). All three refuse completed: false as well as completed: true, because the app withdraws the whole control rather than just the completing half. Two of them apply to accounts that have never touched the setting: an unsaved "Require action to complete follow-ups" reads as ON, and an unsaved "Enable time off events completion" reads as OFF, which is what the app itself defaults them to. No idempotency_key: the call is replay-safe by construction. version is optional and honoured: send the version you read and the completion is refused with 412 stale_version if the task changed in between.
tasks.create
ChatGPTCreates a task — a to-do, a deadline, a meeting, time off, or a repeating series. task_type is required; there is no default, because the type changes what Projul does. status defaults to Active. Scheduling is `assignments`. A task with no assignments is real and normal (an unscheduled to-do). Each assignment is one window with the people on it; there is no assignment endpoint, and on create you send each window inline with no id. A window with a starts_at and no ends_at, left out or sent as null, ends 2 hours after it starts. `steps` is the checklist. Send steps without ids to create them; array position is their order. A repeating task cannot carry steps — see tasks.update. Repeating tasks carry the rule, never the occurrences. Set is_recurring: true and send recurring_rule as the iCal string Projul stores (DTSTART:20260805T140000Z\r\nRRULE:FREQ=WEEKLY;BYDAY=MO). Projul does not materialize occurrences and this API does not expand them; expand client-side from the rule. Side effects you should use deliberately (they are the app's, not this API's): - Creating an EstimateMeeting task on a lead-stage project advances that lead to "pending estimate" and assigns the people on the task as estimators. Scheduling an estimate meeting is how you move a lead forward. The create response returns the task, not the project, so re-read the project's stage/status back to confirm the transition. - TimeOff tasks may be created tentative, depending on the tenant's settings. - An EstimateMeeting queues reminders for its assigned workers, but never the customer's reminder emails unless you send notify_customer: true. idempotency_key is required: a retry after an ambiguous network failure returns the ORIGINAL task rather than creating a second one.
tasks.delete
ChatGPTDeletes a task. Its schedule goes with it, and its queued reminders are cancelled. Time logs recorded against the task are NOT deleted — they survive, detached from the task. A deleted task is gone from tasks.list immediately and its id 404s from then on; deleting it again is another 404. version is optional and honoured: send the version you read and the delete is refused with 412 stale_version if the task changed in between. Worth sending — this removes the task's whole checklist and schedule, and tasks.update requires a precondition to touch either of them.
tasks.get
ChatGPTReturns one task with its assignments, steps and recurrence inline. Recurrence is the raw iCal rule the app stores — recurring_rule is a DTSTART:…\r\nRRULE:FREQ=… string, returned byte-for-byte. Projul never materializes the individual occurrences and this API never expands them: expand client-side from the rule, and treat excluded_dates as the days the series skips. version covers the task row and its current assignments and steps, so a colleague dragging a schedule bar or editing a step in the Projul app invalidates the token you are holding. That is deliberate — it is what stops an integration from silently overwriting someone's work.
tasks.list
ChatGPTReturns the tenant's tasks, newest first, using cursor pagination. Each task carries its whole schedule and checklist inline: assignments (the scheduled windows and who is on them) and steps. There are no assignment or step endpoints — read the task, edit the arrays, write the task back. Scheduling on a lead is scheduling on a project. A lead is a project in a lead-stage status, so filter with that project's prj_ id; there is no separate lead-task concept. Deleted tasks are never returned.
tasks.update
ChatGPTPartially updates a task using JSON Merge Patch semantics (RFC 7396): an omitted field is untouched, an explicit null clears it. `assignments` is an ID-KEYED MERGE. Restate a window by its id to change only the members you send (omitted members keep their stored values), omit the id to add a window, and leave a window out of the array to remove it; [] clears the schedule. An id that is not one of this task's windows is 422 validation_failed. Assignment ids are stable only within a read-then-write cycle, because Projul recreates the rows on every save, so re-read after any schedule change to get fresh ids. One member works differently: a restated window whose starts_at moves with no ends_at member keeps its length rather than its old end. Any window saved with a starts_at and no end, ends_at: null included, ends 2 hours after it starts, so a window with a start cannot be left without an end. `steps` is an ID-KEYED MERGE. Send a step's id back to update it (its completion state survives), omit the id to add a step, and leave a step out of the array to remove it. Array position sets the order. An id that is not one of this task's steps is 422 validation_failed — it is never silently turned into a new step. `version` is required on this tool: send the version from your most recent read. A stale one is 412 stale_version, which means someone moved that schedule in Projul since you read it — re-read and re-apply rather than overwriting their work. The guard matters most when the call carries assignments or steps, because those are the collection writes. Steps and repeating tasks do not mix: any steps write against a repeating task (or a request that makes the task repeating in the same call) is 409 steps_not_supported_on_recurring, and nothing is written. On a repeating task, step completion is per-occurrence state this API does not expose, so its steps read as template text. Writable fields: name, description, details, project_id, color, icon, task_type, status, is_recurring, recurring_rule, excluded_dates, assignments, steps, pay_item, external_ref. excluded_dates is a wholesale replace. completed_at is read-only — completion state moves through the tasks.complete tool, never through this one.
taxes.create
ChatGPTCreates a company tax rate and returns it. The rate is created ENABLED, so it is immediately selectable as a version's tax_id or a line's applied_tax_id on an estimate write. The name is trimmed and required, at most 255 characters. Duplicate names are allowed: the app keeps a retired rate and its replacement as separate rows, so this API does not refuse a repeat. percent is required, is a PERCENT rather than a fraction (9.1 means 9.1%), must be zero or greater, carries at most five decimal places, and has no upper cap. Editing or retiring a rate is not offered here. Changing a rate that documents already point at retires the old row and creates a new one, so that stays an in-app action; a signed estimate keeps the rate it was signed at.
taxes.get
ChatGPTReturns one tax rate. Retired rates still resolve, so the rate an estimate was priced at always has a name and a percent — which is the whole point of publishing tax_id on a version and applied_tax_id on a line.
taxes.list
ChatGPTReturns the tenant's tax-rate library: the rows an estimate version and a cost line point at, and the value space of the rates an estimate reports. Disabled rates are still returned, and that matters more here than it does for tags and trades: changing a rate that documents already use does not edit the row, it retires the row and creates a new one, so a signed estimate keeps the rate it was signed at. Filter on is_disabled yourself to build a picker. A new rate can be created here; editing and retiring a rate stay in-app. No endpoint on this API sets tax on an estimate: set the rate on the version for an estimate-level document, or on each taxable line for a per-line one.
teams.create
ChatGPTCreates a team (worker group), optionally seeding its roster with member_user_ids. A team IS a worker-group tag, so the created team's id is a tag_ id the tags surface also serves; this resource adds the membership join that surface cannot express. Only worker groups are creatable here. The rest of the tag taxonomy is read-only. The name is trimmed and must be unique among the tenant's active teams. A duplicate name is refused with 422 validation_failed on /name, and nothing is created. Each member_user_ids entry must resolve to an active employee or subcontractor of the tenant; anything else is 422 validation_failed with a pointer to the offending id, and nothing is created. Populate a team's roster before assigning it to a task. An assignment to a team with no members is refused, so a memberless team is not yet usable in task.assignments[].team_ids.
teams.delete
ChatGPTDeletes a team (worker group). This does not touch its people. A team member is an employee or subcontractor who carries this worker group; deleting the team only removes the group itself, and every person who was on it keeps their record. It also leaves existing schedules alone: a task assignment stores the specific people who were on it, not the team, so a task staffed from this team earlier keeps those people. The only effect on scheduling is forward — the team is no longer offered as a team_ids value on new writes. On success nothing is returned, and teams.get on the id answers 404 not_found from then on.
teams.get
ChatGPTReturns one team (worker group) with its roster. The id is the worker group's tag_ id, the same one the tags.get tool returns.
teams.list
ChatGPTReturns the tenant's teams (worker groups), newest first, each with its roster as member_user_ids — the union of active employees and active subcontractors in the group. A team IS a worker group. Its id is the same tag_ id the tags.list tool returns; this tool adds the membership join the tags surface cannot express. To assign a whole team to a task, send its id in task.assignments[].team_ids, which expands to these members at write time.
teams.update
ChatGPTUpdates a team. Send name to rename it, member_user_ids to replace its roster, or both in one call. A field you omit is left unchanged, so a rename never touches the roster and a roster change never touches the name. A body carrying neither field is 422 validation_failed. Rename. name is trimmed and must be unique among the tenant's active teams. A blank name, or one already used by another active team, is 422 validation_failed on /name. Roster. The member_user_ids you send become the team's members, exactly: an id already on the team stays, one you drop is removed, and one you add is added. Send an empty array to clear the roster. Each entry must resolve to an active employee or subcontractor of the tenant; anything else is 422 validation_failed with a pointer to the offending id. On any refusal nothing is changed. An unknown, cross-tenant, deleted or non-worker-group id is 404 not_found.
time_logs.create
ChatGPTRecords one finished shift for an employee, as a manual entry: user_id, started_at and ended_at are required, and the log is created unapproved. There is no clock in or clock out through this API. Pay is never sent. Name a payroll_item_id from payroll_items.list; the rate is the employee's own rate for that item, else the item's default. With no payroll item, Projul uses the task's, the project's, the employee's or the account's default, as the app does. Refused with 422 validation_failed when the account turned off manual time entries, when ended_at is not after started_at or the shift is longer than 9,999.99 hours, or when task_id belongs to a different project than project_id. An overlap with the employee's other time is 409 time_log_overlap, and an external_ref another live log already carries is 409 duplicate_external_ref with that log in existing_id. A new entry inside a signed day or pay period voids that signature, and a voided supervisor signature unapproves the time it had approved. The response says so in sign_offs_voided and reverted_time_log_ids. Confirm with the person before adding time to a period that may already be signed. idempotency_key is required: a retry returns the original log rather than a second one.
time_logs.delete
ChatGPTDeletes a time log. version is required. Deleting time inside a signed day or pay period voids that signature, and a voided supervisor signature unapproves every log it approved, so confirm with the person first. The result reports sign_offs_voided and reverted_time_log_ids; a log already in QuickBooks stays there (quickbooks_synced).
time_logs.get
ChatGPTReturns one time log. version changes with every save, in the app or here: send it as version on time_logs.update or time_logs.delete.
time_logs.list
ChatGPTReturns the tenant's time logs, newest first, using cursor pagination. Deleted logs are never returned. Each log carries its hours, time type, payroll item and pay rate, its approval state, and quickbooks_synced. No location data is returned. hours is the figure Projul pays and costs: a typed duration when one was entered in the app, otherwise the clock. A log with ended_at: null is a shift still running. Filter by from/to on started_at for a pay period, and approved=true for payroll-ready time.
time_logs.update
ChatGPTPartially updates a time log using JSON Merge Patch semantics (RFC 7396): an omitted field is untouched, an explicit null clears it. version is required: send the version from your most recent read. A stale one is 412 stale_version, which means someone changed the log since you read it. Correcting approved or signed time reopens it. A change to user_id, started_at, ended_at, project_id, task_id, time_type or payroll_item_id unapproves the log and voids every day or pay-period signature covering its old and new start time; when a voided signature was a supervisor's, every log it approved is unapproved too, which can reopen the whole signed week. The response reports approval_reverted, sign_offs_voided and reverted_time_log_ids. Changing only notes, costline_id or external_ref leaves approval and signatures alone. Confirm with the person before editing approved or signed time. A log already pushed to QuickBooks (quickbooks_synced: true) reopens like any other, and QuickBooks keeps the hours it was sent until the log is approved and synced again. Moving the time to another payroll item or employee re-prices it from that item, at the employee's own rate for it. Sending payroll_item_id: null, or moving a log that has no item to another employee, re-prices it from the defaults a new entry uses (the task's, the project's, the employee's, then the company's default payroll item), so the log can come back set to that default item. If no default prices it, the item stays empty and the existing rate is kept. Moving the clock drops a duration typed in the app, so hours follows the new clock. An ended log cannot be reopened.
trades.get
ChatGPTReturns one estimating trade — the discipline that performs a line of work. Archived and disabled trades still resolve, so a trade already assigned to a line always has a name.
trades.list
ChatGPTReturns the tenant's estimating trades. Archived and disabled trades are still returned, so a trade already assigned to a line always resolves to a name.
users.get
ChatGPTReturns one assignable person — an active employee or an active subcontractor. type says which. The id is the same user_ id a task assignment carries.
users.list
ChatGPTReturns the tenant's assignable people, newest first, using cursor pagination: the union of active employees and active subcontractors. Every id this returns is assignable on a task — task.assignments[].user_ids accepts exactly this set, and nothing outside it. Narrow with ?type=employee or ?type=subcontractor, and with ?team_id=<tag_> to list one worker group's members, and with ?email= to find the person an email address names. Use updated_since to sync incrementally.
vendors.create
ChatGPTCreates a vendor, or returns the existing one. This tool never manufactures a duplicate supplier. Send at least one of company_name, first_name or last_name; the Projul app asks for the same. When company_name is present, Projul first looks for a live vendor with the same company name (ignoring case) and the same first-listed email; if none, the same company name and the same first-listed phone number, compared on digits only. - A match returns matched: true and the vendor as stored. Nothing else you submitted is applied to it (see external_ref below); use vendors.update to change it. - No match creates the vendor and returns matched: false. - Two or more matches are `409 ambiguous_match` and nothing is created. - A match that is archived is `409 resource_archived`. No tool unarchives a vendor: ask the person to restore it in Projul, then retry with the same idempotency_key. external_ref is unique among the tenant's live vendors: a create naming one another vendor already holds is 409 duplicate_external_ref, carrying that vendor's id in existing_id; when that vendor is archived the detail says so. A vendor bound to a different external_ref is a different supplier and is never matched. When a match has no external_ref and you sent one, it is stored on the matched vendor and the result shows it.
vendors.get
ChatGPTReturns one active vendor. An archived, deleted, unknown, or other-tenant id is the same 404 not_found.
vendors.list
ChatGPTReturns the tenant's active vendors, newest first, using cursor pagination. Archived and deleted vendors are not listed. Use updated_since to keep an external system in step with Projul, external_ref to find the vendor you created for a row in your own system, and company_name to look a supplier up by name.
vendors.update
ChatGPTPartially updates an active vendor with JSON Merge Patch semantics (RFC 7396): a field you omit is left untouched, and a field you send as null is cleared. Each collection (emails, phones, additional_addresses) is replaced wholesale when sent and untouched when omitted. The vendor must keep at least one of company_name, first_name or last_name. A new external_ref that another live vendor holds is 409 duplicate_external_ref. version is optional and honoured: send the version you read and the update is refused with 412 stale_version if someone changed the vendor in between.
customers.archive
Claudecustomers.create
Claudecustomers.delete
Claudecustomers.get
Claudecustomers.list
Claudecustomers.unarchive
Claudecustomers.update
Claudediscounts.get
Claudediscounts.list
Claudeestimate_templates.add_line_items
Claudeestimate_templates.add_sections
Claudeestimate_templates.create
Claudeestimate_templates.delete
Claudeestimate_templates.delete_line_items
Claudeestimate_templates.delete_sections
Claudeestimate_templates.get
Claudeestimate_templates.list
Claudeestimate_templates.update
Claudeestimate_templates.update_line_items
Claudeestimate_templates.update_sections
Claudeestimates.add_line_items
Claudeestimates.add_sections
Claudeestimates.create
Claudeestimates.create_version
Claudeestimates.delete
Claudeestimates.delete_line_items
Claudeestimates.delete_sections
Claudeestimates.delete_version
Claudeestimates.get
Claudeestimates.list
Claudeestimates.update
Claudeestimates.update_line_items
Claudeestimates.update_sections
Claudeestimates.update_version
Claudefiles.complete_upload
Claudefiles.create_upload
Claudefiles.delete
Claudefiles.get
Claudefiles.get_download_url
Claudefiles.list
Claudefiles.refresh_upload_url
Claudefiles.update
Claudelead_sources.get
Claudelead_sources.list
Claudeline_item_templates.create
Claudeline_item_templates.delete
Claudeline_item_templates.get
Claudeline_item_templates.list
Claudeline_item_templates.update
Claudemessages.create
Claudemessages.delete
Claudemessages.get
Claudemessages.list
Claudemessages.update
Claudeproject_templates.list
Claudeprojects.create
Claudeprojects.delete
Claudeprojects.get
Claudeprojects.list
Claudeprojects.update
Claudesection_templates.create
Claudesection_templates.delete
Claudesection_templates.get
Claudesection_templates.list
Claudesection_templates.update
Claudetags.get
Claudetags.list
Claudetasks.complete
Claudetasks.create
Claudetasks.delete
Claudetasks.get
Claudetasks.list
Claudetasks.update
Claudetaxes.create
Claudetaxes.get
Claudetaxes.list
Claudeteams.create
Claudeteams.delete
Claudeteams.get
Claudeteams.list
Claudeteams.update
Claudetrades.get
Claudetrades.list
Claudeusers.get
Claudeusers.list
Claude