# Chronly API Base URL: https://chronly.ca/api Auth: `Authorization: Bearer chronly_api_…` (create an API user in Portal → Settings → Users). Scope: a token belongs to one account. Every request must name the company it acts on via `companyId`. It goes in the query string on GET and DELETE, and usually in the body on POST, PUT and PATCH — but a number of write endpoints authorise before reading the body and need it in the QUERY STRING even so (all of scheduling, every /jobs/{jobId}/* sub-route, quote send and schedule-send, and the multipart uploads). Each endpoint's parameter table is authoritative. Find the value in the portal under Settings → API. ## Errors - **400** — A required field is missing or fails validation, or companyId was not supplied. - **401** — No session cookie and no valid bearer token, or the token is not in chronly_api_ form. - **403** — The API user lacks permission on the module being called, or the account's plan does not include it. - **404** — The record exists in no company the token can reach, the id is wrong, or companyId is not a company this token owns. - **409** — The write collides with an existing record - a duplicate externalId or customer email. - **500** — Unhandled server error. Safe to retry idempotent reads. ## Invoices Create, send, schedule, update and delete invoices, record payments against them, and read their email and view activity. Invoice numbering, dates and every money field are computed server-side. ### GET /invoices Returns every invoice for the company, with recurring templates appended to the same array. Tell them apart by the presence of recurringInvoiceId. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "invoices": [ { "invoiceId": "a1b2c3d4e5f6", "invoiceNumber": 1042, "title": "Invoice", "customer": { "value": "byr_8f3k", "label": "Northline Roofing" }, "status": "sent", "invoiceDate": "2026-08-16", "paymentDue": "2026-09-15", "subTotal": 2400, "totalTax": 120, "totalAmount": 2520, "totalPaid": 0, "remainingBalance": 2520 } ] } ``` ### POST /invoices Creates a draft invoice. Send the customer as a buyerId and the line items as name/quantity/price — the invoice number, dates, title, line totals and every money field are derived server-side. The invoice is created in draft; use POST /invoices/{invoiceId}/send to email it. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. - `customer` (string · required) - The buyerId of an existing customer in this company. Returns 404 if the buyer belongs to another company. The legacy { label, value } object is still accepted. - `items` (array · required) - Line items. Each needs name and price; quantity defaults to 1. Note the field is items, not lineItems. - `items[].name` (string · required) - What the line is for. description is accepted as an alias. - `items[].price` (number · required) - Unit price. rate is accepted as an alias. - `items[].quantity` (number) - Defaults to 1. - `items[].taxIds` (array of string) - Tax rates to charge on this line, by taxId from GET /taxes. Each tax is charged only on the lines that carry it, so a mixed taxable and non-taxable invoice is taxed correctly. Unknown ids return 400. - `items[].productId` (string) - Links the line to a product. - `paymentTerms` (string) - Used to derive paymentDue from invoiceDate. Defaults to the company's invoice default. One of: `on-receipt`, `net-7`, `net-14`, `net-30`, `net-45`, `net-60`, `net-90`. - `paymentDue` (string (YYYY-MM-DD)) - Overrides paymentTerms. Derived when omitted. - `invoiceDate` (string (YYYY-MM-DD)) - Defaults to today. - `invoiceNumber` (number) - Defaults to the company's highest invoice number plus one. - `title` (string) - Defaults to the company's configured invoice title. - `summary` (string) - Shown under the line items. - `invoiceNotes` (string) - Free-text notes on the invoice. - `jobId` (string) - Attaches the invoice to a job on creation and writes the link to the job activity log. - `subTotal / totalTax / totalAmount` (number) - Computed from items and discounts. Supply all three to override; supplying only some is ignored, so a partly-specified invoice can never disagree with its own lines. - `enableReminders` (boolean) - Defaults to whether the company has any enabled invoice reminder templates. Example request body: ```json { "companyId": "cmp_4k9x2m", "customer": "byr_8f3k", "paymentTerms": "net-30", "items": [ { "name": "Site prep", "quantity": 2, "price": 1200, "taxIds": ["tax_gst"] }, { "name": "Permit fee", "quantity": 1, "price": 100 } ] } ``` Response 201: ```json { "invoiceId": "a1b2c3d4e5f6", "invoiceNumber": 1043, "title": "Invoice", "status": "draft", "customer": { "label": "Northline Roofing", "value": "byr_8f3k" }, "invoiceDate": "2026-08-20", "paymentDue": "2026-09-19", "subTotal": 2500, "totalTax": 120, "totalAmount": 2620 } ``` Errors: - **400** - companyId is missing, or an items[].taxIds value is not a tax rate in this company. - **404** - The customer buyerId does not exist in this company. > createdBy and createdByUserId come from the authenticated identity and are ignored if sent. ### GET /invoices/{invoiceId} Returns the invoice together with the buyer record, the company it belongs to, and any purchase records against it. If no invoice matches, the id is retried as a recurring invoice. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - An invoiceId or a recurringInvoiceId. - `companyId` (query · required) - The company the records belong to. Response 200: ```json { "invoice": { "invoiceId": "a1b2c3d4e5f6", "status": "paid" }, "customer": { "buyerId": "byr_8f3k", "emailAddress": "ap@northline.ca" }, "company": { "companyId": "cmp_4k9x2m", "companyName": "Aurora Contracting" }, "purchases": [] } ``` Errors: - **404** - No invoice or recurring invoice with that id in the company. ### PUT /invoices/{invoiceId} Updates a whitelisted set of fields. Anything financial that is derived from payment activity — status, payments, totalPaid, paid, paidAt — is dropped. Sending items without totals recomputes subTotal, totalTax and totalAmount from the new lines, and remainingBalance follows totalAmount. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `invoiceId` (path · required) - The invoice to update. - `companyId` (query · required) - The company the records belong to. Request body: - `customer` (string) - A buyerId, or the legacy { label, value } object. - `items` (array) - Replaces the line items. Same shape as create, including taxIds. Totals are recomputed unless you send them. - `title / summary / invoiceNotes` (string) - Free-text fields. - `invoiceNumber` (number) - Not re-derived on update — an absent value leaves the existing number alone. - `invoiceDate / paymentDue` (string (YYYY-MM-DD)) - Not re-derived on update. - `jobId` (string | null) - Moving the invoice between jobs updates both jobs and logs the change on each. - `attachPdfInvoice` (boolean) - Attach a rendered PDF when this invoice is emailed. - `enableReminders` (boolean) - Whether payment reminders run for this invoice. Example request body: ```json { "jobId": "job_71kd", "paymentDue": "2026-10-01" } ``` Response 200: ```json { "invoiceId": "a1b2c3d4e5f6", "paymentDue": "2026-10-01", "jobId": "job_71kd" } ``` > Rejected fields are logged server-side rather than failing the request, so a 200 does not guarantee every field you sent was applied. ### DELETE /invoices/{invoiceId} Deletes an unpaid invoice. Recurring templates can always be deleted. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - The invoice to delete. - `companyId` (query · required) - The company the records belong to. Response 200: ```json { "success": true } ``` Errors: - **400** - The invoice is paid or partially paid — deleting it would break financial records. ### GET /invoices/search Filtered list, scoped to single invoices only. Recurring templates are excluded. Paid and overdue are derived states, not just stored status values. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. - `search` (query) - Matches customer name or invoice number, case-insensitive. - `statuses` (query · csv) - e.g. sent,viewed,paid. paid also matches settled balances; partially-paid means some payment with a balance left. One of: `draft`, `sent`, `viewed`, `paid`, `partially-paid`, `overdue`, `cancelled`, `refunded`, `scheduled`, `failed`. - `overdue` (query · boolean) - true restricts to past-due, unpaid, non-cancelled invoices. Combines with statuses. - `startDate / endDate` (query (YYYY-MM-DD)) - Range on paymentDue, inclusive. - `minAmount / maxAmount` (query · number) - Range on totalAmount, inclusive. Response 200: ```json { "success": true, "invoices": [ { "invoiceId": "a1b2c3d4e5f6", "totalAmount": 2400 } ] } ``` ### POST /invoices/{invoiceId}/send Emails the invoice and moves it from draft to sent, stamping sentAt and sentBy. Sending an already-sent invoice appends to its email history and leaves the status alone, so this doubles as a resend. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `invoiceId` (path · required) - The invoice to send. Request body: - `companyId` (string · required) - The company the invoice belongs to. - `emailAddresses` (array of string · required) - Recipients. The first is the To address; any others are BCC'd. An empty array sends to nobody and fails at the mail provider. - `message` (string) - Optional note included in the email body. Example request body: ```json { "companyId": "cmp_4k9x2m", "emailAddresses": ["ap@northline.ca"], "message": "Invoice for last week's site prep — thanks!" } ``` Response 200: ```json { "success": true, "message": "Invoice sent successfully" } ``` Errors: - **404** - No invoice with that id in the company. > Whether a PDF is attached is decided by the invoice's attachPdfInvoice flag, falling back to the company setting. ### POST /invoices/{invoiceId}/schedule-send Queues the invoice to be emailed at a future local date and time, and sets its status to scheduled. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `invoiceId` (path · required) - The invoice to schedule. Request body: - `companyId` (string · required) - The company the invoice belongs to. - `sendDate` (string (YYYY-MM-DD) · required) - Local date to send on. - `sendTime` (string (HH:mm) · required) - Local time to send at. - `timezone` (string · required) - IANA zone the date and time are read in, e.g. America/Vancouver. - `emailAddresses` (array of string · required) - At least one recipient. An empty array is rejected with 400. - `message` (string) - Optional note included in the email body. Example request body: ```json { "companyId": "cmp_4k9x2m", "sendDate": "2026-09-01", "sendTime": "09:00", "timezone": "America/Vancouver", "emailAddresses": ["ap@northline.ca"] } ``` Response 200: ```json { "success": true, "jobId": "518", "scheduledFor": "2026-09-01T16:00:00.000Z" } ``` Errors: - **400** - sendDate, sendTime or timezone is missing, emailAddresses is empty, the date format is invalid, or the moment is in the past. - **404** - No invoice with that id in the company. ### DELETE /invoices/{invoiceId}/cancel-scheduled-send Removes the queued job and returns the invoice to draft, clearing its scheduled metadata. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - The invoice whose scheduled send should be cancelled. - `companyId` (query · required) - The company the invoice belongs to. Response 200: ```json { "success": true, "message": "Scheduled send cancelled" } ``` Errors: - **404** - The invoice has no active scheduled send. ### POST /invoices/{invoiceId}/update-status Sets the status directly, validated against the schema enum. Cancelling an invoice that was converted from a quote releases any deposit applied to it back to a liability. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `invoiceId` (path · required) - The invoice to update. Request body: - `companyId` (string · required) - The company the invoice belongs to. - `status` (string · required) - The new status. One of: `draft`, `sent`, `viewed`, `paid`, `overdue`, `cancelled`, `refunded`, `scheduled`, `failed`. Example request body: ```json { "companyId": "cmp_4k9x2m", "status": "cancelled" } ``` Response 200: ```json { "invoice": { "invoiceId": "a1b2c3d4e5f6", "status": "cancelled" } } ``` Errors: - **400** - The status is not one of the allowed values. - **404** - No invoice with that id in the company. > This changes the stored status ONLY. Setting it to sent does NOT email anything — use POST /invoices/{invoiceId}/send for that. ### POST /invoices/{invoiceId}/record-payment Records a payment taken outside Chronly — cash, cheque, e-transfer — against the invoice, recalculates totalPaid and remainingBalance, and flips the invoice to paid once the balance settles. A matching purchase record is written so the payment appears in the books. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `invoiceId` (path · required) - The invoice being paid. Request body: - `companyId` (string · required) - The company the invoice belongs to. - `paymentType` (string · required) - How the money arrived. One of: `cash`, `cheque`, `e-transfer`, `bank-transfer`, `credit-card-manual`, `other`. - `amount` (number · required) - Must be greater than zero. Rounded to two decimals. - `paymentDate` (string (YYYY-MM-DD) · required) - Cannot be in the future. - `cardBrand` (string) - Only meaningful with credit-card-manual. One of: `visa`, `mastercard`, `amex`, `discover`. - `depositToLedgerAccountId` (string) - The bank or credit-card ledger account the money landed in. Omit for cash and cheques, which stay in Undeposited Funds until a deposit banks them. - `referenceNumber` (string) - Cheque number, e-transfer reference, and so on. - `notes` (string) - Free-text note stored on the payment. Example request body: ```json { "companyId": "cmp_4k9x2m", "paymentType": "cheque", "amount": 2620, "paymentDate": "2026-08-20", "referenceNumber": "00418" } ``` Response 200: ```json { "success": true, "invoice": { "invoiceId": "a1b2c3d4e5f6", "status": "paid", "totalPaid": 2620, "remainingBalance": 0 } } ``` Errors: - **400** - paymentType, amount or paymentDate is missing, the amount is not positive, the date is in the future, or depositToLedgerAccountId is not a live bank or credit-card account in this company. - **404** - No invoice with that id in the company. ### GET /invoices/{invoiceId}/email-events Every delivery event the mail provider reported for this invoice — processed, delivered, open, click, bounce, dropped — newest first. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - The invoice. - `companyId` (query · required) - The company the invoice belongs to. Response 200: ```json { "success": true, "data": [ { "eventType": "delivered", "email": "ap@northline.ca", "timestamp": "2026-08-20T15:04:11.000Z" } ] } ``` ### GET /invoices/{invoiceId}/email-stats The same email events rolled up into counts and rates. Rates are percentages, and are 0 when the denominator is 0. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - The invoice. - `companyId` (query · required) - The company the invoice belongs to. Response 200: ```json { "success": true, "data": { "processed": 1, "delivered": 1, "opened": 1, "clicked": 0, "bounced": 0, "dropped": 0, "totalEvents": 3, "openRate": 100, "clickRate": 0, "deliveryRate": 100, "bounceRate": 0, "uniqueRecipients": 1 } } ``` ### GET /invoices/{invoiceId}/view-stats How often the customer-facing invoice page was viewed, from page-view tracking. Portal views are excluded, so this reflects the customer rather than your own team. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - The invoice. - `companyId` (query · required) - The company the invoice belongs to. Response 200: ```json { "success": true, "data": { "pageViews": 4, "clicks": 1, "conversions": 0, "uniqueVisitors": 2 } } ``` ### GET /invoices/{invoiceId}/tracking-events The raw tracking events behind the view stats, newest first. Unlike view-stats this includes portal views. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - The invoice. - `companyId` (query · required) - The company the invoice belongs to. Response 200: ```json { "success": true, "data": [ { "type": "page-view", "pathname": "/pdf-invoice/a1b2c3d4e5f6", "createdAt": "2026-08-20T15:31:02.000Z" } ] } ``` ### GET /invoices/{invoiceId}/delivery-issues Bounces, drops and spam reports for this invoice — the subset of email events that mean the customer did not receive it. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - The invoice. - `companyId` (query · required) - The company the invoice belongs to. Response 200: ```json { "success": true, "data": [ { "eventType": "bounce", "email": "ap@northline.ca", "reason": "550 5.1.1 unknown recipient" } ] } ``` ### POST /invoices/recurring Creates a recurring invoice template. Templates live in their own collection keyed by recurringInvoiceId, and are returned by GET /invoices alongside ordinary invoices. Creating the template does not start it — set its schedule with PUT /invoices/{recurringInvoiceId}/recurring/schedule. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the template in. - `customer` (string · required) - The buyerId of an existing customer. The legacy { label, value } object is still accepted. - `items` (array · required) - Line items copied onto each generated invoice. Same shape as POST /invoices, including taxIds. - `title` (string) - Defaults to the company's configured invoice title. - `subTotal / totalTax / totalAmount` (number) - Computed from items. Supply all three to override. - `paymentSettings` (object) - Overrides the company payment defaults, which are used when omitted. Example request body: ```json { "companyId": "cmp_4k9x2m", "customer": "byr_8f3k", "items": [{ "name": "Monthly retainer", "quantity": 1, "price": 900 }] } ``` Response 201: ```json { "recurringInvoiceId": "K2mQ81xVn4Bd7Lp", "status": "draft", "companyId": "cmp_4k9x2m", "totalAmount": 900 } ``` Errors: - **403** - The account does not have the recurringInvoices capability. The body is { "error": "subscription_required", "capability": "recurringInvoices" }. - **404** - The customer buyerId does not exist in this company. > invoiceNumber is never stored on a template — each generated invoice takes the next number at the moment it is created. ### PUT /invoices/{invoiceId}/recurring/schedule Attaches a recurrence to a recurring invoice template and activates it. The path id is a recurringInvoiceId. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `invoiceId` (path · required) - The recurringInvoiceId of the template. Request body: - `companyId` (string · required) - The company the template belongs to. - `startDate` (string (YYYY-MM-DD) · required) - First occurrence. - `recurrenceType` (string · required) - How often invoices are generated. One of: `daily`, `weekly`, `monthly`, `yearly`. - `timezone` (string · required) - IANA zone the schedule runs in. - `sendTime` (string (HH:mm) · required) - Local time of day to generate and send. - `weeklyDay` (string · required for weekly) - Day of week, 0 = Sunday. One of: `0`, `1`, `2`, `3`, `4`, `5`, `6`. - `monthlyDay` (number · required for monthly) - Day of month, 1-31. - `yearlyMonth` (number · required for yearly) - Month, 1-12. Required together with yearlyDay. - `yearlyDay` (number · required for yearly) - Day of month. Required together with yearlyMonth. - `endDate` (string (YYYY-MM-DD)) - Must be after startDate. Runs indefinitely when omitted. - `emailAddresses` (array of string) - Recipients for each generated invoice. Each must be a valid address. Example request body: ```json { "companyId": "cmp_4k9x2m", "startDate": "2026-09-01", "recurrenceType": "monthly", "monthlyDay": 1, "timezone": "America/Vancouver", "sendTime": "09:00", "emailAddresses": ["ap@northline.ca"] } ``` Response 200: ```json { "success": true, "nextRun": "2026-09-01T16:00:00.000Z" } ``` Errors: - **400** - A required field is missing, the recurrence-specific field for the chosen recurrenceType is absent, the timezone or sendTime is invalid, an email address is malformed, or endDate is not after startDate. - **403** - The account does not have the recurringInvoices capability. - **404** - No recurring invoice with that id in the company. ### PATCH /invoices/{invoiceId}/recurring/schedule Stops or restarts generation without discarding the schedule. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `invoiceId` (path · required) - The recurringInvoiceId of the template. Request body: - `companyId` (string · required) - The company the template belongs to. - `action` (string · required) - What to do to the schedule. One of: `pause`, `resume`. Example request body: ```json { "companyId": "cmp_4k9x2m", "action": "pause" } ``` Response 200: ```json { "success": true, "status": "paused" } ``` Errors: - **400** - action is not pause or resume. - **404** - No recurring invoice with that id in the company. ### DELETE /invoices/{invoiceId}/recurring/schedule Cancels the recurrence and removes its queued jobs. The template itself survives and can be rescheduled. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `invoiceId` (path · required) - The recurringInvoiceId of the template. - `companyId` (query · required) - The company the template belongs to. Response 200: ```json { "success": true } ``` Errors: - **404** - No recurring invoice with that id in the company. ### GET /taxes/company/{companyId} The company's tax registry. Use the taxId values here for items[].taxIds when creating an invoice — without this endpoint that parameter is unusable, since the ids appear nowhere else. Soft-deleted rates are omitted. The response is a bare array, not an object. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (path · required) - The company whose tax rates to list. There is no query parameter on this endpoint — the company goes in the path. Response 200: ```json [ { "taxId": "tax_gst", "name": "GST", "rate": 5, "taxType": "gst-hst" }, { "taxId": "tax_pst", "name": "PST", "rate": 7, "taxType": "pst" } ] ``` Errors: - **404** - The company is not one this token's account owns. > Needs no module grant — any token or session for the company can read it, because tax rates are shared by invoices, quotes and events. ## Checkouts Hosted payment pages. This is the module built for token access first: it accepts a bearer token and arbitrary metadata you can read back later. ### GET /checkout Returns all checkouts for the company, newest first, including drafts and duplicates. Ad-hoc checkouts with a maxPurchases cap also carry live purchase counts. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "checkouts": [ { "checkoutId": "Xk92mLp01qZa", "name": "Deposit — 2026 season", "checkoutMode": "ad-hoc", "price": 500, "currency": "CAN", "isActive": true, "maxPurchases": 25, "currentPurchaseCount": 11, "cancelledPurchaseCount": 1 } ] } ``` ### POST /checkout Creates a checkout page. Payment methods fall back to the company defaults when omitted, and a checkout with an expiry and an expiry webhook schedules that webhook at creation time. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. Required for token auth. - `name` (string · required) - Internal name for the checkout. - `type` (string · required) - Checkout type. Accepts a raw string or { value }. One of: `physical`, `digital`, `service`, `professional_service`, `taxable_service`, `tax_exempt`, `bundle`. - `price` (number · required) - Unit price, parsed as a float. - `checkoutMode` (string) - Defaults to ad-hoc. Ad-hoc checkouts also require description. One of: `ad-hoc`, `product`. - `currency` (string) - Defaults to CAN. - `billingType` (string) - Defaults to one-time; set recurring with a recurrence object. One of: `one-time`, `recurring`. - `externalId` (string) - Your own identifier. Unique per account — a repeat returns 409. - `maxPurchases` (number) - Cap on completed and pending purchases. - `checkoutExpires` (boolean) - When true, expiresSettings.expiresAt must be a valid datetime. - `metadata` (object) - Free-form key/value data stored on the checkout and echoed in webhooks. Example request body: ```json { "companyId": "cmp_4k9x2m", "name": "Deposit — 2026 season", "type": "product", "price": 500, "description": "Season booking deposit", "externalId": "booking-8841", "metadata": { "bookingId": "8841" } } ``` Response 200: ```json { "success": true, "checkout": { "checkoutId": "Xk92mLp01qZa", "isActive": true }, "message": "Checkout created successfully" } ``` Errors: - **400** - A required field is missing, description is absent on an ad-hoc checkout, or expiresAt is not a valid datetime. - **409** - A checkout already exists with that checkout ID or external ID. > Webhooks are a Seller/Pro capability. On accounts without it, webhookEnabled and webhookUrl are stripped rather than rejected. ### GET /checkout/{checkoutId} Returns one checkout with its settings, discounts, attached forms, inventory and payment configuration. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `checkoutId` (path · required) - The 12-character checkout id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "checkout": { "checkoutId": "Xk92mLp01qZa" } } ``` ### PUT /checkout/{checkoutId} Updates name, price, activity, expiry, discounts, forms and inventory on an existing checkout. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `checkoutId` (path · required) - The checkout to update. - `companyId` (string · required) - The company to create the record in. Required for token auth. Request body: - `isActive` (boolean) - Take the page offline without deleting it. - `price` (number) - New unit price. Example request body: ```json { "isActive": false } ``` Response 200: ```json { "success": true, "checkout": { "checkoutId": "Xk92mLp01qZa", "isActive": false } } ``` ### DELETE /checkout/{checkoutId} Removes the checkout page. Purchases already made against it are retained. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `checkoutId` (path · required) - The checkout to delete. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true } ``` ### POST /checkout/pay Processes a payment against a checkout: card, Apple Pay, Google Pay or bank, with surcharge and tax handling, authorization-only capture, and post-purchase webhooks. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. Required for token auth. - `checkoutId` (string · required) - The checkout being paid. - `quantity` (number) - Units purchased, when the checkout allows quantity selection. - `customer` (object) - Contact details collected at the checkout. - `paymentInstrument` (object) - Tokenized payment method. Example request body: ```json { "checkoutId": "Xk92mLp01qZa", "quantity": 1, "customer": { "emailAddress": "ap@northline.ca" } } ``` Response 200: ```json { "success": true, "orderId": "ord_5m2k9x" } ``` > This is the endpoint the hosted checkout page itself calls. Talk to us before driving it directly — surcharge, tax and receipt behaviour depend on company payment settings. ## Quotes Quotes carry line items, deposits and approval state, and convert into invoices or jobs. Send the customer as a buyerId and the lines as description/quantity/price — line amounts, totals and tax are recalculated from the items every time a quote is saved, so values you send for them are overwritten. ### GET /quotes Returns every quote for the company. A quote whose validUntil has passed is auto-expired the next time it is saved. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Response 200: ```json { "success": true, "quotes": [ { "quoteId": "quote_m9x2kap01", "quoteNumber": "QUO-481920", "title": "Quote", "customer": { "value": "byr_8f3k", "label": "Northline Roofing" }, "status": "sent", "subtotal": 2500, "totalTax": 120, "totalAmount": 2620, "validUntil": "2026-09-19T00:00:00.000Z" } ] } ``` ### POST /quotes Creates a draft quote. Line amounts, subtotal, totalTax and totalAmount are computed from the items on save — values you send for them are overwritten, so there is no way for a quote to disagree with its own lines. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. - `customer` (string · required) - The buyerId of an existing customer in this company. Returns 404 if the buyer belongs to another company. The legacy { label, value } object is still accepted. Name, email, phone and address are copied onto the quote from the buyer record. - `quoteNumber` (string · required) - Your reference for the quote. NOT generated server-side — unlike an invoice number, you must supply one. The portal uses the form QUO-481920. - `items` (array · required) - Line items. - `items[].description` (string · required) - What the line is for. name is accepted as an alias, so an invoice-shaped line works here too. - `items[].price` (number · required) - Unit price. rate is accepted as an alias. Defaults to 0, so omitting it silently creates a free line. - `items[].quantity` (number) - Defaults to 1. - `items[].taxIds` (array of string) - Tax rates to charge on this line, by taxId from GET /taxes. Each tax is charged only on the lines that carry it. Unknown ids return 400. - `items[].taxRates` (array) - The long form of taxIds, as { id, name, value } where value is the percentage. Takes precedence when both are sent. - `title` (string) - Defaults to the company's configured quote title. - `quoteDate` (string (YYYY-MM-DD)) - Defaults to today. - `validUntil` (string (YYYY-MM-DD)) - Defaults to the company's quote validity period. Past this date the quote auto-expires and can no longer be accepted. - `summary / quoteNotes` (string) - Free-text fields shown on the quote. - `deposit` (object) - Deposit required before work starts, as { type, value } where type is percent or dollarAmount. One of: `percent`, `dollarAmount`. - `allowLinkAcceptance` (boolean) - Lets the customer accept from the emailed link without signing in. Required for the public acceptance flow. - `attachPdfQuote` (boolean) - Attach a rendered PDF when the quote is emailed. - `jobId` (string) - Attaches the quote to a job on creation and writes the link to the job activity log. Example request body: ```json { "companyId": "cmp_4k9x2m", "customer": "byr_8f3k", "quoteNumber": "QUO-481920", "items": [ { "description": "Site prep", "quantity": 2, "price": 1200, "taxIds": ["tax_gst"] }, { "description": "Permit fee", "quantity": 1, "price": 100 } ] } ``` Response 201: ```json { "quoteId": "quote_m9x2kap01", "quoteNumber": "QUO-481920", "status": "draft", "subtotal": 2500, "totalTax": 120, "totalAmount": 2620 } ``` Errors: - **400** - customer is missing, an items[].taxIds value is not a tax rate in this company, or a line item has no description. - **404** - The customer buyerId does not exist in this company. > quoteNumber is the one field a quote will not derive for you — an invoice takes the next number automatically, a quote does not. ### GET /quotes/{quoteId} Returns the quote with its line items, deposit state and acceptance record. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `quoteId` (path · required) - The quote to retrieve. - `companyId` (query · required) - The company the quote belongs to. Response 200: ```json { "quote": { "quoteId": "quote_m9x2kap01", "status": "accepted", "totalAmount": 2620, "acceptedBy": { "name": "Sam Doyle", "acceptedAt": "2026-08-19T18:22:04.000Z" } } } ``` Errors: - **404** - No quote with that id in the company. ### PUT /quotes/{quoteId} Updates the quote and bumps its version. Totals are recalculated from the items on every save. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `quoteId` (path · required) - The quote to update. - `companyId` (query · required) - The company the quote belongs to. Request body: - `customer` (string) - A buyerId, or the legacy { label, value } object. Reassigning re-copies the name, email, phone and address onto the quote. - `items` (array) - Replaces the line items. Same shape as create, including taxIds and the name alias. Totals follow automatically. - `title / summary / quoteNotes` (string) - Free-text fields. - `validUntil` (string (YYYY-MM-DD)) - Extends or shortens the acceptance window. - `deposit` (object) - Deposit terms, as { type, value }. - `jobId` (string | null) - Moving the quote between jobs updates both jobs and logs the change on each. Example request body: ```json { "companyId": "cmp_4k9x2m", "validUntil": "2026-10-15" } ``` Response 200: ```json { "quoteId": "quote_m9x2kap01", "version": 2, "validUntil": "2026-10-15T00:00:00.000Z" } ``` Errors: - **400** - An items[].taxIds value is not a tax rate in this company. - **404** - No quote with that id in the company, or the customer buyerId does not exist in it. ### DELETE /quotes/{quoteId} Deletes the quote. A quote with a paid deposit cannot be deleted, since that would orphan the payment. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `quoteId` (path · required) - The quote to delete. - `companyId` (query · required) - The company the quote belongs to. Response 200: ```json { "success": true } ``` Errors: - **400** - The quote has a paid deposit or has already been converted to an invoice. ### GET /quotes/search Filtered list of quotes by status, customer, date range and amount. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. - `search` (query) - Matches customer name or quote number, case-insensitive. - `statuses` (query · csv) - Restrict to these statuses. One of: `draft`, `sent`, `viewed`, `accepted`, `rejected`, `expired`, `scheduled`, `converted`. - `startDate / endDate` (query (YYYY-MM-DD)) - Range on quoteDate, inclusive. - `minAmount / maxAmount` (query · number) - Range on totalAmount, inclusive. Response 200: ```json { "success": true, "quotes": [ { "quoteId": "quote_m9x2kap01", "totalAmount": 2620 } ] } ``` ### POST /quotes/{quoteId}/send Emails the quote and moves it to sent. Sending an already-sent quote resends it. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `quoteId` (path · required) - The quote to send. - `companyId` (query · required) - IN THE QUERY STRING, not the body — this endpoint's authorization runs before the body is read. Request body: - `emailAddresses` (array of string · required) - Recipients. The first is the To address; any others are BCC'd. - `message` (string) - Optional note included in the email body. - `attachPdfQuote` (boolean) - Attach a rendered PDF, overriding the quote and company settings. Example request body: ```json { "emailAddresses": ["ap@northline.ca"], "message": "Here's the quote we discussed." } ``` Response 200: ```json { "success": true, "message": "Quote sent successfully" } ``` Errors: - **400** - The quote could not be sent — no recipients, or the quote no longer exists. ### POST /quotes/{quoteId}/schedule-send Queues the quote to be emailed at a future local date and time and sets its status to scheduled. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `quoteId` (path · required) - The quote to schedule. - `companyId` (query · required) - IN THE QUERY STRING, not the body — this endpoint's authorization runs before the body is read. Request body: - `sendDate` (string (YYYY-MM-DD) · required) - Local date to send on. - `sendTime` (string (HH:mm) · required) - Local time to send at. - `timezone` (string · required) - IANA zone the date and time are read in. - `emailAddresses` (array of string · required) - At least one recipient. - `message` (string) - Optional note included in the email body. - `attachPdfQuote` (boolean) - Attach a rendered PDF. Example request body: ```json { "sendDate": "2026-09-01", "sendTime": "09:00", "timezone": "America/Vancouver", "emailAddresses": ["ap@northline.ca"] } ``` Response 200: ```json { "success": true, "jobId": "612", "scheduledFor": "2026-09-01T16:00:00.000Z" } ``` ### DELETE /quotes/{quoteId}/cancel-scheduled-send Removes the queued job and returns the quote to draft. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `quoteId` (path · required) - The quote whose scheduled send should be cancelled. - `companyId` (query · required) - The company the quote belongs to. Response 200: ```json { "success": true } ``` Errors: - **404** - The quote is not scheduled for sending. ### POST /quotes/{quoteId}/accept Marks the quote accepted on the customer's behalf. Only a quote in sent or viewed status can be accepted, and only before validUntil passes. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `quoteId` (path · required) - The quote to accept. Request body: - `companyId` (string · required) - The company the quote belongs to. - `name` (string) - Who accepted it. Falls back to the customer name on the quote. Example request body: ```json { "companyId": "cmp_4k9x2m", "name": "Sam Doyle" } ``` Response 200: ```json { "success": true, "quote": { "quoteId": "quote_m9x2kap01", "status": "accepted" } } ``` Errors: - **400** - The quote is not in sent or viewed status, or it has expired. - **404** - No quote with that id in the company. > Sending acceptanceType: "client" takes a different, PUBLIC code path used by the emailed acceptance link — it needs no token, requires the quote to have allowLinkAcceptance, and records the accepter's IP and user agent. Do not use it for server-to-server acceptance. ### POST /quotes/{quoteId}/convert-to-invoice Creates an invoice from the quote, copying its line items and totals exactly so the two always agree. A paid deposit on the quote is carried across as a payment on the invoice, and the quote moves to converted. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `quoteId` (path · required) - The quote to convert. Request body: - `companyId` (string · required) - The company the quote belongs to. - `paymentTerms` (string) - Sets the new invoice's paymentDue relative to today. Defaults to net-30. One of: `on-receipt`, `net-7`, `net-14`, `net-30`, `net-45`, `net-60`, `net-90`. Example request body: ```json { "companyId": "cmp_4k9x2m", "paymentTerms": "net-30" } ``` Response 201: ```json { "invoiceId": "a1b2c3d4e5f6", "invoiceNumber": 1044, "status": "draft", "totalAmount": 2620 } ``` Errors: - **400** - The quote does not exist, or it has already been converted. - **403** - The token holds quotes:write but not invoices:create. > Needs TWO grants: quotes:write AND invoices:create. Holding the quotes grant alone must not be a way to mint invoices. ### POST /quotes/{quoteId}/create-job Creates a job from the quote and links the two, writing both the creation and the link to the job activity log. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `quoteId` (path · required) - The quote to build the job from. Request body: - `companyId` (string · required) - The company the quote belongs to. - `name` (string) - Job name. Defaults to something derived from the quote. - `startDate / endDate` (string (YYYY-MM-DD)) - Job window. - `assignedUserIds` (array of string) - Crew assigned to the job. Example request body: ```json { "companyId": "cmp_4k9x2m", "name": "Northline — roof replacement" } ``` Response 201: ```json { "jobId": "job_71kd", "job": { "jobId": "job_71kd", "name": "Northline — roof replacement", "status": "unscheduled" } } ``` Errors: - **400** - The quote does not exist or a job could not be created from it. - **403** - The token holds quotes:read but not jobs:create, or the account's plan does not include jobs. > Needs TWO grants: quotes:read AND jobs:create. Jobs are also plan-gated, so a free-tier account gets 403 subscription_required. ### GET /quotes/{quoteId}/email-events Every delivery event the mail provider reported for this quote, newest first. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `quoteId` (path · required) - The quote. - `companyId` (query · required) - The company the quote belongs to. Response 200: ```json { "success": true, "data": [ { "eventType": "open", "email": "ap@northline.ca", "timestamp": "2026-08-19T18:02:00.000Z" } ] } ``` ### GET /quotes/{quoteId}/email-stats The quote's email events rolled up into counts and rates. Rates are percentages, and are 0 when the denominator is 0. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `quoteId` (path · required) - The quote. - `companyId` (query · required) - The company the quote belongs to. Response 200: ```json { "success": true, "data": { "processed": 1, "delivered": 1, "opened": 1, "clicked": 0, "openRate": 100, "deliveryRate": 100, "uniqueRecipients": 1 } } ``` ### GET /quotes/{quoteId}/view-stats How often the customer-facing quote page was viewed. Portal views are excluded, so this reflects the customer rather than your own team. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `quoteId` (path · required) - The quote. - `companyId` (query · required) - The company the quote belongs to. Response 200: ```json { "success": true, "data": { "pageViews": 3, "clicks": 1, "uniqueVisitors": 1 } } ``` ### GET /quotes/{quoteId}/tracking-events The raw tracking events behind the view stats, newest first. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `quoteId` (path · required) - The quote. - `companyId` (query · required) - The company the quote belongs to. Response 200: ```json { "success": true, "data": [ { "type": "page-view", "pathname": "/pdf-quote/quote_m9x2kap01" } ] } ``` ### GET /quotes/{quoteId}/delivery-issues Bounces, drops and spam reports for this quote — the subset of email events that mean the customer did not receive it. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `quoteId` (path · required) - The quote. - `companyId` (query · required) - The company the quote belongs to. Response 200: ```json { "success": true, "data": [ { "eventType": "bounce", "email": "ap@northline.ca", "reason": "550 5.1.1 unknown recipient" } ] } ``` ## Jobs Jobs are the container the rest of the work hangs off: scheduled visits, assigned crew, linked quotes and invoices, documents, client shares and an activity log. ### GET /jobs Returns jobs newest first, each with a scheduling summary so a list view needs no follow-up calls. Visibility is the greater of the company-wide jobs permission and per-job assignment, so a token scoped below viewer sees only jobs it created or is assigned to. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "jobs": [ { "jobId": "job_71kd", "name": "Northline re-roof", "status": "unscheduled", "color": "#1c659e", "assignedUserIds": ["usr_2k9m"], "visitSummary": { "count": 3, "nextStart": "2026-09-02T15:00:00.000Z" } } ] } ``` ### POST /jobs Creates a job. Assigning crew at creation writes viewer-level assignment records, emails those people, and logs both the creation and the assignment on the job. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. Required for token auth. - `name` (string · required) - Job name. Blank or whitespace is rejected. - `status` (string) - Defaults to unscheduled. One of: `draft`, `scheduled`, `unscheduled`, `completed`, `cancelled`, `archived`, `active`, `on-hold`. - `customer` (object) - { value, label } — defaults to nulls. - `location` (object) - Job site address. - `startDate / endDate` (string) - Planned job window. - `color` (string) - Calendar colour. Defaults to #1c659e. - `assignedUserIds` (array) - User ids to put on the job as viewers. - `projectCoordinator` (string) - Coordinator user id. Example request body: ```json { "companyId": "cmp_4k9x2m", "name": "Northline re-roof", "customer": { "value": "byr_8f3k", "label": "Northline Roofing" }, "startDate": "2026-09-02", "assignedUserIds": ["usr_2k9m"] } ``` Response 201: ```json { "success": true, "job": { "jobId": "job_71kd", "name": "Northline re-roof", "status": "unscheduled" } } ``` Errors: - **400** - Job name is required. ### GET /jobs/{jobId} Returns the job with its linked records and scheduling detail. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "job": { "jobId": "job_71kd" } } ``` ### PUT /jobs/{jobId} Updates job details. Changes are written to the job activity log. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `jobId` (path · required) - The job to update. - `companyId` (query · required) - IN THE QUERY STRING. Job sub-routes authorize before reading the body, so a companyId in the body is not seen. Request body: - `status` (string) - Job workflow state. One of: `draft`, `scheduled`, `unscheduled`, `completed`, `cancelled`, `archived`, `active`, `on-hold`. - `assignedUserIds` (array) - Replace the assigned crew. Example request body: ```json { "status": "scheduled" } ``` Response 200: ```json { "success": true, "job": { "jobId": "job_71kd", "status": "scheduled" } } ``` ### DELETE /jobs/{jobId} Deletes the job. Use the restore endpoint to bring one back. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job to delete. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true } ``` ### GET /jobs/{jobId}/activity Chronological log of everything that happened on the job: creation, assignments, linked and unlinked quotes and invoices. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "activity": [] } ``` ### GET /jobs/{jobId}/assignments The people on the job and the role each holds. POST the same path to add one; PUT and DELETE /jobs/{jobId}/assignments/{assignmentId} change or remove it. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "assignments": [ { "assignmentId": "asg_3k1m", "userId": "usr_2k9m", "role": "viewer" } ] } ``` ### POST /jobs/{jobId}/invoices Attaches an existing invoice to the job and logs the link. DELETE the same path to unlink. /jobs/{jobId}/quotes behaves identically for quotes. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `jobId` (path · required) - The job id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Request body: - `invoiceId` (string · required) - The invoice to link. Example request body: ```json { "invoiceId": "a1b2c3d4e5f6" } ``` Response 200: ```json { "success": true } ``` ### GET /jobs/{jobId}/stats Rolled-up figures for the job — quoted, invoiced and collected. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "stats": { "quoted": 8600, "invoiced": 8600, "paid": 1720 } } ``` ### GET /jobs/{jobId}/shares Share links that let a client view the job. POST creates one; PUT and DELETE /jobs/{jobId}/shares/{shareId} manage it; /jobs/{jobId}/share-email-stats reports on the share emails. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "shares": [] } ``` ### GET /jobs/{jobId}/documents Files attached to the job. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "documents": [] } ``` ### POST /jobs/{jobId}/assignments Adds a crew member to the job, or updates their role if they are already on it. A genuinely new assignment also emails the person; a role change on an existing assignment does not. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `jobId` (path · required) - The job. - `companyId` (query · required) - IN THE QUERY STRING. Job sub-routes authorize before reading the body, so a companyId in the body is not seen. Request body: - `userId` (string · required) - The user to assign. - `role` (string) - Their role on this job. Anything else falls back to viewer. One of: `viewer`, `editor`, `admin`. - `permissions.canCompleteJob` (boolean) - Lets a viewer mark the job complete. Editors and admins can already do this through their role, so it is only stored for viewers. Example request body: ```json { "userId": "usr_3k9m", "role": "editor" } ``` Response 201: ```json { "success": true, "assignment": { "jobId": "job_71kd", "userId": "usr_3k9m", "role": "editor" } } ``` Errors: - **400** - userId is missing. - **403** - The caller's role on this job is below admin. ### PUT /jobs/{jobId}/assignments/{assignmentId} Changes a crew member's role or per-job permissions. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `jobId` (path · required) - The job. - `assignmentId` (path · required) - The assignment to update. - `companyId` (query · required) - IN THE QUERY STRING, not the body. Request body: - `role` (string) - New role. Must be valid if sent. One of: `viewer`, `editor`, `admin`. - `permissions.canCompleteJob` (boolean) - Lets a viewer mark the job complete. Example request body: ```json { "role": "admin" } ``` Response 200: ```json { "success": true, "assignment": { "role": "admin" } } ``` Errors: - **400** - Neither role nor permissions was sent, or role is not one of the allowed values. - **404** - No assignment with that id on this job. ### DELETE /jobs/{jobId}/assignments/{assignmentId} Takes a crew member off the job and logs it to the job activity log. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job. - `assignmentId` (path · required) - The assignment to remove. - `companyId` (query · required) - The company the job belongs to. Response 200: ```json { "success": true } ``` Errors: - **404** - No assignment with that id on this job. ### DELETE /jobs/{jobId}/invoices Detaches the invoice from the job. The invoice itself is untouched. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job. - `invoiceId` (query · required) - The invoice to unlink. - `companyId` (query · required) - The company the job belongs to. Response 200: ```json { "success": true } ``` Errors: - **400** - The invoiceId query parameter is missing. ### POST /jobs/{jobId}/quotes Attaches an existing quote to the job and writes the link to the job activity log. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `jobId` (path · required) - The job. - `companyId` (query · required) - IN THE QUERY STRING, not the body. Request body: - `quoteId` (string · required) - The quote to link. Must be in the same company. Example request body: ```json { "quoteId": "quote_m9x2kap01" } ``` Response 200: ```json { "success": true } ``` Errors: - **400** - quoteId is missing. - **404** - No quote with that id in the company. ### DELETE /jobs/{jobId}/quotes Detaches the quote from the job. The quote itself is untouched. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job. - `quoteId` (query · required) - The quote to unlink. - `companyId` (query · required) - The company the job belongs to. Response 200: ```json { "success": true } ``` Errors: - **400** - The quoteId query parameter is missing. ### GET /jobs/{jobId}/restore Reports whether a cancelled job can be brought back and how many of its cancelled visits would come with it. Read-only — nothing changes. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job. - `companyId` (query · required) - The company the job belongs to. Response 200: ```json { "success": true, "canRestore": true, "restorableVisitCount": 3 } ``` ### POST /jobs/{jobId}/restore Brings a cancelled job back. Its new status comes from its schedule — scheduled if it has visits, unscheduled if not — rather than from whatever it was before the cancellation. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `jobId` (path · required) - The job to restore. - `companyId` (query · required) - IN THE QUERY STRING, not the body. Request body: - `restoreVisits` (boolean) - Also restore the visits that were cancelled with the job. Defaults to false. Example request body: ```json { "restoreVisits": true } ``` Response 200: ```json { "success": true, "job": { "jobId": "job_71kd", "status": "scheduled" }, "visitsRestored": 3 } ``` Errors: - **400** - The job is not cancelled. ### POST /jobs/{jobId}/shares Creates a share link that lets a customer view the job without an account, and optionally emails it to them. The share is reached at /{company}/job/{shareCode}. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `jobId` (path · required) - The job to share. - `companyId` (query · required) - IN THE QUERY STRING, not the body. Request body: - `recipientEmail` (string · required) - Who the link is for. - `recipientName` (string) - Display name for the recipient. - `canUploadDocuments` (boolean) - Lets the recipient upload files to the job. Defaults to false. - `expiresAt` (string (ISO 8601)) - When the link stops working. Never expires when omitted. - `sendEmail` (boolean) - Email the link to recipientEmail on creation. Example request body: ```json { "recipientEmail": "ap@northline.ca", "recipientName": "Sam Doyle", "canUploadDocuments": true } ``` Response 200: ```json { "success": true, "share": { "shareId": "jsh_2k91", "shareCode": "7Qm2xL9pRt4Bd0Vs", "status": "active" }, "shareUrl": "https://chronly.ca/aurora-contracting/job/7Qm2xL9pRt4Bd0Vs" } ``` Errors: - **400** - recipientEmail is missing. - **403** - The caller's role on this job is below admin. ### PUT /jobs/{jobId}/shares/{shareId} Changes what a share link allows, when it expires, or revokes it. Each change is named individually in the job activity log. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `jobId` (path · required) - The job. - `shareId` (path · required) - The share to update. - `companyId` (query · required) - IN THE QUERY STRING, not the body. Request body: - `permissions.canUploadDocuments` (boolean) - Allow or disallow uploads through this link. - `expiresAt` (string (ISO 8601) | null) - New expiry, or null to remove it. - `status` (string) - Revoke or reinstate the link. Any other value is ignored. One of: `active`, `revoked`. Example request body: ```json { "status": "revoked" } ``` Response 200: ```json { "success": true, "share": { "shareId": "jsh_2k91", "status": "revoked" } } ``` Errors: - **404** - No share with that id on this job. ### DELETE /jobs/{jobId}/shares/{shareId} Removes the share link entirely. The link stops working immediately. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job. - `shareId` (path · required) - The share to delete. - `companyId` (query · required) - The company the job belongs to. Response 200: ```json { "success": true } ``` Errors: - **404** - No share with that id on this job. ### GET /jobs/{jobId}/share-email-stats Delivery and engagement counts for the share-link emails sent from this job, keyed by shareId. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job. - `companyId` (query · required) - The company the job belongs to. Response 200: ```json { "success": true, "data": { "jsh_2k91": { "delivered": 1, "open": 2 } } } ``` ### POST /jobs/{jobId}/documents/upload Attaches a file to the job. This endpoint takes multipart/form-data, not JSON. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `jobId` (path · required) - The job. - `companyId` (query · required) - IN THE QUERY STRING — this is a multipart request, so there is no JSON body to carry it. - `file` (form-data · required) - The file to attach. Response 200: ```json { "success": true, "document": { "documentId": "jdoc_5m2p", "fileName": "permit.pdf" } } ``` Errors: - **400** - No file was provided. ## Customers Customers — buyers, in the data model — are the records quotes, invoices and checkouts point at. Creation guards against duplicate email addresses within a company. ### GET /buyers Returns the customers for the company as a plain array. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json [ { "buyerId": "byr_8f3k", "firstName": "Dana", "lastName": "Whitfield", "companyName": "Northline Roofing", "emailAddress": "ap@northline.ca" } ] ``` ### POST /buyers Creates a customer with a generated buyerId. If the email already exists in the company the call returns 409 with the existing record instead of creating a second one — send forceCreate to override. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. Required for token auth. - `emailAddress` (string · required) - Lower-cased and checked against existing customers in the company. - `firstName` (string · required) - Customer's first name. - `lastName` (string · required) - Customer's last name. - `companyName` (string) - Business name. - `forceCreate` (boolean) - Skip the duplicate check and create anyway. Example request body: ```json { "companyId": "cmp_4k9x2m", "firstName": "Dana", "lastName": "Whitfield", "companyName": "Northline Roofing", "emailAddress": "ap@northline.ca" } ``` Response 200: ```json { "buyerId": "byr_8f3k", "emailAddress": "ap@northline.ca", "companyId": "cmp_4k9x2m" } ``` Errors: - **409** - error is DUPLICATE_CUSTOMER and existingBuyer holds the record already on file. ### GET /buyers/{buyerId} Returns one customer record. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `buyerId` (path · required) - The customer id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "buyerId": "byr_8f3k", "companyName": "Northline Roofing" } ``` ### PUT /buyers/{buyerId} Updates contact details, addresses and notes. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `buyerId` (path · required) - The customer to update. - `companyId` (string · required) - The company to create the record in. Required for token auth. Request body: - `emailAddress` (string) - New email address. - `phoneNumber` (string) - New phone number. Example request body: ```json { "phoneNumber": "+1 250 555 0142" } ``` Response 200: ```json { "buyerId": "byr_8f3k", "phoneNumber": "+1 250 555 0142" } ``` ### DELETE /buyers/{buyerId} Removes the customer record. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `buyerId` (path · required) - The customer to delete. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true } ``` ### GET /buyers/search Type-ahead search across name, business name and email — what the customer pickers in the portal call. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `search` (query) - The search term. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "buyers": [] } ``` ## Products The catalogue behind checkouts and line items, with sort order, inventory tracking and file attachments. ### GET /products Returns products sorted by sortOrder, then newest first. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true, "products": [ { "productId": "prd_9m2k", "name": "Site prep, per day", "price": 1200, "sortOrder": 1, "isActive": true } ] } ``` ### POST /products Creates a product with a generated productId. Any inventory stock changes sent along are stamped with the creating identity and a timestamp. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. - `name` (string · required) - Product name. - `type` (string · required) - What kind of thing this is. Drives tax treatment. One of: `physical`, `digital`, `service`, `professional_service`, `taxable_service`, `tax_exempt`. - `billingType` (string · required) - Whether it is billed once or on a recurrence. One of: `one-time`, `recurring`. - `pricing` (object · required) - Price and currency, as { price, currency }. NOTE: there is no top-level price field — a bare price is silently discarded and the request fails validation. - `pricing.price` (number · required) - Unit price. Must be zero or greater. - `pricing.currency` (string) - Defaults to CAD. - `recurrence` (string) - Required in practice when billingType is recurring. One of: `daily`, `weekly`, `monthly`, `yearly`. - `status` (string) - Defaults to active. One of: `active`, `internal-only`, `inactive`, `archived`. - `sku` (string) - Your own stock-keeping code. - `description` (string) - Customer-facing description. - `taxable` (boolean) - Whether tax applies to this product. - `inventory` (object) - Tracking settings and opening stock changes. Use POST /products/{productId}/enable-inventory to turn tracking on afterwards. Example request body: ```json { "companyId": "cmp_4k9x2m", "name": "Site prep, per day", "type": "service", "billingType": "one-time", "pricing": { "price": 1200, "currency": "CAD" } } ``` Response 201: ```json { "productId": "prd_9m2k", "name": "Site prep, per day", "type": "service", "billingType": "one-time", "pricing": { "price": 1200, "currency": "CAD" }, "status": "active" } ``` Errors: - **400** - Missing account or company information. - **500** - A required field is absent or an enum value is invalid — this surfaces as a Mongoose validation error rather than a 400. > createdBy is taken from the authenticated identity and ignored if sent. ### GET /products/{productId} Returns one product with its inventory state. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `productId` (path · required) - The product id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "productId": "prd_9m2k", "price": 1200 } ``` ### PUT /products/{productId} Updates catalogue fields on a product. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `productId` (path · required) - The product to update. - `companyId` (string · required) - The company to create the record in. Required for token auth. Request body: - `price` (number) - New unit price. Example request body: ```json { "price": 1250 } ``` Response 200: ```json { "productId": "prd_9m2k", "price": 1250 } ``` ### DELETE /products/{productId} Removes the product from the catalogue. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `productId` (path · required) - The product to delete. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Response 200: ```json { "success": true } ``` ### PATCH /products/{productId}/status Changes a product's status without touching the rest of the record. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `productId` (path · required) - The product id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Request body: - `status` (string · required) - The product's new status. Anything outside this set is rejected. One of: `active`, `internal-only`, `inactive`, `archived`. Example request body: ```json { "companyId": "cmp_4k9x2m", "status": "inactive" } ``` Response 200: ```json { "productId": "prd_6k2m", "status": "inactive" } ``` Errors: - **400** - status is missing, or is not one of the four allowed values. ### PATCH /products/{productId}/inventory Records a stock change against a product that already has inventory tracking on. POST /products/{productId}/enable-inventory turns tracking on first. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `productId` (path · required) - The product id. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. Request body: - `quantity` (number · required) - Signed stock movement. Must be a number; zero is rejected. - `reason` (string · required) - Why the stock moved. Cannot be blank. - `type` (string) - What kind of movement this is. Defaults to adjustment. One of: `initial`, `purchase`, `adjustment`, `return`, `damaged`, `transfer`. - `reference` (string) - Your own reference for the movement, stored on the stock change. Example request body: ```json { "companyId": "cmp_4k9x2m", "quantity": -3, "reason": "Used on job_71kd", "type": "adjustment" } ``` Response 200: ```json { "productId": "prd_6k2m", "inventory": { "stockStatus": "in-stock" } } ``` Errors: - **400** - quantity is missing or not a number, or reason is missing or blank. ### PATCH /products/reorder Writes a new sortOrder across products in one call — the order customers see on a checkout. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. Required for token auth. - `productIds` (array · required) - Product ids in the order you want them. Example request body: ```json { "productIds": ["prd_9m2k", "prd_4x8p"] } ``` Response 200: ```json { "success": true } ``` ### POST /products/upload Uploads product images or PDFs. This endpoint takes multipart/form-data, not JSON. Returns a presigned URL per file, valid for one hour. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - IN THE QUERY STRING — this is a multipart request, so there is no JSON body to carry it. - `files` (form-data · required) - One to five files under the repeated field name files. JPEG, PNG, GIF, WebP or PDF, each at most 10 MB. Response 200: ```json { "success": true, "files": [ { "id": "cmp_4k9x2m/abc123.pdf", "url": "https://…" } ] } ``` Errors: - **400** - No files were provided, more than five were sent, a file is not an allowed type, or a file exceeds 10 MB. ### POST /products/{productId}/enable-inventory Turns on stock tracking for a product that was created without it, recording the opening stock as an initial stock change. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `productId` (path · required) - The product. Request body: - `companyId` (string · required) - The company the product belongs to. - `initialStock` (number · required) - Opening stock. Must be a number and zero or greater — a numeric string is rejected. - `lowStockThreshold` (number · required) - Stock level that triggers a low-stock alert. Must be a number and zero or greater. Example request body: ```json { "companyId": "cmp_4k9x2m", "initialStock": 40, "lowStockThreshold": 5 } ``` Response 200: ```json { "success": true, "product": { "productId": "prd_9m2k", "inventory": { "trackInventory": true, "currentStock": 40 } } } ``` Errors: - **400** - initialStock or lowStockThreshold is not a non-negative number, or inventory tracking is already enabled. - **404** - No product with that id in the company. ### GET /products/presigned-url Issues a short-lived URL for a stored product file. The key must sit under your own company prefix. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the file belongs to. - `s3Key` (query · required) - Storage key, as returned by POST /products/upload. Response 200: ```json { "success": true, "url": "https://…" } ``` Errors: - **400** - s3Key is missing. - **404** - The key is not under this company's prefix. ### GET /products/pdf-proxy Streams a stored product PDF through Chronly rather than handing back a storage URL, for viewers that will not follow a redirect. Same company-prefix restriction as the presigned URL. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the file belongs to. - `s3Key` (query · required) - Storage key, as returned by POST /products/upload. Response 200: ```json The PDF bytes, with Content-Type: application/pdf. ``` Errors: - **400** - s3Key is missing. - **403** - The key is not under this company's prefix. ## Discounts Discount codes applied at checkout, with usage limits, expiry and validation. Stats can come back with the list. ### GET /discounts Returns discounts for the company, filterable, with an optional stats block. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Required when authenticating with a token, since a token is not bound to one company. - `status` (query) - Restrict to a status. One of: `active`, `inactive`, `expired`, `used_up`. - `applicableType` (query) - Restrict to what the code applies to. One of: `checkout`, `invoice`, `product`. - `expiring` (query · boolean) - true returns only codes close to expiry. - `includeStats` (query · boolean) - true adds a stats object alongside discounts. Response 200: ```json { "discounts": [ { "discountId": "dsc_2k9m", "code": "SPRING10", "type": "percentage", "amount": 10, "isActive": true } ], "stats": { "active": 4, "redeemed": 137 } } ``` ### POST /discounts Creates a discount code. Codes are compared upper-cased and must be unique within the company. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. - `name` (string · required) - Internal name for the discount. - `code` (string · required) - The code customers type. Must be unique in the company, compared case-insensitively. - `discountType` (string · required) - Whether value is a percentage or a dollar amount. Send the bare string; the { label, value } object the portal uses is also accepted. One of: `percentage`, `dollar`. - `value` (number · required) - The amount off. Must be 0-100 for percentage, and positive for dollar. NOTE: this is the magnitude — there is no amount field. - `applicableTypes` (object · required) - What the code may be used on, as { checkouts, invoices, products, all } booleans. At least one must be set; an empty object is rejected. - `description` (string) - Customer-facing description. - `maxUses` (number) - Total redemptions allowed. null means unlimited. - `startDate / endDate` (string (ISO 8601)) - Window the code is valid in. startDate must be before endDate. - `minimumAmount` (number) - Order subtotal the code needs before it applies. Defaults to 0. - `maximumDiscountAmount` (number) - Caps a percentage discount. null means no cap. - `status` (string) - Defaults to active. One of: `active`, `inactive`, `expired`, `used_up`. Example request body: ```json { "companyId": "cmp_4k9x2m", "name": "Spring promo", "code": "SPRING10", "discountType": "percentage", "value": 10, "applicableTypes": { "invoices": true, "checkouts": true }, "maxUses": 100 } ``` Response 201: ```json { "discountId": "dsc_2k9m", "name": "Spring promo", "code": "SPRING10", "discountType": { "label": "Percentage", "value": "percentage" }, "value": 10, "status": "active" } ``` Errors: - **400** - discountType is not percentage or dollar. - **500** - The code already exists in this company, a percentage value is outside 0-100, a dollar value is negative, startDate is not before endDate, or applicableTypes is empty. These validations pre-date the API and throw without a status, so they surface as 500 rather than 400 — the message body still names the problem. > createdBy is taken from the authenticated identity and ignored if sent. ### GET /discounts/{discountId} Returns one discount with its usage counters. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. - `discountId` (path · required) - The discount id. Response 200: ```json { "discountId": "dsc_2k9m", "usedCount": 23 } ``` ### PUT /discounts/{discountId} Changes amount, limits, expiry or active state. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `discountId` (path · required) - The discount to update. Request body: - `companyId` (string · required) - The company the record belongs to. Find it in the portal under Settings > API. - `isActive` (boolean) - Turn the code on or off. Example request body: ```json { "isActive": false } ``` Response 200: ```json { "discountId": "dsc_2k9m", "isActive": false } ``` ### DELETE /discounts/{discountId} Removes the code. Past redemptions are retained. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. - `discountId` (path · required) - The discount to delete. Response 200: ```json { "success": true } ``` ### POST /discounts/validate Checks a code against a cart before charging: active, in date, within its usage limit and applicable. /discounts/validate-public is the unauthenticated variant the hosted checkout uses. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. Required for token auth. - `code` (string · required) - The code to check. - `checkoutId` (string) - Context the code is being applied in. - `amount` (number) - Cart subtotal the discount would apply against. Example request body: ```json { "code": "SPRING10", "checkoutId": "Xk92mLp01qZa" } ``` Response 200: ```json { "valid": true, "discount": { "type": "percentage", "amount": 10 } } ``` ### POST /discounts/apply Records a redemption and returns the discounted totals. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. Required for token auth. - `code` (string · required) - The code being redeemed. - `checkoutId` (string) - The checkout the redemption belongs to. Example request body: ```json { "code": "SPRING10", "checkoutId": "Xk92mLp01qZa" } ``` Response 200: ```json { "success": true, "amountOff": 50 } ``` ### POST /discounts/bulk Creates or updates many discount codes in one request — useful for seasonal campaigns. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (string · required) - The company to create the record in. Required for token auth. - `discounts` (array · required) - Discount objects to write — each with code, type and amount. Example request body: ```json { "discounts": [ { "code": "SPRING10", "type": "percentage", "amount": 10 } ] } ``` Response 200: ```json { "success": true, "created": 1, "updated": 0 } ``` ## Scheduling Calendars and the scheduled items on them. A scheduled item can stand alone or be linked to a job, in which case it appears as a visit on that job. Availability endpoints answer "who is free between these two times". ### GET /scheduling/calendars Returns the calendars the caller can see. Visibility is per calendar (private, internal or public) and is combined with the scheduling manage_all permission. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. Response 200: ```json { "success": true, "data": [ { "calendarId": "cal_7x2k", "name": "Install crew", "timezone": "America/Vancouver", "visibility": "internal" } ] } ``` ### POST /scheduling/calendars Creates a calendar. Requires the scheduling manage_calendars or manage_all permission. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (query · required) - IN THE QUERY STRING. Scheduling writes authorize before the body is read, so a companyId in the body is not seen. - `name` (string · required) - Display name. - `timezone` (string) - IANA timezone; scheduled items inherit it when they do not set their own. - `visibility` (string) - Who can see the calendar. One of: `private`, `internal`, `public`. - `type` (string) - What the calendar represents. Defaults to general. One of: `team`, `resource`, `personal`, `booking`, `general`. - `members` (array) - Users with a role on the calendar, as { userId, role }. role is owner, editor or viewer. Example request body: ```json { "companyId": "cmp_4k9x2m", "name": "Install crew", "timezone": "America/Vancouver", "visibility": "internal" } ``` Response 200: ```json { "success": true, "data": { "calendarId": "cal_7x2k" } } ``` ### GET /scheduling/calendars/{calendarId} Returns one calendar with its members and settings. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `calendarId` (path · required) - The calendar id. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. Response 200: ```json { "success": true, "data": { "calendarId": "cal_7x2k" } } ``` ### PUT /scheduling/calendars/{calendarId} Updates name, colour, timezone, visibility or membership. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `calendarId` (path · required) - The calendar to update. Request body: - `companyId` (query · required) - IN THE QUERY STRING. Scheduling writes authorize before the body is read, so a companyId in the body is not seen. - `name` (string) - New display name. Example request body: ```json { "companyId": "cmp_4k9x2m", "name": "Install crew (east)" } ``` Response 200: ```json { "success": true, "data": { "calendarId": "cal_7x2k" } } ``` ### DELETE /scheduling/calendars/{calendarId} Removes a calendar. Requires manage_all. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `calendarId` (path · required) - The calendar to delete. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. Response 200: ```json { "success": true } ``` ### GET /scheduling/items Returns scheduled items across the calendars the caller can see, filtered by date range, calendar, assignee or linked record. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. - `from / to` (query (ISO datetime)) - Restrict to items overlapping the range. - `calendarId` (query) - Restrict to one calendar. - `assigneeId` (query) - Restrict to items a given user is assigned to. - `linkedType / linkedId` (query) - Restrict to items linked to a record. linkedType is job or event; linkedId is that record’s id. Response 200: ```json { "success": true, "data": [ { "scheduledItemId": "sch_9m2k", "calendarId": "cal_7x2k", "title": "Roof install - day 1", "start": "2026-09-14T15:00:00.000Z", "end": "2026-09-14T23:00:00.000Z", "status": "scheduled", "linkedType": "job", "linkedId": "job_71kd" } ] } ``` ### POST /scheduling/items Books time on a calendar. Linking the item to a job turns it into a visit on that job and writes the scheduling event to the job activity log. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (query · required) - IN THE QUERY STRING. Scheduling writes authorize before the body is read, so a companyId in the body is not seen. - `calendarId` (string · required) - The calendar to book on. - `title` (string · required) - What the booking is. - `start` (ISO datetime · required) - Start of the booking. - `end` (ISO datetime · required) - End of the booking. Must be after start. - `timezone` (string) - Defaults to the calendar timezone. - `allDay` (boolean) - Occupies the assignee working day rather than a fixed window. - `assignees` (array) - User ids to assign. Assignment also grants those users access to a linked job. - `linkedType / linkedId` (string) - Attach the item to a record. linkedType is job or event; linkedId is that record’s id. Example request body: ```json { "companyId": "cmp_4k9x2m", "calendarId": "cal_7x2k", "title": "Roof install - day 1", "start": "2026-09-14T15:00:00.000Z", "end": "2026-09-14T23:00:00.000Z", "linkedType": "job", "linkedId": "job_71kd" } ``` Response 200: ```json { "success": true, "data": { "scheduledItemId": "sch_9m2k" } } ``` Errors: - **400** - calendarId, title, start or end is missing, the dates do not parse, or end is not after start. - **403** - The caller cannot edit that calendar, or the item links to a job they do not have editor access to. > Linking to a job requires editor access on that job, which is the greater of the company jobs permission and any per-job assignment. ### GET /scheduling/items/{itemId} Returns one scheduled item. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `itemId` (path · required) - The scheduledItemId. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. Response 200: ```json { "success": true, "data": { "scheduledItemId": "sch_9m2k" } } ``` ### PUT /scheduling/items/{itemId} Moves, retitles, reassigns or re-statuses a booking. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `itemId` (path · required) - The item to update. Request body: - `companyId` (query · required) - IN THE QUERY STRING. Scheduling writes authorize before the body is read, so a companyId in the body is not seen. - `start / end` (ISO datetime) - Move the booking. - `status` (string) - e.g. scheduled or cancelled. Cancelled visits stop occupying availability. One of: `scheduled`, `confirmed`, `in-progress`, `completed`, `cancelled`, `no-show`. Example request body: ```json { "companyId": "cmp_4k9x2m", "start": "2026-09-15T15:00:00.000Z", "end": "2026-09-15T23:00:00.000Z" } ``` Response 200: ```json { "success": true, "data": { "scheduledItemId": "sch_9m2k" } } ``` ### DELETE /scheduling/items/{itemId} Removes the booking. A job-linked visit is logged as unscheduled on the job. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `itemId` (path · required) - The item to delete. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. Response 200: ```json { "success": true } ``` ### POST /scheduling/availability/check Answers whether the given users are free across a window, taking working hours and existing non-cancelled bookings into account. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json Request body: - `companyId` (query · required) - IN THE QUERY STRING. Scheduling writes authorize before the body is read, so a companyId in the body is not seen. - `userIds` (array · required) - The users to test. - `start` (ISO datetime · required) - Window start. - `end` (ISO datetime · required) - Window end. - `excludeItemId` (string) - Ignore one existing booking, so rescheduling does not collide with itself. Example request body: ```json { "companyId": "cmp_4k9x2m", "userIds": ["usr_3k9m"], "start": "2026-09-14T15:00:00.000Z", "end": "2026-09-14T23:00:00.000Z" } ``` Response 200: ```json { "success": true, "data": { "usr_3k9m": { "available": false, "conflicts": [ { "scheduledItemId": "sch_9m2k" } ] } } } ``` Errors: - **400** - userIds is not an array, or start or end is missing. ### GET /scheduling/assignable-users Returns the users who can be assigned to a booking. API users and users who never accepted their invite are excluded, as are users hidden from scheduling. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the records belong to. Find it in the portal under Settings > API. Response 200: ```json { "success": true, "data": [ { "userId": "usr_3k9m", "email": "crew@aurora.ca", "firstName": "Sam" } ] } ``` ### GET /scheduling/availability Returns the working hours for one owner — a user or the company — including date overrides and extended leave. Requesting someone else's schedule needs the manage_all scheduling permission. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the schedule belongs to. - `ownerType` (query) - Whose schedule to read. Defaults to user. One of: `user`, `company`. - `ownerId` (query) - The user whose schedule to read. Defaults to the caller. Ignored when ownerType is company. - `list` (query) - Pass all to return every schedule in the company instead of one. Requires manage_all. Response 200: ```json { "success": true, "data": { "ownerType": "user", "ownerId": "usr_3k9m", "timezone": "America/Vancouver", "weeklyHours": [ { "weekday": 1, "intervals": [ { "start": "08:00", "end": "16:30" } ] } ], "overrides": [], "leave": [] } } ``` Errors: - **403** - Reading another user's or the company's schedule without manage_all. ### PUT /scheduling/availability Creates or replaces the schedule for one owner. This is a REPLACE — weeklyHours, overrides and leave are each overwritten wholesale, and an omitted array is stored as empty. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `companyId` (query · required) - IN THE QUERY STRING. Scheduling writes authorize before the body is read, so a companyId in the body is not seen. Request body: - `ownerType` (string) - Whose schedule to write. Defaults to user. One of: `user`, `company`. - `ownerId` (string) - The user to write. Defaults to the caller. Ignored when ownerType is company. - `timezone` (string) - IANA zone the hours are expressed in. - `weeklyHours` (array) - Recurring hours, as [{ weekday, intervals: [{ start, end }] }]. weekday is 0-6 with 0 = Sunday; start and end are HH:MM, 24-hour. - `overrides` (array) - Date-specific exceptions, as [{ date: "YYYY-MM-DD", available, intervals }]. available: false with no intervals is a day off. - `leave` (array) - Continuous stretches away, as [{ startDate, endDate }]. A null endDate is open-ended. Example request body: ```json { "ownerType": "user", "ownerId": "usr_3k9m", "timezone": "America/Vancouver", "weeklyHours": [ { "weekday": 1, "intervals": [{ "start": "08:00", "end": "16:30" }] }, { "weekday": 2, "intervals": [{ "start": "08:00", "end": "16:30" }] } ] } ``` Response 200: ```json { "success": true, "data": { "ownerType": "user", "ownerId": "usr_3k9m" } } ``` Errors: - **403** - Editing your own schedule without manage_availability, or anyone else's without manage_all. ### GET /scheduling/availability/working-intervals Flattens weekly hours, overrides and leave into the concrete intervals each user actually works over a date range — what you want before deciding when to book. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the users belong to. - `from` (query (YYYY-MM-DD) · required) - Start of the range, inclusive. - `to` (query (YYYY-MM-DD) · required) - End of the range, inclusive. - `userIds` (query · csv · required) - Users to resolve. An empty list returns an empty object rather than every user. Response 200: ```json { "success": true, "data": { "usr_3k9m": [ { "date": "2026-09-01", "start": "08:00", "end": "16:30" } ] } } ``` Errors: - **400** - from or to is missing. ### PATCH /scheduling/assignable-users Controls whether a user appears in the assignable-users list. Hiding does not affect their existing bookings. Requires the manage_all scheduling permission. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `companyId` (query · required) - IN THE QUERY STRING, not the body. Request body: - `userId` (string · required) - The user to hide or show. - `hidden` (boolean · required) - Must be a real boolean — a missing or non-boolean value is rejected with 400. Example request body: ```json { "userId": "usr_3k9m", "hidden": true } ``` Response 200: ```json { "success": true, "data": { "userId": "usr_3k9m", "hidden": true } } ``` Errors: - **400** - userId is missing, or hidden is not a boolean. - **403** - The caller lacks manage_all. ### GET /scheduling/calendar-feed Everything on the calendar between two dates, already merged and flattened for display — scheduled items plus, optionally, jobs. Callers without manage_all see only the calendars they have access to. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `companyId` (query · required) - The company the calendars belong to. - `from` (query (ISO 8601) · required) - Start of the window. - `to` (query (ISO 8601) · required) - End of the window. - `calendarIds` (query · csv) - Restrict to these calendars. - `assigneeIds` (query · csv) - Restrict to items assigned to these users. - `includeJobs` (query · boolean) - Pass true to include jobs alongside scheduled items. Response 200: ```json { "success": true, "data": [ { "id": "sci_4b2x", "title": "Roof inspection", "start": "2026-09-01T16:00:00.000Z", "end": "2026-09-01T18:00:00.000Z", "calendarId": "cal_7p1m" } ] } ``` Errors: - **400** - from or to is missing. ### PUT /scheduling/items/{itemId}/visit-update Records how a job visit went. Deliberately separate from the item PUT: moving a visit needs editor rights on the job, but the person who actually did the work may only be a viewer on the crew, so this route authorizes on assignment instead and does NOT require the view_calendar permission. Parameters: - `Authorization` (header · required) - Bearer chronly_api_… - the token issued to the API user. - `Content-Type` (header · required) - application/json - `itemId` (path · required) - The scheduled item. Must be a job visit. - `companyId` (query · required) - IN THE QUERY STRING, not the body. Request body: - `outcome` (string) - How the visit went. Anything unrecognised falls back to completed. One of: `completed`, `partial`, `blocked`. - `internalNotes` (string) - Notes for your team only. Truncated at 5000 characters. - `publicNotes` (string) - Notes the customer can see on a job share. Truncated at 5000 characters. - `followUpRequired` (boolean) - Flags that more work is needed. - `followUpNotes` (string) - Only stored when followUpRequired is true. Truncated at 1000 characters. - `jobComplete` (boolean) - Also marks the parent job complete. The caller must be allowed to complete the job. Example request body: ```json { "outcome": "partial", "publicNotes": "Framing done, roofing to follow.", "followUpRequired": true, "followUpNotes": "Need the shingle delivery first." } ``` Response 200: ```json { "success": true, "data": { "scheduledItemId": "sci_4b2x", "visitUpdate": { "outcome": "partial" } } } ``` Errors: - **400** - The scheduled item is not a job visit. - **403** - The caller can neither edit the job nor is assigned to the visit. ## Not part of the token API ### Public payment and acceptance pages Reached by your customer from an emailed link, so they take no token. They are listed for completeness; do not call them server-to-server. - `POST /checkout/pay` — Pays a hosted checkout. - `POST /checkout/abandon-webhook` — Fires the abandoned-cart webhook when a customer leaves without paying. - `POST /invoices/{invoiceId}/pay` — Pays an invoice from its public link. - `POST /invoices/{invoiceId}/receipt` — Emails a payment receipt. - `POST /quotes/{quoteId}/accept-with-deposit` — Accepts a quote and takes its deposit in one step. - `POST /quotes/{quoteId}/receipt` — Emails a deposit receipt. - `POST /discounts/validate-public` — Checks a discount code from a storefront, without revealing the rest of the discount. ### Portal-session only Guarded by a separate session gate that a bearer token cannot satisfy. There is no token equivalent today. - `POST /invoices/export/sage` — Runs a Sage 50 .IMP export. - `POST /invoices/export/sage/preview` — Previews what the export would contain. - `POST /invoices/export/sage/confirm` — Marks the previewed invoices exported.