Genie API Reference Guide
- Application
- Genie (Smartbookgenie)
- Backend
GenieBack— Node.js / NestJS 11 (TypeScript, Express adapter, TypeORM + MySQL)- Frontend
GenieFront— React / Next.js 15 (App Router)- Document version
- 1 September 2026
Table of Contents
- 1. Application Overview
- 2. API Overview
- 3. Base URL and Environments
- 4. Authentication and Session
- 5. Common API Conventions
- 5.1 Request headers
- 5.2 Response envelopes
- 5.3 Status codes
- 5.4 The shared company and period selection
- 5.5 Filtering, sorting and pagination
- 5.6 Dates, times and periods
- 5.7 Request size limits
- 5.8 Numeric precision
- 5.9 Encrypted identifiers
- 5.10 File uploads and downloads
- 5.11 Error handling and diagnostics
- 6. How to Read the Endpoint Reference
- 7. Module Directory
- 8. Authentication, Session & Core
- 8.1 How authentication works end to end
- 8.2 Session and CSRF cookies
- 8.3 Health & CSRF
- 8.4 Credential Login & Password Recovery
- 8.5 Two-Factor Verification
- 8.6 Sign in with Google
- 8.7 Logout
- 8.8 Session State & Company Selection
- 8.9 Notifications
- 8.10 Company & Group Tree
- 8.11 Role Map Diagnostics
- 9. Admin
- 9.1 Company
- 9.2 Company Groups
- 9.3 Users
- 9.4 Reporting Groups
- 9.5 Logs (Task Audit / Activity Log)
- 9.6 QBO Sync Status
- 10. Client Success
- 10.1 Client Info
- 10.1.1 Company Info Configuration
- 10.1.2 Client Contacts Configuration
- 10.1.3 Company Data
- 10.1.4 Template Download and Import
- 10.2 Configure
- 11. Tasks & Time
- 11.1 Tasks
- 11.2 Task Board
- 11.3 Calendar
- 11.4 Projects
- 11.5 Task & Time Templates
- 11.6 Configure
- 11.7 Reports (Reports Builder)
- 11.8 Invoicing
- 11.8.1 Create Invoice
- 11.8.2 Configure
- 11.8.3 Post to GL
- 11.8.4 Timesheet Details
- 11.8.5 Invoicing Audit Log
- 11.9 Timesheet
- 12. Monthly Close
- 12.1 Close Status
- 12.2 Close Details
- 12.3 Open Comments
- 12.4 GL Validation
- 12.5 Firm Configuration
- 12.6 Review Assignments
- 12.7 QuickBooks Close Dates
- 12.8 Company Group Tree
- 13. Comments Center
- 14. Report Center
- 15. Feedback Center
- 16. Integrations
- 16.1 Google Integration (Drive / Sheets OAuth)
- 16.2 QuickBooks Online Integration
- 16.3 Integration Notes
- 17. Deployment & Operational Considerations
- 18. Appendix A — Complete Endpoint Index
- A.1 Authentication, Session & Core
- A.2 Admin
- A.3 Client Success
- A.4 Tasks & Time
- A.5 Monthly Close
- A.6 Comments Center
- A.7 Report Center
- A.8 Feedback Center
- A.9 Integrations
- 19. Appendix B — Environment Variables
- B.1 Application
- B.2 Database
- B.3 Mail (SMTP)
- B.4 QuickBooks Online
- B.5 Google (Drive / Sheets)
- B.6 Object storage
- B.7 Web application
- 20. Appendix C — Health Checks & Operations
1. Application Overview
Genie is a practice-management platform for accounting firms. A firm's staff use it to plan and track client work, log time against that work, bill it, close each client's books every month, and report on all of it.
The product is organised into the following functional areas, which this document mirrors:
| Area | What it covers |
|---|---|
| Authentication & Session | Sign-in (password, two-factor, Google), CSRF, the shared company/period selection carried across every screen, notifications |
| Tasks & Time | The core work-management area: tasks, task boards, calendar, projects, recurring work, templates, timesheets, invoicing, reports, and per-firm configuration |
| Monthly Close | The month-end close workflow per client company: section-by-section review (Balance Sheet, Income Statement, Bank & Card, AP, AR, Payroll, Uncharacterized, Cash Flow, Other), GL validation, review assignments, close status, QuickBooks close dates |
| Client Success | Client (company) master information, contacts, service teams, service roles, tags |
| Report Center | Firm-level financial report definitions, the report writer, report packages and report previews/exports |
| Admin | Companies, company groups, reporting groups, users and their access matrices, activity logs, QuickBooks sync status |
| Feedback Center | In-product suggestions, support tickets and issue threads with attachments |
| Comments Center | Comment threads attached to tasks and to invoices |
| Integrations | QuickBooks Online (accounting data and invoice posting), Google Drive/Sheets (exports), Google Cloud Storage (attachments), SMTP (notifications) |
A number of route spellings, field names and response envelopes are retained for compatibility with earlier clients of this API. Where that affects how a request must be formed, this document states it at the endpoint concerned.
2. API Overview
- Style: JSON over HTTP. The API is not REST-shaped: most operations are a
POSTto a verb-like path (/api/task/save,/api/invoicing/preview/delete) carrying the payload in the body, including many read operations. Path parameters are used sparingly. - Transport:
application/jsonfor almost all endpoints. A small number acceptmultipart/form-data(file uploads) or return binary or base64 content (exports). - Authentication: server-side session cookie. There is no bearer token or JWT scheme and no public API key.
- Versioning: the API is unversioned. There is no
/v1prefix and no version header. - Path prefixes: most endpoints are served under
/api/...; the authentication and OAuth routes are served at the root (/login,/csrf,/google/callback). - Machine-readable specification: none is published. This document is the specification.
3. Base URL and Environments
The backend listens on the port given by PORT (default 4000).
| Environment | Backend base URL | Notes |
|---|---|---|
| Local development | http://localhost:4000 |
The web application runs on http://localhost:3000, which is also the default allowed browser origin |
| Deployed | Configured through the deployment environment (APP_URL / BACKEND_ORIGIN) |
Runs behind a TLS-terminating proxy; proxy headers are trusted so that secure session cookies are issued correctly |
No production hostname is fixed in the application. Clients must substitute the base URL for their own environment; every path in this document is relative to it. The example requests use https://api.example.com as a placeholder.
3.1 Calling through the web application
The Genie web application proxies the API rather than calling it cross-origin. A browser client should call the application path, which is same-origin and therefore carries the session cookie without any cross-origin configuration.
| Application path | Backend path |
|---|---|
/api/csrf |
/csrf |
/api/login, /api/login/check, /api/login/select-2fa |
/login, /login/check, /login/select-2fa |
/api/login/verify/otp, /api/login/verify/googleotp, /api/login/verify/resend |
same paths without /api |
/api/password/email, /api/password/reset |
/password/email, /password/reset |
/api/logout |
/api/logout |
/google/redirect, /google/callback, /google/drive/callback |
same (browser-visited, proxied straight through) |
/api/google/signup |
/google/signup |
/api/google/:path* |
/api/google/:path* |
/api/logs/*, /api/check/session, /api/user-based/notification[/all] |
identical |
/api/company/*, /api/company-group/*, /api/reporting-group/*, /api/firm/list, /api/firm/company/list |
identical |
/api/task[/*], /api/tat/*, /api/invoicing/*, /api/send/* |
identical |
/api/mc/*, /api/other/*, /api/open-comment/*, /api/thread/*, /api/comment/* |
identical |
/api/cs/*, /api/user/*, /api/utilities/*, /api/subscription/*, /api/subscribe/* |
identical |
/api/reports/*, /api/feedbackcenter/*, /api/support/*, /api/new/*, /api/issue/*, /api/ajax_upload/*, /api/qbo/*, /api/get/* |
identical |
Note that /api/google/signup maps to the backend's /google/signup (no /api segment), while the Google Drive routes map to /api/google/.... A proxy configuration must therefore declare the sign-up rule ahead of the general /api/google/* rule.
3.2 Cross-origin requests
A single browser origin is allowed, configured as FRONTEND_ORIGIN, and credentials are permitted on it. A browser client calling the backend directly must therefore be served from that origin and must send credentials with each request (credentials: 'include'). Calling through the application paths in §3.1 avoids cross-origin configuration altogether and is the recommended integration for browser clients.
4. Authentication and Session
Endpoint-level detail is in 8. Authentication, Session & Core. This is the operational summary.
4.1 Session model
- Sessions are server-side and are persisted in the database, so any application instance can serve any request once a session exists.
- Cookie name:
genie.sid—httpOnly,SameSite=Lax,Securein production, lifetime 7 days. - The session carries the signed-in user, the authentication state (
2fa_pendingorauthenticated), the two-factor method in use, and the shared company and period selection described in §5.4. - There is no refresh-token mechanism. A session ends when the cookie expires, when the user signs out, or when the stored session is removed.
4.2 CSRF protection
State-changing authentication endpoints require a synchronizer CSRF token:
GET /csrfissues a 32-byte hex token, stores it on the session, and returns it in a JavaScript-readableXSRF-TOKENcookie.- The client replays it on protected requests in the
X-XSRF-TOKENheader (a_tokenbody field is also accepted). - A missing or mismatched token returns 403
{"message": "CSRF token mismatch."}.
CSRF protection applies to: POST /login, /password/email, /password/reset, /login/select-2fa, /login/verify/otp, /login/verify/googleotp, /login/verify/resend and POST /google/signup. It does not apply to the application (/api/...) endpoints, which rely on the session cookie and its SameSite=Lax restriction.
4.3 Sign-in flow
GET /csrf → issue CSRF token (sets XSRF-TOKEN cookie)
POST /login → email + password (X-XSRF-TOKEN required)
│
├─ no 2FA required → session state = 'authenticated'
├─ auth_type = '0' → user must choose a method: POST /login/select-2fa
├─ auth_type = '1' → Google Authenticator: POST /login/verify/googleotp
└─ auth_type = '3' → emailed OTP: POST /login/verify/otp
(resend via POST /login/verify/resend)
│
GET /login/check → reports which branch applies; also generates and sends the OTP
↓
on success: enc_log cookie set (HttpOnly), { redirect } returned
↓
GET /api/check/session → confirm the session on subsequent page loads
POST /api/logout → end the session
Sign-in guardrails:
| Rule | Value |
|---|---|
| Maximum login attempts before lockout | 6 |
| Lockout window | 60 seconds |
| Emailed OTP validity | 5 minutes |
| OTP resend cooldown | 30 seconds |
| Failed-credentials message | These credentials do not match our records. |
Sign in with Google is an alternative entry point: GET /google/redirect → Google consent → GET /google/callback, which either signs the user in or, for a Google identity that is not yet linked to a Genie account, sets a 60-second google cookie and routes to the account-linking screen served by GET /google/signup and POST /google/signup.
4.4 Authorization model
Authorization is enforced per endpoint rather than by a single global gate. Every authenticated request first resolves the caller from the session — an absent session, or a user who is inactive or deleted, produces 401 — after which each endpoint applies its own scope and permission rules. This document records those rules for every endpoint in the Authorization row of its fact table.
Two mechanisms are used.
a) Named roles — rows in user_roles:
| Id | Role | Id | Role |
|---|---|---|---|
| 1 | Global Master System Administrator | 10 | Feedback Center Administrator |
| 2 | Global User Administrator | 11 | Monthly Close Manager |
| 3 | Global Company Administrator | 12 | Primary Accountant |
| 4 | Global Company Group Administrator | 13 | Invoice Approver |
| 5 | Global Report and Dashboard Administrator | 14 | Invoice Manager |
| 6 | Standard | 15 | Invoice Approver By Service Line |
| 7 | Company Group Company Administrator | 16 | Timesheet Approver |
| 8 | Company Group Administrator | 17 | Firm Internal Accounting Manager |
| 9 | Company Group Report and Dashboard Administrator |
Two flags on the user record sit above the role table: global_admin = 1 (system-wide access, ignoring firm scoping) and firm_admin = 1 (full access within the user's own firm).
b) Per-menu access levels — user_company_access and user_group_access rows grant an access level per menu. Throughout this document the levels are referred to by name — ACCESS_NO (1), ACCESS_VIEW (2), ACCESS_STANDARD (3) and ACCESS_ADMIN (4) — and the menus by id: 3 (Monthly Close), 10 (Client Success) and 81 (Tasks & Time):
| Level | Meaning | Menu | Id |
|---|---|---|---|
| 1 | No access | Monthly Close | 3 |
| 2 | View | Client Success | 10 |
| 3 | Standard (read and write) | Tasks & Time | 81 |
| 4 | Admin |
5. Common API Conventions
5.1 Request headers
| Header | When | Value |
|---|---|---|
Content-Type |
any request with a body | application/json (or multipart/form-data for uploads) |
Cookie |
every authenticated request | must include genie.sid |
X-XSRF-TOKEN |
the CSRF-protected authentication requests listed in §4.2 | the token from GET /csrf |
No Authorization header, API key or tenant header is used — the firm and company scope is derived from the session.
5.2 Response envelopes
Three response shapes occur across the API.
a) The error envelope (most common):
{ "error": false, "data": [ ... ], "message": "Saved successfully." }
b) A bare object or array — several list endpoints return the rows directly, with no envelope.
c) A redirect instruction — the authentication endpoints return { "redirect": "/home" } for the client to navigate to, rather than issuing an HTTP redirect. The browser-visited OAuth routes are the exception and do issue real 302 responses.
Important for integrators: a failed business rule usually returns HTTP 200 (or 201) with
{"error": true, "message": "..."}, not a 4xx status. Duplicate names, permission refusals applied server-side, "not found" conditions and grid validation failures are all reported this way. Clients must inspect the response body, not only the status code. HTTP error statuses are reserved for request validation (400), a missing or invalid session (401), explicit permission refusals (403) and unhandled failures (500).
5.3 Status codes
| Code | Meaning in this API |
|---|---|
200 OK |
Successful GET; also successful PUT, PATCH and DELETE |
201 Created |
Every successful POST — the framework default is not overridden, so 201 is returned even for read-style POST requests and for {"error": true} bodies |
302 Found |
The browser-visited OAuth routes only (/google/redirect, /google/callback, /google/drive/callback) |
400 Bad Request |
Request validation failure, or an explicit bad-request condition |
401 Unauthorized |
No session, or the session user is inactive or deleted — {"message": "These credentials do not match our records."} |
403 Forbidden |
CSRF mismatch, or an explicit permission refusal |
404 Not Found |
Explicit not-found condition (uncommon — most "not found" cases use the error envelope) |
413 Payload Too Large |
Body over 20 MB (see §5.7) |
500 Internal Server Error |
Unhandled failure — {"error": true, "msg": "The server failed to complete this request.", "debug": "..."} |
503 Service Unavailable |
GET / and GET /health when the database check fails |
5.4 The shared company and period selection
Genie keeps one company selection and one as-on period on the session, shared by every screen. Selecting a company in Report Center also changes it in Client Success, Admin, Tasks & Time and Monthly Close. This is intentional: the selection is a single application-wide value.
| Endpoint | Purpose |
|---|---|
GET /api/company/session |
Read the current selection (company and period) |
POST /api/company/session |
Change the company. company_id: 0 is a real, persistent value meaning "Global", not "unset" |
POST /api/company/session/period |
Change only the month and year, keeping the company |
Most data endpoints fall back to this session selection when the corresponding id is omitted from the request body.
5.5 Filtering, sorting and pagination
There is no single global convention. Endpoints that support these capabilities take them as body fields, and the field names differ per endpoint; each endpoint's Request body table is authoritative. The recurring patterns are:
- Filtering — a
filterobject, or discrete fields such ascompanyId,firmId,userId,status,fromDate/toDate,monthVal/yearVal. Multi-select filters are frequently sent as comma-separated id strings (for example"12,45,78") rather than JSON arrays; both forms are documented per endpoint. - Sorting — where supported,
sortByorsortColumnplussortOrder(asc/desc). - Pagination — where supported,
offset+limit, orpage+perPage. Many list endpoints are unpaginated and return the full result set. A few endpoints acceptoffset/limitfields that do not affect the result set; these are noted individually.
5.6 Dates, times and periods
| Concept | Format | Notes |
|---|---|---|
| Calendar date (wire format) | YYYY-MM-DD |
Some endpoints accept or emit MM/DD/YYYY; noted per endpoint |
| Date-time (wire format) | YYYY-MM-DD HH:mm:ss |
Server timezone |
| Monthly Close period | monthVal (for example "Jan") + yearVal (for example 2026) |
Resolved to a month-start date server-side |
| Timesheet week | Week string; weeks run Saturday → Friday | "Week Ending" is the Friday |
| Durations | Stored in seconds, presented as hours | Rounding rules are documented in the Timesheet section |
5.7 Request size limits
Request bodies are accepted up to 20 MB for both JSON and URL-encoded content. The allowance exists for Report Writer and Reports Builder saves, whose per-value conditional-formatting rules can produce very large payloads. Exceeding 20 MB returns 413.
5.8 Numeric precision
QuickBooks Online issues reference ids of up to 18 digits, beyond the range JavaScript can represent exactly as a number. Such values are returned as numbers when they fit that range and as strings when they do not. A client must therefore accept either a number or a numeric string for QuickBooks id fields (class_qbo, customer, item and location ids, and similar).
5.9 Encrypted identifiers
Some route parameters and body ids are exchanged in encrypted form rather than as plain integers — a base64 value that the API decrypts server-side. Endpoints accept either form:
- a plain numeric id —
"123" - a numeric id with a suffix —
"123_profile" - an encrypted base64 id — decrypted server-side (
+and space URL-encoding variants are both tolerated)
Where an endpoint's behaviour differs between the two forms, this document says so. Clients should treat these ids as opaque strings and echo back exactly what the API returned.
5.10 File uploads and downloads
- Uploads use
multipart/form-data. The field name, accepted file types and any size limit are documented per endpoint. Feedback Center attachments are stored in Google Cloud Storage. - Downloads take one of three forms, which differ by endpoint:
1. a direct binary stream (.xlsxand similar) with aContent-Dispositionheader;
2. base64 content inside a JSON envelope — several export endpoints return the file this way rather than as a stream, so the client must decode it rather than stream it;
3. a two-step token flow —POST .../exportbuilds the file and returns a single-use token, thenGET .../export/generated/:tokendownloads it.
Deployment consideration: in the two-step flow the generated file is held in temporary server-side storage associated with the application instance that produced it, so the download request must reach that same instance. On several of these routes the single-use token is the only credential presented. See Deployment & Operational Considerations.
5.11 Error handling and diagnostics
- Failures that occur outside normal request handling are returned as JSON rather than as plain text:
{"error": true, "msg": "The server failed to complete this request.", "debug": "<message>"}. - All
5xxresponses are logged server-side. Report requests are additionally traced on arrival and completion, and a connection closed before a response was sent is logged explicitly, so that "the request never arrived", "the request is still running" and "the request failed" can be distinguished during support investigations.
6. How to Read the Endpoint Reference
Each endpoint is documented as:
- a heading with the HTTP method and full path;
- a one-line statement of purpose;
- a fact table — Module, Authentication, Authorization, Content type;
- Path parameters, Query parameters and Request body tables (omitted where the endpoint has none);
- an Example request;
- a Success response, with status code, example body and field descriptions;
- an Errors table listing the conditions the endpoint actually produces; where no endpoint-specific conditions are documented, the standard responses in §5.3 apply;
- Notes covering business rules, side effects, related endpoints and compatibility behaviour.
Where a detail is not established by the current implementation, it is marked "Not specified in the current implementation." rather than assumed.
7. Module Directory
| # | Section | Endpoints |
|---|---|---|
| 8 | Authentication, Session & Core | 25 |
| 9 | Admin | 71 |
| 10 | Client Success | 41 |
| 11 | Tasks & Time | 184 |
| 12 | Monthly Close | 83 |
| 13 | Comments Center | 8 |
| 14 | Report Center | 27 |
| 15 | Feedback Center | 20 |
| 16 | Integrations | 17 |
| 17 | Deployment & Operational Considerations | — |
| 18 | Appendix A — Complete Endpoint Index | 476 |
| 19 | Appendix B — Environment Variables | — |
| 20 | Appendix C — Health Checks & Operations | — |
8. Authentication, Session & Core
This module is the front door of the Genie backend. It covers backend health checks, the CSRF token lifecycle, credential login and password recovery, both two-factor branches (Google Authenticator and emailed OTP), "Sign in with Google", logout, and the shared server-side session state that every other screen reads when it loads — the signed-in user, their roles and per-company access map, and the selected company + as-on period that drives every data query in the product. It also carries three session-scoped areas that belong to no other feature area: the user notification feed, the shared company/group tree used by the "Select company/group" picker, and two read-only role-map diagnostics. Except where a route is documented as a browser redirect, every endpoint in this module exchanges JSON.
8.1 How authentication works end to end
- Mint a CSRF token. The client calls
GET /csrfonce at boot. The token is stored on the server session and echoed in a JS-readableXSRF-TOKENcookie; every state-changing auth POST must replay it in theX-XSRF-TOKENheader (or a_tokenbody field). - Submit credentials.
POST /loginverifies the email and password against the stored password hash, stampsusers.last_login, and records the user id and two-factor method on the session. Failures are throttled: 6 attempts peremail|ipthen a 60-second lockout. - Branch on
auth_type. Login always answersredirect: "/login/check". The client then callsGET /login/check, which decides what the user must do next:
-auth_type = 0→ the user has never chosen a 2FA method. Response istwoFactorType: "select"; they must callPOST /login/select-2fato pick Google Authenticator ('1') or email OTP ('3').
-auth_type = 1→ Google Authenticator. Response istwoFactorType: "google", plus a QR code and secret when the user is not yet enrolled.
-auth_type = 2or3→ an OTP is generated, stored onusers.otpand emailed once per login attempt; response istwoFactorType: "otp".
- Any other state → already authenticated,redirect: "/home". - Verify.
POST /login/verify/googleotp(TOTP) orPOST /login/verify/otp(emailed code). The body carries only the code — the user identity is read from the server session, never from the request. Emailed OTPs expire after 5 minutes;POST /login/verify/resendissues a fresh one with a 30-second cooldown. enc_log+ redirect. On success the server writesusers.enc_log(an encrypted<userId>_<Y-m-d H:i:s>value), sets it as an HttpOnlyenc_logcookie, marks the sessionauthenticated, and returns{ "redirect": "/home" }. The cookie is HttpOnly and is not readable by client-side scripts.- Bootstrap the app. The client then calls
GET /api/check/session, which returns the user, roles, per-company access map and the selected company/period.
Google sign-in bypasses 2FA entirely. GET /google/redirect → Google consent → GET /google/callback, which either logs an already-linked user straight in (setting enc_log), or — for a brand-new Google identity — sets a 60-second google cookie and redirects to the "One Last Time" linking screen backed by GET /google/signup and POST /google/signup.
8.2 Session and CSRF cookies
| Cookie | Set by | Flags | Purpose |
|---|---|---|---|
genie.sid |
the session middleware (global) | httpOnly, sameSite=lax, secure in production, maxAge 7 days |
The session id. Sessions are persisted in the MySQL sessions table, so every backend instance reads the same session state and the requests of a single login flow may be served by different instances. See Deployment & Operational Considerations. |
XSRF-TOKEN |
GET /csrf |
httpOnly: false (JS-readable), sameSite=lax, path=/, secure in production |
The synchronizer CSRF token. Replayed as X-XSRF-TOKEN. |
enc_log |
the two OTP verify routes, GET /google/callback, POST /google/signup |
httpOnly, sameSite=lax, path=/, secure in production |
The login token, also written to users.enc_log. Cleared by POST /api/logout. |
google |
GET /google/callback (new Google identity only) |
httpOnly, sameSite=lax, path=/, secure in production, maxAge 60 s |
Holds the Google account id for the 1-minute account-linking window. |
Request validation is applied globally: declared types are coerced and unknown body properties are stripped rather than rejected. A validation failure returns HTTP 400 with { "message": [ ...messages ], "error": "Bad Request", "statusCode": 400 }.
8.3 Health & CSRF
Backend liveness ping used by hosting/uptime checks. Verifies the database connection as well as the process.
| Module | Authentication, Session & Core > Health & CSRF |
| Authentication | Not required |
| Authorization | No role requirement |
| Content type | application/json |
Example request
curl https://api.example.com/
Success response — 200 OK (or 503 Service Unavailable when the database probe fails)
{
"status": "ok",
"service": "smartbook-genie-backend",
"message": "Backend and database are healthy.",
"checks": {
"database": { "status": "up", "latencyMs": 7 }
},
"timestamp": "2026-09-01T09:14:22.481Z"
}
| Field | Type | Description |
|---|---|---|
status |
string | "ok" or "error" |
service |
string | Always "smartbook-genie-backend" |
message |
string | Human-readable summary |
checks.database.status |
string | "up" or "down" |
checks.database.latencyMs |
number | Round-trip of SELECT 1 (only when up) |
checks.database.error |
string | Driver error message (only when down) |
timestamp |
string | ISO 8601 timestamp |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 503 | SELECT 1 against the database fails |
Same payload with status: "error" and checks.database.status: "down" |
Explicit health endpoint. Returns the same payload and behaviour as GET /, for platforms that require a named health path.
| Module | Authentication, Session & Core > Health & CSRF |
| Authentication | Not required |
| Authorization | No role requirement |
| Content type | application/json |
Example request
curl https://api.example.com/health
Success response — 200 OK (or 503)
{
"status": "ok",
"service": "smartbook-genie-backend",
"message": "Backend and database are healthy.",
"checks": {
"database": { "status": "up", "latencyMs": 5 }
},
"timestamp": "2026-09-01T09:14:31.902Z"
}
Fields are identical to GET /.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 503 | Database connectivity probe fails | status: "error", checks.database.status: "down", checks.database.error |
Issues (or returns the existing) synchronizer CSRF token for this session and echoes it in a JS-readable cookie. Must be called before any CSRF-protected POST.
| Module | Authentication, Session & Core > Health & CSRF |
| Authentication | Not required — the request creates or reuses the genie.sid session |
| Authorization | No role requirement |
| Content type | application/json |
Example request
curl -c cookies.txt https://api.example.com/csrf
Success response — 200 OK
{ "csrfToken": "3f0b8c1de4a95c77b2c9f1a06d8e4471a2c5b93e0f6d18ca7b4e2d905c31af68" }
| Field | Type | Description |
|---|---|---|
csrfToken |
string | 64-char hex token (32 random bytes) |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Idempotent: a session that already holds a token gets the same value back; a new token is only minted when the session has none.
- Sets cookie
XSRF-TOKENwithhttpOnly: false,path=/,sameSite=lax,securein production — readable by JavaScript so the client can replay it.
8.4 Credential Login & Password Recovery
Verifies email and password and establishes the pre-2FA session.
| Module | Authentication, Session & Core > Credential Login & Password Recovery |
| Authentication | Not required, but a CSRF token (X-XSRF-TOKEN) must be supplied |
| Authorization | No role requirement — this endpoint is reached before a user is authenticated |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
email |
string | Yes | Valid email address ("The email field must be a valid email address."), must not be empty ("The email field is required.") | Account email |
password |
string | Yes | String, must not be empty ("The password field is required.") | Plain-text password, compared against the bcrypt hash |
Example request
curl -b cookies.txt -c cookies.txt -X POST https://api.example.com/login \
-H 'Content-Type: application/json' \
-H 'X-XSRF-TOKEN: 3f0b8c1de4a95c77b2c9f1a06d8e4471a2c5b93e0f6d18ca7b4e2d905c31af68' \
-d '{"email":"jane.doe@example.com","password":"S3cret!pass"}'
Success response — 200 OK
{ "requires2fa": true, "redirect": "/login/check" }
| Field | Type | Description |
|---|---|---|
requires2fa |
boolean | true when auth_type is 1, 2, 3 or 0 (an unchosen method still blocks entry) |
redirect |
string | Always /login/check — the client must call that endpoint next |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | { "message": ["The email field must be a valid email address."], "error": "Bad Request", "statusCode": 400 } |
| 401 | No active, non-deleted user with that email, or wrong password | { "message": "These credentials do not match our records." } |
| 403 | Missing/mismatched CSRF token | { "message": "CSRF token mismatch." } |
| 429 | More than 6 failures for the same email\|ip |
{ "message": "Too many login attempts. Please try again in 47 seconds." } |
Notes
- Only users with
active = 1anddeleted = 0can authenticate. - Failed attempts are throttled at 6 attempts with a 60-second decay, counted per
email|ip; the counter is cleared on a successful credential check. Throttle counters are held per application instance, so multi-instance deployments must preserve instance affinity for the login routes if the limit is to apply exactly as documented. See Deployment & Operational Considerations. - Stored hashes carrying a
$2y$prefix are normalised to$2b$for comparison only — stored values are never rewritten. - On success
users.last_loginis stamped andsession.logingetsuserId,authType, andstate(2fa_pendingorauthenticated).otpGeneratedAtis cleared so a fresh OTP can be issued byGET /login/check.
The post-credential branch point: tells the client whether the user must select a 2FA method, enrol in Google Authenticator, enter an emailed OTP, or go straight to the app.
| Module | Authentication, Session & Core > Credential Login & Password Recovery |
| Authentication | Required — a login session established by POST /login |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Example request
curl -b cookies.txt https://api.example.com/login/check
Success response — 200 OK (emailed-OTP branch)
{
"requires2fa": true,
"authType": "3",
"twoFactorType": "otp",
"redirect": "/login/verify"
}
Google Authenticator branch, first-time enrolment:
{
"requires2fa": true,
"authType": "1",
"twoFactorType": "google",
"redirect": "/login/verify",
"enroll": true,
"google2fa_secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
"otpauthUrl": "otpauth://totp/jane.doe%40example.com?secret=JBSWY3DP...&issuer=Genie",
"qr": "data:image/png;base64,iVBORw0KGgoAAAANS..."
}
| Field | Type | Description |
|---|---|---|
requires2fa |
boolean | Whether verification is still outstanding |
authType |
"0" | "1" | "2" | "3" |
Effective users.auth_type: 0 = not chosen, 1 = Google Authenticator, 2 & 3 = emailed OTP |
twoFactorType |
"select" | "google" | "otp" |
Present only when requires2fa is true. "select" means the user must first choose a method |
redirect |
string | /login/verify while pending, /home once authenticated |
enroll |
boolean | Google branch only: true when the user had no google2fa_secret and one was just generated |
google2fa_secret |
string | Google branch, un-enrolled only: base32 secret to type into the authenticator app |
otpauthUrl |
string | Google branch, un-enrolled only: otpauth:// URL, issuer from APP_NAME (default Genie) |
qr |
string | Google branch, un-enrolled only: inline PNG data URL. Absent when QR rendering is unavailable on the server — the client then falls back to showing google2fa_secret |
Already-authenticated response:
{ "requires2fa": false, "authType": "3", "redirect": "/home" }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No userId on the session, or the user is no longer active/exists |
{ "message": "These credentials do not match our records." } |
| 400 | SMTP is not fully configured, or the OTP email could not be sent | { "message": "SMTP is not fully configured. Please set MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, and MAIL_SENDER_ADDRESS." } |
Notes
- Side effect: for
auth_type2 and 3 this endpoint generates a 4-digit OTP, writes it tousers.otp, stampssession.login.otpGeneratedAt, and emails it (subject "OTP For Genie Login"). Delivery is email-only, soauth_type2 and 3 behave identically. - Idempotency: the OTP is issued only once per login attempt. Repeat calls to this route within the same login attempt do not issue a second code; issuance is gated on
otpGeneratedAt, not onstate. - Side effect: for an un-enrolled
auth_type = 1user, a new base32 secret is generated and persisted tousers.google2fa_secret. auth_type = 0is treated as "must choose" — the user cannot reach the app untilPOST /login/select-2fasets a real method.
Requests a password-reset link for an email address.
| Module | Authentication, Session & Core > Credential Login & Password Recovery |
| Authentication | Not required, but a CSRF token (X-XSRF-TOKEN) must be supplied |
| Authorization | No role requirement — this endpoint is reached before a user is authenticated |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
email |
string | Yes | String, must not be empty, valid email address | Account email to send the reset link to |
Example request
curl -b cookies.txt -X POST https://api.example.com/password/email \
-H 'Content-Type: application/json' \
-H 'X-XSRF-TOKEN: 3f0b8c1d...' \
-d '{"email":"jane.doe@example.com"}'
Success response — 201 Created (the framework's default status for POST)
{ "message": "We have emailed your password reset link." }
| Field | Type | Description |
|---|---|---|
message |
string | Always the same text, whether or not the account exists |
resetUrl |
string | Non-production only, and only when SHOW_DEV_RESET_LINK=true. The reset link, echoed for local testing |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | { "message": ["email must be an email"], "error": "Bad Request", "statusCode": 400 } |
| 400 | SMTP not configured, or the email could not be sent | { "message": "Unable to send password reset email. Please verify SMTP credentials and sender configuration." } |
| 403 | Missing/mismatched CSRF token | { "message": "CSRF token mismatch." } |
Notes
- Account enumeration is prevented — the same message is returned for unknown emails, and no work is done for them.
- Side effect (known account only): any existing
password_resetsrows for that email are deleted and a new row is inserted holding a bcrypt hash of a 32-byte random token; the plain token only ever leaves the server inside the emailed link. - Link format:
{FRONTEND_ORIGIN|FRONTEND_URL|http://localhost:3000}/password/reset/<token>?email=<email>. resetUrlis never returned whenNODE_ENV=production, and requires an explicitSHOW_DEV_RESET_LINK=trueopt-in otherwise, so a mis-configured non-production deployment still cannot leak reset links.
Consumes a reset token, sets the new password, and signs the user straight in.
| Module | Authentication, Session & Core > Credential Login & Password Recovery |
| Authentication | Not required, but a CSRF token (X-XSRF-TOKEN) must be supplied |
| Authorization | No role requirement — this endpoint is reached before a user is authenticated |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
token |
string | Yes | String, must not be empty | The raw token from the emailed link |
email |
string | Yes | String, must not be empty, valid email address | The account being reset |
password |
string | Yes | String, must not be empty, minimum length 8 | New password |
password_confirmation |
string | Yes | String, must not be empty; must equal password (checked server-side) |
Confirmation of the new password |
Example request
curl -b cookies.txt -c cookies.txt -X POST https://api.example.com/password/reset \
-H 'Content-Type: application/json' \
-H 'X-XSRF-TOKEN: 3f0b8c1d...' \
-d '{
"token":"9ad3...c1",
"email":"jane.doe@example.com",
"password":"N3wS3cret!",
"password_confirmation":"N3wS3cret!"
}'
Success response — 201 Created
{ "message": "Your password has been reset!", "redirect": "/home" }
| Field | Type | Description |
|---|---|---|
message |
string | Confirmation text |
redirect |
string | Always /home — the session is already authenticated |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure (including password shorter than 8 characters) |
{ "message": ["password must be longer than or equal to 8 characters"], ... } |
| 400 | Empty token, no password_resets row for the email, or token hash mismatch |
{ "message": "This password reset token is invalid." } |
| 400 | password and password_confirmation differ |
{ "message": "The password confirmation does not match." } |
| 400 | Token valid but the account is inactive/deleted/missing | { "message": "These credentials do not match our records." } |
| 403 | Missing/mismatched CSRF token | { "message": "CSRF token mismatch." } |
| 409 | Token older than 60 minutes (or with an unusable created_at) |
{ "message": "This password reset token has expired." } |
Notes
- Tokens are valid for 60 minutes; an expired token also deletes every
password_resetsrow for that email. - Side effect: on success the password is re-hashed with bcrypt (cost 10),
users.last_loginis stamped, allpassword_resetsrows for the email are deleted, and the session is markedauthenticated— the user does not go through 2FA on this path.
8.5 Two-Factor Verification
For users whose auth_type is still 0, records their chosen second factor and returns everything the verify screen needs to continue.
| Module | Authentication, Session & Core > Two-Factor Verification |
| Authentication | Required — a mid-login session awaiting two-factor verification, plus a CSRF token (X-XSRF-TOKEN) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
authType |
string | Yes | Normalised server-side to a trimmed string, then One of 1, 3 — message "Invalid auth type." |
'1' = Google Authenticator, '3' = email OTP. Coerced to a string so it binds to the MySQL ENUM correctly |
Example request
curl -b cookies.txt -X POST https://api.example.com/login/select-2fa \
-H 'Content-Type: application/json' \
-H 'X-XSRF-TOKEN: 3f0b8c1d...' \
-d '{"authType":"1"}'
Success response — 201 Created (Google Authenticator chosen)
{
"requires2fa": true,
"authType": "1",
"twoFactorType": "google",
"redirect": "/login/verify",
"enroll": true,
"google2fa_secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
"otpauthUrl": "otpauth://totp/jane.doe%40example.com?secret=JBSWY3DP...&issuer=Genie",
"qr": "data:image/png;base64,iVBORw0KGgoAAAANS..."
}
Email OTP chosen:
{ "requires2fa": true, "authType": "3", "twoFactorType": "otp", "redirect": "/login/verify" }
Fields are the same as GET /login/check. Note that on the Google branch this endpoint always returns the QR and secret — even for a user who already had a google2fa_secret — so the selection screen can display the QR inline the moment Google Authenticator is picked.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | authType is not '1' or '3' |
{ "message": "Invalid auth type." } |
| 401 | No userId on the session, session is not 2fa_pending, or the user is inactive/deleted |
{ "message": "These credentials do not match our records." } |
| 403 | Missing/mismatched CSRF token | { "message": "CSRF token mismatch." } |
| 400 | SMTP not configured or OTP email send failed (email branch) | { "message": "Unable to send OTP email. Please verify SMTP credentials and sender configuration." } |
Notes
- Side effect: persists the choice to
users.auth_type— this is permanent, not per-session. - Side effect (email branch): immediately generates, stores and emails a fresh OTP and stamps
otpGeneratedAt. - Side effect (Google branch): generates and persists a
google2fa_secretif the user did not already have one. - Callable only mid-login. Only
'1'and'3'are selectable;'2'exists in the schema but is not offered at login (it behaves the same as'3'— email delivery).
Verifies a Google Authenticator TOTP code and completes login.
| Module | Authentication, Session & Core > Two-Factor Verification |
| Authentication | Required — a mid-login session awaiting two-factor verification, plus a CSRF token (X-XSRF-TOKEN) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
otp |
string | Yes | String, must not be empty ("Please Enter OTP") | The 6-digit TOTP from the authenticator app |
Example request
curl -b cookies.txt -c cookies.txt -X POST https://api.example.com/login/verify/googleotp \
-H 'Content-Type: application/json' \
-H 'X-XSRF-TOKEN: 3f0b8c1d...' \
-d '{"otp":"418205"}'
Success response — 201 Created
{ "redirect": "/home" }
| Field | Type | Description |
|---|---|---|
redirect |
string | Always /home |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | { "message": ["Please Enter OTP"], "error": "Bad Request", "statusCode": 400 } |
| 400 | User has no google2fa_secret, or auth_type is not 1 |
{ "message": "2FA not configured for this account." } |
| 401 | No userId on the session or session is not 2fa_pending |
{ "message": "These credentials do not match our records." } |
| 401 | TOTP does not verify | { "message": "Invalid OTP" } |
| 403 | Missing/mismatched CSRF token | { "message": "CSRF token mismatch." } |
Notes
- Identity comes from the server session only — the body carries the code and nothing else, so a caller cannot verify on another user's behalf.
- TOTP is checked with a
window: 1tolerance (one step either side of the current 30-second window). - Side effect: clears
users.otp, writesusers.enc_log(the encrypted<userId>_<Y-m-d H:i:s>value) andusers.enc_log_on, and marks the sessionauthenticated. - Cookie: sets
enc_logwithhttpOnly: true,path=/,sameSite=lax,securein production. The cookie is not readable by client-side scripts. - Development environments only: when
APP_ENVis set tolocalordevelopment, a development-only bypass allows a small set of pre-configured test accounts to complete this step without a real authenticator code. It never applies to the emailed-OTP path and is inactive wheneverAPP_ENVis unset or set to anything else, so it does not exist in a production deployment.
Verifies the emailed OTP (for auth_type 2 and 3) and completes login.
| Module | Authentication, Session & Core > Two-Factor Verification |
| Authentication | Required — a mid-login session awaiting two-factor verification, plus a CSRF token (X-XSRF-TOKEN) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
otp |
string | Yes | String, must not be empty ("Please Enter OTP") | The 4-digit code from the OTP email |
Example request
curl -b cookies.txt -c cookies.txt -X POST https://api.example.com/login/verify/otp \
-H 'Content-Type: application/json' \
-H 'X-XSRF-TOKEN: 3f0b8c1d...' \
-d '{"otp":"7413"}'
Success response — 201 Created
{ "redirect": "/home" }
| Field | Type | Description |
|---|---|---|
redirect |
string | Always /home |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | { "message": ["Please Enter OTP"], "error": "Bad Request", "statusCode": 400 } |
| 400 | User missing, or auth_type is not 2/3 |
{ "message": "2FA not configured for this account." } |
| 401 | No userId on the session or session is not 2fa_pending |
{ "message": "These credentials do not match our records." } |
| 401 | OTP older than 5 minutes, or never issued this attempt | { "message": "Your OTP has expired. Please request a new one." } |
| 401 | Submitted code does not match users.otp |
{ "message": "Invalid OTP" } |
| 403 | Missing/mismatched CSRF token | { "message": "CSRF token mismatch." } |
Notes
- Identity comes from the server session only; the body carries just the code.
- OTP TTL is 5 minutes, measured from
session.login.otpGeneratedAt. An expired code is cleared fromusers.otpas part of the rejection, forcing a resend. - Side effect: on success clears
users.otpandotpGeneratedAt, writesusers.enc_log/enc_log_on, and marks the sessionauthenticated. - Cookie: sets
enc_log(HttpOnly,path=/,sameSite=lax,securein production). - There is no dev master-code bypass on this route — the emailed path is never bypassed.
Issues a fresh emailed OTP and restarts the expiry clock.
| Module | Authentication, Session & Core > Two-Factor Verification |
| Authentication | Required — a mid-login session awaiting two-factor verification, plus a CSRF token (X-XSRF-TOKEN) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Example request
curl -b cookies.txt -X POST https://api.example.com/login/verify/resend \
-H 'X-XSRF-TOKEN: 3f0b8c1d...' \
-H 'Content-Length: 0'
Success response — 201 Created
{ "message": "A new OTP has been sent." }
| Field | Type | Description |
|---|---|---|
message |
string | Confirmation text |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | User missing, or auth_type is not 2/3 |
{ "message": "2FA not configured for this account." } |
| 400 | Called within 30 seconds of the previous OTP generation | { "message": "Please wait 18 seconds before requesting another OTP." } |
| 400 | SMTP not configured, or the email could not be sent | { "message": "Unable to send OTP email. Please verify SMTP credentials and sender configuration." } |
| 401 | No userId on the session or session is not 2fa_pending |
{ "message": "These credentials do not match our records." } |
| 403 | Missing/mismatched CSRF token | { "message": "CSRF token mismatch." } |
Notes
- Takes no request body.
- Anti-spam cooldown of 30 seconds between generations.
- Side effect: overwrites
users.otpwith a new 4-digit code, re-stampsotpGeneratedAt(resetting the 5-minute TTL), and emails the code. - Only valid for the emailed-OTP branch — Google Authenticator users have nothing to resend.
8.6 Sign in with Google
Starts the Google OAuth flow by redirecting the browser to Google's consent screen.
| Module | Authentication, Session & Core > Sign in with Google |
| Authentication | Not required |
| Authorization | No role requirement |
| Content type | No body — HTTP redirect |
Example request
GET /google/redirect HTTP/1.1
Host: api.example.com
Success response — 302 Found
HTTP/1.1 302 Found
Location: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=https%3A%2F%2Fapp.example.com%2Fgoogle%2Fcallback&response_type=code&scope=openid+email+profile&access_type=online&prompt=select_account
Response body: no body — this route only issues a redirect.
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- The consent request uses the scopes
openid email profile, withaccess_type=onlineandprompt=select_account. redirect_uriis derived as${APP_URL}/google/callback(trailing slash trimmed;APP_URLdefaults tohttp://localhost:3000).client_idcomes fromGOOGLE_CLIENT_ID.- This is a browser navigation endpoint, not an XHR endpoint.
Google's OAuth redirect target. Exchanges the authorization code, then logs the user in, auto-links the Google account to a matching Genie account, or sends a brand-new Google identity to the account-linking screen.
| Module | Authentication, Session & Core > Sign in with Google |
| Authentication | Not required |
| Authorization | No role requirement |
| Content type | No body — HTTP redirect |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
code |
string | No (treated as empty when absent) | '' |
Google's authorization code. A missing or unusable code redirects to the login page with an error |
Example request
GET /google/callback?code=4%2F0AeanS0b... HTTP/1.1
Host: api.example.com
Success response — 302 Found
Existing / auto-linked user:
HTTP/1.1 302 Found
Set-Cookie: enc_log=U0ZkV...; Path=/; HttpOnly; SameSite=Lax
Location: /home
Brand-new Google identity:
HTTP/1.1 302 Found
Set-Cookie: google=104829173650192837465; Path=/; HttpOnly; SameSite=Lax; Max-Age=60
Location: /login/google-link?code=U1ZWa1Zt...
Failure (never a non-2xx status — the error is carried in the redirect):
HTTP/1.1 302 Found
Location: /login?g_error=Login%20failed.%20Please%20try%20again
Response body: no body — this route only issues a redirect.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 302 | Missing code, token exchange failure, profile fetch failure, or a profile with no id/email |
Redirect to /login?g_error=Login%20failed.%20Please%20try%20again |
| 302 | The matched account has active = 0 |
Redirect to /login?g_error=This%20user%20is%20inactive.%20Please%20contact%20Administrator. |
Notes
- Resolution order: (1) a user already holding this
google_id; (2) a user with the same email — theirgoogle_id/google_emailare written on the spot (auto-link); (3) otherwise, a new identity that must be linked manually. - Google sign-in bypasses 2FA entirely — a matched user's session is marked
authenticatedimmediately. - Side effect (matched user): stamps
last_login, writesenc_log/enc_log_on, and sets the HttpOnlyenc_logcookie. - Side effect (new identity): sets a 60-second HttpOnly
googlecookie holding the Google account id, and passes an encrypted<googleId>_<email>value as thecodequery parameter of the linking screen. - The linking
codeis transient: it is minted here, consumed by the linking screen, and never persisted. It is encrypted with a padded variant of the cipher so that long Google identifier/email pairs survive the round trip intact. - The redirect targets are frontend routes, not backend routes.
Validates the linking code from GET /google/callback against the 1-minute google cookie and returns the Google email for the "One Last Time" linking screen to display.
| Module | Authentication, Session & Core > Sign in with Google |
| Authentication | Not required — but the short-lived google cookie set by GET /google/callback must be present |
| Authorization | No role requirement |
| Content type | application/json |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
code |
string | Yes (empty is rejected) | '' |
The encrypted <googleId>_<email> value handed to the linking screen |
Example request
curl -b 'google=104829173650192837465' \
'https://api.example.com/google/signup?code=U1ZWa1Zt...'
Success response — 200 OK
{ "email": "jane.doe@gmail.com" }
| Field | Type | Description |
|---|---|---|
email |
string | The Google account email, for display on the linking screen |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | code missing or empty |
{ "message": "Invalid Code, Please try again" } |
| 400 | google cookie absent or expired (60-second window), or the decoded Google id does not match the cookie |
{ "message": "Link has been Expired" } |
| 400 | code cannot be decrypted, or does not decode to exactly <id>_<email> |
{ "message": "Invalid Link" } |
Notes
- The
googlecookie is read from theCookierequest header and must be sent with the request. - Purely a read/validation step; it makes no database changes.
Completes account linking: verifies the user's existing Genie email + password, attaches the Google account, and signs them in.
| Module | Authentication, Session & Core > Sign in with Google |
| Authentication | Not required, but a CSRF token (X-XSRF-TOKEN) must be supplied |
| Authorization | No role requirement — this endpoint is reached before a user is authenticated |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
code |
string | Yes | String, must not be empty ("Link has been Expired") | The encrypted <googleId>_<email> linking code |
email |
string | Yes | String, must not be empty ("Incorrect email or password") | The user's existing Genie account email |
password |
string | Yes | String, must not be empty ("Incorrect email or password") | That account's password |
Example request
curl -b cookies.txt -c cookies.txt -X POST https://api.example.com/google/signup \
-H 'Content-Type: application/json' \
-H 'X-XSRF-TOKEN: 3f0b8c1d...' \
-d '{
"code":"U1ZWa1Zt...",
"email":"jane.doe@example.com",
"password":"S3cret!pass"
}'
Success response — 201 Created
{ "redirect": "/home" }
| Field | Type | Description |
|---|---|---|
redirect |
string | Always /home |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | { "message": ["Incorrect email or password"], "error": "Bad Request", "statusCode": 400 } |
| 400 | code cannot be decrypted, or does not decode to exactly <id>_<email> |
{ "message": "Link has been Expired" } |
| 400 | No active, non-deleted user with that email, or wrong password | { "message": "Incorrect email or password" } |
| 400 | The Genie account already has a google_id |
{ "message": "This Email already connected with some other Google accounts" } |
| 403 | Missing/mismatched CSRF token | { "message": "CSRF token mismatch." } |
Notes
- Depends on
GET /google/callbackhaving issued thecode; unlikeGET /google/signupthis endpoint does not re-check thegooglecookie, only that the code decrypts to two parts. - Side effect: writes
google_idandgoogle_emailonto the user, stampslast_login, and writesenc_log/enc_log_on. - Cookie: sets
enc_log(HttpOnly,path=/,sameSite=lax,securein production). - Marks the session
authenticatedimmediately — no 2FA on the Google path. - Password comparison uses the same
$2y$→$2b$bcrypt normalisation asPOST /login.
8.7 Logout
Tears down the authenticated session for browser clients.
| Module | Authentication, Session & Core > Logout |
| Authentication | Not required — the endpoint clears whatever session the caller presents |
| Authorization | No role requirement |
| Content type | application/json |
Example request
curl -b cookies.txt -c cookies.txt -X POST https://api.example.com/api/logout -H 'Content-Length: 0'
Success response — 201 Created
{ "error": false, "message": "Logged out successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false |
message |
string | Always "Logged out successfully." |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- No CSRF token is required on this route, unlike the other authentication POSTs.
- Resets
session.loginto an empty object and clears theenc_logcookie (path=/). It does not destroy the underlyinggenie.sidsession row or clear theXSRF-TOKENcookie, so the CSRF token minted byGET /csrfsurvives and can be reused for the next login. users.enc_login the database is left as-is; only the cookie is removed.
8.8 Session State & Company Selection
The bootstrap call every screen makes at mount. Validates the session and returns the signed-in user, their roles, their per-company menu access map, the selected company and as-on period, and supplementary dashboard data.
| Module | Authentication, Session & Core > Session State & Company Selection |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. The response reports the caller's effective roles: global_admin is true when the assigned-role list contains role id 1 (Global Master System Administrator), or a role literally named "Global Master System Administrator", or users.global_admin = 1 |
| Content type | application/json |
Example request
curl -b cookies.txt https://api.example.com/api/check/session
Success response — 200 OK
{
"userData": {
"id": 237,
"email": "jane.doe@example.com",
"first_name": "Jane",
"last_name": "Doe",
"user_name": "Jane Doe",
"firm_id": 340,
"firm_admin": 1,
"roleArray": [11, 13],
"roles": [
{ "id": 11, "role_name": "Monthly Close Manager" },
{ "id": 13, "role_name": "Invoice Approver" }
]
},
"user_id": "U0ZkV1ZtMTNaR1p0",
"menuArray": [],
"global_user_profile": "",
"global_company_logo": "uploads/company/1163.png",
"selectedCompany": {
"company_id": 1163,
"global_company_name": "Acme Manufacturing LLC",
"global_company_logo": "uploads/company/1163.png",
"qbo_connection": 1
},
"carryOver": {
"company_id": 1163,
"asOnDate": "2026-09-01",
"monthVal": "Sep",
"yearVal": 2026
},
"global_role_name": "Monthly Close Manager,Invoice Approver",
"global_admin": false,
"accessData": {
"1163": [
{ "menu_id": 4, "access_type": 2 },
{ "menu_id": 9, "access_type": 1 }
]
},
"disableEmail": false,
"downloadReport": [],
"utilizationData": {
"resData": [
{ "name": "Jane Doe", "billable_hours": "31.50", "target": "35.00" }
]
},
"firmInternalCompany": {
"id": 1163,
"name": "Acme Manufacturing LLC",
"firm_internal_company": 1,
"qbo_connection": 1
},
"sessionData": {
"login": {
"userId": 237,
"state": "authenticated",
"authType": "3",
"company": {
"company_id": 1163,
"global_company_name": "Acme Manufacturing LLC",
"global_company_logo": "uploads/company/1163.png",
"qbo_connection": 1
},
"carryOver": {
"company_id": 1163,
"asOnDate": "2026-09-01",
"monthVal": "Sep",
"yearVal": 2026
}
},
"company": {
"company_id": 1163,
"global_company_name": "Acme Manufacturing LLC",
"global_company_logo": "uploads/company/1163.png",
"qbo_connection": 1
}
}
}
| Field | Type | Description |
|---|---|---|
userData.id / email / first_name / last_name / user_name |
number / string | Identity of the signed-in user |
userData.firm_id |
number | The user's firm |
userData.firm_admin |
number | 1 when the user is a firm administrator |
userData.roleArray |
number[] | Assigned role ids |
userData.roles |
array | Assigned roles as { id, role_name } |
user_id |
string | Encrypted <userId>_profile token, used for profile deep links |
menuArray |
array | Always [] |
global_user_profile |
string | Always "" |
global_company_logo |
string | Logo path of the selected company |
selectedCompany |
object | null | { company_id, global_company_name, global_company_logo, qbo_connection }. company_id: 0 is the sticky Global scope, not "unset" |
carryOver |
object | null | { company_id, asOnDate, monthVal, yearVal } — the as-on period every data query filters by |
global_role_name |
string | Comma-joined assigned role names, or the primary role name when none are assigned |
global_admin |
boolean | Whether the user is a Global Master System Administrator |
accessData |
object | Map of company_id → array of { menu_id, access_type }. Rows with access_type = 0 (no access) are omitted |
disableEmail |
boolean | Always false |
downloadReport |
array | Always [] |
utilizationData.resData |
array | { name, billable_hours, target } rows for the utilization widget |
firmInternalCompany |
object | null | { id, name, firm_internal_company, qbo_connection } for the resolved company |
sessionData |
object | Raw echo of the server session: { login, company } |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No userId on the session, session not authenticated, or the user row is missing/deleted |
{ "message": "These credentials do not match our records." } |
Notes
- Side effect: this endpoint validates and seeds the session company. If the session holds no company, the first active company for the firm (preferring the firm-internal one) is selected and written to the session together with a matching
carryOver. An already-selected company is resolved without anactive = 1filter, so a selection that points at an inactive company — as Report Center may legitimately hold — is preserved rather than replaced by a default. company_id: 0(Global) round-trips untouched and is never replaced by a default company.- The utilization query is raced against a 10-second timeout; on timeout
utilizationData.resDatacomes back as[]rather than failing the whole session check.
Reads the shared company selection and as-on period out of the session, so a fresh browser or a second tab starts on the same company.
| Module | Authentication, Session & Core > Session State & Company Selection |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Example request
curl -b cookies.txt https://api.example.com/api/company/session
Success response — 200 OK
{
"error": false,
"company": {
"company_id": 1163,
"qbo_connection": 1,
"global_company_name": "Acme Manufacturing LLC",
"global_company_logo": "uploads/company/1163.png"
},
"carryOver": {
"company_id": 1163,
"asOnDate": "2026-09-01",
"monthVal": "Sep",
"yearVal": 2026
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success |
company |
object | null | { company_id, qbo_connection, global_company_name, global_company_logo }, or null when nothing is selected yet |
carryOver.company_id |
number | The company the period belongs to |
carryOver.asOnDate |
string | YYYY-MM-01 — the first of the selected month |
carryOver.monthVal |
string | Three-letter month label (Jan … Dec) |
carryOver.yearVal |
number | Four-digit year |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
carryOveris never null: a session that only ever stored a company id still reads back a usable period, defaulting to the current month and year.- The selection is held server-side in the session rather than in browser storage, so it is shared across tabs and browsers.
Changes the company every screen is scoped to. This is the single shared switch point — picking a company on Reports also changes it on Client Success, Admin, Tasks & Time and Monthly Close.
| Module | Authentication, Session & Core > Session State & Company Selection |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session, but scoping is enforced: a non-global-admin may only select a company whose firm_id matches their own firm. Global admins (users.global_admin = 1, or the Global Master System Administrator role) may select any company |
| Content type | application/json |
Request body — the body is loosely typed; no request body schema is applied, so no field-level validation rules are enforced. Keys read by this endpoint:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
number | numeric string | Yes | Coerced to a number; must be finite and >= 0. undefined, null, '', NaN and negatives are rejected |
The company to select. 0 means the sticky "Global" scope, not "unset" |
preserve_period |
boolean (any truthy value) | No | Coerced to a boolean | When truthy, keeps the existing month/year; otherwise the period resets to the current month |
Example request
curl -b cookies.txt -X POST https://api.example.com/api/company/session \
-H 'Content-Type: application/json' \
-d '{"company_id":1163,"preserve_period":true}'
Success response — 201 Created
{
"error": false,
"company": {
"company_id": 1163,
"qbo_connection": 1,
"global_company_name": "Acme Manufacturing LLC",
"global_company_logo": "uploads/company/1163.png"
},
"carryOver": {
"company_id": 1163,
"asOnDate": "2026-09-01",
"monthVal": "Sep",
"yearVal": 2026
}
}
Fields are identical to GET /api/company/session. Selecting Global (company_id: 0) returns global_company_name: "Global", global_company_logo: "", qbo_connection: 0.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | company_id missing, empty, non-numeric or negative |
{ "message": "Company not found." } |
| 400 | The id resolves to no company row, or to a company outside the caller's firm | { "message": "Company not found." } |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Side effect: writes both session keys together (
companyandcarryOver) so they can never drift apart, and touchescompany.updated_at— which drives the "Last used" ordering on the Admin company list. - Period semantics: without
preserve_periodthe as-on month/year reset to today; with it, the existing period is kept and only the company id changes. - The company lookup applies no
active/deletedfilter, so an inactive company whose historical reports must remain accessible can still be selected.
Changes only the as-on month/year, keeping the selected company. Backs Monthly Close's month/year selects.
| Module | Authentication, Session & Core > Session State & Company Selection |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body — the body is loosely typed; no request body schema is applied, so no field-level validation rules are enforced. Keys read by this endpoint:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
monthVal |
string | No | Coerced to a string; matched case-insensitively against its first three letters (Jan … Dec). Empty or unrecognised falls back to the current stored month, and an unmatched label resolves asOnDate to January |
Three-letter month label |
yearVal |
number | No | Coerced to a number; must be > 0, otherwise the current stored year is kept |
Four-digit year |
Example request
curl -b cookies.txt -X POST https://api.example.com/api/company/session/period \
-H 'Content-Type: application/json' \
-d '{"monthVal":"Jul","yearVal":2026}'
Success response — 201 Created
{
"error": false,
"company": {
"company_id": 1163,
"qbo_connection": 1,
"global_company_name": "Acme Manufacturing LLC",
"global_company_logo": "uploads/company/1163.png"
},
"carryOver": {
"company_id": 1163,
"asOnDate": "2026-07-01",
"monthVal": "Jul",
"yearVal": 2026
}
}
Fields are identical to GET /api/company/session.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Side effect: rewrites
session.carryOverwith a recomputedasOnDate(always the 1st of the resolved month) and leavessession.companyuntouched. - Because the period lives in the shared session, changing it on Monthly Close carries it to every other screen — the same way the company does.
8.9 Notifications
Returns the 10 most recent notifications for the signed-in user plus a total count — the header bell feed.
| Module | Authentication, Session & Core > Notifications |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. Which notifications a user sees is decided per source server-side — e.g. invoice-rejection rows only reach users holding the Invoice Manager role (id 14) |
| Content type | application/json |
Example request
curl -b cookies.txt https://api.example.com/api/user-based/notification
Success response — 200 OK
{
"notifications": [
{
"id": "invoice-approval-9814",
"date": "08/28/2026",
"time": "04:12 PM",
"subtitle": "Approval required for INV-10422 Invoice",
"title": "Jane Doe raised the Invoice(INV-10422). You Can Approve/Reject the Invoice.",
"subject": null,
"notificationsCount": 1,
"user": "237",
"enc_id": "U0ZkV1ZtMTNaR1p0",
"invoiceBatchId": 9814,
"invoiceNo": "INV-10422",
"fromInvoice": true,
"strtotime": 1787932320
}
],
"notificationsCount": 27
}
| Field | Type | Description |
|---|---|---|
notifications |
array | The 10 newest items, sorted by strtotime descending |
notificationsCount |
number | Total number of notifications available (not the length of the array above) |
Each notification item is an untyped object whose keys vary by source. Common keys across sources: id, date (MM/DD/YYYY), time, title, subtitle, subject, user, strtotime (unix seconds; the only sort key). Source markers and extras include fromTimesheet + week, fromInvoice + enc_id/invoiceBatchId/invoiceNo, fromComment + attribute/invoiceNo, and fromMonthlyClose.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Aggregates four sources — timesheet approvals/rejections, invoice approvals/rejections, comment threads, and Monthly Close — merged in the order
timesheet → invoice → comments → monthly close, which is what decides display order wheneverstrtotimevalues tie. - Read-only; nothing is marked as read or written back.
Returns the complete notification list for the signed-in user — the "See All" screen.
| Module | Authentication, Session & Core > Notifications |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session; per-source visibility rules apply as above |
| Content type | application/json |
Example request
curl -b cookies.txt https://api.example.com/api/user-based/notification/all
Success response — 200 OK
[
{
"id": "invoice-approval-9814",
"date": "08/28/2026",
"time": "04:12 PM",
"subtitle": "Approval required for INV-10422 Invoice",
"title": "Jane Doe raised the Invoice(INV-10422). You Can Approve/Reject the Invoice.",
"subject": null,
"notificationsCount": 1,
"user": "237",
"enc_id": "U0ZkV1ZtMTNaR1p0",
"invoiceBatchId": 9814,
"invoiceNo": "INV-10422",
"fromInvoice": true,
"strtotime": 1787932320
},
{
"id": "timesheet-approval-237-2026-W34",
"date": "08/24/2026",
"time": "09:03 AM",
"subtitle": "Approval required for John Smith Timesheet",
"title": "Timesheet approval required for the John Smith for the week 08/15/2026 - 08/21/2026",
"subject": "",
"week": "2026-W34",
"user": "237",
"fromTimesheet": true,
"strtotime": 1787814180
}
]
The response is a bare array (not wrapped in an object), sorted by strtotime descending, with no 10-item cap. Item fields are the same untyped, source-dependent shape described for GET /api/user-based/notification.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Same aggregation and ordering as the bell feed; only the pagination differs.
- Takes no query parameters or request body.
8.10 Company & Group Tree
Returns the company/group hierarchy behind the shared "Select company/group" picker, plus flat lists of every visible group and company.
| Module | Authentication, Session & Core > Company & Group Tree |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. Visibility is role-driven server-side: a Global Company Administrator sees every group and every company with no firm filter; everyone else is scoped to their own firm |
| Content type | application/json |
Example request
curl -b cookies.txt -X POST https://api.example.com/api/get/web-treeview -H 'Content-Length: 0'
Success response — 201 Created
{
"tree": [
{
"id": 42,
"name": "Est's Firm(test) Accounting Group",
"expandable": true,
"child": {
"group": [
{
"id": 57,
"name": "Boston project Group",
"expandable": true,
"child": {
"group": [],
"company": [{ "id": 1163, "name": "Acme Manufacturing LLC" }]
}
}
],
"company": [{ "id": 1201, "name": "Northwind Traders Inc" }]
}
}
],
"group": [
{ "id": 42, "name": "Est's Firm(test) Accounting Group" },
{ "id": 57, "name": "Boston project Group" }
],
"company": [
{ "id": 1163, "name": "Acme Manufacturing LLC" },
{ "id": 1201, "name": "Northwind Traders Inc" }
],
"cacheKey": "_TREE_DATA_237_3"
}
| Field | Type | Description |
|---|---|---|
tree |
array | Root group nodes |
tree[].id / name |
number / string | Group identity |
tree[].expandable |
boolean | Whether the row renders an expand toggle. Decided on the raw child pointers, so a group whose only child pointer is orphaned still shows a toggle over an empty body |
tree[].child.group |
array | Nested group nodes, same shape, recursive |
tree[].child.company |
array | { id, name } companies attached to this node, in junction-row order |
group |
array | Flat list of every visible group as { id, name } |
company |
array | Flat list of every visible company as { id, name } |
cacheKey |
string | _TREE_DATA_<userId>_3 — a client-side cache key |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Takes no request body, query or path parameters despite being a POST.
- The canonical path is
api/get/web-treeview. A separate Monthly Close endpoint is available atapi/get/treeview; the two are not interchangeable — this route returns the tree behind the shared "Select company/group" picker, while the Monthly Close route returns that module's own tree, built by different rules. - Companies attach to every node, root or nested, with no gate — a group with companies but no child groups is still expandable.
firm_internal_group/firm_internal_companyrows are not hidden from the tree.- Soft-deleted companies (
deleted = 1) still appear as leaves; onlydeleted = 2placeholder rows drop out. Groups apply whichever soft-delete column the schema has. - Recursion is capped at a depth of 20 as a defence against malformed parent pointers.
8.11 Role Map Diagnostics
Read-only dry run that compares what the role-map engine would derive for a set of companies against what the database currently holds. Used to validate Client Success → Configure role propagation.
| Module | Authentication, Session & Core > Role Map Diagnostics |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session — any authenticated user may call it. It performs no writes |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyIds |
number[] | Yes | Array, Numeric (numeric strings are coerced), each item must be an integer. Ids are then de-duplicated and any id <= 0 is dropped |
Companies to compare |
Example request
curl -b cookies.txt -X POST https://api.example.com/api/utilities/configure/debug/role-map-compare \
-H 'Content-Type: application/json' \
-d '{"companyIds":[1163,1201]}'
Success response — 201 Created
{
"error": false,
"totalCompanies": 2,
"mismatches": 1,
"allMatch": false,
"report": [
{
"companyId": 1163,
"firmId": 340,
"primaryAccountant": { "computed": 512, "current": 512, "match": true },
"mcManager": { "computed": 488, "current": 488, "match": true },
"invoiceApprovers": { "computed": [512, 907], "current": [512], "match": false }
},
{
"companyId": 1201,
"firmId": 340,
"primaryAccountant": { "computed": null, "current": null, "match": true },
"mcManager": { "computed": 488, "current": 488, "match": true },
"invoiceApprovers": { "computed": [], "current": [], "match": true }
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success, true for the "no usable ids" case |
totalCompanies |
number | Count of distinct valid ids received |
mismatches |
number | How many companies differ in at least one of the three roles |
allMatch |
boolean | mismatches === 0 |
report[].companyId / firmId |
number | The company and the firm it was grouped under |
report[].primaryAccountant |
object | { computed, current, match } — user id or null, from mc_peer_review_primary.primary_accountant |
report[].mcManager |
object | { computed, current, match } — user id or null, from client_assignments.team_leader |
report[].invoiceApprovers |
object | { computed: number[], current: number[], match } — from assign_user_roles where role_id = 13 |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | No valid company ids after filtering (not an HTTP error) | { "error": true, "message": "companyIds required." } |
| 400 | Request validation failure (e.g. companyIds not an array of integers) |
{ "message": ["each value in companyIds must be an integer number"], "error": "Bad Request", "statusCode": 400 } |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Strictly read-only — SELECT queries only, no derived table is written.
- Companies are grouped by
company.firm_id; a company whosefirm_idis null or 0 is silently skipped and will not appear inreport. - "Computed" values are derived by the role-map engine from
service_roles,service_role_usersandcs_override_roles, with override-beats-default resolution; "current" values are read straight from the live derived tables. - Diagnostic/support endpoint, not part of any end-user screen.
Read-only diagnostic dump of the raw inputs the role-map engine reads for a single company — useful when role-map-compare reports a mismatch and you need to see why.
| Module | Authentication, Session & Core > Role Map Diagnostics |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session — any authenticated user may call it. It performs no writes |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced), integer. A value of 0 is additionally rejected |
The company to dump inputs for |
Example request
curl -b cookies.txt -X POST https://api.example.com/api/utilities/configure/debug/role-map-inputs \
-H 'Content-Type: application/json' \
-d '{"companyId":1163}'
Success response — 201 Created
{
"error": false,
"companyId": 1163,
"companyFirmId": 340,
"firm": { "id": 88, "user_id": 340, "use_service_role": 1 },
"roleFirmId": 340,
"engineCurrentScope": "firm_id = ?",
"engineRoleCount": 2,
"engineRoles": [
{
"id": 21,
"role": "Senior Accountant",
"firm_id": 340,
"deleted": 0,
"primary_accountant": 1,
"mc_manager": 0,
"invoice_approver": 1
},
{
"id": 22,
"role": "Close Manager",
"firm_id": 340,
"deleted": 0,
"primary_accountant": 0,
"mc_manager": 1,
"invoice_approver": 0
}
],
"companyServiceRoleUsers": [
{ "service_role_id": 21, "user_id": 512, "deleted": 0 },
{ "service_role_id": 22, "user_id": 488, "deleted": 0 }
],
"companyOverrides": [
{
"service_role_id": 22,
"override_primary_accountant": 0,
"override_mc_manager": 1,
"override_invoice_approver": 0,
"deleted_at": null
}
],
"involvedRoles": [
{
"id": 21,
"role": "Senior Accountant",
"firm_id": 340,
"deleted": 0,
"primary_accountant": 1,
"mc_manager": 0,
"invoice_approver": 1
},
{
"id": 22,
"role": "Close Manager",
"firm_id": 340,
"deleted": 0,
"primary_accountant": 0,
"mc_manager": 1,
"invoice_approver": 0
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
companyId |
number | Echo of the requested company |
companyFirmId |
number | company.firm_id, or 0 when the company does not exist |
firm |
object | { id, user_id, use_service_role } from firms (values are null when no firm row matches) |
roleFirmId |
number | The firm scope the engine actually uses: 0 when use_service_role = 0, otherwise companyFirmId |
engineCurrentScope |
string | The literal SQL predicate applied to service_roles — "firm_id IS NULL" or "firm_id = ?" |
engineRoleCount |
number | Number of service_roles rows in that scope |
engineRoles |
array | Those rows: id, role, firm_id, deleted, primary_accountant, mc_manager, invoice_approver |
companyServiceRoleUsers |
array | service_role_users rows for this company: service_role_id, user_id, deleted |
companyOverrides |
array | cs_override_roles rows for this company: service_role_id, the three override_* flags, deleted_at |
involvedRoles |
array | The service_roles rows referenced by the two lists above, whatever their firm scope — this is how an out-of-scope role shows up |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | companyId resolves to 0 (not an HTTP error) |
{ "error": true, "message": "companyId required." } |
| 400 | Request validation failure (e.g. companyId not an integer) |
{ "message": ["companyId must be an integer number"], "error": "Bad Request", "statusCode": 400 } |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- SELECT-only, no writes.
- A company id that matches no row returns
companyFirmId: 0and a firm block of nulls rather than a 404. - Diagnostic/support endpoint, not part of any end-user screen.
9. Admin
The Admin module is the administrative back office of Genie: it owns companies (create, edit, activate/deactivate/delete, logo, billing details, reporting-group definitions, chart-of-accounts mapping and industry verticals), the company-group hierarchy and the per-group user access matrices, the user directory (invite, roles, module/company access, impersonation, 2FA type, profile picture, and the "User Check" reassignment workflow), reporting groups, the global activity/audit log, and the QuickBooks Online sync-status board.
Every route in this module is served under the /api prefix. The caller is resolved from the session on every request: an established, authenticated session is required, otherwise the request fails with 401 { "message": "These credentials do not match our records." }. All role and scope checks are enforced server-side.
Request validation is applied globally, so request bodies and query strings are validated against the schemas documented below, unknown properties are stripped, and numeric fields are coerced from strings. A failed validation returns 400 { "message": [...], "error": "Bad Request", "statusCode": 400 }.
Many identifiers accept either a plain numeric id or the application's encrypted (base64) id: 123, 123_suffix and encrypted strings are all accepted, and an identifier that cannot be resolved is treated as 0. Responses that carry an id usually also carry an encryptId / encryptedId for use in subsequent calls.
9.1 Company
Company administration: the company pickers used across the app, the Admin > Company list, company creation (single and bulk Excel import), status changes, the company edit screen (details, billing, logo), the per-company reporting-group definitions, the chart-of-accounts to reporting-group mapping, and industry / business-model verticals.
Returns the company dropdown for the signed-in user, scoped by their role and by their access to the requested module.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the backend scopes results by role — a global admin (Global Master System Administrator 1 / Global Company Administrator 3 / Global Company Group Administrator 4) is unscoped, everyone else is narrowed to the companies they hold access to for the requested module |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
module |
number | No | Integer, coerced from string | Menu id to scope company access by. Defaults to 3 (Monthly Close) when omitted; the value 1 is remapped to 81 (Tasks & Time). |
Example request
curl -X POST https://api.example.com/api/company/list \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"module": 81}'
Success response — 200 OK
{
"companies": [
{
"id": 412,
"name": "Northwind Services",
"encId": "3kan9eKI6bt+Qq1w==",
"firmId": 27,
"firmInternalCompany": 0,
"deleted": 0
}
]
}
| Field | Type | Description |
|---|---|---|
companies[].id |
number | Company id |
companies[].name |
string | Company name ('' when null) |
companies[].encId |
string | Encrypted company id, accepted by every company_id field in this section |
companies[].firmId |
number | Owning firm id (0 when null) |
companies[].firmInternalCompany |
number | 1 when this is the firm's internal-accounting company |
companies[].deleted |
number | 0 active, 1 inactive, 2 deleted |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | module is not an integer |
Validation error array |
| 401 | No authenticated session, or the user row is missing/deleted | { "message": "These credentials do not match our records." } |
Notes
- The module id drives an access-scope lookup, so the same user can see different companies for Tasks & Time than for Monthly Close.
Returns the "Account" (firm) dropdown shown above the Company dropdown for a Global Master System Administrator, who has no firm of their own to scope by.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Example request
curl https://api.example.com/api/firm/list \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"accounts": [
{ "id": 27, "name": "Acme Accounting Partners" },
{ "id": 41, "name": "Beacon CPA Group" }
]
}
| Field | Type | Description |
|---|---|---|
accounts[].id |
number | Firm's user_id (the id POST /api/firm/company/list expects as firmId) |
accounts[].name |
string | Firm account name |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Compatibility route: retained for compatibility with earlier clients.
- Schema-adaptive: returns
{ "accounts": [] }when neither afirmsnor afirmtable (or its id/name columns) exists. - Non-deleted firms only, ordered by account name ascending.
Returns the Company dropdown for one specific firm — the second step after a Global Master System Administrator picks an Account from GET /api/firm/list.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | Yes | Integer, coerced from string | Firm user_id from GET /api/firm/list |
Example request
curl -X POST https://api.example.com/api/firm/company/list \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId": 27}'
Success response — 200 OK
{
"companies": [
{ "id": 412, "name": "Northwind Services", "encId": "3kan9eKI6bt+Qq1w==", "firmId": 27 }
]
}
| Field | Type | Description |
|---|---|---|
companies[].id |
number | Company id |
companies[].name |
string | Company name |
companies[].encId |
string | Encrypted company id |
companies[].firmId |
number | Owning firm id |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId missing or not an integer |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Compatibility route: retained for compatibility with earlier clients.
- Results are not narrowed by the caller's own company access, because the caller has already chosen the firm to drill into. Ordered by company name ascending.
firmIdis required and must be an integer.
The Admin > Company list: a paged, searchable company table plus every page-level flag and dropdown source the screen needs.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No hard role gate; four separate role tests shape the response — isGlobalAdmin = roles 1/3/4, canFilterAccounts = roles 1/3, canAddCompany = roles 1/3 and not a firm admin, canSelectAccountForAdd = roles 1/2. Non-global, non-firm-admin users are narrowed to their mapped companies. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
content |
string | No | String | Free-text search on company name |
selectCompany |
string | No | String | Show mode. show_active (default) or show_all |
selcet_company |
number | No | Integer | Accounts filter: restrict to this firm id. Honoured only for canFilterAccounts users. The field name is spelled exactly as shown and must be sent that way. |
offset |
number | No | Integer, minimum value 0 | Row offset. Default 0 |
limit |
number | No | Integer, minimum value 1 | Page size. Default 50, clamped to a maximum of 200 |
Example request
curl -X POST https://api.example.com/api/company/view_all \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"content": "north", "selectCompany": "show_active", "offset": 0, "limit": 50}'
Success response — 200 OK
{
"firms": [{ "user_id": 27, "account_name": "Acme Accounting Partners", "plan_type": 2 }],
"canFilterAccounts": true,
"canAddCompany": true,
"canSelectAccountForAdd": true,
"firmAdmin": false,
"isGlobalAdmin": true,
"userPlanTypes": [2, 3],
"isMultiple": false,
"defaultPlan": 0,
"firmPlanType": 0,
"runningQueue": false,
"companyData": [
{
"id": 412,
"encryptId": "3kan9eKI6bt+Qq1w==",
"name": "Northwind Services",
"total_tasks": 7,
"firm_internal_company": 0,
"qbo_connection": 1,
"deleted": 0,
"active": 1,
"isCurrent": true,
"last_used": "Aug 21, 2026"
}
],
"currentCompany": {
"id": 412,
"encryptId": "3kan9eKI6bt+Qq1w==",
"name": "Northwind Services",
"qbo_connection": 1
},
"total": 128
}
| Field | Type | Description |
|---|---|---|
firms |
array | Account options; populated only for a global company admin |
canFilterAccounts |
boolean | Render the Accounts filter (roles 1/3) |
canAddCompany |
boolean | Render the add/import/download toolbar (roles 1/3 and not a firm admin) |
canSelectAccountForAdd |
boolean | Render the Accounts select inside the add form (roles 1/2) |
firmAdmin |
boolean | Caller is a firm admin |
isGlobalAdmin |
boolean | Caller holds role 1 |
userPlanTypes |
number[] | Hardcoded [2, 3] — 2 = Accounting Tier Plan, 3 = All Modules Plan |
isMultiple |
boolean | Mirrors firmAdmin |
defaultPlan |
number | Always 0 |
firmPlanType |
number | Firm's plan type; 0 unless the caller is a firm admin |
runningQueue |
boolean | A companyImport queue job is currently running for this user |
companyData[] |
array | The page of company rows |
companyData[].total_tasks |
number | Open task count (same predicate the inactivate guard uses) |
companyData[].deleted |
number | 0 active, 1 inactive, 2 deleted |
companyData[].isCurrent |
boolean | Row is the session's currently selected company |
companyData[].last_used |
string | updated_at formatted as MMM DD, YYYY (zero-padded day) |
currentCompany |
object | null | The session's selected company, or null |
total |
number | Total matching rows, ignoring offset/limit |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Invalid offset / limit / selcet_company |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
show_allincludes inactive and soft-deleted companies;show_activedoes not.- When the caller has an empty access scope, the response still carries all page meta but
companyData: [],currentCompany: null.
Search variant of the company list. Identical contract and behaviour to POST /api/company/view_all.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Same as POST /api/company/view_all |
| Content type | application/json |
Request body — identical to POST /api/company/view_all.
Example request
curl -X POST https://api.example.com/api/company/search \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"content": "north"}'
Success response — 200 OK. Identical shape to POST /api/company/view_all.
Errors — identical to POST /api/company/view_all.
Notes
- Compatibility route: retained for compatibility with earlier clients;
POST /api/company/view_allis the canonical route. Both routes share one implementation, so the search response carries the full page metadata as well as the matching rows.
Creates a company, seeds its accounting defaults and service roles, attaches it to a company group, and makes it the session's selected company.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires (role 1 Global Master System Administrator OR role 3 Global Company Administrator) AND not a firm admin, or the caller is a firm admin. accUserId is honoured only for roles 1/2. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_name |
string | Yes | String | New company name. Trimmed; must be non-empty and globally unique |
plan_type |
number | No | Integer | userPlanRadio from the "Choose plan type" block — 2 = Accounting Tier Plan, 3 = All Modules Plan |
accUserId |
number | No | Integer | Firm user_id to create the company under (the add form's Accounts select). Honoured only for roles 1/2; otherwise the caller's own firm is used |
domain |
string | No | String | Company domain |
group_id |
number | No | Integer | Company group to attach the new company to |
Example request
curl -X POST https://api.example.com/api/company/newcompany \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_name": "Northwind Services", "plan_type": 3, "accUserId": 27, "domain": "northwind.example", "group_id": 88}'
Success response — 200 OK
{
"error": false,
"id": 412,
"encryptId": "3kan9eKI6bt+Qq1w==",
"name": "Northwind Services",
"redirect": "/admin/company/3kan9eKI6bt%2BQq1w%3D%3D"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id |
number | New company id |
encryptId |
string | Encrypted company id |
name |
string | Stored company name |
redirect |
string | Client route for the new company's edit screen |
Business-rule failures return 200 with an error envelope instead of an HTTP error:
{ "error": true, "msg": "Company Name already Exist" }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | company_name blank after trim |
{ "error": true, "msg": "Please provide company name" } |
| 200 | A non-deleted company already has that name (global, case-insensitive) | { "error": true, "msg": "Company Name already Exist" } |
| 400 | company_name missing / wrong type |
Validation error array |
| 401 | No authenticated session, or caller may not add companies | { "message": "These credentials do not match our records." } |
Notes
- Company names are unique across the whole install, not per firm.
- Side effects: seeds the four per-company accounting config tables from
accounting_default; attaches the company to the firm's group (orgroup_idwhen supplied); seeds service roles; writessession.login.companyandsession.login.carryOver, so the new company becomes the active one everywhere. - The new company's
firm_idis set to the selected account (or the caller's own firm when no account is selectable).
Live duplicate check for the company-name field in the add form.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
company_name |
string | Yes | — | Name to test. String; compared case-insensitively after trimming |
Example request
curl -G https://api.example.com/api/company/check_company_exists \
--data-urlencode 'company_name=Northwind Services' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "is_company": "exists" }
| Field | Type | Description |
|---|---|---|
is_company |
string | "exists" when a non-deleted company already has that name, otherwise "add" |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | company_name missing |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- A blank name short-circuits to
{"is_company": "add"}. - The lookup is global (not firm-scoped) and matches only
deleted = 0rows — unlikePOST /api/company/newcompany, which also blocks names held by inactive companies.
Builds the bulk-import Excel template for a firm and returns a one-shot download link.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json (the file itself is fetched from the returned link) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
plan_type |
number | No | Integer | Accepted by the request body schema but not used by this endpoint |
firm_id |
number | No | Integer | Firm whose company groups seed the template's dropdown sheet. Falls back to the caller's firm |
Example request
curl -X POST https://api.example.com/api/company/import/template/download \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firm_id": 27}'
Success response — 200 OK
{ "redirect": "/api/company/import/template/generated/1f0c2b1e-8a4a-4c1d-9a71-3ad0f1f9c2ab" }
| Field | Type | Description |
|---|---|---|
redirect |
string | Path to GET /api/company/import/template/generated/:token, which streams the workbook |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Generates a two-sheet
.xlsx: Company Info (header rowCompany Name,Plan Type,Company Domain,Company Group,Industry Verticals,Business Model Verticals, plus one sample row) and Available Data (valid plan types, the firm's company groups, industry verticals, business-model verticals). - The generated workbook is held as temporary server-side storage and referenced by a single-use token. The download itself is performed by
GET /api/company/import/template/generated/:token, which must be served by the same application instance that produced the workbook; see Deployment & Operational Considerations.
Uploads a filled-in import workbook, validates every row, and queues the valid ones for background company creation.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | multipart/form-data |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
file |
file | Yes | Extension must be .xlsx, .xls or .xlx |
The filled-in template, sent under the field name file |
plan_type |
number | No | Integer | Accepted by the request body schema but not used by this endpoint |
firm_id |
number | No | Integer | Firm to import the companies into. Falls back to the caller's firm |
Example request
curl -X POST https://api.example.com/api/company/import/template \
-b 'genie.sid=<session-cookie>' \
-F 'file=@Company Import Template.xlsx' \
-F 'firm_id=27'
Success response — 200 OK
{
"error": false,
"message": "Data are Imported Successfully.",
"createdCount": 14,
"errorData": [],
"runningQueue": true
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false only when at least one row imported and no row failed |
message |
string | Toast text. Omitted entirely when errorData is non-empty |
createdCount |
number | Rows accepted for import |
errorData |
array | Rejected rows with their reasons; drives the error modal |
errorExportLink |
string | Present only when errorData is non-empty: link to an .xlsx export of the rejected rows |
runningQueue |
boolean | number | true when this call started a queue job, otherwise the current companyImport queue state |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No file, or an unsupported extension | { "error": true, "message": "Please provide Excel sheet format supported file.", "runningQueue": 0 } |
| 200 | Workbook has no data rows | { "error": true, "message": "Empty Excel Sheet.Please put on some data to import.", "runningQueue": 0 } |
| 200 | Header row does not match the template | { "error": true, "message": "Invalid Excel File. Columns are not matched with the template.", "runningQueue": 0 } |
| 200 | Every row was rejected | { "error": true, "message": "No Data to Import.", "createdCount": 0, "errorData": [], "runningQueue": 0 } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The header must match
Company Name | Plan Type | Company Domain | Company Group | Industry Verticals | Business Model Verticalsexactly. - Valid rows are inserted asynchronously by a
queue_managerjob; the response returns before the import finishes. PollGET /api/company/import/queue/statusfor completion. - When the response contains error rows it carries no
message, so the client shows only the error modal and no toast. - Duplicate detection is scoped to the target firm and treats
deleted IN (0, 1)names as taken.
Reports whether a bulk company-import job is currently running for the caller.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Example request
curl https://api.example.com/api/company/import/queue/status \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "runningQueue": true }
| Field | Type | Description |
|---|---|---|
runningQueue |
boolean | A companyImport queue row is still running for this user |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Intended for polling after
POST /api/company/import/templatereturnsrunningQueue: true.
Streams a previously generated import template workbook and then deletes it.
| Module | Admin > Company |
| Authentication | Not required — possession of the single-use token is the only credential |
| Authorization | No role requirement; the single-use token is the only credential |
| Content type | Binary .xlsx download, delivered as Company Import Template.xlsx |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | UUID returned in the redirect of POST /api/company/import/template/download (or of an import error export) |
Example request
curl -OJ https://api.example.com/api/company/import/template/generated/1f0c2b1e-8a4a-4c1d-9a71-3ad0f1f9c2ab
Success response — 200 OK, binary body, with Content-Disposition: attachment; filename="Company Import Template.xlsx".
Response body: binary workbook — not JSON.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 404 | Token unknown or already consumed | { "error": true, "message": "File not found." } |
Notes
- Single use: the token is invalidated before the download starts and the temporary file is removed once the transfer completes. A second request for the same token returns 404.
- The download must be served by the same application instance that generated the workbook, so multi-instance deployments must preserve instance affinity for these routes. See Deployment & Operational Considerations.
Called on every company-row click: pins the company into the session, touches its "Last used" timestamp, and returns the edit URL plus registration state.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | The company must be inside the caller's access scope; additionally, a non-global-company-admin (not roles 1/3/4) may only target a company belonging to their own firm |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company to select |
type |
number | No | Integer | Ignored by this route; the request body schema is shared with POST /api/company/delete |
Example request
curl -X POST https://api.example.com/api/company/redirect \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w=="}'
Success response — 200 OK
{
"error": false,
"id": 412,
"encryptId": "3kan9eKI6bt+Qq1w==",
"qbo_connection": 1,
"steps": 5,
"no_register": 5,
"company_logo": "company-412-1756700000000.png",
"companyEditUrl": "/admin/company/3kan9eKI6bt%2BQq1w%3D%3D"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success |
id / encryptId |
number / string | Selected company |
qbo_connection |
number | 1 when QuickBooks is connected |
steps / no_register |
number | company.reg_steps — how far registration got; the client uses it to redirect to the registration wizard instead of the edit screen |
company_logo |
string | Stored logo file name ('' when none) |
companyEditUrl |
string | Client route for the company edit screen |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | company_id does not resolve, or the company does not exist |
"Company not found." |
| 401 | Company outside the caller's scope, or belongs to another firm | { "message": "These credentials do not match our records." } |
Notes
- The route is a
POSTbecause it mutates the session and updates the company row. - Side effects: writes
session.login.companyandsession.login.carryOver(resetting the as-on period) and updatescompany.updated_at, which is what the list's "Last used" column renders. - The firm check is enforced server-side, so a firm admin cannot pin another firm's company into their session by posting a raw company id.
Reactivates, deactivates, or soft-deletes a company.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | type = 2 (delete) requires role 1 (Global Master System Administrator). type = 0 / 1 have no additional role gate. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company to act on |
type |
number | Yes in practice | Optional Integer — but a missing value becomes NaN and is rejected as an invalid status type |
0 = make active, 1 = make inactive, 2 = delete |
Example request
curl -X POST https://api.example.com/api/company/delete \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w==", "type": 1}'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | false when the status change was applied |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | type = 1 and the company still has open tasks |
{ "error": true, "msg": "The company was unable to be inactive. This company has 7 open task(s)." } |
| 400 | Company not found | "Company not found." |
| 400 | type is not 0, 1 or 2 |
"Invalid company status type." |
| 401 | type = 2 without role 1 |
{ "message": "These credentials do not match our records." } |
Notes
- The company name is rewritten to carry the state:
"<name>","<name> (inactive)"or"<name> (deleted)", alongsidedeleted= 0/1/2 andactive= 1/0/0. - On delete the QuickBooks realm id is prefixed with
D_andqbo_connectionis cleared. - On every transition the company's
company_group_companiesmemberships are removed, and saved Task List "Company" filters are rewritten to follow the rename. - The open-task guard uses the same predicate as the list's
total_tasks, so the client count and the server guard can never disagree.
Loads the company edit screen: core details, billing information, the edit permission flag and the QuickBooks reconnection warning.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Company must be inside the caller's access scope, and (unless the caller holds roles 1/3/4) must belong to their firm. canEdit is false for an inactive/deleted company unless the caller is a firm admin or holds role 1. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Company id, plain or encrypted |
Example request
curl https://api.example.com/api/company/details/3kan9eKI6bt%2BQq1w%3D%3D \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"company": {
"id": 412,
"encryptId": "3kan9eKI6bt+Qq1w==",
"name": "Northwind Services",
"domain": "northwind.example",
"budget": 1,
"qbo_connection": 0,
"logo": "company-412-1756700000000.png",
"logo_url": "/api/company/logo/3kan9eKI6bt%2BQq1w%3D%3D"
},
"billing": {
"id": 91,
"company_id": 412,
"billing_name": "Northwind Services LLC",
"billing_email": "ap@northwind.example",
"address": "1 Harbour Way, Portland OR"
},
"canEdit": true,
"warning": "QuickBooks connection not found for Northwind Services. Please click here to reconnect."
}
| Field | Type | Description |
|---|---|---|
company.budget |
number | company.actual_vs_budjet — the Actual vs Budget flag (column name misspelled in the schema) |
company.logo_url |
string | Link to GET /api/company/logo/:id, or '' when no logo is stored |
billing |
object | Raw user_billing_info row for the company; {} when there is none |
canEdit |
boolean | Drives the Edit button; every mutation on this screen re-checks it server-side |
warning |
string | Non-empty only when qbo_connection = 0 |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Id does not resolve, or the company row is missing | "Company not found." |
| 401 | Company outside the caller's scope or another firm's | { "message": "These credentials do not match our records." } |
Notes
- Both this endpoint and
POST /api/company/saveenforce the company scope check server-side, so an authenticated session cannot read or rename a company outside its scope by id. billingis read straight fromuser_billing_infoand its exact columns depend on the deployment's schema.
Saves the company edit form (name, domain, budget flag).
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Same scope check as GET /api/company/details/:id; additionally rejects the save when canEdit is false |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company to update |
company_name |
string | Yes | String; must be non-empty after trimming | New name |
company_domain |
string | No | String | Written to company.domain |
budget |
number | No | Integer | Written to company.actual_vs_budjet |
Example request
curl -X POST https://api.example.com/api/company/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w==", "company_name": "Northwind Services", "company_domain": "northwind.example", "budget": 1}'
Success response — 200 OK
{ "error": false, "id": 412, "name": "Northwind Services" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id |
number | Company id |
name |
string | Stored name |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Another company in the same firm already uses that name | { "error": true, "msg": "Company name already exist." } |
| 400 | company_name blank after trim |
"Company name is required." |
| 400 | Company not found | "Company not found." |
| 401 | Outside scope, or the company is read-only (canEdit = false) |
{ "message": "These credentials do not match our records." } |
Notes
- The duplicate check here is firm-scoped and treats
deleted IN (0, 1)as taken — narrower than the global check onPOST /api/company/newcompany. - Side effects on rename: saved Task List "Company" filters are rewritten, the
companyToReplaceplaceholder inuser_logsis substituted, the company is (re-)attached to the firm's first company group with the saver's mapping, andsession.login.company.global_company_nameis refreshed so the header repaints without a re-login.
Uploads a company logo image.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | The company must belong to the caller's firm |
| Content type | multipart/form-data |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
file |
file | Yes | Extension must be .png, .jpg, .jpeg, .gif, .webp or .svg; must be non-empty |
The image, under the field name file |
company_id |
string | Yes | String; plain or encrypted id | Company to attach the logo to |
Example request
curl -X POST https://api.example.com/api/company/logo/upload \
-b 'genie.sid=<session-cookie>' \
-F 'file=@logo.png' \
-F 'company_id=3kan9eKI6bt+Qq1w=='
Success response — 200 OK
{ "error": false, "logo_url": "/api/company/logo/3kan9eKI6bt%2BQq1w%3D%3D" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
logo_url |
string | Link to GET /api/company/logo/:id |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No file, or an empty buffer | { "error": true, "message": "Please choose an image file." } |
| 200 | Extension not in the allow-list | { "error": true, "message": "Unsupported image format." } |
| 401 | Company belongs to another firm | Rejected by the firm-ownership check |
Notes
- The image is kept as temporary server-side storage under a generated file name of the form
company-<id>-<timestamp><ext>, and only that file name is stored incompany.logo. Logo storage is local to the application instance that received the upload, so deployments must preserve instance affinity for the logo routes and must expect stored logos not to survive an instance restart. See Deployment & Operational Considerations.
Removes a company's logo file and clears company.logo.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | The company must belong to the caller's firm |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company whose logo to remove |
Example request
curl -X POST https://api.example.com/api/company/logo/remove \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w=="}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Company belongs to another firm | Rejected by the firm-ownership check |
Notes
- Deleting the stored image file is best-effort; the
company.logocolumn is cleared regardless.
Streams a company's logo image.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | The company must belong to the caller's firm |
| Content type | binary download — image/png, image/jpeg, image/gif, image/webp, image/svg+xml, or application/octet-stream by extension |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Company id, plain or encrypted |
Example request
curl https://api.example.com/api/company/logo/3kan9eKI6bt%2BQq1w%3D%3D \
-b 'genie.sid=<session-cookie>' --output logo.png
Success response — 200 OK, raw image bytes with Content-Type set from the stored file's extension.
Response body: binary image — not JSON.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 404 | No logo recorded, or the file is missing from server-side storage | { "error": true, "message": "Logo not found." } |
| 401 | Company belongs to another firm | Rejected by the firm-ownership check |
Notes
- This is the URL returned as
logo_urlbyGET /api/company/details/:idandPOST /api/company/logo/upload.
Upserts the company's billing contact block.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company to bill |
billing_name |
string | No | String | Billing contact name; trimmed |
billing_email |
string | No | String — no email-format validation | Billing email; trimmed |
address |
string | No | String | Billing address; trimmed |
Example request
curl -X POST https://api.example.com/api/company/billInfo/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w==", "billing_name": "Northwind Services LLC", "billing_email": "ap@northwind.example", "address": "1 Harbour Way, Portland OR"}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | company_id missing |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Writes to
user_billing_info, inserting when no row exists for the company and updating otherwise. Every column is written only when present in the deployment's schema. - Returns
{ "error": false }unchanged when theuser_billing_infotable is absent — the save silently no-ops. - Unlike the other company-edit routes, this one performs no company-scope or
canEditcheck.
Loads the "Define Reporting Groups" screen for a company: the global groups, the company's custom groups and the Name/Number mode.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Company must be inside the caller's scope and (unless roles 1/3/4) belong to their firm; canEdit follows the same read-only rule as GET /api/company/details/:id |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Company id, plain or encrypted |
Example request
curl https://api.example.com/api/company/defineGroups/3kan9eKI6bt%2BQq1w%3D%3D \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"canEdit": true,
"define_group_type": 1,
"global_groups": [
{ "id": 1, "report_name": "Revenue", "type": 1, "is_removable": 0 },
{ "id": 2, "report_name": "Cost of Goods Sold", "type": 1, "is_removable": 0 }
],
"custom_groups": [
{ "id": 55, "name": "Owner Draws", "type": 2, "is_removable": 1 }
]
}
| Field | Type | Description |
|---|---|---|
canEdit |
boolean | When false, the client hides New Group, the row actions and the Name/Number switch |
define_group_type |
number | 1 = map by account Name, 2 = map by account Number. Defaults to 1 |
global_groups[] |
array | Install-wide reporting groups (global_account_groups), in id order |
global_groups[].type |
number | 1 = Income Statement, 2 = Balance Sheet |
global_groups[].is_removable |
number | 0 for the nine protected P&L/BS groups |
custom_groups[] |
array | The company's own groups (custom_account_group), in id order |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Company not found | "Company not found." |
| 401 | Company outside scope or another firm's | { "message": "These credentials do not match our records." } |
Notes
- Groups are ordered by id, not by name: id order is the P&L reading order accountants expect (Revenue, COGS, Cost of Service Delivery, Marketing and Sales, G&A, AR, AP, Other Income, Other Expense).
Creates a new reporting group — either a global one or a company-specific custom one.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Company scope check plus canEdit; a read-only company rejects the create |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Owning company (used for scope, and for custom groups also for storage) |
group_type |
number | Yes | Integer | 1 = create a global group, anything else = create a custom group |
type |
number | Yes | Integer | Statement the group belongs to — 1 = Income Statement, 2 = Balance Sheet |
custom_name |
string | Yes | String; non-empty after trim | Group name |
Example request
curl -X POST https://api.example.com/api/company/defineGroups \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w==", "group_type": 2, "type": 1, "custom_name": "Owner Draws"}'
Success response — 200 OK
{ "error": false, "id": 55, "name": "Owner Draws" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id |
number | New group id |
name |
string | Stored name |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Global group name already used | { "error": true, "msg": "Global reporting group name already exist" } |
| 200 | Custom group name already used for this company | { "error": true, "msg": "Custom reporting group name already exist" } |
| 200 | Backing table missing | { "error": true } |
| 400 | custom_name blank after trim |
"Group name is required." |
| 401 | Outside scope, or company is read-only | { "message": "These credentials do not match our records." } |
Notes
- The
canEditgate is enforced server-side, not only by hiding the Add Group button in the client. - New global groups are created with
is_removable = 1, so they can be deleted again; the nine seeded groups cannot.
Renames an existing reporting group.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | For a custom group the owning company must be in scope and editable (canEdit). A global group has no company to scope by and therefore no additional check. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer | Group id to rename |
type |
string | Yes | String | Group kind. Treated as global when the string contains global (case-insensitive), otherwise custom |
name |
string | Yes | String; non-empty after trim | New name |
Example request
curl -X POST https://api.example.com/api/company/defineGroups/edit \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id": 55, "type": "custom", "name": "Owner Distributions"}'
Success response — 200 OK
{ "error": false, "msg": "Updated successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | New global name collides with another global group | { "error": true, "msg": "Global reporting group name already exist" } |
| 200 | New custom name collides within the same company | { "error": true, "msg": "Custom reporting group name already exist" } |
| 400 | name blank after trim |
"Group name is required." |
| 400 | Custom group id not found | "Group not found." |
| 401 | Owning company outside scope, or read-only | { "message": "These credentials do not match our records." } |
Notes
- Renaming enforces the same name-uniqueness rule as creation, using the same predicate, so a rename cannot introduce a duplicate name.
- Authorisation for a custom group is derived from the group's stored
company_id, not from anything the caller sends.
Soft-deletes a reporting group, unless it is still referenced by Report Writer.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | For a custom group, the owning company must be in scope and editable. A global group has no additional check. |
| Content type | application/json (request body on a DELETE) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
g_id |
number | Yes | Integer | Group id to delete |
type |
string | Yes | String | Treated as global when the string contains global, otherwise custom |
Example request
curl -X DELETE https://api.example.com/api/company/defineGroups \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"g_id": 55, "type": "custom"}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | The group is referenced in Report Writer | { "error": true, "msg": "Cannot remove this Custom Reporting Group. Because, it was added in Report Writer" } (Global when the group is global) |
| 400 | Custom group id not found | "Group not found." |
| 401 | Owning company outside scope, or read-only | { "message": "These credentials do not match our records." } |
Notes
- Soft delete only: sets
deleted = 1(plusactive = 0/deleted_atwhen those columns exist). Custom groups are additionally constrained bycompany_idin theUPDATE. - The Report Writer usage check runs against
report_writer_detailsand resolves its account/group-type columns defensively across schema variants.
Switches a company between mapping its chart of accounts by account Name and by account Number.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Company scope check plus canEdit |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company to update |
type |
number | Yes | Integer; must be 1 or 2 |
1 = by Name, 2 = by Number |
Example request
curl -X POST https://api.example.com/api/company/defineGroups/type/edit \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w==", "type": 2}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | type is neither 1 nor 2 |
"Invalid define group type." |
| 400 | Company not found | "Company not found." |
| 401 | Outside scope, or read-only | { "message": "These credentials do not match our records." } |
Notes
typeis validated against the allowed values and the submittedcompany_idis checked for ownership before the update is applied.
Loads the chart-of-accounts to reporting-group mapping grid for one statement, in either by-name or by-number mode.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Company scope check; canEdit is returned as a hint and re-enforced on save |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company |
type |
number | Yes | Integer | Statement: 1 = Income Statement, 2 = Balance Sheet. Defaults to 1 if absent from the parsed body |
group_mode |
number | No | Integer | 1 = map by account name, 2 = map by account-number range. Default 1 |
changes |
number | No | Integer | Set to 1 from the "Changes have been made to the Chart of Accounts" banner; narrows the account list to changed accounts |
Example request
curl -X POST https://api.example.com/api/company/mapping/get \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w==", "type": 1, "group_mode": 1}'
Success response — 200 OK
{
"canEdit": true,
"type": 1,
"group_mode": 1,
"global_groups": [{ "id": 1, "report_name": "Revenue", "type": 1 }],
"custom_groups": [{ "id": 55, "name": "Owner Draws", "type": 1 }],
"accounts": [
{
"id": 9001,
"qbo_id": "84",
"account_name": "Consulting Income",
"account_num": "4000",
"account_type": "Income",
"active": 1,
"deleted": 0,
"global_group_id": 1,
"custom_group_ids": [55]
}
],
"ranges": [
{ "global_group_id": 1, "custom_group_id": 0, "from": "4000", "to": "4999" }
]
}
| Field | Type | Description |
|---|---|---|
canEdit |
boolean | Read-only hint for the client |
type / group_mode |
number | Echo of the resolved request values |
global_groups[] / custom_groups[] |
array | Groups available for this statement, in id order |
accounts[] |
array | Chart-of-accounts rows for the statement's account types |
accounts[].account_name |
string | Falls back to qbo_id when blank |
accounts[].account_num |
string | Falls back to "-" when blank |
accounts[].global_group_id |
number | Currently mapped global group, 0 when none |
accounts[].custom_group_ids |
number[] | Currently mapped custom groups |
ranges[] |
array | By-number mapping ranges (populated in group_mode = 2) |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Company not found | "Company not found." |
| 401 | Company outside scope or another firm's | { "message": "These credentials do not match our records." } |
Notes
- The account-type filter is fixed per statement:
type = 1covers Income, Other Income, Cost of Goods Sold, Expense, Other Expense;type = 2covers Accounts Payable, Accounts Receivable, Bank, Credit Card, Equity, Fixed Asset, Long Term Liability, Other Current Liability, Other Current Asset, Other Asset. - Existing mappings come from
accounting_group_config(by name) andaccounting_group_config_by_number(ranges).
Saves the chart-of-accounts mapping for one statement and one mapping mode.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Company scope check plus canEdit. Any role that can reach the company may save unless the company is read-only. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company |
type |
number | Yes | Integer | Statement: 1 = Income Statement, 2 = Balance Sheet. Falls back to 1 |
group_mode |
number | Yes | Integer | 1 = by name, 2 = by number range. Falls back to 1 |
account_group_mode |
number | No | Integer | For by-number saves: 1 = Global column, 2 = Custom column. Anything other than 2 is treated as 1. Default 1 |
data |
unknown | No | Optional, untyped | The mapping payload. Must be an array of row objects; any other value is treated as an empty array |
Example request
curl -X POST https://api.example.com/api/company/mapping/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"company_id": "3kan9eKI6bt+Qq1w==",
"type": 1,
"group_mode": 1,
"account_group_mode": 1,
"data": [{ "qbo_id": "84", "global_group_id": 1, "custom_group_ids": [55] }]
}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Company not found | "Company not found." |
| 401 | Outside scope, or read-only (canEdit = false) |
{ "message": "These credentials do not match our records." } |
Notes
datais untyped: its element shape is whatever the mapping screen posts, and it is interpreted server-side according togroup_mode.- By-number ranges are cleaned up per company, statement and scope (Global or Custom), so saving one scope never affects the ranges saved for the other.
- Access is governed by the company-scoped
canEditcheck rather than by a global-admin/firm-admin-only gate, so roles such as Company Group Company Administrator can save mappings for companies they can reach. - Returns
{ "error": false }unchanged when the required by-number columns are missing from the schema.
Loads the Industry Verticals and Business Model Verticals pickers for a company.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Company scope check; canEdit is returned as a hint and re-enforced on save |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company |
Example request
curl -X POST https://api.example.com/api/company/industry/get \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w=="}'
Success response — 200 OK
{
"canEdit": true,
"verticals": {
"selected": [{ "vertical_id": 12, "name": "Professional Services", "vertical_type": 1 }],
"available": [{ "id": 15, "name": "Retail", "vertical_type": 1 }]
},
"models": {
"selected": [],
"available": [{ "id": 31, "name": "Subscription", "vertical_type": 2 }]
}
}
| Field | Type | Description |
|---|---|---|
canEdit |
boolean | Read-only hint; enforced server-side on save |
verticals.selected[] |
array | Industry verticals (vertical_type = 1) already mapped to the company; carries vertical_id, name, vertical_type |
verticals.available[] |
array | Unselected industry verticals; carries id, name, vertical_type |
models.selected[] / models.available[] |
array | Same, for Business Model verticals (vertical_type = 2) |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Company not found | "Company not found." |
| 401 | Company outside scope or another firm's | { "message": "These credentials do not match our records." } |
Notes
selectedrows come from thevertical_mappingjoin and therefore use the keyvertical_id;availablerows come fromverticalsand useid. Both lists are ordered by name.- Returns empty lists rather than erroring when the
verticals/vertical_mappingtables are absent.
Replaces a company's selected verticals for one vertical type.
| Module | Admin > Company |
| Authentication | Required — authenticated session cookie |
| Authorization | Company scope check plus canEdit |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String; plain or encrypted id | Company |
vertical_type |
number | Yes | Integer, one of 1, 2 |
1 = Industry Verticals, 2 = Business Model Verticals |
verticalArr |
number[] | Yes | Array, maximum 500 items, each item must be an integer, minimum value 1 | The complete selected set for that type. May be empty to clear the selection |
Example request
curl -X POST https://api.example.com/api/company/industry/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id": "3kan9eKI6bt+Qq1w==", "vertical_type": 1, "verticalArr": [12, 15]}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | vertical_type not 1 or 2, or verticalArr exceeds 500 entries / contains non-positive integers |
Validation error array |
| 400 | Company not found | "Company not found." |
| 401 | Outside scope, or read-only | { "message": "These credentials do not match our records." } |
Notes
- Ids whose stored
vertical_typedoes not match the submittedvertical_typeare silently dropped rather than rejected; the rest of the payload is still applied. - The 500-item limit bounds the per-element database work each save performs.
- Returns
{ "error": false }unchanged when thevertical_mappingtable is absent.
9.2 Company Groups
The company-group hierarchy: the group tree, group creation and editing, the placement preview, per-group user access matrices, and the "Manage Access to Internal Firm Accounting" screen.
Three role predicates recur across this subsection:
- View —
users.global_admin = 1, or one of roles 1 (Global Master System Administrator), 4 (Global Company Group Administrator), 8 (Company Group Administrator). There is no firm-admin bypass. - Manage —
global_admin,firm_admin, or one of roles 1 / 4 / 8. - Delete —
global_adminor role 1 only. Firm admins and role 4 do not hold it.
A "role-8 scoped actor" (holds role 8, is not a firm admin and not a global admin) is further restricted: they see only the group trees containing a group they are mapped to, and cannot create or remove user access.
Returns the company-group tree, optionally filtered by a search term, plus the tree's action flags.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | View required. A Global Master System Administrator sees every group; a role-8 scoped actor sees only their mapped trees; everyone else is firm-scoped. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
query |
string | No | String | Free-text filter applied to the built tree (case-insensitive) |
Example request
curl -X POST https://api.example.com/api/company-group/list \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"query": "north"}'
Success response — 200 OK
{
"canEdit": true,
"canDelete": true,
"canDetachCompany": true,
"tree": [
{
"id": 88,
"encryptId": "Yk9wQmJ4TA==",
"name": "Northwest Region",
"type": "group",
"firm_internal_group": 0,
"children": [
{ "id": 412, "encryptId": "3kan9eKI6bt+Qq1w==", "name": "Northwind Services", "type": "company", "children": [] }
]
}
],
"totalGroups": 14
}
| Field | Type | Description |
|---|---|---|
canEdit |
boolean | Manage permission — render New Group / Edit |
canDelete |
boolean | Delete permission (Global Master System Administrator only) |
canDetachCompany |
boolean | Mirrors canDelete; gates the company-leaf "x" |
tree[] |
array | Nested nodes with id, encryptId, name, type (group | company), optional firm_internal_group, and children |
totalGroups |
number | Group count before the search filter |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller lacks View | "You do not have access to company groups." |
Notes
- The tree is not filtered by
company_group.firm_idfor a global admin, who has no firm of their own; such a caller sees every group.
Creates a company group.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Manage required. A global creator (global admin, or role 1 / role 4) creates the group unstamped — no firm id — and its name is checked globally; everyone else creates inside their own firm and is checked within it. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_name |
string | No | String | Group name. Either this or g_name must be present and non-empty |
g_name |
string | No | String | Alternative spelling of group_name, accepted for compatibility with earlier clients; used when group_name is absent |
parent_id |
number | No | Integer | Parent group id. Default 0 (root) |
Example request
curl -X POST https://api.example.com/api/company-group \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"group_name": "Northwest Region", "parent_id": 0}'
Success response — 200 OK
{
"error": false,
"id": 88,
"encryptId": "Yk9wQmJ4TA==",
"message": "Company Group Added Successfully",
"msg": "Company Group Added Successfully"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id / encryptId |
number / string | New group |
message / msg |
string | The same success text under both keys; msg is retained for compatibility with earlier clients |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Name already used in the applicable scope | { "error": true, "message": "Company Group Already exist", "msg": "Company Group Already exist" } |
| 400 | Both name fields blank after trim | "Group name field is required" |
| 400 | company_group table missing |
"Company group table is unavailable." |
| 401 | Caller lacks Manage | "Not Allowed to add company group" |
Notes
company_group.managerisNOT NULLwith no default, so a new group is written withmanager = 0; the Manager select is hidden in the UI.- The parent column is resolved defensively across
parent/parent_id/parent_group_id.
Loads the company-group detail screen: the group, its parent options, child-group and company checklists, the users with access, and the placement preview tree.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | View required |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Company group id, plain or encrypted |
Example request
curl https://api.example.com/api/company-group/Yk9wQmJ4TA%3D%3D \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"canEdit": true,
"group": {
"id": 88,
"encryptId": "Yk9wQmJ4TA==",
"group_name": "Northwest Region",
"parent": 0,
"firm_internal_group": 0,
"manager": 0
},
"firmType": 2,
"noCompanySelect": 0,
"isFirmGroup": false,
"parentOptions": [{ "id": 12, "encryptId": "MTI=", "group_name": "All Regions" }],
"companyGroupOptions": [{ "id": 91, "group_name": "Portland Cluster", "checked": true }],
"companyOptions": [],
"usersWithAccess": [],
"users": [],
"viewerUserType": 2,
"buttonDisable": false,
"moduleRows": [{ "id": 81, "menu_name": "Tasks & Time" }],
"placementTree": []
}
| Field | Type | Description |
|---|---|---|
canEdit |
boolean | Manage permission |
group |
object | The group itself; manager is the hidden Manager select value (0 = none) |
firmType |
number | firms.plan_type — 0 none/global, 1 End Company plan, 2 Firm plan. Picks the default access mode |
noCompanySelect |
number | 1 when neither this group nor any child owns a company; gates "New User Access" |
isFirmGroup |
boolean | true only for a firm admin viewing their firm's first group; gates the "Manage Access to Internal Firm Accounting" link |
parentOptions[] |
array | Groups eligible as parent (firm-internal groups excluded) |
companyGroupOptions[] |
array | Child-group checklist with current checked state |
companyOptions |
array | Companies that are unassigned or already this group's |
usersWithAccess / users |
array | Users currently mapped to the group, and the assignable-user picker |
viewerUserType |
number | The viewer's users.user_type, which drives the "( End Company Admin )" vs "( Firm Admin )" badge |
buttonDisable |
boolean | true for a role-8 scoped actor: hides "New User Access" and the row trash (row edit stays available) |
moduleRows[] |
array | Modules for the access matrix (id, menu_name) |
placementTree |
array | Preview tree with the active group marked; nodes carry isParent / isActive |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller lacks View | "You do not have access to company groups." |
Notes
companyOptionslists non-deleted companies that are not attached to any other group. Unchecking a box is what detaches a company — there is no separate remove control on this screen.companyGroupOptionsandparentOptionsboth exclude firm-internal accounting groups, so those groups cannot be nested under another group.
Saves the company-group detail form: name, parent, child groups, member companies and the Manager select.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Manage required |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_group_id |
string | Yes | String; plain or encrypted id | Group to update |
group_name |
string | Yes | String; non-empty after trim | New name |
parent_id |
number | No | Integer | New parent. 0 = root. Cannot be the group itself or one of its descendants |
companyGroupArr |
number[] | No | Array | Child group ids to attach |
companyArr |
number[] | No | Array | Company ids to attach |
userList |
number | string | No | Optional, untyped | The hidden Manager select value; persisted to company_group.manager (0 = none) |
Example request
curl -X POST https://api.example.com/api/company-group/details/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"company_group_id": "Yk9wQmJ4TA==",
"group_name": "Northwest Region",
"parent_id": 12,
"companyGroupArr": [91],
"companyArr": [412, 413],
"userList": 0
}'
Success response — 200 OK
{
"error": false,
"message": "Company group details saved successfully.",
"msg": "Information saved successfully",
"userAccessUpdated": true,
"alertMsg": 1
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
message / msg |
string | Success text under both keys |
userAccessUpdated |
boolean | Membership changes caused user access to be recomputed |
alertMsg |
number | 1 when userAccessUpdated; the UI raises a delayed second toast ("User Access has been updated for all the users …") |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Name already used in the firm | { "error": true, "message": "Company Group Already exist", "msg": "Company Group Already exist" } |
| 400 | group_name blank after trim |
"Group name field is required" |
| 400 | Group not found | "Company group not found." |
| 400 | parent_id equals the group id |
"Group cannot be its own parent." |
| 400 | parent_id is a descendant of the group |
"Circular hierarchy is not allowed." |
| 401 | Caller lacks Manage | "Not allowed to edit company groups." |
Notes
- Attaching or detaching companies and child groups fans out into
user_company_mapping/user_group_accessso members' effective access follows the new membership; that fan-out is what setsuserAccessUpdated. userListcarries the Manager select value and is the field that maintainscompany_group.managerfrom this screen.
Renders a preview of where a group and its selected children/companies would sit in the tree, using unsaved checkbox state.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Manage required |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_group_id |
string | No | String; plain or encrypted id | Group being placed. Falls back to group_id |
group_id |
string | No | String; plain or encrypted id | Alias for company_group_id |
parent_id |
number | No | Integer | Proposed parent. 0 re-roots the group |
companyGroupArr |
number[] | No | Array | Proposed child group ids |
childIdArr |
number[] | No | Array | Additional proposed child group ids (merged with companyGroupArr) |
companyArr |
number[] | No | Array | Proposed company ids |
companyIdArr |
number[] | No | Array | Additional proposed company ids (merged with companyArr) |
Example request
curl -X POST https://api.example.com/api/company-group/placement \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_group_id": "Yk9wQmJ4TA==", "parent_id": 12, "companyGroupArr": [91], "companyArr": [412]}'
Success response — 200 OK
{
"placementTree": [
{
"id": 12,
"encryptId": "MTI=",
"name": "All Regions",
"type": "group",
"isParent": true,
"children": [
{
"id": 88,
"encryptId": "Yk9wQmJ4TA==",
"name": "Northwest Region",
"type": "group",
"isActive": true,
"children": []
}
]
}
]
}
| Field | Type | Description |
|---|---|---|
placementTree[] |
array | The active group's subtree wrapped in its ancestor header. Nodes carry id, encryptId, name, type, optional firm_internal_group, isParent, isActive, children |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Caller lacks Manage | "Not allowed to preview company group placement." |
Notes
- Read-only: nothing is persisted.
- Companies are de-duplicated by id, and a ticked company that is not yet attached to any group is included in the preview.
- Unlike the list, this preview is always scoped to the caller's own firm.
Loads the per-group user-access editor: the assignable users, the module matrix, and the selected user's current grants.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Manage required. The group must belong to the caller's firm; a supplied user_id must be assignable and mutable in this group. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_id |
string | Yes | String; plain or encrypted id | Company group |
user_id |
string | No | String; plain or encrypted id | The user whose current access to load. Omit for the empty "New User Access" state |
mode |
string | No | One of new, edit |
Which entry point this call serves. new (default) = the New User Access picker, edit = the Edit Access button. Changes only how splUser is computed |
Example request
curl -X POST https://api.example.com/api/company-group/user-access/load \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"group_id": "Yk9wQmJ4TA==", "user_id": "1042", "mode": "edit"}'
Success response — 200 OK
{
"users": [{ "id": 1042, "name": "Reed, Alex", "email": "alex@acme.example" }],
"modules": [{ "id": 81, "menu_name": "Tasks & Time" }],
"reportDashboard": false,
"checkRole": false,
"roleName": "",
"splUser": 0,
"firmAdmin": 1,
"endclientUser": 0,
"userType": 2,
"companies": [{ "id": 412, "name": "Northwind Services" }],
"firmType": 2,
"selectedUserId": 1042,
"currentAccess": {
"company_access": 1,
"accessType": 1,
"moduleAccessData": { "81": 3 },
"separateAccessData": {}
},
"selected_user_id": 1042,
"current_access": {
"company_access": 1,
"accessType": 1,
"moduleAccessData": { "81": 3 },
"separateAccessData": {}
},
"check_role": 0,
"view": null
}
| Field | Type | Description |
|---|---|---|
users[] |
array | Assignable users; users already mapped to the group are filtered out unless they are the selected user |
modules[] |
array | Modules for the matrix — the firm's top menus minus Home/Feedback and menu 97 |
reportDashboard |
boolean | Target holds role 9 (Company Group Report and Dashboard Administrator); menus 4 and 5 lock to Administrator |
checkRole |
boolean | Target holds role 1, 7, 8 or 9 — the UI shows a red role warning and hides the Company Access selector |
roleName |
string | Comma-space joined role names, only when checkRole |
splUser |
number | 1 forces Company Access to Administrator and hides its selector. Computed from the target in mode: "new" and from the viewer in mode: "edit" |
firmAdmin |
number | 1 when the viewer is a firm admin |
endclientUser |
number | 1 when the target's users.user_type is 1 (End Client User) |
userType |
number | Target's users.user_type — 1 End Client User, 2 Firm User, 3 End Company User |
companies[] |
array | Companies of the whole group subtree — the set the Separate-access grid is built from |
firmType |
number | firms.plan_type |
currentAccess |
object | null | company_access (1/2), accessType (1 = group access, 2 = separate/per-company), moduleAccessData {menuId: level}, separateAccessData {companyId: {menuId: level}} |
selected_user_id / current_access / check_role |
— | Aliases of the fields above, retained for compatibility with earlier clients |
view |
null | Always null |
Access levels used throughout the matrices: 1 No Access, 2 View Only, 3 Standard, 4 Administrator.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Caller lacks Manage | "Not allowed to view user access settings." |
| 401 | Group belongs to another firm, or the user is not assignable / not mutable here | Rejected by the corresponding ownership or assignability check |
Notes
- This one endpoint serves both the New User Access picker and the Edit Access button;
modeis what distinguishes them, and it affects only howsplUseris derived. - The matrices cover the whole group subtree's companies, not just the group's direct companies.
Grants or updates one user's access within a company group, in either group-wide or per-company mode.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Manage required; group must belong to the caller's firm and the target user must be assignable. A role-8 scoped actor may only edit a user already mapped to the group — they cannot create a new grant. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_id |
string | Yes | String; plain or encrypted id | Company group |
user_id |
string | Yes | String; plain or encrypted id | User to grant |
accessType |
number | No | Integer | 1 = group-wide access (default), 2 = separate per-company access. Any value other than 2 is treated as 1 |
company_access |
number | No | Integer | Company Access level flag; 2 when explicitly 2, otherwise 1. Falls back to the accessData.companyAccessData payload |
moduleAccessData |
object | No | Optional, Record<string, number> |
Group-wide grid, { menuId: level }. Used when accessType = 1 |
separateAccessData |
object | No | Optional, Record<string, Record<string, number>> |
Per-company grid, { companyId: { menuId: level } }. Used when accessType = 2 |
accessData |
object | No | Optional, untyped | Alternative envelope accepted for compatibility with earlier clients; accessData.moduleAccessData and accessData.companyAccessData are read when the top-level fields are absent |
firmType |
number | No | Integer | Firm plan type hint from the client |
Example request
curl -X POST https://api.example.com/api/company-group/user-access/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"group_id": "Yk9wQmJ4TA==",
"user_id": "1042",
"accessType": 1,
"company_access": 1,
"moduleAccessData": { "3": 4, "81": 3 }
}'
Success response — 200 OK
{
"error": false,
"message": "User access saved successfully.",
"msg": "Information saved successfully",
"user_id": 1042,
"user_name": "Alex Reed",
"user_email": "alex@acme.example",
"removedFromGroup": false
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
message / msg |
string | Success text under both keys |
user_id |
number | Target user |
user_name |
string | "First Last" (the table renders "Last, First"; both shapes exist so the client never has to guess) |
user_email |
string | Target email |
removedFromGroup |
boolean | true when every level in the grid was 1 (No Access) and the membership row was therefore removed |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | user_company_mapping has no group-mapping column |
"User company mapping schema is missing group mapping column." |
| 400 | Any other failure during the save | "Could not save user access: <underlying message>" (the full error is also logged server-side) |
| 401 | Caller lacks Manage | "Not allowed to manage access." |
| 401 | Role-8 scoped actor granting access to an unmapped user | "Not allowed to add user access." |
Notes
- All-No-Access short-circuit: when every level in the grid is
1, the membership row is soft-deleted so the user drops off the group's "Users with access" list — but the explicit all-1rows are still written touser_group_access. This applies to both access modes. - Writes an Admin > Logs activity entry carrying old vs new access; logging failures never break the save.
- The save covers the whole group subtree's companies, so a child company's grid is persisted along with the group's direct companies.
Revokes a user's access to a company group.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Manage required; group must belong to the caller's firm and the target must be assignable and mutable. A role-8 scoped actor is rejected outright. |
| Content type | application/json (request body on a DELETE) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_id |
string | Yes | String; plain or encrypted id | Company group |
user_id |
string | Yes | String; plain or encrypted id | User to revoke |
Example request
curl -X DELETE https://api.example.com/api/company-group/user-access \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"group_id": "Yk9wQmJ4TA==", "user_id": "1042"}'
Success response — 200 OK
{
"error": false,
"user_id": 1042,
"user_data": "Alex Reed | alex@acme.example",
"user_name": "Alex Reed",
"user_email": "alex@acme.example"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
user_id |
number | Revoked user |
user_data |
string | "<name> \| <email>" — the ready-formatted string the client re-inserts into the user picker without a reload |
user_name / user_email |
string | The same values split out |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | user_company_mapping has no group-mapping column |
"User company mapping schema is missing group mapping column." |
| 401 | Caller lacks Manage | "Not allowed to remove access." |
| 401 | Caller is a role-8 scoped actor | "Not allowed to remove user access." |
Notes
- Soft delete: mapping and access rows are flagged
deleted = 1rather than removed, so a revoke can be audited and reversed. - Writes an Admin > Logs activity entry (event type 3, "Group Access") capturing the access map before the revoke.
Deletes a company group and its whole descendant subtree.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Delete required — users.global_admin = 1 or role 1 (Global Master System Administrator) only. The group must belong to the caller's firm. |
| Content type | application/json (request body on a DELETE) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_id |
string | Yes | String; plain or encrypted id | Group to delete |
Example request
curl -X DELETE https://api.example.com/api/company-group \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"group_id": "Yk9wQmJ4TA=="}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Caller lacks Delete | "Not allowed to delete groups." |
| 401 | Group belongs to another firm | Rejected by the firm-ownership check |
Notes
- Cascades: every descendant is collected across both the
parentcolumn and thecompany_group_childjoin table, then all of them are soft-deleted. The removed root is detached from its parent chain, and child links, company memberships and user access rows pointing at any deleted group are cleaned up. - Falls back to a hard
DELETEonly when the table has no soft-delete column.
Loads the "Manage Access to Internal Firm Accounting" screen for the caller's firm.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Firm admin only (users.firm_admin = 1). When group_id is supplied it must be that firm's first group — the same gate the link itself uses. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_id |
string | No | String; plain or encrypted id | Optional company group. The screen has no id in its path, so the firm is normally derived from the session |
Example request
curl -X POST https://api.example.com/api/company-group/firm-accounting-managers \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{}'
Success response — 200 OK
{
"error": false,
"firmId": 27,
"firmInternalCompany": { "id": 511, "name": "Acme Internal Accounting" },
"firmCompanies": [{ "id": 412, "name": "Northwind Services" }],
"firmUsers": [{ "id": 1042, "name": "Alex Reed" }],
"accountingManagers": [{ "manager": 1042 }]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
firmId |
number | Firm the screen is scoped to (the group's firm_user_id when a group was supplied) |
firmInternalCompany |
object | null | The firm's internal-accounting company |
firmCompanies[] |
array | Companies under the firm |
firmUsers[] |
array | Users under the firm — the manager picker's source |
accountingManagers[] |
array | Currently assigned managers as { manager: <userId> }, filtered to users still under the firm |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Caller is not a firm admin, or the supplied group is not the firm's first group | "Not allowed to manage internal firm accounting." |
Notes
- The firm is derived server-side — from the session, or from the encrypted group id — and is never taken from the request body, so a caller cannot read another firm's internal-accounting setup.
Assigns the Firm Internal Accounting Manager role on the firm's internal company and rebuilds the corresponding internal group and access fan-out.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Firm admin only; same gate as POST /api/company-group/firm-accounting-managers |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_id |
string | No | String; plain or encrypted id | Optional company group used to derive the firm |
companyId |
number | Yes | Integer | The firm's internal-accounting company id. Must be greater than 0 |
managers |
array | No | Array; elements are number or { manager?: number } |
Users to assign as internal accounting managers. Both the object form and a plain array of ids are accepted |
Example request
curl -X POST https://api.example.com/api/company-group/save-firm-accounting-managers \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId": 511, "managers": [{ "manager": 1042 }, 1043]}'
Success response — 200 OK
{
"error": false,
"message": "Firm Internal Accounting Managers Created/Updated Successfully.",
"msg": "Firm Internal Accounting Managers Created/Updated Successfully.",
"groupId": 903
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
message / msg |
string | Success text under both keys |
groupId |
number | The firm-internal group created or reused for the access fan-out |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId is 0 or negative |
"Firm Internal Company Field is Required." |
| 400 | Any other failure during the save | "Could not save firm internal accounting managers: <underlying message>" (also logged server-side) |
| 401 | Caller is not a firm admin, or the group gate fails | "Not allowed to manage internal firm accounting." |
Notes
- The save touches
assign_user_roles,company,company_group,company_group_companies,user_company_mappinganduser_group_access. Database errors are logged server-side and returned as a400carrying a readable message. - The role assigned is Firm Internal Accounting Manager (role 17).
Detaches one company from one company group, without touching the group.
| Module | Admin > Company Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Delete required — users.global_admin = 1 or role 1. The group must belong to the caller's firm. |
| Content type | application/json (request body on a DELETE) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_id |
string | Yes | String; plain or encrypted id | Company group |
company_id |
string | Yes | String; plain or encrypted id | Company to detach |
Example request
curl -X DELETE https://api.example.com/api/company-group/company \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"group_id": "Yk9wQmJ4TA==", "company_id": "3kan9eKI6bt+Qq1w=="}'
Success response — 200 OK
{ "error": false, "msg": "Company removed from the company group." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Either id fails to resolve to a positive number | "Invalid company group or company selected." |
| 401 | Caller lacks Delete | "Not allowed to remove a company from this group." |
| 401 | Group belongs to another firm | Rejected by the firm-ownership check |
Notes
- This route backs the company-leaf "x" on the group tree. Detaching a company and deleting a child group are separate routes, so the two delete semantics are never selected by a flag on one route.
- Exactly one company link is removed — the one named by
company_id. The group's other company links are left intact. - Side effects: soft-deletes the
company_group_companieslink, clearscompany.company_group_idwhen it still points at this group, soft-deletes theuser_company_mappingrows this group created for the company, and soft-deletesuser_company_accessrows for users who no longer reach the company through any other group.
9.3 Users
The user directory and user detail screen: listing and searching users, inviting new ones, activating/deactivating/deleting them, the "User Check" dependency-reassignment workflow, impersonation, per-user roles, two-factor method, company/group access matrices, and the profile picture.
Two role predicates recur:
- View the user list —
users.firm_admin = 1, or any of roles 1, 2, 3, 4, 5, 6, 8. - Open another user's details — role 1, role 2, or firm admin. A user may always open their own details.
Every endpoint that names a target user resolves that user within the caller's own scope; a target outside that scope is reported as not found rather than as forbidden, so existence is not disclosed.
Returns the Admin > Users list (active users) together with every page-level flag and dropdown the screen needs.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | Firm admin, or one of roles 1 (Global Master System Administrator), 2 (Global User Administrator), 3, 4, 5, 6 (Standard), 8. Roles 1/2 see every firm's users; a role 7/8 non-firm-admin sees only their mapped groups' users; everyone else is firm-scoped. |
| Content type | application/json |
Example request
curl https://api.example.com/api/user/list \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"userData": [
{
"id": 1042,
"encryptId": "MTA0Mg==",
"first_name": "Alex",
"last_name": "Reed",
"lf_name": "Reed, Alex",
"email": "alex@acme.example",
"active": 1,
"deleted": 0,
"created_on": "Jun 02, 2022",
"created_at_display": "Jun 02, 2022",
"last_login": "Aug 21, 2026",
"role_name": "Standard",
"roleByCompany": [{ "companyName": "Northwind Services", "roleName": "Primary Accountant" }]
}
],
"allRoles": [{ "id": 8, "role_name": "Company Group Administrator" }],
"firm_admin": 0,
"firms": [{ "user_id": 27, "account_name": "Acme Accounting Partners", "plan_type": 2 }],
"canFilterAccounts": true,
"showAction": 1,
"parentUser": 27,
"authUserId": 1001,
"canImpersonate": true,
"canOpenDetails": true,
"firmAdmin": false,
"firmPlanType": 2
}
| Field | Type | Description |
|---|---|---|
userData[] |
array | User rows. Columns follow the users table plus the derived fields below |
userData[].created_on / created_at_display / last_login |
string | Formatted as MMM DD, YYYY with a zero-padded day; "-" when null or unparseable |
userData[].lf_name |
string | "Last, First" |
userData[].roleByCompany[] |
array | Company-scoped role assignments (companyName, roleName) |
allRoles[] |
array | Roles the caller may assign |
firm_admin |
number | 1 when the caller is a firm admin |
firms[] |
array | Accounts dropdown; populated only for roles 1/2 |
canFilterAccounts |
boolean | Render the Accounts select (roles 1/2) |
showAction |
number | 0 for a role 7/8 admin who is not a firm admin — hides the whole action-icon cluster |
parentUser |
number | The parent firm user id, or 0 when the caller is that user. Row actions are hidden for this user |
authUserId |
number | The caller's own id (rows for self hide their actions) |
canImpersonate |
boolean | Render "Login as this user" (role 1 or firm admin) |
canOpenDetails |
boolean | A row click navigates to details (roles 1/2 or firm admin) |
firmAdmin |
boolean | Which Add User affordance to render — firm admins get the popover, everyone else the New User modal |
firmPlanType |
number | Decides the user_type the firm-admin popover submits |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller holds none of the allowed roles | "You do not have access to the user list." |
Notes
- Equivalent to
POST /api/user/searchwithselectUser: "show_active"and no filters; the two share the same response envelope so the client can re-render the list from either.
Returns the Permission Exclusion configuration — the module_permission rows grouped by module.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | Firm admin (users.firm_admin = 1), global admin (users.global_admin = 1), or role 1 |
| Content type | application/json |
Example request
curl https://api.example.com/api/user/module/access \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"Tasks & Time": [
{
"id": 4,
"module": "Tasks & Time",
"sub_menu": "Projects",
"fields": "budget,rate",
"created_at": "2025-02-11 09:14:00",
"updated_at": null,
"deleted_at": null
}
]
}
The response is an object keyed by module name; each value is the array of that module's permission rows (id, module, sub_menu, fields, created_at, updated_at, deleted_at). Rows with a blank module name are dropped. Returns {} when the module_permission table does not exist.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller is neither firm admin, global admin, nor role 1 | "You do not have access to permission exclusions." |
Notes
- The role requirement is enforced server-side; the route is not reachable without an authenticated session that satisfies it.
Switches the session out of any selected company and into "Global" scope.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json (no request body) |
Example request
curl -X POST https://api.example.com/api/user/global \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"redirect": "/admin/company",
"company": {
"company_id": 0,
"qbo_connection": 0,
"global_company_name": "Global",
"global_company_logo": ""
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false |
redirect |
string | Client route to navigate to |
company |
object | The session's new company block. company_id: 0 is a real, sticky value meaning "Global", not "unset" |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Writes both
session.login.companyandsession.login.carryOver, so the whole shell re-scopes to Global.
Filtered/sorted user list. Returns the same envelope as GET /api/user/list minus allRoles.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | Same as GET /api/user/list |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
content |
string | No | String | Free-text search |
order_by |
string | No | String | Column to order by |
sort_by |
string | No | String | Sort direction |
s_id |
number | No | Integer | Accounts filter: restrict to this firm |
selectUser |
string | No | String | Show mode. show_active (default) restricts to active = 1 AND deleted = 0; show_all applies no active/deleted predicate, so deactivated and soft-deleted users are included |
Example request
curl -X POST https://api.example.com/api/user/search \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"content": "reed", "selectUser": "show_all", "s_id": 27}'
Success response — 200 OK
{
"userData": [],
"firms": [],
"canFilterAccounts": true,
"showAction": 1,
"parentUser": 27,
"authUserId": 1001,
"canImpersonate": true,
"canOpenDetails": true,
"firmAdmin": false,
"firmPlanType": 2
}
Fields are as documented for GET /api/user/list; allRoles and firm_admin are not included here.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller holds none of the allowed roles | "You do not have access to the user list." |
Notes
show_allapplies nodeletedpredicate, so soft-deleted users are returned alongside deactivated ones. Ashow_inactivesuperset mode is also accepted but is not offered by the standard UI.- The full page metadata is returned on every search, so the client can re-render the whole list from a single response.
Creates a user, assigns roles, and emails them a create-password link.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. s_id (create under another firm) is honoured only for roles 1/2; everyone else always creates inside their own firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
fname |
string | Yes | String | First name |
lname |
string | Yes | String | Last name |
email |
string | Yes | String; non-empty after trim. No email-format validation | Login email. Stored exactly as supplied; it is not lower-cased |
phone_no |
string | No | String | Phone number |
user_type |
number | No | Integer | New User modal's User Type — 1 Standard, 2 Administrator |
admin_type |
number[] | No | Array, Numeric (numeric strings are coerced) | Administrator-type role ids from the modal |
s_id |
number | No | Integer | Owning firm's user_id (the modal's Accounts select). 0/omitted means the caller's own firm. Honoured only for roles 1/2 |
end_user_type |
number | No | Integer | Set only by the firm-admin "Add new user" popover, which always assigns role 6 (Standard). Firm plan 2 → 2 ("Firm User"), firm plan 1 → 3 ("Firm End Client User"). Kept separate because user_type already means Standard/Administrator on the modal path |
Example request
curl -X POST https://api.example.com/api/user/add_new \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"fname": "Alex", "lname": "Reed", "email": "alex@acme.example", "phone_no": "+1 503 555 0142", "user_type": 1, "admin_type": [8], "s_id": 27}'
Success response — 200 OK
{
"error": false,
"msg": "New User Added Successfully",
"createPasswordLink": "https://app.example.com/create-password/8f2c…",
"createPasswordToken": "8f2c…"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
msg |
string | Success text |
createPasswordLink |
string | null | The password-setup URL, or null when a token could not be minted |
createPasswordToken |
string | null | The raw token behind that link |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | A non-deleted user already has that email | { "error": true, "msg": "Email already exist." } |
| 400 | email blank after trim |
"Email is required." |
| 400 | fname / lname / email missing or wrong type |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Duplicate detection is a plain equality against non-deleted users; emails are stored exactly as supplied and are not lower-cased first.
- The create-password email is sent fire-and-forget; a mail failure does not fail the request, and the link is also returned in the response.
- Writes a
user_addaudit entry recordinguser_type, role ids (defaulting to[6]) and email.
Applies a status change to a user: activate, deactivate, or delete.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String; plain or encrypted id | Target user |
type |
number | Yes | Integer; must be 1, 2 or 3 |
1 = activate, 2 = deactivate, 3 = delete |
Example request
curl -X POST https://api.example.com/api/user/save/active \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id": "1042", "type": 2}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 400 | type is not 1, 2 or 3 |
"Invalid status type." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
type = 1setsactive = 1, deleted = 0;type = 2setsactive = 0, deleted = 0;type = 3setsactive = 0, deleted = 1and rewrites the email to(deleted_<id>)<original email>so the address can be reused. All three stampdeleted_at = now().- This endpoint applies the change unconditionally. The "dependencies remaining" check is a client-side pre-check that routes to the User Check screen; the server does not block the status change.
- Writes a
user_status_active/user_status_inactive/user_status_deletedaudit entry.
Finalizes the User Check workflow — the "In Active" / "Delete" button that appears once every dependency count reaches zero.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String; plain or encrypted id | Target user |
type |
number | Yes | Integer | 2 = make inactive (renames the user), anything else = delete |
Example request
curl -X POST https://api.example.com/api/user/delete \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id": "1042", "type": 2}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Target not found or out of scope | { "error": true, "message": "Something Went Wrong" } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Unlike
POST /api/user/save/active, the inactive path here renames the user to"<name> (inactive)", stampsdeleted_at, and rewrites saved Report Builder and Task board filters so they follow the rename. - The delete path sets
active = 0, deleted = 1and prefixes the email with(deleted_<id>). - Writes a
user_check_inactive/user_check_deleteaudit entry. - No subscription-billing side effect is triggered by this endpoint.
Loads the "User Check" screen: every open dependency the user still holds, grouped into sections, with the alternatives available for reassignment.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String; plain or encrypted id | User being checked |
type |
number | No | Integer | The pending action — 2 = deactivate (default), 3 = delete. Echoed back so the screen knows which button to show |
Example request
curl -X POST https://api.example.com/api/user/check \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id": "1042", "type": 2}'
Success response — 200 OK
{
"error": false,
"user": { "id": 1042, "name": "Reed, Alex", "email": "alex@acme.example" },
"sections": [
{
"key": "company_invoice_approver",
"count": 2,
"rows": [{ "company_id": 412, "company_name": "Northwind Services" }]
}
],
"alternatives": [{ "id": 1043, "name": "Jordan Vale" }],
"timesheetAlternatives": [{ "id": 1044, "name": "Sam Ortiz" }],
"companyServiceTeam": {
"412": [{ "id": 7, "name": "Bookkeeping", "user": "Jordan Vale" }]
},
"totalItems": 2,
"type": 2
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success |
user |
object | The user being checked (id, name, email) |
sections[] |
array | One entry per dependency area: key, count, rows. Row shape varies per section |
alternatives[] |
array | Firm users the dependency can be reassigned to |
timesheetAlternatives[] |
array | Narrower list used only by the Timesheet Approver popup: users who already hold an authorized timesheet_config row for this firm |
companyServiceTeam |
object | Keyed by company id; the service-line team for companies appearing in the Company Level Invoice Approver section |
totalItems |
number | Sum of every section's count — the screen enables the finalize button when this reaches zero |
type |
number | Echo of the requested action |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Section coverage includes firm-level and company-level invoice managers/approvers, service-line approvers, timesheet approvers, Monthly Close primary/reviewer assignments, tasks and service-team memberships. The exact section keys are determined server-side and are not part of the request schema.
- The Timesheet Approver popup uses
timesheetAlternatives, not the genericalternativeslist; the Invoice Manager popup usesalternatives.
Applies one section's reassignments from the User Check screen and returns the refreshed counts.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String; plain or encrypted id | User being checked |
type |
number | Yes | Integer | The pending action (2 deactivate / 3 delete); passed straight back into the refresh |
changes |
object | No | Optional, Record<string, unknown> |
Reassignments to apply, keyed by section. Untyped: the keys are the section keys returned by POST /api/user/check and the values are that section's row payloads |
Example request
curl -X POST https://api.example.com/api/user/check/update \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"id": "1042",
"type": 2,
"changes": { "company_invoice_approver": [{ "company_id": 412, "new_user_id": 1043 }] }
}'
Success response — 200 OK. The response is exactly the payload of POST /api/user/check with id and type from this request — the recomputed sections, counts and totalItems.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- This endpoint only applies the submitted reassignments and re-reads the counts; it never changes the user's status. The deactivate or delete step is a separate call to
POST /api/user/delete. - Writes a
user_check_updateaudit entry naming the sections touched.
Impersonates another user ("Login as this user"): rewrites the session to that user and pins their first accessible company.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | Role 1 (Global Master System Administrator) or firm admin. The target must resolve inside the caller's scope. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
string | Yes | String; plain or encrypted id | User to impersonate |
Example request
curl -X POST https://api.example.com/api/subscription/user/switch \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"user_id": "1042"}'
Success response — 200 OK
{ "error": false, "msg": "Switched user successfully.", "switchedUserId": 1042 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
msg |
string | Success text |
switchedUserId |
number | The impersonated user id. When greater than 0 this endpoint rewrites the session |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Target is inactive or deleted | { "error": true, "msg": "User is inactive or deleted!" } |
| 400 | Target not found or out of scope | "User not found." |
| 403 | Caller is neither role 1 nor a firm admin | "You are not allowed to switch to this user." |
Notes
- On a positive
switchedUserIdthe session is rewritten:session.login.userIdbecomes the impersonated user,session.login.stateis set to'authenticated'andsession.login.loginAsis set totrue, and the change is persisted to the session store. company,carryOver,authTypeandotpGeneratedAtare cleared first, then the impersonated user's first accessible company is pinned, so the impersonated session does not retain the administrator's previously selected company.- The "global first company" selection branch applies only when the impersonated user's id is
1.
Loads the user detail screen: profile, roles, module/company access, multi-inbox emails, and the flags that lock parts of the form.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | Opening another user's details requires role 1, role 2, or firm admin. A user may always open their own. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Target user id, plain or encrypted |
Example request
curl https://api.example.com/api/user/details/1042 \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"userData": {
"id": 1042,
"encryptId": "MTA0Mg==",
"first_name": "Alex",
"last_name": "Reed",
"email": "alex@acme.example",
"qrImage": null
},
"multiInboxes": [],
"disableEmail": false,
"menuData": [],
"menuAccessData": [],
"company_id": 0,
"isUserTypeStd": true,
"isUserTypeAdmin": false,
"roleArr": [6],
"accessArr": [],
"firmType": 2,
"user": {},
"new_access": {},
"allRoles": [],
"targetRoleNames": "Standard",
"roleVisible": true,
"reportDashboard": false,
"AccessContentShow": true,
"AuthFirmAdmin": 1,
"parentUser": 1001,
"profile": 0,
"isParentFirmUser": false
}
| Field | Type | Description |
|---|---|---|
userData |
object | The target's users row plus id, encryptId, and qrImage (an inline data-URL TOTP QR when the user's auth type is Google Authenticator, otherwise null) |
multiInboxes[] |
array | Multi-inbox email rows |
disableEmail |
boolean | The inbox store is unavailable — hides the "+" button |
menuData / menuAccessData |
array | Module list and the target's current module access |
company_id |
number | Always 0 |
isUserTypeStd / isUserTypeAdmin |
boolean | Standard vs Administrator user type |
roleArr |
number[] | The target's assigned role ids |
accessArr |
array | The target's company/group access rows |
firmType |
number | firms.plan_type |
user / new_access |
object | Current access state and the new-access editor payload |
allRoles[] |
array | Roles the caller may assign |
targetRoleNames |
string | The target's full role-name list, used for the New Company/Group Access modal warning |
roleVisible / AccessContentShow |
boolean | Section visibility flags |
reportDashboard |
boolean | Target holds the report/dashboard admin role |
AuthFirmAdmin |
number | 1 when the caller is a firm admin |
parentUser |
number | The caller's own id |
profile |
number | 1 when viewing one's own profile |
isParentFirmUser |
boolean | Target is the parent-firm user — User Type / Admin Type stay locked |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 403 | Opening another user's details without role 1/2 or firm admin | "You do not have access to this user." |
Notes
- Multi-inbox rows come from the
email_inboxestable, which is created on first use.
Same as GET /api/user/details/:id, but flags the request as the "profile" variant.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | Same as GET /api/user/details/:id |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Target user id, plain or encrypted |
type |
string | Yes | Any non-empty value turns on profile mode; the value itself is not inspected further |
Example request
curl https://api.example.com/api/user/details/1042/profile \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK. Identical shape to GET /api/user/details/:id; the only difference is that profile is forced to 1.
Errors — identical to GET /api/user/details/:id.
Notes
- Compatibility route: retained for compatibility with earlier clients;
GET /api/user/details/:idis the canonical route. This route serves the same "Your Details" screen withprofileforced to1. - Because an id may also be supplied in the
<id>_suffixform, some earlier links encode the mode into the id itself; this route is the explicit form.
Saves the user detail form: names, emails, phone, profile path and global role assignments.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
string | Yes | String; plain or encrypted id | Target user |
first_name |
string | No | String | First name |
last_name |
string | No | String | Last name |
email |
string | No | String — no format validation | Primary login email |
add_email |
string | No | String | Additional email |
multi_email |
string[] | No | Array, each item must be a string | Multi-inbox email rows |
phone_no |
string | No | String | Phone number |
profile_path |
string | No | String | Stored profile image path |
role |
number[] | No | Array, Numeric (numeric strings are coerced) | Global role ids. Replaces the target's entire global role set |
Example request
curl -X POST https://api.example.com/api/user/details/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"user_id": "1042",
"first_name": "Alex",
"last_name": "Reed",
"email": "alex@acme.example",
"phone_no": "+1 503 555 0142",
"multi_email": ["alex.reed@acme.example"],
"role": [6, 8]
}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | The multi-inbox sync failed | { "error": true, "message": "<inbox error>" } — the user row and roles are still saved |
| 400 | Target not found or out of scope | "User not found." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Global role assignment is destructive: all
assign_user_rolesrows with a null/zerocompany_idare deleted and re-inserted fromrole. Company-scoped role rows are untouched. - Writes a
user_details_saveaudit entry with the email, role ids and firm-admin flag.
Sets the user's two-factor authentication method and, for Google Authenticator, returns the enrolment secret and QR code.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
string | Yes | String; plain or encrypted id | Target user |
authType |
number | Yes | Integer; must be 0, 1, 2 or 3 |
Two-factor method. 1 = Google Authenticator (TOTP); the other values are the remaining users.auth_type enum members |
Example request
curl -X POST https://api.example.com/api/user/details/auth/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"user_id": "1042", "authType": 1}'
Success response — 200 OK
{
"error": false,
"authType": 1,
"qrImage": "data:image/png;base64,iVBORw0KGgo…",
"otpauthUrl": "otpauth://totp/alex%40acme.example?secret=<base32-secret>&issuer=Genie",
"google2fa_secret": "JBSWY3DPEHPK3PXP"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
authType |
number | The stored auth type |
qrImage |
string | null | Inline data-URL QR code, or null when the QR library is unavailable (the client then shows the secret as text). Empty for non-TOTP auth types |
otpauthUrl |
string | otpauth:// enrolment URL, issuer Genie. '' for non-TOTP auth types |
google2fa_secret |
string | Base32 TOTP secret. '' for non-TOTP auth types |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 400 | authType is not 0–3 |
"Invalid auth type." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- A stable secret is reused across saves; a new one is generated only when switching to TOTP and none is stored.
users.auth_typeis stored as the enumeration('0','1','2','3')and is written as a string literal, so the stored value always matches the submittedauthType.- The QR is rendered server-side; the TOTP secret is never handed to a third-party image host.
- Best-effort side effect: the remembered-2FA columns (
enc_log_on/enc_log) are cleared so the next login re-challenges. A type mismatch there is swallowed and never sinks the auth-type change.
Grants a user access to a company or a company group from the "New Company/Group Access" modal.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
string | Yes | String; plain or encrypted id | Target user |
company_id |
number | No | Integer | Company to grant access to |
group_id |
number | No | Integer | Company group to grant access to |
accessType |
string | No | String | Access mode. A value containing group selects group access; anything else selects separate (per-company) access |
firmType |
number | No | Integer | Firm plan type hint from the client |
cdataCompanyArr |
object | No | Optional, Record<string, unknown> |
Company selection payload from the modal |
gdataCompanyArr |
object | No | Optional, Record<string, unknown> |
Group selection payload |
sdataCompanyArr |
object | No | Optional, Record<string, unknown> |
Separate-access selection payload |
cdataCompanyAccess |
object | No | Optional, Record<string, unknown> |
Company access-level grid |
gdataCompanyAccess |
object | No | Optional, Record<string, unknown> |
Group access-level grid |
sdataCompanyAccess |
object | No | Optional, Record<string, unknown> |
Separate access-level grid |
Example request
curl -X POST https://api.example.com/api/user/save_new_access \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"user_id": "1042",
"group_id": 88,
"accessType": "groupAccess",
"gdataCompanyArr": { "88": true },
"gdataCompanyAccess": { "88": { "81": 3 } }
}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The six
*dataCompany*fields are untyped passthroughs of the modal's grids; their inner shape is{ scopeId: { menuId: level } }with levels1No Access,2View Only,3Standard,4Administrator. - Writes a
user_access_new_savestyle audit entry recording the access mode, firm type and the number of company/group rows written.
Loads the data behind the "New Company/Group Access" modal: the selectable tree, the module list and the plan-derived menu sets.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body — the same body as POST /api/user/save_new_access. Only user_id (required), company_id and group_id are read by this endpoint; the remaining fields are accepted and ignored.
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
string | Yes | String; plain or encrypted id | Target user |
company_id |
number | No | Integer | Preselect this company in the tree |
group_id |
number | No | Integer | Preselect this group in the tree. Ignored when company_id > 0 |
Example request
curl -X POST https://api.example.com/api/user/new_access \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"user_id": "1042", "group_id": 88}'
Success response — 200 OK
{
"treeView": [],
"selectedTree": { "id": 88, "name": "Northwest Region", "type": "group", "children": [] },
"hasCompanies": true,
"msg": "",
"menuData": [{ "id": 81, "menu_name": "Tasks & Time" }],
"allModuleMenu": [81],
"accountingTierMenu": [],
"planType": 2,
"purchaseModule": [81]
}
| Field | Type | Description |
|---|---|---|
treeView[] |
array | The firm's full company-group / company tree |
selectedTree |
object | null | The preselected node, or null when nothing was preselected or found |
hasCompanies |
boolean | false when a preselected group contains no companies |
msg |
string | "No companies found in this company group." when hasCompanies is false, otherwise '' |
menuData[] |
array | Modules for the access matrix |
allModuleMenu |
number[] | Every menu id in menuData |
accountingTierMenu |
number[] | Always [] |
planType |
number | The firm's plan type |
purchaseModule |
number[] | Every menu id in menuData |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Although it shares a request body schema with
POST /api/user/save_new_access, this is a read endpoint — nothing is persisted. It is that endpoint's companion loader.
Saves edits to a user's existing company and group access grids from the detail screen.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
string | Yes | String; plain or encrypted id | Target user |
accessType |
string | No | String | Access mode (group vs separate) |
firmType |
number | No | Integer | Firm plan type hint |
comp_access |
unknown | No | Optional, untyped | Existing company scope mappings |
comp_accessNew |
unknown | No | Optional, untyped | Newly added company scope mappings |
group_access |
unknown | No | Optional, untyped | Existing group scope mappings |
group_accessNew |
unknown | No | Optional, untyped | Newly added group scope mappings |
dataGroupArr |
unknown | No | Optional, untyped | Group access-level matrix |
dataGroupArrNew |
unknown | No | Optional, untyped | Group access-level matrix for new grants |
dataCompArr |
unknown | No | Optional, untyped | Company access-level matrix |
Example request
curl -X POST https://api.example.com/api/user/details/access/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"user_id": "1042",
"accessType": "groupAccess",
"group_access": { "88": true },
"dataGroupArr": { "88": { "81": 3 } }
}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Every payload field beyond
user_id,accessTypeandfirmTypeis untyped: these fields carry the screen's scope maps and access matrices as posted, and are normalized server-side. - Writes a
user_access_edit_savestyle audit entry recording the mapping and matrix row counts plus the access mode.
Applies one access matrix to many groups and companies at once ("batch access").
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
string | Yes | String; plain or encrypted id | Target user |
dataBatchArr |
unknown | No | Optional, untyped | The access matrix to apply to every selected scope, { menuId: level } |
groups |
unknown | No | Optional, untyped | Group ids to apply the matrix to. Parsed leniently into a numeric list |
batch_access_type |
number | No | Integer | Default access level for rows the matrix does not name. Defaults to 3 (Standard) |
notGroupCompanyArr |
unknown | No | Optional, untyped | Company ids to apply the matrix to directly (companies not reached through a group) |
Example request
curl -X POST https://api.example.com/api/user/batch/access/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"user_id": "1042",
"groups": [88, 91],
"notGroupCompanyArr": [412],
"batch_access_type": 3,
"dataBatchArr": { "81": 3, "3": 4 }
}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Group rows are written first, then the standalone company rows; both reuse the same parsed matrix.
- The firm plan type used for the write is resolved from the caller's firm, not the target's.
- Writes a
user_access_batch_saveaudit entry with the group ids, company ids, batch access type and row count.
Uploads a profile picture and returns it as an inline data URL.
| Module | Admin > Users |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the target must resolve inside the caller's scope |
| Content type | multipart/form-data |
Request body
This endpoint accepts a loosely typed request body, so no field-level validation rules apply. The only keys it reads are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
string | Yes | None; read as a string and then resolved as a plain or encrypted id | Target user |
profile_picture |
file | Yes | Extension must be jpg, jpeg, png, gif or webp |
The image, under the field name profile_picture |
Example request
curl -X POST https://api.example.com/api/user/details/profile/save \
-b 'genie.sid=<session-cookie>' \
-F 'profile_picture=@avatar.png' \
-F 'user_id=1042'
Success response — 200 OK
{ "error": false, "logoPath": "data:image/png;base64,iVBORw0KGgo…" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
logoPath |
string | The uploaded image as a data: URL |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Target not found or out of scope | "User not found." |
| 400 | No file uploaded | "Please upload a profile image." |
| 400 | Extension not in the allow-list | "The selected file <name> cannot be uploaded. Only files with the following extensions are allowed: jpg, jpeg, png, gif, webp." |
| 400 | The uploaded file is empty or unreadable | "Invalid profile image." |
Notes
- The endpoint does not persist the image itself — it validates, base64-encodes and returns it. Persisting the resulting path is the caller's job, via
profile_pathonPOST /api/user/details/save. - Writes a
user_profile_uploadaudit entry with the file name, MIME type and byte size.
9.4 Reporting Groups
Reporting groups collect companies for consolidated reporting. Three kinds exist, distinguished by type: 1 = firm reporting group, 2 = cross-firm reporting group (Global Master System Administrator only), 3 = Multi-Entity group.
Every endpoint in this subsection runs the same gate: the caller must hold the Global Master System Administrator role, or hold both role 7 (Company Group Company Administrator) and role 8 (Company Group Administrator). Nobody else may reach these routes. Cross-firm capability (canCrossFirm) is the Global Master System Administrator role specifically, matched by role id or by role name, so a deployment whose user_roles ids differ from the standard set is still resolved correctly.
Returns the three reporting-group tabs (firm, cross-firm, multi-entity) for the reporting-groups landing screen.
| Module | Admin > Reporting Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Global Master System Administrator (role 1), or roles 7 and 8 together. Only a Global Master System Administrator sees cross-firm groups or groups outside their own firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
query |
string | No | String | Free-text name filter, lower-cased before matching |
Example request
curl -X POST https://api.example.com/api/reporting-group/list \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"query": "west"}'
Success response — 200 OK
{
"canCrossFirm": true,
"firmGroups": [
{ "id": 21, "encryptId": "MjE=", "name": "Western Portfolio", "type": 1 }
],
"crossFirmGroups": [],
"multiEntityGroups": [
{ "id": 44, "encryptId": "NDQ=", "name": "Westbrook Holdings", "type": 3 }
]
}
| Field | Type | Description |
|---|---|---|
canCrossFirm |
boolean | Caller holds the Global Master System Administrator role |
firmGroups[] |
array | type = 1 groups |
crossFirmGroups[] |
array | type = 2 groups; always [] unless canCrossFirm |
multiEntityGroups[] |
array | type = 3 groups |
Each row carries id, encryptId, name, type.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the reporting-group gate | "You do not have access to reporting group configuration." |
Notes
- Only this landing render is firm-scoped;
POST /api/reporting-group/searchis not (see its notes).
Creates a reporting group.
| Module | Admin > Reporting Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Reporting-group gate. Creating a cross-firm group (type = 2) additionally requires the Global Master System Administrator role. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
name |
string | Yes | String; non-empty after trim | Group name |
type |
number | Yes | Integer. Values outside 1, 2, 3 fall back to 1 |
1 firm, 2 cross-firm, 3 multi-entity |
Example request
curl -X POST https://api.example.com/api/reporting-group/add \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name": "Western Portfolio", "type": 1}'
Success response — 200 OK
{ "error": false, "id": 21, "encryptId": "MjE=" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id / encryptId |
number / string | The new reporting group |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | name blank after trim |
{ "error": true, "message": "Please provide group name." } — or "Please provide multi-entity group name." when type = 3 |
| 200 | Name already used | { "error": true, "message": "Reporting Group name already exist." } — or "Multi-Entity Group name already exist." when type = 3 |
| 400 | reporting_group table missing |
"Reporting group table is unavailable." |
| 401 | type = 2 without the Global Master System Administrator role |
"Not allowed to create cross-firm reporting groups." |
| 403 | Caller fails the reporting-group gate | "You do not have access to reporting group configuration." |
Notes
- The duplicate check is scoped to the caller's firm, except for a Global Master System Administrator, whose names are checked across every firm.
Type-scoped search across reporting groups.
| Module | Admin > Reporting Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Reporting-group gate. Searching type = 2 requires the Global Master System Administrator role; without it the response is { "rows": [] }. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
search_key |
string | No | String | Name fragment, lower-cased. An empty value matches everything |
type |
number | Yes | Integer. Values outside 1, 2, 3 fall back to 1 |
Which tab is being searched |
Example request
curl -X POST https://api.example.com/api/reporting-group/search \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"search_key": "west", "type": 1}'
Success response — 200 OK
{
"rows": [
{ "id": 21, "encryptId": "MjE=", "name": "Western Portfolio", "type": 1 }
]
}
| Field | Type | Description |
|---|---|---|
rows[] |
array | Matching groups (id, encryptId, name, type) |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | type missing or not an integer |
Validation error array |
| 403 | Caller fails the reporting-group gate | "You do not have access to reporting group configuration." |
Notes
- This search carries no firm predicate: it matches on
name,typeanddeleted = 0only. An emptysearch_keytherefore returns every firm's groups of that type. OnlyPOST /api/reporting-group/listis firm-scoped.
Soft-deletes a reporting group.
| Module | Admin > Reporting Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Reporting-group gate only — no ownership, type or cross-firm validation |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
rg_id |
string | Yes | String; plain or encrypted id | Reporting group to delete |
type |
number | Yes | Integer | Accepted by the request body schema but not used; the response echoes the group's stored type instead |
Example request
curl -X POST https://api.example.com/api/reporting-group/delete \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"rg_id": "MjE=", "type": 1}'
Success response — 200 OK
{ "error": false, "type": 1 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
type |
number | The deleted group's stored type, so the client knows which tab to refresh. Defaults to 1 when the row could not be read |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller fails the reporting-group gate | "You do not have access to reporting group configuration." |
Notes
- The group is soft-deleted by
rg_idwith no ownership, type or cross-firm check; the role gate is the only guard. The row is read solely to report its storedtypein the response. - Falls back to a hard
DELETEonly when the table has neitherdeletednordeleted_at.
Duplicates an existing reporting group under a new name.
| Module | Admin > Reporting Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Reporting-group gate; the source group must be reachable (a non-Global-Master caller cannot clone another firm's group) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
cloneName |
string | Yes | String; non-empty after trim | Name for the copy |
cloneId |
string | Yes | String; plain or encrypted id | Source reporting group |
Example request
curl -X POST https://api.example.com/api/reporting-group/clone \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"cloneId": "MjE=", "cloneName": "Western Portfolio (2027)"}'
Success response — 200 OK
{ "error": false, "id": 22, "encryptId": "MjI=" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id / encryptId |
number / string | The new clone |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | cloneName blank after trim |
"Please provide reporting group name." |
| 400 | Source group not found | "Reporting group not found." |
| 400 | reporting_group table missing |
"Reporting group table is unavailable." |
| 401 | Source group belongs to another firm and the caller is not a Global Master System Administrator | "Not allowed to access cross-firm reporting groups." |
| 403 | Caller fails the reporting-group gate | "You do not have access to reporting group configuration." |
Notes
- The clone inherits the source's
typeandgroup_id; the requestedtypeis not consulted. - There is no server-side block on cloning a Multi-Entity (
type = 3) group; the Clone button is simply not rendered on that tab.
Loads the reporting-group editor: the group, the company-group dropdown, the candidate companies and the currently selected ones.
| Module | Admin > Reporting Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Reporting-group gate; the group must be reachable by the caller's firm unless they hold the Global Master System Administrator role |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Reporting group id, plain or encrypted |
Example request
curl https://api.example.com/api/reporting-group/MjE%3D \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"group": { "id": 21, "encryptId": "MjE=", "name": "Western Portfolio", "type": 1, "group_id": 88 },
"companyGroupOptions": [{ "id": 88, "name": "Northwest Region" }],
"availableCompanies": [
{ "id": 413, "name": "Cascade Logistics", "disabled": false }
],
"selectedCompanies": [{ "id": 412, "name": "Northwind Services" }]
}
| Field | Type | Description |
|---|---|---|
group |
object | The reporting group; group_id is the company group it draws candidates from (0 when unset) |
companyGroupOptions[] |
array | Company-group dropdown — all groups for a Global Master System Administrator / Global Company Administrator / Global Company Group Administrator, the caller's firm's groups otherwise |
availableCompanies[] |
array | Candidates from the selected company group's whole subtree. disabled: true marks a company already selected |
selectedCompanies[] |
array | Companies currently attached to the reporting group |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Group not found | "Reporting group not found." |
| 400 | reporting_group table missing |
"Reporting group table is unavailable." |
| 401 | Group belongs to another firm and the caller is not a Global Master System Administrator | "Not allowed to access cross-firm reporting groups." |
| 403 | Caller fails the reporting-group gate | "You do not have access to reporting group configuration." |
Notes
- The company-group dropdown is not scoped by the reporting group's
firm_id; a Global Master System Administrator, who has no firm of their own, receives every company group and can attach companies from any of them. availableCompaniesis empty when the group has nogroup_idyet.
Returns the companies available under a company group; the reporting-group editor calls it whenever the company-group dropdown changes.
| Module | Admin > Reporting Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Reporting-group gate |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
gid |
number | Yes | Integer | Company group id to resolve candidates from |
sel_company |
number[] | No | Array | Already-selected company ids; these come back with disabled: true |
Example request
curl -X POST https://api.example.com/api/reporting-group/company/group \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"gid": 88, "sel_company": [412]}'
Success response — 200 OK
{
"companies": [
{ "id": 412, "name": "Northwind Services", "disabled": true },
{ "id": 413, "name": "Cascade Logistics", "disabled": false }
]
}
| Field | Type | Description |
|---|---|---|
companies[] |
array | Candidates from the group's whole subtree, each with id, name, disabled |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | gid missing or not an integer |
Validation error array |
| 403 | Caller fails the reporting-group gate | "You do not have access to reporting group configuration." |
Notes
- The candidate set is resolved from the group subtree alone, with no viewer-dependent firm scope, so the initial editor render and every subsequent dropdown change produce the same list.
- Candidates come from
company_group_companiesonly — there is noactive = 1filter and no union withcompany.company_group_id. Soft-deleted companies (deleted IN (0, 1)) are included. - Returns
{ "companies": [] }whengid <= 0or thecompany_grouptable is absent.
Saves the reporting-group editor: renames the group, re-points its company group, and syncs its member companies.
| Module | Admin > Reporting Groups |
| Authentication | Required — authenticated session cookie |
| Authorization | Reporting-group gate; the group must be reachable by the caller's firm unless they hold the Global Master System Administrator role |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String; plain or encrypted id | Reporting group to save |
name |
string | Yes | String; non-empty after trim | New name |
type |
number | Yes | Integer | Sent by the client, but the stored type wins; this is used only as a fallback |
group_id |
number | No | Integer | Company group the reporting group draws from. Default 0 |
company_child |
number[] | No | Array | Member company ids. Positive values are de-duplicated |
Example request
curl -X POST https://api.example.com/api/reporting-group/company/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id": "MjE=", "name": "Western Portfolio", "type": 1, "group_id": 88, "company_child": [412, 413]}'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | name blank after trim |
{ "error": true, "message": "Reporting Group field is required." } — or "Multi-Entity Group field is required." when the stored type is 3 |
| 200 | Another non-deleted group of the same type already uses that name | { "error": true, "message": "Reporting Group name already exist." } — or "Multi-Entity Group name already exist." for type 3 |
| 400 | Group not found | "Reporting group not found." |
| 401 | Group belongs to another firm and the caller is not a Global Master System Administrator | "Not allowed to access cross-firm reporting groups." |
| 403 | Caller fails the reporting-group gate | "You do not have access to reporting group configuration." |
Notes
- Company membership is synced only when
company_childis non-empty: an empty array leaves the existing members untouched rather than clearing them. - The duplicate check is by name and type, excluding the group itself, and is not firm-scoped.
9.5 Logs (Task Audit / Activity Log)
Admin > Logs is the global activity log: who changed whose module access, when, and from what to what. Three endpoints back the screen: logs/filters supplies the filter bar's options, logs/filter returns the filtered rows, and logs/details returns the before/after detail for one row.
All three endpoints require the Global Master System Administrator role, matched by role id or by the role name "Global Master System Administrator", so a deployment whose user_roles ids differ from the standard set is still resolved correctly. users.global_admin and users.firm_admin are explicitly not accepted; only the assigned role grants access.
Returns everything the log page's filter bar needs: the user dropdown and the company/company-group lists.
| Module | Admin > Logs |
| Authentication | Required — authenticated session cookie |
| Authorization | Global Master System Administrator only (role id 1 or the role name) |
| Content type | application/json |
Example request
curl https://api.example.com/api/logs/filters \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"users": [
{ "id": 1042, "name": "Alex Reed", "email": "alex@acme.example", "profile_image": "" }
],
"groups": [{ "id": 88, "name": "Northwest Region", "deleted": 0 }],
"companies": [{ "id": 412, "name": "Northwind Services", "deleted": 0 }]
}
| Field | Type | Description |
|---|---|---|
users[] |
array | Non-deleted users ordered by last name; id, name ("First Last"), email, profile_image |
groups[] |
array | Company groups with deleted = 0, ordered by name. Blank-named rows are dropped |
companies[] |
array | Companies with deleted IN (0, 1) — soft-deleted companies are included — ordered by name. Blank-named rows are dropped |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a Global Master System Administrator | "You do not have access to the logs." |
Notes
- For a Global Master System Administrator both lists are unfiltered by firm, because such a user has no
firm_idof their own. - The soft-delete asymmetry between the two lists is intentional: companies are returned for
deleted IN (0, 1)while company groups are returned only fordeleted = 0.
Returns the filtered activity-log rows.
| Module | Admin > Logs |
| Authentication | Required — authenticated session cookie |
| Authorization | Global Master System Administrator only |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | No | String | Numeric company or group id. '' when nothing is picked |
cgType |
string | No | String | 'group', 'company' or '' — which kind of entity id refers to |
allUsers |
string | No | String | Selected user id; '' for "All Users" |
type |
string | No | String | The dropdown's label text: 'All', 'Created', 'Edited' or 'Deleted'. Mapped back to user_logs.event_type 1/2/3; anything else means no event filter |
startDate |
string | No | String | mm-dd-yyyy |
endDate |
string | No | String | mm-dd-yyyy |
Example request
curl -X POST https://api.example.com/api/logs/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id": "88", "cgType": "group", "allUsers": "", "type": "Edited", "startDate": "08-01-2026", "endDate": "08-31-2026"}'
Success response — 200 OK
{
"error": false,
"records": 1,
"rows": [
{
"id": 90312,
"date": "Aug 21, 2026",
"time": "09:14 AM",
"type": "Edited",
"user": "Alex Reed",
"event": "Access edited for Jordan Vale",
"hasDetails": true
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false |
records |
number | Number of rows returned (equal to rows.length; there is no pagination) |
rows[].id |
number | user_logs.id, the value POST /api/logs/details expects as log_id |
rows[].date / rows[].time |
string | Formatted from created_at |
rows[].type |
string | "Created" / "Edited" / "Deleted", or "" for an unknown event type |
rows[].user |
string | The acting user's full name |
rows[].event |
string | Sanitized event description (HTML stripped/escaped) |
rows[].hasDetails |
boolean | The log row has a details payload, so the detail view is available |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a Global Master System Administrator | "You do not have access to the logs." |
Notes
- Returns
{ "error": false, "records": 0, "rows": [] }when theuser_logstable is absent. - Rows are ordered by
created_atdescending, with no limit. - Date behaviour: a start date alone matches that exact day; a start and end date are applied as
BETWEEN '<start> 00:00:00' AND '<end> 00:00:00', so rows recorded on the end day after midnight fall outside the range. Set the end date one day later to include a full final day. - Scope behaviour: picking a group narrows the rows to that group plus its companies; picking a company does not narrow the rows at all. With no selection, the scope is every company (
deleted IN (0,1)) and every group (deleted = 0) a Global Master System Administrator can see.
Returns the module-access before/after detail for one log row.
| Module | Admin > Logs |
| Authentication | Required — authenticated session cookie |
| Authorization | Global Master System Administrator only |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
log_id |
string | Yes | String; coerced to a number | user_logs.id from a POST /api/logs/filter row |
Example request
curl -X POST https://api.example.com/api/logs/details \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"log_id": "90312"}'
Success response — 200 OK
{
"user": { "name": "Jordan Vale", "email": "jordan@acme.example", "profile_image": "" },
"cgName": "Northwest Region",
"accessBy": "Alex Reed",
"accessType": "edited",
"accessOn": "Aug 21, 2026 09:14 AM",
"description": "Access edited for Jordan Vale",
"hasOldAccess": true,
"rows": [
{ "module": "Tasks & Time", "oldPermission": "View Only", "newPermission": "Standard" },
{ "module": "Monthly Close", "oldPermission": "No Access", "newPermission": "Administrator" }
]
}
| Field | Type | Description |
|---|---|---|
user |
object | The user whose access changed (name, email, profile_image) |
cgName |
string | Company or company-group name, first letter upper-cased |
accessBy |
string | Who made the change |
accessType |
string | Lower-cased event label — "created" / "edited" / "deleted". The UI reuses it inside "Access edited By :" / "Access edited on:" |
accessOn |
string | Formatted updated_at |
description |
string | Sanitized event text |
hasOldAccess |
boolean | false when the log recorded no prior state; oldPermission is then "" on every row |
rows[] |
array | One row per module in the new-access map, in the map's original key order |
rows[].oldPermission |
string | "No Access" / "View Only" / "Standard" / "Administrator", "No Access" for a module missing from the old map, or "" when hasOldAccess is false |
rows[].newPermission |
string | Same vocabulary |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a Global Master System Administrator | "You do not have access to the logs." |
| 404 | log_id is 0/unparseable, the user_logs table is absent, or the row does not exist |
"Log not found." |
Notes
- Module order is preserved by scanning the raw JSON's key order rather than re-sorting, so the detail view matches how the access was actually recorded.
- Menus that are excluded from module access — Home (1), Forecast (6), Analytics (7), Knowledge (8), Feedback Center (61) — are filtered out of the rows.
9.6 QBO Sync Status
A single read-only endpoint backing the Admin > QBO Sync Status board, which shows the last QuickBooks Online sync result for every company and every client.
Returns the sync-history board: every company, every client, and the Lambda sync runs their histories reference.
| Module | Admin > QBO Sync Status |
| Authentication | Required — authenticated session cookie |
| Authorization | Global Master System Administrator only — resolved the same way the session endpoint computes its global_admin flag (role id 1 or the role name "Global Master System Administrator"), so server and client can never disagree about who is allowed in |
| Content type | application/json |
Example request
curl https://api.example.com/api/qbo/sync/history \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"companies": [
{
"id": 412,
"name": "Northwind Services",
"connected": 1,
"syncHistory": { "manual": [9014], "cron": [9013] }
}
],
"clients": [
{
"id": 77,
"name": "Harbor Dental",
"firm_name": "Acme Accounting Partners",
"connected": 0,
"syncHistory": {}
}
],
"lambdaSync": [
{
"id": 9014,
"json_status": {
"companies": {
"412": {
"status": "low_error",
"timestamp": 1787000000,
"modules": ["gl", "ap", "ar", "bs"],
"errorModules": ["ap"],
"moduleMessages": ["Vendor 214 could not be resolved"],
"message": null
}
},
"clients": {}
}
}
]
}
| Field | Type | Description |
|---|---|---|
companies[].id / name |
number / string | Company identity |
companies[].connected |
number | company.qbo_connection, returned under the key connected |
companies[].syncHistory |
object | company.qbo_sync_history JSON: manual and cron arrays of qbo_lambda_sync.id values. Both keys are genuinely optional on the wire |
clients[].firm_name |
string | null | firms.account_name; null when the LEFT JOIN has no match |
clients[].connected |
number | clients.qbo_connection |
clients[].syncHistory |
object | Same shape as the company one |
lambdaSync[].id |
number | qbo_lambda_sync.id |
lambdaSync[].json_status |
object | null | companies and clients maps keyed by id |
…json_status.<id>.status |
string | null | completed, low_error, medium_error, high_error, full_error or inprogress |
…json_status.<id>.timestamp |
number | string | null | Unix seconds; the board renders it in America/New_York |
…json_status.<id>.modules |
string[] | null | Module short codes, e.g. gl, ap, ar, bs |
…json_status.<id>.errorModules |
string[] | null | Subset of modules that failed |
…json_status.<id>.moduleMessages |
string[] | null | Positionally paired with errorModules |
…json_status.<id>.message |
string | null | Whole-run failure message; when it is present the board hides the module lists |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a Global Master System Administrator | { "error": true, "msg": "Not Found" } — mirrors the "404 | Not Found" the page itself shows |
Notes
- Because the response covers every company and client in the system, the Global Master System Administrator requirement is enforced server-side and not left to the client.
- The
manualandcronkeys insidesyncHistoryare genuinely optional and are not defaulted by the server. Clients must treat a missing key as an empty list. - Read-only.
company.qbo_sync_history,clients.qbo_sync_historyandqbo_lambda_sync.json_statusare written by an external AWS Lambda sync worker, not by this application, so the board reports exactly what the worker has recorded and is empty when it has recorded nothing. - The board is an Admin section item (menu id 111, parent 11 = Admin), and its path is distinct from
qbo/connect,qbo/callbackandqbo/syncstatus. - Ordering: companies by name ascending; clients by firm account name then client name.
10. Client Success
Client Success is the area of Genie where a firm defines what it records about its clients and who services them. It has two halves. Client Info owns the per-company data itself: the firm-level and global definitions of the Company Info and Client Contacts fields, the values stored against each company, and the Excel template download/import used to load that data in bulk. Configure owns the settings behind it: the firm's client service roles and their pricing, the per-company Service Team (which user holds which role, at what rate), the Service Line invoice-approver matrix, the Client Info start-date defaults, and the firm's client tags.
Every route in this section is mounted under the api prefix and is authenticated by the server-side session (genie.sid cookie); there is no bearer token and no CSRF protection on these particular routes. Request validation runs on every request, so any body property that is not declared in the request body schema is silently stripped before the endpoint sees it.
Two distinct permission models apply, and it matters which one a given route uses:
- Configuration scope — used for anything that edits definitions (field definitions, service roles, tags).
globalscope requiresusers.global_admin = 1(or the Global Master System Administrator assigned role, which is promoted toglobal_admin);firmscope requiresusers.firm_admin = 1. Failure is403. - Per-company Client Success access — used for anything that edits a company's data. A global admin or firm admin always passes. Anyone else must hold
access_type = ACCESS_ADMIN (4)onCLIENT_SUCCESS_MENU_ID = 10for that company, resolved fromuser_company_access, then inheriteduser_group_accessup the company-group tree. The levels areACCESS_NO = 1,ACCESS_VIEW = 2,ACCESS_STANDARD = 3,ACCESS_ADMIN = 4. The edit check additionally rejects soft-deleted (inactive) companies as read-only. Two routes use relaxed variants: the Start Date save needs onlyACCESS_STANDARD, and the template download needs onlyACCESS_VIEW.
Many of these routes answer HTTP 200/201 with { "error": true, "message": "..." } rather than a 4xx status. This convention is documented per endpoint; callers must inspect the error property of the body rather than relying on the status code alone.
10.1 Client Info
Client Info owns the per-company data itself: the firm-level and global definitions of the Company Info and Client Contacts fields, the values stored against each company, and the Excel template download/import used to load that data in bulk.
10.1.1 Company Info Configuration
Field definitions for the Company Info tab. Each definition lives in company_info and is scoped either globally (firm_id IS NULL OR 0) or to one firm (firm_id = <firm>).
Lists the Company Info field definitions for either the global or the firm scope, together with whether the current user may edit them.
| Module | Client Success > Client Info > Company Info Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No hard gate — this endpoint returns canEdit/readOnly instead of throwing. canEdit is global_admin = 1 for type=global, firm_admin = 1 otherwise |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Case-insensitive. global selects the global definitions; any other value selects the caller's firm scope |
Example request
curl -X GET "https://api.example.com/api/cs/company-info/firm" \
-H "Accept: application/json" \
-b "genie.sid=<session-cookie>"
Success response — 200 OK
{
"company_info": [
{
"id": "eyJpdiI6IjhmMj...",
"row_id": 412,
"company_id": null,
"field": "Client Since",
"field_type": 2,
"field_values": "",
"default_values": "",
"order": 1
}
],
"canEdit": true,
"readOnly": false
}
| Field | Type | Description |
|---|---|---|
company_info[].id |
string | Encrypted row id; the value to pass back to the PATCH/DELETE routes |
company_info[].row_id |
number | Plain numeric company_info.id |
company_info[].company_id |
number | null | Always null for definitions |
company_info[].field |
string | Field label |
company_info[].field_type |
number | 1 = picklist (uses field_values), otherwise free text. Defaults to 2 |
company_info[].field_values |
string | Picklist options; empty unless field_type = 1 |
company_info[].default_values |
string | Default value for new companies |
company_info[].order |
number | Display order |
canEdit |
boolean | Whether the user passes the configuration-scope check |
readOnly |
boolean | true whenever the scope is global, the user cannot edit, or a firm admin is viewing global |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No session.login.userId, session state is not authenticated, or the user row is missing/deleted |
{ "message": "These credentials do not match our records." } |
Notes
readOnlyis alwaystruefor theglobalscope, for every user, including a global admin.canEditstill reports the underlying permission, so the two fields can legitimately disagree.- Results exclude soft-deleted rows (
COALESCE(deleted, 0) = 0) and are ordered byorder, thenid.
Creates a new Company Info field definition in the global or firm scope.
| Module | Client Success > Client Info > Company Info Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the scope named by scope: global requires global_admin = 1, firm requires firm_admin = 1 |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
field |
string | Yes | String | Field label. Trimmed; rejected if empty after trimming |
scope |
string | No | String | global (case-insensitive) selects the global scope; anything else, including omission, means firm scope |
fieldType |
number | No | Integer; numeric strings are coerced | 1 = picklist, otherwise free text. Defaults to 2 |
fieldValues |
string | No | String | Picklist options. Persisted only when fieldType = 1; otherwise stored as '' |
defaultValues |
string | No | String | Default value. Defaults to '' |
order |
number | No | Integer | Display order. Defaults to 0 |
id |
string | No | String | Accepted but not read by this endpoint |
staticField |
number | No | Integer | Accepted but not read by this endpoint |
Example request
curl -X PUT "https://api.example.com/api/cs/company-info" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{
"scope": "firm",
"field": "Client Since",
"fieldType": 2,
"defaultValues": "",
"order": 5
}'
Success response — 200 OK
{ "error": false, "companyId": "eyJpdiI6IjhmMj..." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success |
companyId |
string | Encrypted id of the newly inserted company_info row; '' if the insert id could not be read |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | field missing/not a string, or a supplied field fails its validator |
Request validation error array |
| 400 | field is empty after trimming |
{ "message": "Field is required." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
Notes
- Rows are inserted with
company_id = NULL; the global scope also writesfirm_id = NULL. - The response key is
companyIdeven though the value is acompany_inforow id. It is named exactly as shown.
Updates an existing Company Info field definition.
| Module | Client Success > Client Info > Company Info Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the scope of the stored row (resolved from its firm_id), not on any scope supplied in the body |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The row's encrypted id, or its plain numeric id — both are accepted |
Request body (same shape as PUT /api/cs/company-info above). This endpoint reads field, fieldType, fieldValues, defaultValues and order; scope, id and staticField are ignored.
Example request
curl -X PATCH "https://api.example.com/api/cs/company-info/412" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "field": "Client Since (FY)", "fieldType": 1, "fieldValues": "2023,2024,2025", "order": 5 }'
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | :id does not resolve to a positive integer |
{ "message": "Invalid row id." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check for the row's scope | { "message": "You do not have permission to modify this configuration." } |
Notes
- The update writes all five columns unconditionally, so an omitted
fieldbecomes''and an omittedorderbecomes0. Send the full record, not a partial patch, despite the HTTP verb. field_valuesis cleared to''unlessfieldType = 1.
Soft-deletes a Company Info field definition.
| Module | Client Success > Client Info > Company Info Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the stored row's scope |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Encrypted or plain row id |
Example request
curl -X DELETE "https://api.example.com/api/cs/company-info/412" \
-b "genie.sid=<session-cookie>"
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | :id does not resolve to a positive integer |
{ "message": "Invalid row id." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
Notes
- Soft delete only: sets
deleted = 1,deleted_atandupdated_at. Existingcompany_info_detailsvalues are left untouched.
Persists a drag-and-drop reordering of the Company Info field definitions.
| Module | Client Success > Client Info > Company Info Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the scope of the first row in data; falls back to firm scope when data is empty or the first id is unreadable |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
array | Yes | Array; each item is validated | The full ordered list |
data[].id |
string | Yes | String | Encrypted or plain company_info.id |
data[].order |
number | Yes | Integer | New display order |
Example request
curl -X POST "https://api.example.com/api/cs/company-info/change/order" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "data": [ { "id": "412", "order": 1 }, { "id": "413", "order": 2 } ] }'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data missing, not an array, or an item fails String/Integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
Notes
- The permission check samples only the first item's scope. Every item is then updated by id, one
UPDATEper row, without re-checking scope — so a caller must not mix global and firm rows in one payload. - Items whose id does not resolve to a positive integer are skipped silently.
- The default status for
POSTapplies:201, not200.
10.1.2 Client Contacts Configuration
The same five operations against company_contacts, which defines the columns of the Client Contacts grid. The one structural difference is static_field: rows flagged static_field = 1 are system-defined and cannot be edited or deleted.
Lists the Client Contacts column definitions for the global or firm scope.
| Module | Client Success > Client Info > Client Contacts Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No hard gate — returns canEdit/readOnly. canEdit is global_admin = 1 for type=global, firm_admin = 1 otherwise |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | global (case-insensitive) for the global scope; anything else for the caller's firm |
Example request
curl -X GET "https://api.example.com/api/cs/company-contacts/firm" \
-b "genie.sid=<session-cookie>"
Success response — 200 OK
{
"error": false,
"companyContacts": [
{
"id": "eyJpdiI6Ik9wcT...",
"row_id": 77,
"company_id": null,
"field": "Contact Name",
"field_type": 2,
"field_values": "",
"default_values": "",
"order": 1,
"static_field": 1
}
],
"canEdit": true,
"readOnly": false
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false |
companyContacts[].id |
string | Encrypted row id |
companyContacts[].row_id |
number | Plain company_contacts.id |
companyContacts[].field |
string | Column label |
companyContacts[].field_type |
number | 1 = picklist, otherwise free text |
companyContacts[].field_values |
string | Picklist options |
companyContacts[].default_values |
string | Default value |
companyContacts[].order |
number | Column order |
companyContacts[].static_field |
number | 1 = system column, not editable or deletable |
canEdit |
boolean | Configuration-scope permission |
readOnly |
boolean | true for global scope, for non-editors, or for a firm admin viewing global |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
Notes
- Unlike
GET /api/cs/company-info/:type, this response includes a top-levelerror: falseand nests the rows undercompanyContacts. - The same forced
readOnlyon the global scope applies here.
Creates a new Client Contacts column definition.
| Module | Client Success > Client Info > Client Contacts Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the scope named by scope |
| Content type | application/json |
Request body (identical to PUT /api/cs/company-info). This endpoint reads scope, field, fieldType, fieldValues, defaultValues and order; id and staticField are ignored, and new rows are always written with static_field = 0.
Example request
curl -X PUT "https://api.example.com/api/cs/company-contacts" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "scope": "firm", "field": "Portal Access", "fieldType": 1, "fieldValues": "Yes,No", "order": 6 }'
Success response — 200 OK
{ "error": false, "companyContactId": "eyJpdiI6Ik9wcT..." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success |
companyContactId |
string | Encrypted id of the new company_contacts row; '' if unavailable |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Validation failure, or field empty after trimming ("Field is required.") |
Request validation error array / { "message": "Field is required." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
Updates a Client Contacts column definition, unless it is a static system column.
| Module | Client Success > Client Info > Client Contacts Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the stored row's scope |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Encrypted or plain company_contacts.id |
Request body. Reads field, fieldType, fieldValues, defaultValues, order.
Example request
curl -X PATCH "https://api.example.com/api/cs/company-contacts/77" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "field": "Primary Contact", "fieldType": 2, "order": 1 }'
Success response — 200 OK
{ "error": false }
Static column rejection, also 200 OK:
{ "error": true, "message": "Static fields cannot be modified." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | :id does not resolve to a positive integer |
{ "message": "Invalid row id." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
| 200 | Target row has static_field = 1 |
{ "error": true, "message": "Static fields cannot be modified." } |
Notes
- The static-field rejection is returned as a
200body, not a 4xx. Callers must inspecterror. - As with Company Info, all five columns are overwritten, so send a full record.
Soft-deletes a Client Contacts column definition, unless it is static.
| Module | Client Success > Client Info > Client Contacts Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the stored row's scope |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Encrypted or plain company_contacts.id |
Example request
curl -X DELETE "https://api.example.com/api/cs/company-contacts/77" \
-b "genie.sid=<session-cookie>"
Success response — 200 OK
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | :id does not resolve to a positive integer |
{ "message": "Invalid row id." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
| 200 | Target row has static_field = 1 |
{ "error": true, "message": "Static fields cannot be deleted." } |
Notes
- Sets
deleted = 1,deleted_at,updated_at. Stored contact values in the details table are not removed.
Persists a reordering of the Client Contacts column definitions.
| Module | Client Success > Client Info > Client Contacts Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the scope of the first row in data; firm when the list is empty |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
array | Yes | Array; each item is validated | Ordered list of columns |
data[].id |
string | Yes | String | Encrypted or plain company_contacts.id |
data[].order |
number | Yes | Integer | New order |
Example request
curl -X POST "https://api.example.com/api/cs/company-contacts/change/order" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "data": [ { "id": "77", "order": 1 }, { "id": "78", "order": 2 } ] }'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Validation failure on data or an item |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
Notes
- Static columns are reordered like any other; the static-field restriction applies only to update and delete.
- Unresolvable ids are skipped without error.
10.1.3 Company Data
The per-company values behind the definitions above: the Company Info tab, the Client Contacts grid, and the company-level email-capture flag. All of these routes are POST (default status 201) and resolve the company either from an explicit companyId or, failing that, from a task id via tasks.company_id.
Returns one company's Company Info tab: every field definition in the company's scope, joined to that company's stored value.
| Module | Client Success > Client Info > Company Data |
| Authentication | Required — authenticated session cookie |
| Authorization | No hard gate — returns canEdit/readOnly from the per-company Client Success check (global admin, firm admin, or ACCESS_ADMIN on menu 10 for this company) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
string | No | String | Encrypted or plain company id. Takes precedence over task |
task |
string | No | String | Encrypted or plain task id; the company is read from tasks.company_id when companyId is absent or unresolvable |
Example request
curl -X POST "https://api.example.com/api/cs/company-info/details" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": "1042" }'
Success response — 201 Created
{
"data": [
{
"id": "eyJpdiI6IjhmMj...",
"info_id": "eyJpdiI6IjhmMj...",
"list_id": "eyJpdiI6InJUcT...",
"field": "Client Since",
"field_type": 2,
"field_values": "",
"default_values": "",
"value": "2021-04-01",
"order": 1
}
],
"head": ["Company Info"],
"canEdit": true,
"readOnly": false
}
| Field | Type | Description |
|---|---|---|
data[].id / data[].info_id |
string | Encrypted company_info.id — both keys carry the same value |
data[].list_id |
string | Encrypted company_info_details.id, or '' when no value row exists yet |
data[].field |
string | Field label |
data[].field_type |
number | 1 = picklist, otherwise free text |
data[].field_values |
string | Picklist options |
data[].default_values |
string | Configured default |
data[].value |
string | Stored value, falling back to default_values |
data[].order |
number | Display order |
head |
string[] | Grid heading, always ["Company Info"] |
canEdit |
boolean | Per-company edit permission |
readOnly |
boolean | !canEdit |
When the company cannot be resolved, or the company_info table is absent, the response is instead:
{ "data": [], "company": null, "head": [], "canEdit": false, "readOnly": true }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId/task fail String |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
Notes
- Historic data can contain more than one
company_info_detailsrow per(field, company). The reader collapses duplicates by keeping the row with the highestlist_id, andPOST /api/cs/company-info/details-savetargets that same row, so reader and writer always agree. - Only the empty-company shape includes a
companykey; the populated shape does not. - The field definitions are scoped by the company's own firm (resolved from
company.firm_id), not the caller's firm.
Saves one Company Info value for one company — an upsert into company_info_details.
| Module | Client Success > Client Info > Company Data |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights — global admin, firm admin, or ACCESS_ADMIN on menu 10 for this company. The company must also be active (not soft-deleted) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
infoId |
string | Yes | String | Encrypted or plain company_info.id |
companyId |
string | Yes | String | Encrypted or plain company id |
listId |
string | No | String | Accepted but not read — the target row is located by (infoId, companyId) |
value |
unknown | No | Optional; no type validation | The value to store. null/undefined is stored as ''; anything else is stringified |
Example request
curl -X POST "https://api.example.com/api/cs/company-info/details-save" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "infoId": "412", "companyId": "1042", "value": "2021-04-01" }'
Success response — 201 Created
{ "error": false, "id": "eyJpdiI6InJUcT..." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id |
string | Encrypted company_info_details.id — the updated row, or the newly inserted one |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | infoId or companyId missing / not a string |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | companyId or infoId does not resolve to a positive integer |
{ "error": true, "message": "Invalid identifiers." } |
| 201 | company_info_details table absent |
{ "error": true, "message": "Company info details table not available." } |
Notes
- The existing-row lookup is
ORDER BY id DESC LIMIT 1, matching the reader's duplicate-collapsing rule, so an edit always lands on the row the grid displays. - An update also clears
deletedback to0where that column exists, reviving a previously removed value. valueaccepts any JSON type and is coerced to a string on write.
Returns one company's Client Contacts grid: the column definitions plus the row/value matrix, along with the company's email-capture flag.
| Module | Client Success > Client Info > Company Data |
| Authentication | Required — authenticated session cookie |
| Authorization | No hard gate — returns canEdit (ACCESS_ADMIN or admin) and canViewResource (>= ACCESS_VIEW or admin) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
string | No | String | Encrypted or plain company id |
task |
string | No | String | Encrypted or plain task id, used to resolve the company when companyId is absent |
Example request
curl -X POST "https://api.example.com/api/cs/company-contacts/details" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": "1042" }'
Success response — 201 Created
{
"data": {
"infoData": [
{
"id": "eyJpdiI6Ik9wcT...",
"raw_id": 77,
"field": "Contact Name",
"field_type": 2,
"field_values": "",
"default_values": "",
"static_field": 1,
"order": 1
}
],
"valueDetailArray": [
{
"order": 0,
"77": { "listId": "eyJpdiI6IlZ4...", "value": "Dana Reyes" },
"eyJpdiI6Ik9wcT...": { "listId": "eyJpdiI6IlZ4...", "value": "Dana Reyes" }
}
]
},
"emailCapture": 1,
"head": ["Client Contacts"],
"canEdit": true,
"readOnly": false,
"canViewResource": true
}
| Field | Type | Description |
|---|---|---|
data.infoData[] |
object[] | Column definitions: encrypted id, plain raw_id, field, field_type, field_values, default_values, static_field, order |
data.valueDetailArray[] |
object[] | One entry per contact row. order is the row's order key; every other key is a column id (present twice — once as the plain numeric id, once as the encrypted id) mapping to { listId, value } |
emailCapture |
number | company.email_capture, 0 or 1 |
head |
string[] | Always ["Client Contacts"] |
canEdit / readOnly |
boolean | Per-company edit permission and its inverse |
canViewResource |
boolean | Whether the Resource tab may be viewed (>= ACCESS_VIEW) |
When no company resolves, or no contacts table exists:
{
"data": { "infoData": [], "valueDetailArray": [] },
"emailCapture": 0,
"head": [],
"canEdit": false,
"readOnly": true,
"canViewResource": false
}
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId/task fail String |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
Notes
- Row order follows detail-row
idascending, and rows are grouped in first-seen order — not sorted by the numericordervalue. - Detail rows are restricted to the column ids currently visible in this scope, so rows belonging to the other (global vs firm) configuration scope cannot shift a legitimate cell into the wrong row.
- When a company has no contact values at all, one empty placeholder row is returned with
order: 0and blank cells for every column. There is no cross-company fallback: an empty grid never shows another company's contacts. - Each cell is emitted under both the plain and the encrypted column id so either key works client-side.
Saves one cell of the Client Contacts grid — an upsert keyed on (field, company, row order).
| Module | Client Success > Client Info > Company Data |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights — admin or ACCESS_ADMIN on menu 10 for this company; company must be active |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
infoId |
string | Yes | String | Encrypted or plain column id |
companyId |
number | Yes | Integer; numeric strings are coerced | Plain numeric company id (this endpoint does not decrypt it) |
order |
number | Yes | Integer | Row order key — which contact row within the grid |
listId |
string | No | String | Accepted but not read; the row is located by (infoId, companyId, order) |
value |
unknown | No | Optional; no type validation | Cell value. null/undefined stores '' |
Example request
curl -X POST "https://api.example.com/api/cs/company-contacts/details-save" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "infoId": "77", "companyId": 1042, "order": 0, "value": "Dana Reyes" }'
Success response — 201 Created
{ "error": false, "id": "eyJpdiI6IlZ4..." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id |
string | Encrypted id of the updated or inserted detail row |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Validation failure on infoId, companyId or order |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | companyId is falsy |
{ "error": true, "message": "Invalid identifiers: company is missing." } |
| 201 | infoId does not resolve |
{ "error": true, "message": "Invalid identifiers: unrecognised field id \"…\"." } |
| 201 | No contact-details table exists | { "error": true, "message": "Company contact details table not available." } |
| 201 | The details table lacks a required column | { "error": true, "message": "Company contact details schema is unsupported." } |
Notes
companyIdmust be sent as a plain numeric company id; this endpoint does not decrypt it.- The two "invalid identifiers" cases carry distinct messages so they can be told apart from the response alone.
- Like Company Info, the existing-row lookup is
ORDER BY id DESC LIMIT 1so the writer targets the row the reader displays.
Deletes an entire contact row (all columns at one row-order position) for a company.
| Module | Client Success > Client Info > Company Data |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights; company must be active |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer; numeric strings are coerced | Plain numeric company id |
order |
number | Yes | Integer | Row order key identifying the contact row to remove |
Example request
curl -X POST "https://api.example.com/api/cs/company-contacts/deleteDetail" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": 1042, "order": 2 }'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Validation failure on companyId or order |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | companyId is falsy |
{ "error": true, "message": "Invalid identifiers." } |
Notes
- The operation targets every detail row matching
(company_id, order)— it removes the whole grid row, not a single cell. - Where the details table has a
deletedcolumn the rows are soft-deleted (deleted = 1); where it does not, the value column is blanked to''instead. - When no contacts-details table exists, or the table lacks an order/company column, this endpoint returns
{ "error": false }having done nothing.
Toggles the company-level email-capture flag (company.email_capture).
| Module | Client Success > Client Info > Company Data |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights; company must be active |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
number | Yes | Integer; numeric strings are coerced | Plain numeric company id. Note the snake_case key |
email_capture |
number | Yes | Integer | Truthy stores 1, 0 stores 0 |
Example request
curl -X POST "https://api.example.com/api/cs/company-contacts" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "company_id": 1042, "email_capture": 1 }'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Validation failure on company_id or email_capture |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | company_id is falsy |
{ "error": true, "message": "Invalid identifiers." } |
Notes
- This route shares its path with
PUT /api/cs/company-contacts(create column definition) and is distinguished only by HTTP method —POSTis the email-capture toggle,PUTis the column create. Its snake_case body keys are unique in this module and must be sent exactly as shown. - The stored value is normalised to
0or1; any truthy integer becomes1. - The current value is read back by
POST /api/cs/company-contacts/detailsasemailCapture.
10.1.4 Template Download and Import
Bulk load of Client Info data via Excel. Download is a two-step handshake: the POST builds the workbook server-side and returns a one-shot URL, and a follow-up GET streams the file and deletes it.
Generates an Excel import template for one of four Client Info models and returns a single-use download link.
| Module | Client Success > Client Info > Template Download and Import |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success view rights — global admin, firm admin, or >= ACCESS_VIEW (2) on menu 10 for this company. This is the relaxed view gate, not the admin gate |
| Content type | Request application/json; response application/json (the file itself is fetched separately) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer; numeric strings are coerced | The company whose scope determines the template's fields and company list |
model |
string | Yes | One of company_info, company_contact, service_team, company_resource |
Which template to build |
comType |
string | No | String | Accepted but not read by this endpoint |
type |
string | No | String | Accepted but not read by this endpoint |
Example request
curl -X POST "https://api.example.com/api/cs/company-info/download/file" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": 1042, "model": "company_info" }'
Success response — 201 Created
{ "error": false, "redirect": "/api/cs/company-info/download/generated/9f2c8b1e-4a7d-4c33-93a1-6e0f2b5c7a11" }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success |
redirect |
string | Relative URL to GET in order to receive the .xlsx file. Single use |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | model not one of the four allowed values, or companyId not an integer |
Request validation error array |
| 400 | companyId or model resolves falsy after coercion |
{ "message": "Company and model are required." } |
| 400 | model passes validation but reaches the fallback branch |
{ "message": "Unsupported template model." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller lacks view rights on this company | { "message": "You do not have permission to access client info resources for this company." } |
Notes
- The generated workbook is held as temporary server-side storage and referenced by a single-use token. The follow-up
GETmust be served by the same application instance that produced the export, so multi-instance deployments must preserve instance affinity for these routes. See Deployment & Operational Considerations. - Sheet layout per model:
company_info→Company Info(header row only) +Available Data. File nameCompany Info Import Template.xlsx.company_contact→Company Contact(header row only) +Available Data. File nameCompany Contacts Import Template.xlsx.service_team→Override Price(one row per company with Yes/No),Service Team(header row only:Company,Service Role,User,Price per Hour($)), andAvailable Datawith four columns: company list,Service Role(s),Users,Override Default Price. File nameService Team Import Template.xlsx.company_resource→Company Resource(header row only:Company,Name,Link) +Available Data(company column only). File nameCompany Resource Import Template.xlsx.- The
Available Datasheet is written in columns, not rows, and column A's header is the literal stringCompanyfollowed by the company names. - Data sheets contain the header row and nothing else — they are templates to fill in, not exports.
- Service-role options for the
service_teamtemplate are filtereddeleted IN (0, 2)and ordered by theordercolumn.
Uploads a filled-in Excel template and imports its rows.
| Module | Client Success > Client Info > Template Download and Import |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights — admin or ACCESS_ADMIN on menu 10 for this company; company must be active |
| Content type | multipart/form-data (the file part is named file); response application/json |
Request body — multipart/form-data. The file part is file; the remaining parts are the fields listed below.
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
file |
file | Yes | Extension must be .xlsx, .xls or .xlx, checked on upload |
The completed workbook |
companyId |
number | Yes | Integer, coerced | The company driving field scope and the permitted company list |
model |
string | Yes | One of company_info, company_contact, service_team, company_resource |
Which import to run |
comType |
string | No | String | Not read by this endpoint |
type |
string | No | String | Not read by this endpoint |
Example request
curl -X POST "https://api.example.com/api/cs/company-info/import/file" \
-b "genie.sid=<session-cookie>" \
-F "file=@Company Info Import Template.xlsx" \
-F "companyId=1042" \
-F "model=company_info"
Success response — 201 Created
{ "error": false, "message": "Data are Imported Successfully." }
Partial failure (also 201), returning an error log plus a downloadable error workbook:
{
"error": true,
"errorLog": {
"head": ["Company", "Client Since", "Reason"],
"body": [["Acme Holdings", "2021-04-01", "Company Not found in the Available data."]]
},
"errorExcelLink": "/api/cs/company-info/download/generated/3b7e...",
"toBeExportArray": [["Acme Holdings", "2021-04-01", "Company Not found in the Available data."]]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false when every row imported |
message |
string | Present on the all-or-nothing outcomes |
errorLog.head |
string[] | The expected header row plus a trailing Reason column |
errorLog.body |
string[][] | One entry per rejected row: the original cells plus the reason |
errorExcelLink |
string | Relative URL to a generated Error Log workbook, same single-use mechanism as the template download |
toBeExportArray |
string[][] | The same rejected rows, repeated for compatibility with earlier clients |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | model not in the allowed set, or companyId not an integer |
Request validation error array |
| 400 | companyId or model falsy after coercion |
{ "message": "Company and model are required." } |
| 400 | model reaches the fallback branch |
{ "message": "Unsupported import model." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | No file part, or the file has no buffer | { "error": true, "message": "Upload file is required." } |
| 201 | Extension not .xlsx/.xls/.xlx |
{ "error": true, "message": "Please provide Excel sheet format supported file.", "hidePopUp": true } |
| 201 | First sheet has no rows | { "error": true, "message": "Empty Excel Sheet.Please put on some data to import." } |
| 201 | Header row does not match the expected columns (company_info / company_contact) |
{ "error": true, "message": "The present input fields are not exactly present in the uploaded document. Please download the latest template and upload." } |
| 201 | Header row wrong for service_team |
{ "error": true, "message": "Service Team Excel Formation is Wrong." } |
| 201 | Header row wrong for company_resource |
{ "error": true, "message": "Company Resource Excel Formation is Wrong." } |
| 201 | Workbook parsed but contained no data rows | { "error": true, "message": "No Data to Import." } |
Notes
- Only the first sheet of the uploaded workbook is read. Rows that are entirely blank are dropped before validation.
- A trailing
Reasoncolumn is stripped from the uploaded header before comparison, so a downloaded error log can be corrected and re-uploaded directly rather than rebuilt by hand. - The expected header set is the same one the download route builds its template from, so the template handed out always matches the one the importer accepts.
- The company column is matched case-insensitively against the companies the caller may import into; an unmatched name produces
Company Not found in the Available data. company_resourcerows additionally validate theLinkcolumn (Invalid Link format.) and require bothNameandLink(Name/Link Not Found.).- After a
service_teamimport the firm role map is re-applied for the affected company, including approver updates. This runs best-effort: a failure is not reported and the imported rows are never rolled back. - Rejected rows never block accepted ones: valid rows in the same file are written.
Streams a previously generated template or error workbook, then deletes it.
| Module | Client Success > Client Info > Template Download and Import |
| Authentication | Not required — possession of the single-use token is the only credential |
| Authorization | No role requirement; the single-use token is the only credential |
| Content type | Binary download — application/octet-stream with Content-Disposition: attachment; application/json on the 404 path |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The UUID from the redirect / errorExcelLink returned by the download or import route |
Example request
curl -X GET "https://api.example.com/api/cs/company-info/download/generated/9f2c8b1e-4a7d-4c33-93a1-6e0f2b5c7a11" \
-OJ
Success response — 200 OK, body is the .xlsx file. The suggested filename comes from the generating route (for example Company Info Import Template.xlsx).
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 404 | Token unknown, already consumed, or issued by a different application instance | { "error": true, "message": "Download expired." } |
Notes
- Strictly single use: on completion the token is invalidated and the temporary file is removed.
- The token is valid only on the application instance that issued it, and tokens do not survive a restart of that instance. Deployments must preserve instance affinity for these routes, otherwise this
GETcan return404even immediately after a successfulPOST. See Deployment & Operational Considerations. - Because there is no session check here, treat the token as a bearer credential and do not log or share generated links.
10.2 Configure
Configure owns the settings behind Client Info: the firm's client service roles and their pricing, the per-company Service Team (which user holds which role, at what rate), the Client Info start-date defaults, and the firm's client tags.
10.2.1 Service Roles
The firm's client service role catalogue (service_roles): creation, editing, pricing, ordering, activation state, and the reassignment flow used when an in-use role is retired. All routes are POST (default status 201).
Loads the Client Success Configure screen for either the Global or the Firm tab: the service role list with pricing and flags, the service lines, and the firm-level settings.
| Module | Client Success > Configure > Service Roles |
| Authentication | Required — authenticated session cookie |
| Authorization | No hard gate — returns canEdit/readOnly from the configuration-scope check (global_admin for the Global tab, firm_admin for the Firm tab) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
isGlobal |
number | Yes | Integer; numeric strings are coerced | 1 = Global tab (roles with firm_id IS NULL OR 0); anything else = the caller's firm |
deleted |
number | No | Integer | Status filter: 1 = active only (deleted = 0), 2 = inactive only (deleted = 3), anything else (including omission) = both |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/get" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "isGlobal": 0, "deleted": 1 }'
Success response — 201 Created
{
"error": false,
"firmSetting": 1,
"firmAdmin": 1,
"configGlobal": [],
"configCustom": [],
"cloneData": true,
"userAssignedRole": [],
"firmId": 17,
"useSLApprover": 0,
"canEdit": true,
"readOnly": false,
"serviceLine": [{ "id": 3, "name": "Bookkeeping" }],
"clientServiceRole": [
{
"id": 51,
"role": "Account Manager",
"primary_accountant": 1,
"mc_manager": 0,
"invoice_approver": 0,
"price_per_hour": "85.00",
"deleted": 0,
"order": 1,
"firm_id": 17,
"is_global": 0,
"service_line_based_approvers": [{ "service_line_id": 3, "invoice_approver": 0 }],
"view_only": 0
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false |
firmSetting |
number | firms.use_service_role — whether the firm uses its own role set rather than the global one |
firmAdmin |
number | The caller's users.firm_admin value |
configGlobal, configCustom, userAssignedRole |
array | Always empty; retained for compatibility with earlier clients |
cloneData |
boolean | true when the Global tab is showing and the firm has not yet been stamped with a firm-setting role — the signal for the "overwrite Global setting with Firm setting?" confirm. Always true on the Firm tab |
firmId |
number | The resolved firms.id row id |
useSLApprover |
number | firms.use_service_line_approver |
canEdit / readOnly |
boolean | Configuration-scope permission and its inverse |
serviceLine[] |
object[] | The firm's service lines |
clientServiceRole[].price_per_hour |
string | Fixed to two decimal places |
clientServiceRole[].deleted |
number | 0 active, 3 inactive |
clientServiceRole[].is_global |
number | 1 when the role has no firm |
clientServiceRole[].service_line_based_approvers[] |
object[] | One entry per service line, with that line's invoice_approver flag for this role |
clientServiceRole[].view_only |
number | 1 when the Global tab is showing to a non-global-admin |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | isGlobal missing or not an integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
Notes
- Roles are ordered by
orderthenrole. cloneDatareflects whether this firm has already been stamped with a firm-setting role, and therefore whether the Global → Firm clone confirmation is required.firmIdresolves to the actualfirms.idrow. Write routes on this screen resolve it the same way, so read and write always address the same row.
Creates a new client service role.
| Module | Client Success > Configure > Service Roles |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on global when isGlobal = 1, else firm |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
serviceRole |
string | Yes | String | Role name. Trimmed; rejected if empty |
isGlobal |
number | Yes | Integer, coerced | 1 creates a global role (firm_id = NULL, is_global = 1); otherwise a firm role |
pricePerHour |
number | No | Numeric; numeric strings are coerced, with no further validation | Default hourly rate. Defaults to 0 |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/save" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "serviceRole": "Controller", "pricePerHour": 120, "isGlobal": 0 }'
Success response — 201 Created
{ "error": false, "id": 138 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id |
number | Plain numeric id of the new service_roles row |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | serviceRole or isGlobal missing / wrong type |
Request validation error array |
| 400 | serviceRole empty after trimming |
{ "message": "Client Service Role is required." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
| 201 | A role with the same name already exists in this scope | { "error": true, "message": "Client Service Role already exists." } |
Notes
- Duplicate detection is case-insensitive within the scope and considers both active (
deleted = 0) and inactive (deleted = 3) roles. orderis assigned automatically asMAX(order) + 1within the scope.- The three role flags (
primary_accountant,mc_manager,invoice_approver) are always inserted as0; set them afterwards viaPOST /api/utilities/configure/update.
Multi-purpose role/setting update. The colName field selects one of three distinct behaviours: the firm-level Service Line Approver master switch, one cell of the role × service-line approver matrix, or a direct edit of a service_roles row.
| Module | Client Success > Configure > Service Roles |
| Authentication | Required — authenticated session cookie |
| Authorization | Branch-dependent. colName = 'use_service_line_approver' requires firm scope (firm_admin = 1); colName = 'service_line' requires the scope of the role named by serviceRoleId; every other case requires the scope of the role named by id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
colName |
string | null | No | One of use_service_line_approver, service_line, primary_accountant, mc_manager, invoiceApprover |
Selects the operation. Absent/null means a plain role save or rename |
id |
number | No | Integer, coerced | service_roles.id. Required for every branch except the two named above |
role |
string | No | String | New role name. Written only when non-empty after trimming |
pricePerHour |
number | No | Numeric; numeric strings are coerced, with no further validation | New hourly rate. Written when the key is present |
primaryAccountant |
boolean | No | Boolean | Sets primary_accountant |
mcManager |
boolean | No | Boolean | Sets mc_manager |
invoiceApprover |
boolean | No | Boolean | Sets invoice_approver; also read as the on/off value in the service_line branch |
serviceRoleId |
number | No | Integer | Role id for the service_line branch |
serviceLineId |
number | No | Integer | Service line id for the service_line branch |
firmId |
number | No | Integer | Target firm for the use_service_line_approver branch; defaults to the caller's firm |
value |
number | No | Numeric; numeric strings are coerced, with no further validation | On/off value for the use_service_line_approver branch |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/update" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "id": 51, "colName": "primary_accountant", "primaryAccountant": true }'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | colName present but not one of the five allowed values, or a field fails its validator |
Request validation error array |
| 400 | service_line branch with no serviceRoleId |
{ "message": "Service role is required." } |
| 400 | service_line branch with no serviceLineId |
{ "message": "Service role and service line are required." } |
| 400 | Default branch with no id |
{ "message": "Role id is required." } |
| 400 | Default branch and id matches no row |
{ "message": "Role not found." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check for the branch | { "message": "You do not have permission to modify this configuration." } |
Notes
colNameis restricted to the values listed above; an unrecognised value is rejected rather than accepted as a no-op success.primary_accountantandmc_managerare exclusive within a scope: setting either first clears that flag on every other role in the same scope, then sets it on the target role.invoice_approveris not exclusive.- Only the properties actually present in the body are written.
roleis additionally skipped when blank after trimming. - The
use_service_line_approverbranch writesfirms.use_service_line_approverand returns{ "error": false }without writing if the deployment has no such column. - The
service_linebranch upsertscs_invoice_approver_based_slfor the(role, line)pair and reactivates a previously soft-deleted row. It returns{ "error": false }without writing if the table is absent. Note this is the firm-level matrix — the per-company Service Line approver checkboxes arePOST /api/utilities/configure/save-sl-invoice-approver.
Changes a service role's activation state — restore, inactivate, or delete. Inactivating a role that is still assigned returns the list of companies needing reassignment instead of applying the change.
| Module | Client Success > Configure > Service Roles |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the role's own scope (global if firm_id is null/0, else firm) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer, coerced | service_roles.id |
status |
number | Yes | Integer; further restricted to 1, 2 or 3 |
1 = delete (deleted = 1, name suffixed (deleted)); 2 = restore (deleted = 0, suffix stripped); 3 = inactivate (deleted = 3, name suffixed (inactive)) |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/delete" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "id": 51, "status": 3 }'
Success response — 201 Created
{ "error": false }
Blocked inactivation (also 201), when the role is still assigned to companies:
{
"error": true,
"needAlternativeRole": true,
"message": "Role is assigned to companies. Please choose alternate roles.",
"companyRoleData": [
{ "companyId": 1042, "companyName": "Acme Holdings", "alternateRoleId": 0 }
]
}
| Field | Type | Description |
|---|---|---|
needAlternativeRole |
boolean | true signals the caller must run the reassignment flow |
companyRoleData[].companyId |
number | A company where the role is still assigned |
companyRoleData[].companyName |
string | Company name, falling back to Company <id> |
companyRoleData[].alternateRoleId |
number | Always 0; the client fills this in before calling updateAlterRoles |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | id/status missing or not integers |
Request validation error array |
| 400 | id falsy or status not in [1, 2, 3] |
{ "message": "Invalid role status update request." } |
| 400 | No service_roles row with that id |
{ "message": "Role not found." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
Notes
- Despite the route name this endpoint is a state change, not a hard delete; no row is ever removed.
- The assigned-company check runs only for
status = 3.status = 1(delete) applies unconditionally. - Any existing
(inactive)/(deleted)suffix is stripped from the role name before the new suffix is applied, so repeated state changes do not accumulate suffixes. - The blocked response is the entry point to
POST /api/utilities/configure/getRolesAndCompanyfollowed byPOST /api/utilities/configure/updateAlterRoles.
Persists a drag-and-drop reordering of the service role list.
| Module | Client Success > Configure > Service Roles |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the scope of the first role in the array — and only when that first id is greater than zero. An array whose first id is 0 performs no permission check at all |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
clientServiceRole |
array | Yes | Array; each item is validated | The full ordered role list |
clientServiceRole[].id |
number | Yes | Integer, coerced | service_roles.id |
clientServiceRole[].order |
number | Yes | Integer, coerced | New display order |
Example request
curl -X POST "https://api.example.com/api/utilities/service/role/update" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "clientServiceRole": [ { "id": 51, "order": 1 }, { "id": 52, "order": 2 } ] }'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | clientServiceRole missing, not an array, or an item fails Integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check for the first role's scope | { "message": "You do not have permission to modify this configuration." } |
Notes
- The scope check samples only the first entry, and every row is then updated by id without re-checking. Do not mix global and firm roles in one payload.
- Each row is written with an individual
UPDATE; there is no transaction, so a partial reorder is possible if the connection drops mid-list.
Toggles "Use Firm Settings" (firms.use_service_role). Turning it on clones the global configuration into the firm and queues a role-map rebuild.
| Module | Client Success > Configure > Service Roles |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the firm scope — requires firm_admin = 1 (a global admin also passes) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmSetting |
number | Yes | Integer; numeric strings are coerced | Truthy enables firm settings (stored as 1), 0 disables |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/firm/update" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "firmSetting": 1 }'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmSetting missing or not an integer |
Request validation error array |
| 400 | Deployment has no firms.use_service_role column |
{ "message": "This deployment has no firms.use_service_role column." } |
| 400 | No firms row resolves for this user |
{ "message": "No firm record found for this user, so the setting cannot be saved." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a firm admin | { "message": "You do not have permission to modify this configuration." } |
Notes
- The target
firmsrow is resolved the same way the read path resolves it, and its existence is asserted before the write, so the request fails rather than reporting success without saving. - When the flag goes on, the global
service_roles,client_company_infoandcompany_contactsare copied into the firm andfirm_settingis stamped, inside a single transaction. The copy is self-guarding: a firm that already has its copy is never overwritten. When the flag goes off, nothing is cloned. - The clone is non-fatal: the flag write has already committed, so a clone failure is logged rather than returned as an error.
- A firm-wide role-map rebuild is queued, not run inline: one
queue_managerrow is written and claimed by a background worker on its next five-second poll. It is queued after the clone so the rebuild sees the cloned roles. See Deployment & Operational Considerations.
For a role being retired: lists the alternate roles available and the companies currently using it. Step one of the reassignment flow.
| Module | Client Success > Configure > Service Roles |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the scope of the role named by id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer, coerced | The service_roles.id being retired |
name |
string | No | String | Accepted but not read by this endpoint |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/getRolesAndCompany" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "id": 51 }'
Success response — 201 Created
{
"error": false,
"roles": [ { "id": 52, "role": "Senior Accountant" } ],
"companyRoleData": [
{ "companyId": 1042, "companyName": "Acme Holdings", "alternateRoleId": 0 }
]
}
| Field | Type | Description |
|---|---|---|
roles[] |
object[] | Every other active role in the caller's firm, ordered by name — the candidates for reassignment |
companyRoleData[].companyId |
number | A company with a live service_role_users row for the role |
companyRoleData[].companyName |
string | Company name, falling back to Company <id> |
companyRoleData[].alternateRoleId |
number | Always 0; the client fills this in |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | id missing or not an integer |
Request validation error array |
| 400 | id resolves to 0 |
{ "message": "Role id is required." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
Notes
- The candidate list is scoped strictly to
firm_id = <caller's firm>and excludes the role itself and any soft-deleted role — global roles never appear as reassignment candidates. - The
companyRoleDatashape matches the one returned by the blockedPOST /api/utilities/configure/delete, so the client can populate either result into the same form.
Reassigns every company using a role to a chosen alternate, repoints tasks to a budget role, and inactivates the original. Step two of the reassignment flow.
| Module | Client Success > Configure > Service Roles |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the scope of the role named by mainId |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
mainId |
number | Yes | Integer, coerced | The role being retired |
alternateRoleBudget |
number | Yes | Integer, coerced | The role that inherits this role's task assignments and reviewer slots |
companyRoleData |
array | Yes | Array; each item is validated and at least one item is required | Per-company reassignment instructions |
companyRoleData[].companyId |
number | Yes | Integer, coerced | Company to reassign |
companyRoleData[].alternateRoleId |
number | Yes | Integer, coerced | Role to move that company's service-team rows to |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/updateAlterRoles" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{
"mainId": 51,
"alternateRoleBudget": 52,
"companyRoleData": [ { "companyId": 1042, "alternateRoleId": 52 } ]
}'
Success response — 201 Created
{ "error": false, "message": "Role reassigned and inactivated successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Any required field missing or of the wrong type | Request validation error array |
| 400 | mainId or alternateRoleBudget resolves to 0 |
{ "message": "Main role and budget role are required." } |
| 400 | companyRoleData is empty |
{ "message": "At least one company reassignment is required." } |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller fails the configuration-scope check | { "message": "You do not have permission to modify this configuration." } |
Notes
- Three writes happen, in order: (1) each listed company's live
service_role_usersrows move frommainIdto that entry'salternateRoleId, joined throughcompany.firm_idso the caller's firm boundary is enforced per row; (2) firm-scopedtasks.assigned_rolesandtasks.reviewervalues equal tomainIdare repointed toalternateRoleBudget; (3) the original role is setdeleted = 3and its name suffixed(inactive). - Entries with a falsy
companyIdoralternateRoleIdare skipped silently — the role is still inactivated at the end, so a malformed list can retire a role without reassigning every company. - The task repoint applies to the whole firm, not only the companies listed.
- There is no enclosing transaction; the steps are independent statements.
10.2.2 Client Info Start Date
The "Start Date" panel on Client Info, backed by utilities_config.todo_start_date. It has two independently-gated halves: a per-company Custom Value and a Global Default.
Reads the effective Start Date values for one company: its custom value and the global default.
| Module | Client Success > Configure > Client Info Start Date |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond authentication — any authenticated user may read this |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer; numeric strings are coerced | Plain numeric company id |
Example request
curl -X POST "https://api.example.com/api/utilities/firm-configure" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": 1042 }'
Success response — 201 Created
{ "custom_value": "Apr-2021", "global_value": "Jan-2020" }
| Field | Type | Description |
|---|---|---|
custom_value |
string | This company's own start date, formatted Mon-YYYY |
global_value |
string | The global default start date, same format |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId missing or not an integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
Notes
- When
companyIdis falsy, theutilities_configtable is absent, or it has notodo_start_datecolumn, both values fall back to the current month formattedMon-YYYY(for exampleSep-2026). - The custom row is matched on
company_idalone; the global row requirescompany_id IS NULL AND is_global = 1. Both exclude soft-deleted rows viadeleted_at IS NULL, and ties resolve to the lowestid. The updater uses the identical locator so read and write can never disagree about which row is "the" config row.
Saves the Start Date custom value and/or the global default.
| Module | Client Success > Configure > Client Info Start Date |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success rights of at least ACCESS_STANDARD — global admin, firm admin, or >= ACCESS_STANDARD (3) on menu 10 for this company; company must be active. Additionally, supplying globalDefault requires global_admin = 1 |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer, coerced | Plain numeric company id |
customValue |
string | No | String | This company's start date. Written only when a non-empty value is supplied |
globalDefault |
string | No | String | The global default start date. Requires global_admin = 1 |
Example request
curl -X POST "https://api.example.com/api/utilities/firm-configure/update" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": 1042, "customValue": "2021-04-01" }'
Success response — 201 Created
{ "error": false, "message": "Configuration Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId missing or not an integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 401 | globalDefault supplied by a non-global-admin |
{ "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks ACCESS_STANDARD on this company |
{ "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | companyId falsy or utilities_config table absent |
{ "error": true, "message": "Utilities config not available." } |
| 201 | No todo_start_date column |
{ "error": true, "message": "Start date not supported." } |
Notes
- This route uses the relaxed
ACCESS_STANDARDgate, not theACCESS_ADMINgate that the rest of Client Info editing uses: a company-Standard user may save that company's own Custom Value start date. - The global-default half is gated separately and strictly on global-admin rights, and the rejection is a
401, not a403. - Both halves are upserts against the same located row: an update also clears
deletedback to0, and an insert suppliescompany_id(NULLfor the global row) andis_globalwhere those columns exist. - Omitting a half simply skips it — sending only
customValueleaves the global default untouched, and vice versa. - The success message is returned even if neither half was supplied and nothing was written.
10.2.3 Service Team
The per-company Service Team grid: which service role is held by which user, at what rate, plus the per-company pricing override, role overrides and Service Line invoice-approver checkboxes.
Loads the whole Service Team panel for one company: the role catalogue, the assigned team, selectable users, service lines, override state and the company's project list.
| Module | Client Success > Configure > Service Team |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond authentication — the response carries no canEdit flag |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
string | No | String | Encrypted or plain company id |
task |
string | No | String | Encrypted or plain task id; resolves the company when companyId is absent |
userId |
number | No | Numeric; numeric strings are coerced, with no further validation | Filters the team to rows held by this user |
roleId |
number | No | Numeric; numeric strings are coerced, with no further validation | Filters the role list to this role |
fromFilter |
boolean | No | Optional; no type validation | Accepted but not read by this endpoint |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/get-roles" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": "1042" }'
Success response — 201 Created
{
"error": false,
"clientServiceRole": [
{
"id": 51,
"role": "Account Manager",
"price_per_hour": "85.00",
"deleted": 0,
"primary_accountant": 1,
"mc_manager": 0,
"invoice_approver": 0,
"service_line_based_approvers": [{ "service_line_id": 3, "invoice_approver": 0 }]
}
],
"clientServiceTeam": [
{
"id": "eyJpdiI6IkJ2...",
"role_id": 51,
"role": "Account Manager",
"user_id": 204,
"user": "Reyes, Dana",
"price_per_hour": "85.00"
}
],
"users": [{ "id": 204, "name": "Reyes, Dana" }],
"serviceLine": [
{
"id": 3,
"name": "Bookkeeping",
"invoice_approver": 0,
"role_based_approver": []
}
],
"firmId": 17,
"useSLApprover": 0,
"overRideDefaultPricing": 0,
"defaultRoles": [
{ "id": 51, "role": "Account Manager", "primary_accountant": 1, "mc_manager": 0, "invoice_approver": 0 }
],
"overrideRoles": [
{
"id": 51,
"role": "Account Manager",
"override_primary_accountant": 0,
"override_mc_manager": 0,
"override_invoice_approver": 0
}
],
"projectList": [
{
"id": 900,
"name": "Monthly Close",
"company_id": 1042,
"monthly_budget": "2500.00",
"monthly_minimum": "0.00",
"is_primary_budget": 1
}
]
}
| Field | Type | Description |
|---|---|---|
clientServiceRole[] |
object[] | The roles selectable for this company, with default pricing, the three role flags, and per-service-line approver state |
clientServiceTeam[].id |
string | number | Encrypted service_role_users.id, or literal 0 for a placeholder row that has no stored record |
clientServiceTeam[].user_id / .user |
number | null / string | Assigned user and display name (lf_name, else "Last, First"); blank when the role is unassigned |
clientServiceTeam[].price_per_hour |
string | Two decimal places |
users[] |
object[] | Selectable users: firm-scoped, not deleted, active |
serviceLine[] |
object[] | Each line with its own invoice_approver checkbox state and role_based_approver list |
firmId |
number | Resolved firms.id |
useSLApprover |
number | 1/0. The per-company row wins when present — including when it is 0 — otherwise the firm-level firms.use_service_line_approver applies |
overRideDefaultPricing |
number | company.override_price |
defaultRoles[] |
object[] | The firm-default Primary Accountant / MC Manager / Invoice Approver flags per role |
overrideRoles[] |
object[] | The per-company overrides of those three flags, from cs_override_roles |
projectList[] |
object[] | The company's active projects with budget figures |
When service_role_users or service_roles is missing, a reduced shape is returned:
{
"error": false,
"clientServiceRole": [],
"clientServiceTeam": [],
"users": [],
"serviceLine": [],
"useSLApprover": 0,
"overRideDefaultPricing": 0,
"projectList": []
}
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId/task fail String |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
Notes
- Role scoping is exclusive, not additive: when
firms.use_service_role = 1onlyfirm_id = <firm>roles are returned; when it is0only global (firm_id IS NULL) roles are. - Roles are filtered
deleted IN (0, 2)and ordered byroleascending.deleted = 3(inactive) roles are hidden. cs_override_rolesrows are filtered bydeleted_at IS NULL, not by adeletedflag.- The reduced early-return shape omits
firmId,defaultRolesandoverrideRolesentirely — clients should not assume those keys are always present.
Assigns a user to a service role for a company, or updates the company's role overrides. Which of the two it does is selected by override.
| Module | Client Success > Configure > Service Team |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights — admin or ACCESS_ADMIN on menu 10 for this company; company must be active |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer; numeric strings are coerced | Plain numeric company id |
override |
number | No | Integer, coerced | 0/absent = assign a user. 1 = Primary Accountant override, 2 = MC Manager override, any other positive value = Invoice Approver override |
overrideRoles |
number[] | No | Array of numbers; numeric strings are coerced | In override mode, the role ids that should carry the override. Every other role's flag is cleared first |
role |
number | No | Numeric; numeric strings are coerced, with no further validation | In assign mode, the service_roles.id — required |
user |
number | No | Numeric; numeric strings are coerced, with no further validation | In assign mode, the user to assign. 0/absent stores NULL |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/save-roles" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": 1042, "role": 51, "user": 204 }'
Success response — 201 Created (assign mode)
{ "error": false, "id": "eyJpdiI6IkJ2...", "name": "Reyes, Dana", "price_per_hour": 85 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
id |
string | Encrypted service_role_users.id of the saved row |
name |
string | The assigned user's display name; '' when the role was left unassigned |
price_per_hour |
number | The rate stored on the row |
Override mode (also 201):
{ "error": false, "message": "Override Role Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId missing or not an integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | companyId falsy |
{ "error": true, "message": "Company is required." } |
| 201 | Assign mode with no role |
{ "error": true, "message": "Company and role are required." } |
| 201 | service_role_users table absent |
{ "error": true, "message": "Service team table not found." } |
| 201 | Override mode and cs_override_roles table absent |
{ "error": false } |
Notes
service_role_usersholds one row per(company, service role); the user is a column on that row, not a row of its own. The match key excludesuser_id, so assigning a user to a role that already has an unassigned row updates that row instead of inserting a second one.- Duplicate rows for the same
(company, service role)are repaired on save: extras beyond the lowest-id live row are soft-deleted, or hard-deleted where there is nodeletedcolumn. - Pricing rule: when the company is not on override pricing, the role's own
price_per_houris pushed onto the row on every save. When it is, the row keeps its price unless it had none. - After an assign the firm role map is re-applied for the company, including approver updates. In override mode the approver update runs only for the Invoice Approver override; the Primary Accountant and MC Manager overrides rebuild the role map without touching approvers.
- Override mode first clears the selected override column for every role on the company, then sets it to
1for each id inoverrideRoles— so an emptyoverrideRolesclears the override entirely.
Adds a service role to a company's Service Team without assigning anybody — the row is created (or reset) with user_id = NULL.
| Module | Client Success > Configure > Service Team |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights — admin or ACCESS_ADMIN on menu 10; company must be active |
| Content type | application/json |
Request body (same shape as save-roles). This endpoint reads only companyId and role; user, override and overrideRoles are ignored.
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer, coerced | Plain numeric company id |
role |
number | Yes (by this endpoint) | Numeric; numeric strings are coerced, with no further validation | The service_roles.id to add |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/save-roles-only" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": 1042, "role": 51 }'
Success response — 201 Created
{ "error": false, "id": "eyJpdiI6IkJ2...", "name": "", "price_per_hour": 85 }
| Field | Type | Description |
|---|---|---|
id |
string | Encrypted service_role_users.id |
name |
string | Always '' — the row is unassigned by definition |
price_per_hour |
number | The role's default rate |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId missing or not an integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | companyId or role falsy |
{ "error": true, "message": "Company and role are required." } |
| 201 | service_role_users table absent |
{ "error": true, "message": "Service team table not found." } |
Notes
- This route unconditionally writes
user_id = NULL. Use it, rather thansave-roles, to add a role with nobody assigned: onsave-rolesa falsyusermeans "not supplied", not "set to NULL". - Same one-row-per-
(company, role)model and the same pricing rule assave-roles. - The role map is re-applied without approver updates: an unassigned role cannot contribute an approver, so an approver rebuild here would risk clearing the company's existing approvers on a write that assigned nobody.
- The resulting row renders in the grid as the role with an empty User cell, ready to be assigned via
save-roles.
Removes the user from a Service Team row, leaving the role in place.
| Module | Client Success > Configure > Service Team |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights on the company resolved from the row, not from the body; company must be active |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String | Encrypted or plain service_role_users.id |
companyId |
number | Yes | Integer, coerced | Required by validation but not read — the company is looked up from the row |
userId |
number | No | Numeric; numeric strings are coerced, with no further validation | Accepted but not read by this endpoint |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/remove-roles" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "id": "eyJpdiI6IkJ2...", "companyId": 1042 }'
Success response — 201 Created
{ "error": false }
Blocked removal (also 201):
{
"error": true,
"message": "You cannot delete this role because this user has been mapped in the Service Team for this company."
}
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | id not a string, or companyId missing/not an integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Resolved company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | id does not resolve to a positive integer |
{ "error": true, "message": "Invalid service-team row." } |
| 201 | No service_role_users row with that id |
{ "error": true, "message": "Service-team row not found." } |
| 201 | Role is the company's effective PA / MC Manager / Invoice Approver | { "error": true, "message": "You cannot delete this role because this user has been mapped in the Service Team for this company." } |
Notes
- This clears the user, it does not delete the row:
user_idis set toNULLanddeletedstays0. The role therefore remains in the grid afterwards with an empty User cell, and stays available for reassignment. - The block rule: an explicit
cs_override_rolesentry on this role for any of the three slots blocks outright. Otherwise, a role carrying the firm-default flag for a slot blocks unless some override exists on the company to take that slot over. - Afterwards the role map is re-applied, including approver updates.
companyIdis a required body field that this endpoint ignores; supply it for validation, but the authoritative company is the one on the row.
Sets the hourly rate on one Service Team row.
| Module | Client Success > Configure > Service Team |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights on the company resolved from the row; company must be active |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
encId |
string | Yes | String | Encrypted or plain service_role_users.id |
pricePerHour |
number | Yes | Number; numeric strings are coerced | The new rate. Written as-is (0 when null/undefined) |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/save-role-price" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "encId": "eyJpdiI6IkJ2...", "pricePerHour": 95.5 }'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | encId not a string or pricePerHour not a number |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Resolved company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | encId does not resolve to a positive integer |
{ "error": true, "message": "Invalid row id." } |
| 201 | No service_role_users row with that id |
{ "error": true, "message": "Service-team row not found." } |
Notes
pricePerHourmust be sent as a number or a numeric string; it is required.- The rate written here is a per-row override and survives only while the company is on override pricing; a later
save-roleson a non-override company overwrites it with the role default.override-default-pricewith a restore signal also resets it.
Toggles a company's "override default pricing" flag, and optionally restores every one of its Service Team rows back to the role default rates.
| Module | Client Success > Configure > Service Team |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights — admin or ACCESS_ADMIN on menu 10; company must be active |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer, coerced | Plain numeric company id |
value |
number | Yes | Integer, coerced | Truthy stores company.override_price = 1, 0 stores 0 |
restoreDefaults |
number | No | Integer, coerced | > 0 triggers the price restore |
roles |
unknown[] | No | Array; no nested schema | A non-empty array also triggers the restore on its own. Only service_role_users[].enc_id (or .id) is read from each entry; prices inside are ignored |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/override-default-price" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": 1042, "value": 0, "restoreDefaults": 1 }'
Success response — 201 Created
{ "error": false, "message": "Override Pricing Updated Successfully.", "restoredRows": 12 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
message |
string | Always "Override Pricing Updated Successfully." |
restoredRows |
number | How many service_role_users rows were repriced. 0 when no restore was requested — the field distinguishes a real restore from a silent no-op |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId or value missing / not integers, or roles not an array |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | companyId falsy |
{ "error": true, "message": "Invalid company id." } |
Notes
- Two restore signals, both honoured.
restoreDefaults: 1triggers the restore, and so does a non-emptyrolesarray on its own — a caller that sends only the array does not need the flag. - Prices always come from
service_roles, never from the request body. Any price supplied insiderolesis ignored; each affected row is repriced from its role's ownprice_per_hour. - Restore scope: when the caller names row ids (via
roles[].service_role_users[].enc_idor.id) only those rows are repriced; otherwise every live row for the company is. Ids may be encrypted or plain. - The flag write and the restore are separate statements with no enclosing transaction; the flag is written first and always.
- The restore is logged server-side with the company, the flag value, the scope and the affected-row count.
Saves one of the two per-company Service Line Invoice Approver checkboxes — the master switch, or one specific service line.
| Module | Client Success > Configure > Service Team |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires per-company Client Success edit rights — admin or ACCESS_ADMIN on menu 10; company must be active |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer, coerced | Plain numeric company id |
colName |
string | Yes | String (no enum constraint) | Exactly use_service_line_approver selects the master switch (cs_use_service_line_invoice_approver, one row per company). Any other string selects the per-line table (cs_service_line_invoice_approvers, one row per company + line) |
invoiceApprover |
number | Yes | Integer, coerced | Truthy stores 1, 0 stores 0 |
serviceLineId |
number | No | Integer, coerced | Required in practice when colName is not the master switch |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/save-sl-invoice-approver" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "companyId": 1042, "colName": "service_line", "serviceLineId": 3, "invoiceApprover": 1 }'
Success response — 201 Created
{ "error": false, "message": "Service Line Approver Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Any required field missing or of the wrong type | Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 400 | Company row does not exist | { "message": "Company not found." } |
| 403 | Caller lacks per-company edit rights | { "message": "You do not have permission to modify client info for this company." } |
| 403 | Company is soft-deleted | { "message": "Inactive companies are read-only in Client Info." } |
| 201 | companyId falsy |
{ "error": true, "message": "Invalid company id." } |
| 201 | Target table absent from the schema | { "error": true, "message": "Service line approver table not found." } |
| 201 | Per-line mode with no serviceLineId |
{ "error": true, "message": "Invalid service line id." } |
Notes
colNamechooses which table is written; the two checkboxes are backed by different tables and must not be conflated. Both are per-company settings and must be posted to this route, not toPOST /api/utilities/configure/update, which writes firm-level configuration.- Neither checkbox writes
cs_invoice_approver_based_sl; that table is the firm-level role × service-line matrix and belongs toPOST /api/utilities/configure/updatewithcolName = 'service_line'. - The upsert matches without a
deleted_atfilter and clears the flag on write, so re-enabling a previously removed line revives the tombstoned row rather than inserting a duplicate. - Afterwards the company's existing invoices are re-resolved onto the new approver and the approver role map is re-run, so existing invoices follow the new configuration. Both steps run best-effort: a failure does not undo the checkbox write, which has already succeeded.
colNameis not constrained to a fixed set of values: any value other thanuse_service_line_approverroutes to the per-line table rather than being rejected.
10.2.4 Client Tags
Firm-scoped Client Success tags (client_success_tags), used to label clients. Two routes save the tag list: a canonical one and a compatibility alias.
Lists the firm's active client tags.
| Module | Client Success > Configure > Client Tags |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond authentication |
| Content type | application/json |
Request body — none. This endpoint reads no request body; any body sent is ignored.
Example request
curl -X POST "https://api.example.com/api/utilities/configure/tags/get" \
-H "Content-Length: 0" \
-b "genie.sid=<session-cookie>"
Success response — 201 Created
{ "clientSuccessTags": [ { "id": 8, "name": "Onboarding" }, { "id": 12, "name": "At Risk" } ] }
| Field | Type | Description |
|---|---|---|
clientSuccessTags[].id |
number | Plain client_success_tags.id |
clientSuccessTags[].name |
string | Tag label |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
Notes
- Scoped to the caller's firm and to
deleted = 0. - Ordered by
id ASC, so tags are returned in insertion order rather than alphabetically.
Saves the firm's tag list: creates new names, revives previously deleted ones, and soft-deletes any active tag not present in the payload.
| Module | Client Success > Configure > Client Tags |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the firm scope — requires firm_admin = 1 |
| Content type | application/json |
Request body — a bare JSON array of { text, tiClasses? } objects. No request-body validation is applied to this payload, and no properties are stripped from it. This endpoint reads only text from each element; tiClasses is accepted and ignored.
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
[].text |
string | Yes (in practice) | None | Tag name. Trimmed; empty strings and duplicates (case-insensitive) are dropped |
[].tiClasses |
string[] | No | None | Tag-input CSS classes from the frontend widget; not read |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/tags/save" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '[ { "text": "Onboarding" }, { "text": "At Risk" } ]'
Success response — 201 Created
{
"error": false,
"message": "Client Success Tags saved Successfully.",
"template_id": "eyJpdiI6IlF3..."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
message |
string | Always "Client Success Tags saved Successfully." |
template_id |
string | Encrypted id of the last tag touched (newly inserted, or the last matched existing tag); '' if none |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a firm admin | { "message": "You do not have permission to modify this configuration." } |
| 201 | Payload contains no usable names after trimming and de-duplication | { "error": true, "message": "At least one tag is required." } |
Notes
- This is a full-list replace, not an append: any currently active tag whose name is absent from the payload is soft-deleted (
deleted = 1). Send the complete list every time. - Matching is case-insensitive. An existing tag whose name reappears in the payload is revived (
deleted = 0) rather than re-created, so tag ids — and therefore any task↔tag associations — survive. - Because this payload is not validated, malformed elements are not rejected; an element without a usable
textis simply skipped.
Compatibility route: retained for compatibility with earlier clients. POST /api/utilities/configure/tags/save is the canonical route; behaviour is identical.
| Module | Client Success > Configure > Client Tags |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the firm scope — requires firm_admin = 1 |
| Content type | application/json |
Request body — identical to POST /api/utilities/configure/tags/save: a bare array of { text, tiClasses? }, with no request-body validation.
Example request
curl -X POST "https://api.example.com/api/utilities/configure/save/tags" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '[ { "text": "Onboarding" }, { "text": "At Risk" } ]'
Success response — 201 Created
{
"error": false,
"message": "Client Success Tags saved Successfully.",
"template_id": "eyJpdiI6IlF3..."
}
Errors — identical to POST /api/utilities/configure/tags/save.
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a firm admin | { "message": "You do not have permission to modify this configuration." } |
| 201 | No usable tag names in the payload | { "error": true, "message": "At least one tag is required." } |
Notes
- New integrations should use
POST /api/utilities/configure/tags/save; this path is available for existing clients that already call it. - The same full-list-replace semantics apply, so a caller sending a partial list through this alias will soft-delete the tags it omits.
Renames a single tag by id, preserving its task associations.
| Module | Client Success > Configure > Client Tags |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the firm scope — requires firm_admin = 1 |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer; numeric strings are coerced | client_success_tags.id |
name |
string | Yes | String | New tag name. Trimmed; rejected if empty |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/tags/update" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "id": 8, "name": "Onboarding (2026)" }'
Success response — 201 Created
{ "error": false, "message": "Tag updated successfully.", "id": 8, "name": "Onboarding (2026)" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success |
message |
string | "Tag updated successfully." |
id |
number | The tag id |
name |
string | The trimmed new name |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | id not an integer or name not a string |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a firm admin | { "message": "You do not have permission to modify this configuration." } |
| 201 | id resolves to 0 |
{ "error": true, "message": "Tag id is required." } |
| 201 | name empty after trimming |
{ "error": true, "message": "Tag name is required." } |
| 201 | Tag not found in this firm, or already deleted | { "error": true, "message": "Tag not found." } |
| 201 | Another active tag in the firm has the same name | { "error": true, "message": "<name> already exist in the Tags." } |
Notes
- Renaming by id, rather than delete-and-recreate through
tags/save, is what preserves existing task↔tag associations. - Ownership is verified before the write: the tag must belong to the caller's firm and be active. The
UPDATEitself is also constrained byfirm_id. - The duplicate check is case-insensitive and ignores soft-deleted tags.
Soft-deletes a single tag by id.
| Module | Client Success > Configure > Client Tags |
| Authentication | Required — authenticated session cookie |
| Authorization | Configuration-scope check on the firm scope — requires firm_admin = 1 |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer; numeric strings are coerced | client_success_tags.id |
Example request
curl -X POST "https://api.example.com/api/utilities/configure/tags/delete" \
-H "Content-Type: application/json" \
-b "genie.sid=<session-cookie>" \
-d '{ "id": 8 }'
Success response — 201 Created
{ "error": false, "message": "Tag deleted successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | id missing or not an integer |
Request validation error array |
| 401 | Session invalid or user not found | { "message": "These credentials do not match our records." } |
| 403 | Caller is not a firm admin | { "message": "You do not have permission to modify this configuration." } |
| 201 | id resolves to 0 |
{ "error": true, "message": "Tag id is required." } |
Notes
- Soft delete only (
deleted = 1), constrained to the caller's firm. The row survives, so re-adding the same name throughtags/saverevives this record and its associations. - No existence check is performed: deleting an id that does not exist, or belongs to another firm, still answers
{ "error": false, "message": "Tag deleted successfully." }having written nothing.
11. Tasks & Time
Tasks & Time is the core work-management area of Genie. It covers task creation and tracking (Tasks), saved list views (Task Board), scheduling and drag-reschedule (Calendar), client Projects carrying the monthly budget and service-role allocation used by invoicing, the reusable Task & Time Templates catalogue, the module's firm-level reference data (Configure), the user-defined reporting engine (Reports / Reports Builder), time capture and approval (Timesheet), and turning approved time and fixed-price lines into QuickBooks invoices (Invoicing). Where a route is retained purely for compatibility with earlier clients, that is called out in the Notes for that endpoint.
Conventions that apply to every endpoint in this section
- Base path. Every route in this section is served under an
/api-prefixed route group, so every route below is/api/<path>. - Authentication. Authentication is enforced by each endpoint individually: the session must carry
session.login.userId, andsession.login.statemust beauthenticated. A missing or unauthenticated session yields401with{ "message": "These credentials do not match our records." }. The session cookie isgenie.sidand requests must be sent with credentials. - Validation. Global request validation is applied. Any body property that is not part of the documented request body schema is silently stripped before the endpoint runs; a validation violation returns
400with{ "statusCode": 400, "message": [ ... ], "error": "Bad Request" }. Fields marked Numeric accept numeric strings and coerce them. - Request size. JSON and form-encoded request bodies are accepted up to 20 MB, specifically because a Reports Builder save (
POST /api/tat/reports/save) can carry a per-value conditional-format rule for every distinct value of a column. A larger body returns413 request entity too large. - Error envelope. Most business failures are not HTTP errors: they are returned as
200 OKwith{ "error": true, "message": "..." }for Tasks, Templates, Configure and Invoicing (a few task routes return only{ "message": "Task Not Found" }), and with{ "error": true, "msg": "..." }for Reports. Genuine HTTP errors are limited to401(no session / firm mismatch / global-board write by a non-global admin),403(Client Info resource permissions) and400(validation, invalid project, invalid firm context). - Encrypted identifiers. Task, project, user and email ids are frequently exchanged in an encrypted form (
enc_id,enc_cid,taskId), produced and consumed by the API. Routes that accept a task id accept either the plain numeric id or its encrypted form. - List convention. Task and audit lists use the same shape:
offset(default0),limit(default30),sort_field(a display column name, not a SQL column),sort_order(asc/desc, case-insensitive), andfilters— an array of{ column, filter }entries wherecolumnis again the display name. Unknown column names are ignored rather than rejected. Projects use a different, object-shapedfilter. - Access model. Tasks & Time is menu id
81. Per-company access levels areACCESS_NO = 1,ACCESS_VIEW = 2,ACCESS_STANDARD = 3,ACCESS_ADMIN = 4. Editing requires>= ACCESS_STANDARD; admin-only affordances requireACCESS_ADMIN. A global admin is a user withusers.global_admin = 1(or the assigned Global Master System Administrator role); a firm admin is a user withusers.firm_admin = 1; both bypass the access-level checks. Configure additionally requires the Global Master System Administrator role for global-scoped rows. Access enforcement is applied server-side and is documented per endpoint below. - Disposition / progress vocabulary. Stored
disposition_statusvalues areCompleted,Skipped,Deferred,Reassigned,Open(any other value is title-cased). The derived Progress display value isCompleted,Deferred,Skipped,Overdue(open and past due), orOpen.Invoicingis1 = Hourly,2 = Fixed Price.
The Tasks & Time domain is the operational core of Genie: firms create and track client work as Tasks, arrange saved views of those tasks on the Task Board, schedule and drag-reschedule them on the Calendar, and group them under client Projects that carry the monthly budget and service-role allocation used by invoicing. Where a route is retained purely for compatibility with earlier clients, that is called out in the Notes for that endpoint.
Conventions that apply to every endpoint in this section
- Base path. Every route in this section is served under
/api, so every path below begins with/api/. - Authentication. Authentication is enforced by each endpoint individually: the session must carry
session.login.userId, andsession.login.statemust beauthenticated. A missing or unauthenticated session yields401with{ "message": "These credentials do not match our records." }. The session cookie isgenie.sidand requests must be sent with credentials. - Validation. Global request validation is applied. Any body property that is not part of the documented request body schema is silently stripped before the endpoint runs; a validation violation returns
400with{ "statusCode": 400, "message": [ ... ], "error": "Bad Request" }. Fields marked Numeric accept numeric strings and coerce them. - Error envelope. Most business failures are not HTTP errors: they are returned as
200 OKwith{ "error": true, "message": "..." }(a few task routes return only{ "message": "Task Not Found" }). Genuine HTTP errors are limited to401(no session / firm mismatch / global-board write by a non-global admin),403(Client Info resource permissions) and400(validation, invalid project, invalid firm context). - Encrypted identifiers. Task, project, user and email ids are frequently exchanged in an encrypted form (
enc_id,enc_cid,taskId), produced and consumed by the API. Routes that accept a task id accept either the plain numeric id or its encrypted form. - List convention. Task and audit lists use the same shape:
offset(default0),limit(default30),sort_field(a display column name, not a SQL column),sort_order(asc/desc, case-insensitive), andfilters— an array of{ column, filter }entries wherecolumnis again the display name. Unknown column names are ignored rather than rejected. Projects use a different, object-shapedfilter. - Access model. Tasks & Time is menu id
81(TASK_TIME_MENU_ID). Per-company access levels areACCESS_NO = 1,ACCESS_VIEW = 2,ACCESS_STANDARD = 3,ACCESS_ADMIN = 4. Editing requires>= ACCESS_STANDARD; admin-only affordances requireACCESS_ADMIN. Global Master System Administrators and firm admins bypass these checks. Access enforcement is applied server-side and is documented per endpoint below. - Disposition / progress vocabulary. Stored
disposition_statusvalues areCompleted,Skipped,Deferred,Reassigned,Open(any other value is title-cased). The derived Progress display value isCompleted,Deferred,Skipped,Overdue(open and past due), orOpen.Invoicingis1 = Hourly,2 = Fixed Price.
11.1 Tasks
The Tasks subsection covers task creation and editing, the task list and its exports, per-task steps/resources/time history, disposition (completion) and recurrence, bulk multi-task operations, and the task audit log. It comprises 39 routes.
Returns every dropdown option set the Task List filter panel and the Add/Edit Task form need, scoped to the caller's firm and company access.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Project options are scoped by company access (global admin → all firms; firm user → whole firm; mapped user → only mapped companies), and the firm's use_service_line / use_service_role flags are applied. |
| Content type | application/json |
Example request
curl -s 'https://api.example.com/api/task/filter-options' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"priorityOptions": [{ "id": 3, "name": "High" }],
"assignOptions": [
{ "group": "Roles", "options": [{ "id": 12, "name": "Accountant", "icon": "fa-user-tie", "deleted": 0 }] },
{ "group": "Users", "options": [{ "id": 88, "name": "Doe, Jane", "icon": "fa-user-tie", "active": 1, "deleted": 0 }] }
],
"serviceLineOptions": [{ "id": 4, "name": "Bookkeeping" }],
"projectOptions": [{ "id": 91, "name": "Monthly Close" }],
"typeOptions": [{ "id": 2, "name": "Recurring" }],
"billingOptions": [{ "id": 1, "name": "Standard" }],
"tagOptions": [{ "id": 7, "name": "Urgent" }],
"statusOption": [{ "id": 5, "name": "In Progress" }],
"companyGroupOptions": [{ "id": 3, "name": "West Region" }],
"roleUserMap": { "12": [88, 91] },
"options": []
}
| Field | Type | Description |
|---|---|---|
priorityOptions |
array | Priority rows for firm 0 (global) and the caller's firm. |
assignOptions |
array | Two groups, Roles and Users, used by the Assigned-To picker. |
serviceLineOptions |
array | Service lines; falls back to the global set when the firm has use_service_line = 0. |
projectOptions |
array | Projects visible under the caller's company access scope. |
typeOptions / billingOptions / tagOptions / statusOption |
array | Task type, invoice-detail, tag and status option sets. |
companyGroupOptions |
array | Company-group options for the Company Group filter column. |
roleUserMap |
object | Map of service-role id → array of user ids holding that role, so the UI can narrow the User picker after a Role is chosen. |
options |
array | Always empty; retained for compatibility with earlier clients. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Inactive/soft-deleted options are still returned with a
deletedflag rather than being filtered out, so the client can render "(inactive)" suffixes.
Returns a flat { roleOptions, userOptions } pair for the Reports Builder preview's Role/User global-filter dropdowns.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; scoped to the caller's firm. |
| Content type | application/json |
Example request
curl -s 'https://api.example.com/api/task/multi-task/list' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"roleOptions": [{ "id": 12, "name": "Accountant" }],
"userOptions": [{ "id": 88, "name": "Doe, Jane" }]
}
| Field | Type | Description |
|---|---|---|
roleOptions[].id / .name |
number / string | Firm service roles. |
userOptions[].id / .name |
number / string | Firm users; lf_name when present, otherwise a formatted first/last name. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Compatibility route: retained for compatibility with earlier clients. It returns the same firm-roles/firm-users data that
GET /api/task/filter-optionsalready provides insideassignOptions, re-shaped flat for the Reports Preview page. - Despite the
multi-taskpath segment this is a read-only dropdown feed, unrelated to the multi-task update/delete/skip routes.
Returns the assignable roles/users and the project list for one company — the payload the Add/Edit Task form loads after a company is picked.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer, coerced from string | Company whose projects and assignable people are wanted. |
Example request
curl -s -X POST 'https://api.example.com/api/task/company/projects' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "companyId": 412 }'
Success response — 200 OK
{
"assignOptions": [
{ "group": "Roles", "options": [{ "id": 12, "name": "Accountant", "icon": "fa-user-tie", "deleted": 0 }] },
{ "group": "Users", "options": [{ "id": 88, "name": "Doe, Jane", "lf_name": "Doe, Jane", "icon": "fa-user-tie", "active": 1, "deleted": 0 }] }
],
"projectOptions": [
{ "id": 91, "name": "Monthly Close", "serviceLine": 4, "enc_id": "b2FkOTk..." }
],
"roleOptions": [{ "id": 12, "name": "Accountant", "icon": "fa-user-tie", "deleted": 0 }],
"userOptions": [{ "id": 88, "name": "Doe, Jane", "lf_name": "Doe, Jane", "icon": "fa-user-tie", "active": 1, "deleted": 0 }]
}
| Field | Type | Description |
|---|---|---|
assignOptions |
array | Grouped Roles / Users picker payload. |
projectOptions[].serviceLine |
number | null | The project's service line, used to auto-fill the task's service line. |
projectOptions[].enc_id |
string | Encrypted project id. |
roleOptions / userOptions |
array | The same two sets, flat. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId missing or not an integer |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Resolves the single user who should be auto-assigned for a given service role at a given company.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer | Service role id. |
companyId |
number | Yes | Integer | Company the role is being resolved against. |
Example request
curl -s -X POST 'https://api.example.com/api/task/get-assigned-name' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "id": 12, "companyId": 412 }'
Success response — 200 OK
{ "id": 88, "name": "Doe, Jane", "icon": "user-tie" }
| Field | Type | Description |
|---|---|---|
id |
number | Resolved user id, or 0 when nobody holds the role at that company. |
name |
string | User display name, or the literal "Unassigned". |
icon |
string | "user-tie" when resolved, "user-ban" when unassigned. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing/non-integer id or companyId |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Given a company and a set of candidate service roles, returns the role/user pair to pre-select when exactly one role matches.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company to resolve roles against. |
serviceRoleIds |
number[] | Yes | Array (elements not individually validated) | Candidate service role ids. |
Example request
curl -s -X POST 'https://api.example.com/api/task/company/authUserRoles' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "companyId": 412, "serviceRoleIds": [12, 15] }'
Success response — 200 OK
{ "role": { "id": 12, "name": "Accountant" }, "user": { "id": 88, "name": "Doe, Jane" } }
| Field | Type | Description |
|---|---|---|
role |
object | [] |
{ id, name } when exactly one distinct role is resolved, otherwise an empty array. |
user |
object | [] |
{ id, name } for the user holding that role, otherwise an empty array. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing companyId / serviceRoleIds |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The empty-array form is returned whenever zero or more than one distinct role resolves — the client is expected to leave the pickers untouched in that case.
Lists the company/project pairs eligible for a bulk task creation run, optionally narrowed by project, service line, invoice detail or invoicing type.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
byProject |
string | No | String | Project-name filter. |
byServiceLine |
number | No | Integer | Service line id filter. |
byInvoiceDetails |
number | No | Integer | Invoice-detail (billing) id filter. |
byInvoicing |
number | No | Integer | Invoicing type filter (1 Hourly, 2 Fixed Price). |
Example request
curl -s -X POST 'https://api.example.com/api/task/bulk/fetch/clients' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "byServiceLine": 4, "byInvoicing": 1 }'
Success response — 200 OK
{
"clients": [
{
"company_id": 412,
"company_name": "Acme Holdings",
"project_id": 91,
"project_name": "Monthly Close",
"invoice_details": 1,
"invoicing": 1,
"invoicing_label": "Hourly"
}
]
}
| Field | Type | Description |
|---|---|---|
clients[] |
array | One row per company/project pair, ordered by company name then project name. |
clients[].invoicing_label |
string | Human label derived from invoicing (Hourly / Fixed Price, "" otherwise). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | A supplied filter fails its type check | Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- All four filters are optional; with none supplied the full eligible set for the caller's scope is returned. This endpoint feeds the client picker of
POST /api/task/bulk/create/tasks.
Creates one task per selected client/project from a task template, in a single call.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The per-task assigned-user access check is skipped here because users are resolved server-side from company-scoped service roles. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
templateId |
number | Yes | Integer | Task template to instantiate. |
assignedRole |
number | Yes | Integer | Service role assigned to every created task. |
selectedClients |
object[] | Yes | Array; each item is validated against the item fields below | The company/project pairs to create tasks for. |
selectedClients[].company_id |
number | Yes | Integer | Company id. |
selectedClients[].project_id |
number | Yes | Integer | Project id. |
selectedClients[].invoice_details |
number | No | Integer | Per-row invoice detail override. |
selectedClients[].invoicing |
number | No | Integer | Per-row invoicing override. |
taskDue |
string | Yes | String | Due date for every created task. |
taskName |
string | Yes | String | Task name for every created task. |
invoiceDetails |
number | No | Integer | Default invoice detail. |
invoicing |
number | No | Integer | Default invoicing type. |
serviceLine |
number | Yes | Integer | Service line for every created task. |
taskType |
number | Yes | Integer | Task type for every created task. |
Example request
curl -s -X POST 'https://api.example.com/api/task/bulk/create/tasks' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"templateId": 33,
"assignedRole": 12,
"selectedClients": [{ "company_id": 412, "project_id": 91 }],
"taskDue": "2026-10-15",
"taskName": "September Close",
"serviceLine": 4,
"taskType": 2
}'
Success response — 200 OK
{ "error": false, "message": "Task(s) Created Successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task(s) Created Successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | selectedClients empty |
{ "error": true, "message": "Select at least one client/project." } |
| 200 | templateId does not resolve |
{ "error": true, "message": "Template not found." } |
| 400 | Missing/invalid required fields | Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The template's steps are replicated onto each created task.
- If the template carries a recurrence with a non-empty
cron_string, a recurrence row is created for each task withstartOntaken from the first 10 characters oftaskDueand no end date.
Creates a single task.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: the chosen company must belong to the caller's firm, and the caller needs Standard or Admin access on menu 81 for that company; firm and global admins bypass. The assigned and reviewer users are additionally checked for menu-81 access to that company. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
name |
string | Yes | String (must be non-blank after trim) | Task name. |
company |
number | Yes | Integer | Client company id. |
priority |
number | No | Integer | Priority id. |
assignedRoles |
number | No | Integer | Assigned service role id. |
assignedName |
number | No | Integer | Assigned user id. |
serviceLine |
number | No | Integer | Service line id; derived from the project when a project is supplied. |
project |
number | No | Integer | Project id. |
type |
number | No | Integer | Task type id. |
billing |
number | No | Integer | Invoice detail (billing) id. |
estimatedTime |
string | No | String | Estimated time. |
requireReview |
boolean | No | Boolean | Whether the task requires review. |
reviewer |
number | No | Integer | Reviewer service role id. |
reviewerName |
number | No | Integer | Reviewer user id. |
timeNotes |
string | No | String | Time notes. |
due |
string | No | String | Due date/time; invalid dates are stored as null. |
status |
number | No | Integer | Service-line status id. |
tags |
number[] | No | Array | Tag ids; also written to tasks_tags. |
invoicing |
number | No | Integer | 1 Hourly, 2 Fixed Price. Optional — stored NULL when absent. |
allOccurrence |
string | No | String | Recurrence-scope hint. |
emailId |
number | No | Integer | Linked email id. |
fromTemplate |
boolean | No | Boolean | Marks the audit source as Template. |
fromTemplateGroup |
boolean | No | Boolean | Marks the audit source as Template Group. |
fromQuickTask |
boolean | No | Boolean | Marks the audit source as Quick Task and switches the timesheet check to the soft-alert path. |
fromClone |
boolean | No | Boolean | Marks the audit source as Clone Task. |
fromTodoToTask |
boolean | No | Boolean | Only meaningful on PATCH /api/task/:id; see that endpoint. |
taskDate |
string | No | String | Date used for the timesheet-lock check. |
Example request
curl -s -X PUT 'https://api.example.com/api/task' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"name": "Reconcile bank accounts",
"company": 412,
"project": 91,
"assignedRoles": 12,
"assignedName": 88,
"due": "2026-10-15",
"invoicing": 1,
"tags": [7]
}'
Success response — 200 OK
{
"error": false,
"message": "Task created successfully.",
"taskId": "b2FkOTk...",
"rolePrice": 145,
"haveAlert": false
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task created successfully." |
taskId |
string | Encrypted id of the new task. |
rolePrice |
number | null | Role override price resolved for company + assignedRoles. |
haveAlert |
boolean | Quick Task only — true when saving will move the user's already-submitted timesheet week back to Unsubmitted. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Blank name |
{ "error": true, "message": "The name field is required." } |
| 200 | Missing company |
{ "error": true, "message": "The company field is required." } |
| 200 | taskDate falls in a SUBMIT/APPROVE timesheet week and this is not a Quick Task |
{ "error": true, "message": "<timesheet lock message>" } |
| 200 | Assigned or reviewer user lacks menu-81 access to the company | { "error": true, "message": "<assigned-user access message>" } |
| 200 | Insert produced no id | { "error": true, "message": "task failed" } |
| 400 | Request validation failure | Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 401/403 | Company outside the caller's firm, or caller lacks write access on menu 81 | Raised by the company firm and write-access checks |
Notes
- This endpoint uses
PUT, notPOST. - A
Createdaudit record is written with aFromvalue ofQuick Task,Template Group,Template,Clone TaskorScratch, chosen from thefrom*booleans in that order.
Updates an existing task using the same field set as create.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must belong to the caller's firm and to a non-deleted company. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task id — plain numeric id or encrypted id. |
Request body (same field set as PUT /api/task; see that endpoint's table)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
(all fields as for PUT /api/task) |
||||
fromTodoToTask |
boolean | No | Boolean | Promotes an existing is_todo = 1 row into a real task: is_todo is cleared and the audit event is written as Created with From: 'ToDo' rather than as an Updated diff. |
Example request
curl -s -X PATCH 'https://api.example.com/api/task/10482' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "name": "Reconcile bank accounts (Q3)", "company": 412, "priority": 3 }'
Success response — 200 OK
{
"error": false,
"message": "Task updated successfully.",
"taskId": "b2FkOTk...",
"rolePrice": 145,
"haveAlert": false
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task updated successfully." |
taskId |
string | Encrypted id of the updated task. |
rolePrice |
number | null | Role override price for company + assignedRoles. |
haveAlert |
boolean | Always false on update. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | id does not decode, or the task is not visible to the caller |
{ "error": true, "message": "Task not found." } |
| 200 | Blank name |
{ "error": true, "message": "The name field is required." } |
| 200 | Missing company |
{ "error": true, "message": "The company field is required." } |
| 400 | Request validation failure | Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Normal updates write an
Updatedaudit record containing a before/after diff computed from full task snapshots. - With
fromTodoToTask, the promotion of a ToDo into a task is logged as a creation, not an update.
Loads the full Task Details payload for one task: field values, resolved dropdown labels, steps, resources, recurrence, latest time history, the caller's per-company permission flags, and the dropdown option sets.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The caller's menu-81 access level for the task's company is resolved and returned as accessType / canEdit / canAdmin for client-side gating: an explicit menu row gives its own level, group-inherited access gives ACCESS_VIEW (2) only, no access gives ACCESS_NO (1). Firm and global admins get ACCESS_ADMIN (4). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String | Task id — plain or encrypted. |
createHistory |
number | No | Integer | Requests that a history row be created when the task is opened. |
fromViewTask |
number | No | Integer | Marks the call as coming from the View Task screen. |
Example request
curl -s -X POST 'https://api.example.com/api/task/get-task' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "id": "b2FkOTk...", "fromViewTask": 1 }'
Success response — 200 OK
{
"error": false,
"userId": 88,
"taskDate": "2026-10-15",
"taskValues": {
"id": 10482,
"enc_id": "b2FkOTk...",
"name": "Reconcile bank accounts",
"company": { "id": 412, "name": "Acme Holdings", "deleted": 0 },
"assignedRole": { "id": 12, "name": "Accountant" },
"assignedName": { "id": 88, "name": "Doe, Jane" },
"project": { "id": 91, "name": "Monthly Close", "serviceLine": 4 },
"serviceLine": { "id": 4, "name": "Bookkeeping" },
"status": { "id": 5, "name": "In Progress" },
"priority": { "id": 3, "name": "High" },
"type": { "id": 2, "name": "Recurring" },
"billing": { "id": 1, "name": "Standard" },
"invoicing": { "id": 1, "name": "Hourly" },
"due": "2026-10-15 00:00:00",
"estimated_time": "2:00",
"requires_review": 0,
"time_notes": "",
"disposition_status": "Open",
"tags": [{ "id": 7, "name": "Urgent" }],
"tag_ids": [7],
"recurrence": null,
"recurrence_id": null,
"haveAccess": true,
"accessType": 4,
"canEdit": true,
"canAdmin": true,
"companyDeleted": false,
"isFirmAdmin": true,
"isGlobalAdmin": false,
"steps": [],
"resources": [],
"task_comments": [],
"time_history": []
},
"dropDownValues": { "statusOption": [{ "id": 5, "name": "In Progress" }] },
"haveAlert": false,
"timeHistory": null,
"projectRoleStatus": { "monthlyEstimateHours": "0.00", "last4Wk": "0.00", "mothToDoDate": "0.00", "query": [] }
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
userId |
number | The calling user's id. |
taskDate |
string | null | The due date, date part only. |
taskValues |
object | The task itself. Reference fields (company, assignedRole, assignedName, reviewerRole, reviewerName, serviceLine, project, billing, priority, type, status, invoicing) are { id, name } objects or null. Inactive/deleted references carry an (inactive) / (deleted) suffix in name. |
taskValues.enc_id / email_id |
string | null | Encrypted identifiers. |
taskValues.haveAccess, hasCompanyAccess, accessType, canEdit, canAdmin |
boolean / number | Permission flags for gating Edit / Status / Notes / Disposition / Reopen / Comments and Company-Resource editing. |
taskValues.isFirmAdmin, isGlobalAdmin, isAdminUser |
boolean | Caller's admin flags — also gate "Make into Task Template". |
taskValues.companyDeleted |
boolean | true when the client company is inactive; the details view still loads so the UI can show a read-only banner. |
taskValues.steps, resources, tags, tag_ids, recurrence, time_history |
array/object | Child collections. task_comments is always [] from this endpoint. |
dropDownValues |
object | Option sets plus statusOption. |
haveAlert |
boolean | Warns that opening/logging time will unsubmit the current week's timesheet. |
timeHistory |
object | null | The latest time-history row. |
projectRoleStatus |
object | Monthly estimate / last-4-week / month-to-date role hours. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | id does not decode, or the task is not visible |
{ "error": true, "message": "Task not found." } |
| 400 | id missing |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Several fields are returned in both snake_case and camelCase (
estimated_time/estimatedTime,requires_review/requireReview,time_notes/timeNotes) for compatibility with earlier clients. - Two option tables use an inverted
is_activeflag: forservice_lineandtask_type,is_active = 1means deactivated. The(inactive)suffix logic here follows that convention. - This is the only task read path that loads a task whose company is soft-deleted; every mutation path keeps the strict company filter.
Creates a copy of an existing task, including its steps and resources, under a new name.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the clone is created through the task-create path, so the company write-access rules of PUT /api/task apply. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
taskId |
string | Yes | String | Source task id — plain or encrypted. |
taskName |
string | Yes | String (must be non-blank after trim) | Name for the cloned task. |
Example request
curl -s -X POST 'https://api.example.com/api/task/clone' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "taskId": "b2FkOTk...", "taskName": "Reconcile bank accounts (copy)" }'
Success response — 200 OK
{
"error": false,
"message": "Task cloned successfully.",
"taskId": "Y2xvbmU...",
"rolePrice": 145,
"haveAlert": false
}
| Field | Type | Description |
|---|---|---|
(all fields from PUT /api/task) |
The create response is spread into the clone response. | |
message |
string | Overridden to "Task cloned successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Source task not found or not visible | { "error": true, "message": "Task not found." } |
| 200 | Blank taskName |
{ "error": true, "message": "Clone Task Name field is required." } |
| 400 | Missing taskId / taskName |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The clone's audit record is written with
From: 'Clone Task'. - Child rows (steps, resources) are replicated after the new task id is known.
Converts an existing task into a reusable task template, carrying over its settings, steps and recurrence.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The client gates the action on the admin flags returned by POST /api/task/get-task. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
taskId |
string | Yes | String | Source task id — plain or encrypted. |
templateName |
string | Yes | String (must be non-blank after trim) | Name for the new template. |
Example request
curl -s -X POST 'https://api.example.com/api/task/makeToTemplate' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "taskId": "b2FkOTk...", "templateName": "Monthly bank reconciliation" }'
Success response — 200 OK
{ "error": false, "message": "Task converted to template successfully.", "template_id": 57 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task converted to template successfully." |
template_id |
number | Id of the created template. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Task not found or not visible | { "error": true, "message": "Task not found." } |
| 200 | Blank templateName |
{ "error": true, "message": "Template Name field is required." } |
| 200 | The template settings save fails | The error envelope returned by the template save |
| 400 | Missing taskId / templateName |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Template creation is handled by the Task Templates area, so the template creation rules documented there apply. The task's due date is split into date and time parts;
due_timeis truncated toHH:MM:SS.
Returns the caller's most recent time-history row for a task — the timer state the Task Details view resumes from.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller's firm. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task id — plain or encrypted. |
Example request
curl -s 'https://api.example.com/api/task/10482/time-history' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"getTimeHistory": {
"id": 9931,
"task_id": 10482,
"start_time": 1759312800,
"end_time": 1759316400,
"override_time": null
}
}
| Field | Type | Description |
|---|---|---|
getTimeHistory |
object | The latest row for this task and user, or a zeroed default row when none exists (or when the id does not resolve). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- This endpoint never returns an error envelope: an unresolvable id or an invisible task both yield a default (empty) time-history row, so the timer UI always has something to bind to.
Creates (or updates, when id is supplied) a time-history row for a task — used when the timer is stopped.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
task_id |
string | Yes | String | Task id — plain or encrypted. |
id |
number | No | Integer | Existing time-history row id to update. |
start_time |
number | Yes | Integer | Start timestamp (epoch seconds). |
end_time |
number | Yes | Integer | End timestamp (epoch seconds). |
override_time |
number | null | No | Integer | Manually reported duration overriding the captured span. |
Example request
curl -s -X PUT 'https://api.example.com/api/task/time-history' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "task_id": "b2FkOTk...", "start_time": 1759312800, "end_time": 1759316400 }'
Success response — 200 OK
{
"error": false,
"message": "Time history saved successfully.",
"data": { "id": 9931, "task_id": 10482, "start_time": 1759312800, "end_time": 1759316400, "override_time": null }
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Time history saved successfully." |
data |
object | The persisted time-history row. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | task_id does not decode, or the task is not visible |
{ "error": true, "message": "Task not found." } |
| 400 | Missing task_id / start_time / end_time |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- This endpoint uses
PUT, while the companion update routePOST /api/task/time-history/updateusesPOST.
Updates only the end time of an existing time-history row.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the row's task must be visible to the caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer | Time-history row id. |
end_time |
number | No | Integer | New end timestamp. |
endTime |
number | No | Integer | camelCase alias, accepted for compatibility with earlier clients. |
Example request
curl -s -X POST 'https://api.example.com/api/task/time-history/update' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "id": 9931, "end_time": 1759318000 }'
Success response — 200 OK
{
"error": false,
"message": "Time history updated successfully.",
"data": { "id": 9931, "task_id": 10482, "start_time": 1759312800, "end_time": 1759318000, "override_time": null }
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Time history updated successfully." |
data |
object | The updated row. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | id missing or zero |
{ "error": true, "message": "Invalid time-history id." } |
| 200 | Row not found | { "error": true, "message": "Time history not found." } |
| 200 | The row's task is not visible to the caller | { "error": true, "message": "Task not found." } |
| 400 | id missing/non-integer |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
start_timeandoverride_timeare always taken from the stored row; only the end time can be changed here. If neitherend_timenorendTimeis supplied, the existing end time is retained.
Returns the per-entry time log for a task: who logged time, when, how much was captured and how much was reported.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String | Task id — plain or encrypted. |
flagHistory |
boolean | No | Boolean | Selects the history variant to return. |
Example request
curl -s -X POST 'https://api.example.com/api/task/task-history' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "id": "b2FkOTk..." }'
Success response — 200 OK
{
"taskHistory": [
{
"id": 9931,
"entry_number": 1,
"user_name": "Doe, Jane",
"date": "2026-10-01 09:00:00",
"time_capture": 3600,
"time_reported": 3600,
"progress": "Open"
}
]
}
| Field | Type | Description |
|---|---|---|
taskHistory[].time_capture |
number | Seconds actually captured (end_time − start_time, floored at 0). |
taskHistory[].time_reported |
number | override_time when greater than 0, otherwise the captured value. |
taskHistory[].progress |
string | Derived progress label for the task at read time. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | id does not decode, or the task is not visible |
{ "taskHistory": [] } |
| 400 | id missing |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- A failure reading the underlying rows produces an empty
taskHistoryarray rather than an error response.
Records the outcome of working a task — Complete / Skip / Defer / Reassign, or Reopen — together with the time captured and its billable/non-billable/no-charge allocation.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
task_id |
string | Yes | String | Task id — plain or encrypted. |
id |
number | No | Integer | Existing time-history row to attach the disposition to. |
start_time |
number | Yes | Integer | Start timestamp (epoch seconds). |
end_time |
number | Yes | Integer | End timestamp (epoch seconds). |
override_time |
number | null | No | Integer | Manually reported duration. |
disposition |
object[] | Yes | Array; each item is validated against the item fields below, and only the first element is used | The disposition to apply. |
disposition[].status |
string | Yes | String; normalised to Completed, Skipped, Deferred, Reassigned, Open (any other value is title-cased) |
Outcome. |
disposition[].reason |
string | null | No | String | Reason text (deferral/reassignment). |
disposition[].due |
string | No | String | New due date when deferring. |
disposition[].assigned_roles |
number | No | Integer | New assigned role when reassigning. |
disposition[].assigned_name |
number | No | Integer | New assigned user when reassigning. |
recurrence_due |
string | No | String | Client-supplied next due date (the server computes its own; see Notes). |
allocate_time |
object | No | Nested object; its fields are validated individually | Time split from the Allocate Time modal. |
allocate_time.billableTime |
number | No | Number | Billable seconds. |
allocate_time.noChargeTime |
number | No | Number | No-charge seconds. |
allocate_time.nonBillableTime |
number | No | Number | Non-billable seconds. |
allocate_time.timeNotes |
string | No | String | Client-visible time notes. |
allocate_time.internalNotes |
string | No | String | Internal notes. |
allocate_time.isModified |
boolean | No | Boolean | Whether the user edited the split. |
allocate_time.error |
boolean | No | Boolean | Client-side validation marker. |
isReopen |
boolean | No | Boolean | Reopen path — see Notes. |
revertTimesheet |
boolean | No | Boolean | Requests the associated timesheet week be reverted. |
Example request
curl -s -X POST 'https://api.example.com/api/task/time/disposition' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"task_id": "b2FkOTk...",
"start_time": 1759312800,
"end_time": 1759316400,
"disposition": [{ "status": "Completed" }],
"allocate_time": { "billableTime": 3600, "noChargeTime": 0, "nonBillableTime": 0 }
}'
Success response — 200 OK
{
"error": false,
"message": "Task disposition saved successfully.",
"data": { "id": 9931, "task_id": 10482, "start_time": 1759312800, "end_time": 1759316400, "override_time": null }
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task disposition saved successfully." |
data |
object | The saved (or reopened) time-history row. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | task_id does not decode, or the task is not visible |
{ "error": true, "message": "Task not found." } |
| 200 | disposition[0].status empty or unnormalisable |
{ "error": true, "message": "Disposition status is required." } |
| 400 | Missing task_id / start_time / end_time / disposition |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Reopen. The Reopen action sends an empty
dispositionarray together withisReopen; that path bypasses the status check, writes anUpdatedaudit record withNew: { "Disposition Status": "REOPEN" }, and returns the reopened history row. - Recurrence. After a disposition is saved, if the task carries a recurrence and the whole family is completed or skipped, the next occurrence is cloned synchronously. The next due date is computed server-side;
recurrence_duein the body is not the authority. A failure here is logged as a warning and never fails the disposition save. allocate_timeis validated as a nested object, so its numeric fields are not removed by unknown-property stripping and the submitted split is applied as sent.
Creates or updates the recurrence rule attached to a task.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Integer | Existing recurrence row id. |
taskId |
string | Yes | String | Task id — plain or encrypted. |
due |
string | No | String | Due date to apply alongside the rule. |
cronString |
string | No | String | The recurrence rule, stored in recurrence.cron_string. |
startOn |
string | No | String | First occurrence date. |
endOn |
string | No | String | Last occurrence date. |
weekendRule |
string | No | String — free text; no fixed set of values | How occurrences landing on a weekend are shifted. |
holidayRule |
string | No | String — free text; no fixed set of values | How occurrences landing on a holiday are shifted. |
holidayCalendar |
number | No | Integer | Holiday calendar id used by holidayRule. |
allOccurence |
number | No | Integer | Whether the change applies to the whole occurrence family. The field name is spelled exactly as shown, with a single r, and must be sent that way. |
Example request
curl -s -X POST 'https://api.example.com/api/task/recurrence/all' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "taskId": "b2FkOTk...", "cronString": "0 0 1 * *", "startOn": "2026-10-01", "allOccurence": 1 }'
Success response — 200 OK
{ "error": false, "message": "Recurrence saved successfully.", "recurrenceId": 771 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Recurrence saved successfully." |
recurrenceId |
number | Id of the created/updated recurrence row. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | taskId does not decode, or the task is not visible |
{ "error": true, "message": "Task not found." } |
| 200 | The recurrence table is absent |
{ "error": true, "message": "Recurrence table not found." } |
| 200 | The recurrence row could not be created | { "error": true, "message": "Something went wrong." } |
| 400 | Missing taskId |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Writes are schema-adaptive:
weekend_rule,holiday_rule,updated_atand the step/resource snapshot columns (r_step,r_resource) are only written when those columns exist on therecurrencetable. - Saving a recurrence also snapshots the task's current steps and resources onto the recurrence row so generated occurrences inherit them.
- Allowed values for
weekendRule/holidayRuleare not specified in the current implementation — they are stored as free-form strings.
Changes a task's service-line status (the workflow status, not the disposition).
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. The Task Details view gates the control on canEdit. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task id — plain or encrypted. |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
status |
number | Yes | Integer | service_line_status.id to set. |
Example request
curl -s -X PATCH 'https://api.example.com/api/task/update/status/10482' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "status": 5 }'
Success response — 200 OK
{ "error": false, "message": "Status updated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Status updated successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | id does not decode, or the task is not visible |
{ "error": true, "message": "Task not found." } |
| 400 | Missing/non-integer status |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- An
Updatedaudit record is written carrying the resolved status names (before/after), not the raw ids.
Replaces a task's time notes.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task id — plain or encrypted. |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
timeNotes |
string | Yes | String | New time-notes text (empty string allowed). |
Example request
curl -s -X PATCH 'https://api.example.com/api/task/time-notes/10482' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "timeNotes": "Waiting on October bank statement." }'
Success response — 200 OK
{ "error": false, "message": "Time notes updated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Time notes updated successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | id does not decode, or the task is not visible |
{ "error": true, "message": "Task not found." } |
| 400 | timeNotes missing or not a string |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- An
Updatedaudit record is written carrying the before and afterTime Notestext.
Lists the checklist steps attached to a task, in order.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task id — plain or encrypted. |
Example request
curl -s 'https://api.example.com/api/task/10482/step/list' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"step_list": [
{ "id": 401, "task_id": 10482, "name": "Download statements", "link": "", "order": 1, "done": null }
]
}
| Field | Type | Description |
|---|---|---|
step_list |
array | Step rows as stored; empty when the id does not resolve or the task is not visible. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The step table name is resolved at runtime, so column availability varies by deployment; the exact set of row columns is therefore not fixed.
Saves the full set of checklist steps for a task in one call — updating existing rows and inserting new ones.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
task_id |
string | Yes | String | Task id — plain or encrypted. |
all_occurence |
number | No | Integer | Apply to the whole recurrence family. The field name is spelled exactly as shown and must be sent that way. |
allOccurence |
number | No | Integer | camelCase alias of the same flag. |
step |
object[] | Yes | Array; each item is validated against the item fields below | The complete step list. |
step[].step_id |
number | No | Integer | Existing step id (update). |
step[].id |
number | No | Integer | Alias for step_id. |
step[].name |
string | No | String | Step name. |
step[].description |
string | No | String | Step description. |
step[].link |
string | No | String | Reference link. |
step[].order |
number | No | Integer | Display order; defaults to the array index + 1. |
step[].done |
string | null | No | String | Completion marker. |
Example request
curl -s -X POST 'https://api.example.com/api/task/step/all' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"task_id": "b2FkOTk...",
"allOccurence": 0,
"step": [
{ "step_id": 401, "name": "Download statements", "order": 1 },
{ "name": "Match transactions", "order": 2 }
]
}'
Success response — 200 OK
{ "error": false, "message": "Steps saved successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on a reachable task. |
message |
string | "Steps saved successfully.", or "No steps were saved." when nothing was persisted. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | task_id does not decode, or the task is not visible |
{ "error": true, "message": "Task not found." } |
| 400 | Missing task_id / step |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Saving zero steps is valid and returns success; a task may have no checklist.
- The task's
updated_atis touched on every call.
Deletes one checklist step.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the step's owning task must be visible to the caller. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Step id. Coerced to a number by this endpoint — a non-numeric value is rejected as "Step not found." |
Example request
curl -s -X DELETE 'https://api.example.com/api/task/step/401' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Step deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Step deleted successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Missing/zero id, step row not found, or its task is not visible | { "error": true, "message": "Step not found." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Lists the reference links (resources) attached to a task.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task id — plain or encrypted. |
Example request
curl -s 'https://api.example.com/api/task/10482/resource/list' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"resource": [
{ "id": 55, "task_id": 10482, "name": "Bank portal", "link": "https://bank.example.com", "order": 1 }
]
}
| Field | Type | Description |
|---|---|---|
resource |
array | Resource rows as stored; empty when the id does not resolve or the task is not visible. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Saves the full set of resources for a task.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
taskId |
string | Yes | String | Task id — plain or encrypted. |
allOccurence |
number | No | Integer | Apply to the whole recurrence family. |
resource |
object[] | Yes | Array; each item is validated against the item fields below | The complete resource list. |
resource[].resource_id |
number | No | Integer | Existing resource id (update). |
resource[].id |
number | No | Integer | Alias for resource_id. |
resource[].name |
string | No | String | Display name. Falls back to description when blank. |
resource[].description |
string | No | String | Alternate source for the name. |
resource[].link |
string | No | String | URL. Rows with a blank name or blank link are silently skipped. |
resource[].order |
number | No | Integer | Display order; defaults to the array index + 1. |
Example request
curl -s -X POST 'https://api.example.com/api/task/resource/all' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"taskId": "b2FkOTk...",
"resource": [{ "name": "Bank portal", "link": "https://bank.example.com", "order": 1 }]
}'
Success response — 200 OK
{}
| Field | Type | Description |
|---|---|---|
| (none) | On success the endpoint returns an empty object — no error/message envelope. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | taskId does not decode, or the task is not visible |
{ "error": true, "message": "Task not found." } |
| 400 | Missing taskId / resource |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Unlike the step-save endpoint, this endpoint returns no message on success; clients should treat the absence of an
errorkey as success.
Deletes one task resource.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the resource's owning task must be visible to the caller. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Resource id; coerced to a number by this endpoint. |
Example request
curl -s -X DELETE 'https://api.example.com/api/task/resource/55' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Resource deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Resource deleted successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Missing/zero id, row not found, or its task is not visible | { "error": true, "message": "Resource not found." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
The main Task List query: filtered, sorted and paginated tasks for the caller's company scope.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Company scope is applied automatically: a Global Master System Administrator sees every firm, a firm user or firm admin sees the whole firm, and a non-firm mapped user sees only their user_company_mapping companies. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
offset |
number | No | Integer, minimum value 0; default 0 |
Row offset. |
limit |
number | No | Integer, minimum value 1; default 30 |
Page size. |
sort_field |
string | No | String; must be one of the display names below or it is ignored | Sort column. |
sort_order |
string | No | One of asc, desc, ASC, DESC, ""; anything other than desc (case-insensitive) sorts ascending |
Sort direction. |
filters |
object[] | No | Array; each item is validated against the entry fields below | Per-column filters. |
filters[].column |
string | Yes (within an entry) | String | Display column name. |
filters[].filter |
string | null | No | String | Filter value. |
filters[].id |
number | No | Integer | Saved board column id (round-tripped from the board). |
filters[].task_view_id |
number | No | Integer | Owning board id. |
filters[].order |
number | No | Integer | Column order. |
includeInactive |
number | No | Integer; 1 includes inactive rows |
Include inactive companies/options. |
globalFilter |
object | No | Nested object; its fields are validated individually | Board-level Role/User filter. |
globalFilter.role |
string | No | String | Role scope. |
globalFilter.user |
string | No | String | User scope. |
fromHome |
boolean | No | Boolean | Marks the call as the Home dashboard variant. |
skipTodo |
boolean | No | Boolean | Exclude ToDo rows. |
isPriority |
string | No | String | High-priority-only scope. |
asOn |
string | No | String | As-of date for the query. |
company |
number | No | Integer, minimum value 0 | Restricts the incomplete/all drill-down to a single company. |
Sortable columns (sort_field): Company, Task Name, Company Group, Assigned To, Assigned To Person, Reviewer, Review Person, Progress, Project, Service Line, Status, Due, Priority, Type, Invoice Detail, Invoicing, Tags, Recurrence.
Filterable columns (filters[].column): Company, Company Group, Priority, Project, Service Line, Type, Status, Assigned To, Assigned To Person, Reviewer, Review Person, Invoice Detail, Requires Review.
Example request
curl -s -X POST 'https://api.example.com/api/task/list' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"offset": 0,
"limit": 30,
"sort_field": "Due",
"sort_order": "asc",
"filters": [{ "column": "Status", "filter": "In Progress" }],
"includeInactive": 0
}'
Success response — 200 OK — an array of task rows (not an envelope).
[
{
"id": 10482,
"company_id": 412,
"assigned_name": 88,
"company_group_id": 3,
"Company Group": "West Region",
"Task Name": "Reconcile bank accounts",
"Due": "2026-10-15 00:00:00",
"Estimate Time": "2:00",
"isTodo": 0,
"Company": "Acme Holdings",
"company_deleted": 0,
"Project": "Monthly Close",
"Service Line": "Bookkeeping",
"Invoice Detail": "Standard",
"Priority": "High",
"Type": "Recurring",
"Status": "In Progress",
"Invoicing": "Hourly",
"Tags": "Urgent",
"Assigned To": "Accountant",
"Assigned To Person": "Doe, Jane",
"Reviewer": null,
"Review Person": null,
"Requires Review": "No",
"Progress": "Open",
"Recurrence": "Monthly",
"Recurrence Rule": "On the 1st of every month",
"Next Recurrence": "2026-11-01",
"haveAccess": true,
"getComment": [],
"enc_id": "b2FkOTk...",
"steps": [],
"time_history_count": 1,
"time_history": [],
"task_comments": []
}
]
| Field | Type | Description |
|---|---|---|
Display-name keys (Task Name, Company, Due, …) |
string | null | Human-readable column values, keyed exactly as the grid displays them. |
id, company_id, assigned_name, company_group_id |
number | Raw identifiers. |
enc_id |
string | Encrypted task id, used by the detail/clone/delete routes. |
Progress |
string | Completed / Deferred / Skipped / Overdue / Open (or a title-cased raw status). |
Recurrence, Recurrence Rule, Next Recurrence |
string | Derived recurrence display values. |
haveAccess |
boolean | Whether the caller has task access to this row's company (true for unrestricted scopes). |
steps, time_history, task_comments, getComment |
array | Eager-loaded children. time_history_count is the row count. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure (e.g. sort_order outside the allowed set) |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- No total-count field is returned; paging is driven purely by
offset/limit. - Unknown
sort_fieldorfilters[].columnvalues are ignored rather than rejected — the maps above are the authoritative allow-lists. - The
companyfield applies only to the incomplete/all company drill-downs.
Exports the full filtered Task List as an Excel workbook, returned as a base64 data: URI.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the same company scope as POST /api/task/list applies. |
| Content type | application/json (the workbook is embedded as a base64 data URI, not streamed) |
Request body (extends the POST /api/task/list request body)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
| (all task-list fields) | No | See POST /api/task/list |
Same filters, sort and global scope as the on-screen list. | |
columns |
string[] | No | Array; each item must be a string | Visible column set to emit, by display name. |
fileName |
string | No | String | Requested file name. |
viewType |
string | No | String | View type label from the export modal. |
fromDue |
string | No | String | From-due-date bound. |
toDue |
string | No | String | To-due-date bound. |
Example request
curl -s -X POST 'https://api.example.com/api/task/list/export-excel' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"filters": [{ "column": "Status", "filter": "In Progress" }],
"columns": ["Task Name", "Company", "Due", "Progress"],
"fileName": "October tasks",
"fromDue": "2026-10-01",
"toDue": "2026-10-31"
}'
Success response — 200 OK
{
"error": false,
"redirect": "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,UEsDBBQ...",
"exportName": "October tasks.xlsx",
"total": 148
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
redirect |
string | data: URI containing the base64 workbook — the client navigates to or downloads this. |
exportName |
string | File name to save as. |
total |
number | Number of rows exported. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The export runs over the full filtered dataset, not just the current page —
offset/limitfrom the list request do not cap it. - Column widths are auto-sized within a 12–60 character band.
- The Excel and Google Sheet exports are produced from the same workbook definition, so their contents always match.
Builds the same Task List workbook and uploads it straight into a chosen Google Drive folder, where Drive converts it to a native Sheet.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie plus a Google account connected for the calling user |
| Authorization | No additional role requirement beyond an authenticated session. The Drive upload runs against a token minted for this session's user, so a folder id belonging to another account simply fails the write. |
| Content type | application/json |
Request body (extends the POST /api/task/list/export-excel request body)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
| (all Excel-export fields) | No | See POST /api/task/list/export-excel |
Identical filters and column set. | |
folderId |
string | Yes | String; must be non-blank after trim | Drive folder id chosen in the Google Drive Picker. |
folderName |
string | No | String | Display name of the folder, echoed back in the success message. |
Example request
curl -s -X POST 'https://api.example.com/api/task/list/export-google-sheet' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"columns": ["Task Name", "Company", "Due"],
"fileName": "October tasks",
"folderId": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
"folderName": "Client Reports"
}'
Success response — 200 OK
{
"error": false,
"file": "1XyZ...",
"name": "October tasks.xlsx",
"view_link": "https://docs.google.com/spreadsheets/d/1XyZ.../edit",
"folderName": "Client Reports",
"exportName": "October tasks.xlsx",
"total": 148
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
file |
string | Drive file id of the created Sheet. |
name |
string | File name as stored in Drive. |
view_link |
string | Direct link to open the Sheet. |
folderName |
string | null | Echo of the picked folder name. |
exportName, total |
string / number | Same as the Excel export. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Blank folderId |
{ "error": true, "message": "<message stating that no Drive folder was selected>" } |
| 200 | Google account not connected / access revoked | { "error": true, "needsAuth": true, "message": "<reason>" } |
| 200 | Drive API rejected the upload | { "error": true, "message": "<Drive error message>" } |
| 200 | Any other failure | { "error": true, "message": "<message stating that the Google Sheet could not be created>" } |
| 400 | folderId missing |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The workbook is built and uploaded in a single server round-trip, so it never passes through the browser.
needsAuth: trueis the signal for the client to re-open "Configure Google Account" rather than showing a plain error toast.
Applies one or more field changes to a set of tasks at once (the Multi-Edit grid action).
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; only tasks visible to the caller's firm are touched. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
ids |
string[] | Yes | Array; each item must be a string | Task ids — plain or encrypted. Duplicates and unresolvable entries are dropped. |
fieldset |
object[] | Yes | Array; each item is validated against the entry fields below | The changes to apply. |
fieldset[].field |
string | Yes | String; see the allowed list below | Field name. Unrecognised names are silently ignored. |
fieldset[].value |
any | No | No type validation | New value. |
allOccurrences |
string | No | String | Apply to the whole recurrence family. The field name is spelled exactly as shown, with a double r, unlike the step and resource endpoints, and must be sent that way. |
Allowed fieldset[].field values: Task Name, Role, User, Requires Reviewer, Reviewer Role, Reviewer User, Priority, Due, Invoice Detail, Invoicing, Estimated Time, Service Line, Type, Tags.
Example request
curl -s -X POST 'https://api.example.com/api/task/multi-task/update' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"ids": ["b2FkOTk...", "Y2xvbmU..."],
"fieldset": [
{ "field": "Priority", "value": 3 },
{ "field": "Due", "value": "2026-11-30" }
]
}'
Success response — 200 OK
{ "error": false, "message": "Task Updated successfully", "mismatchServiceLine": [] }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task Updated successfully". |
mismatchServiceLine |
array | Populated when a Type change conflicts with a task's project service line — each entry is { name, project, serviceLine }. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No usable ids, or none of them resolve to visible tasks | { "message": "Task Not Found" } (note: no error key in this response shape) |
| 400 | Missing ids / fieldset |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
Invoicingaccepts either an id (1/2) or a label containing "hourly"/"fixed"; it falls back to1when neither resolves.Tagsaccepts an array or a comma-separated string, of ids or{ id }/{ value }objects.- If a Role is set without a User (or a Reviewer Role without a Reviewer User), the matching users are auto-populated server-side from the company's service-role assignments.
- Each affected task gets its own
Updatedaudit record with a before/after diff.
Soft-deletes a set of tasks.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; only tasks visible to the caller are deleted. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
ids |
string[] | Yes | Array; each item must be a string | Task ids — plain or encrypted. |
Example request
curl -s -X POST 'https://api.example.com/api/task/multi-task/delete' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "ids": ["b2FkOTk...", "10483"] }'
Success response — 200 OK
{ "error": false, "message": "Task Deleted Successfully" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task Deleted Successfully". |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No usable ids, or none resolve to visible tasks | { "message": "Task Not Found" } |
| 400 | ids missing |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- A
Deletedaudit record carrying a pre-delete snapshot is written for each task.
Deletes a single task.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; same visibility rules as the multi-delete. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task id — plain or encrypted. |
Example request
curl -s -X DELETE 'https://api.example.com/api/task/10482' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Task Deleted Successfully" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task Deleted Successfully". |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Task not found or not visible | { "message": "Task Not Found" } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Compatibility route: retained for compatibility with earlier clients. The canonical route is
POST /api/task/multi-task/delete; this endpoint applies exactly the same processing to the single id, including theDeletedaudit write, and returns an identical response shape.
Marks a set of tasks as skipped.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; only visible tasks are affected. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
ids |
string[] | Yes | Array; each item must be a string | Task ids — plain or encrypted. |
Example request
curl -s -X POST 'https://api.example.com/api/task/multi-task/skip' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "ids": ["b2FkOTk..."] }'
Success response — 200 OK
{ "error": false, "message": "Task Skipped Successfully" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task Skipped Successfully". |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No usable ids, or none resolve to visible tasks | { "message": "Task Not Found" } |
| 400 | ids missing |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
disposition_statusis set to the upper-case literalSKIPPED, while the audit record storesNew: { "Disposition Status": "Skipped" }. Clients must handle both spellings.
Returns the Home dashboard's saved view definition together with the task list rendered through it.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: a firmId that differs from the caller's own firm throws 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | No | Integer; must equal the caller's firm | Firm to load the home view for. Defaults to the caller's firm. |
Example request
curl -s -X POST 'https://api.example.com/api/task/home/list' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "firmId": 45 }'
Success response — 200 OK
{
"homeView": {
"id": 12,
"name": "Home View",
"priority": null,
"pinned": 0,
"firm_id": 45,
"user_id": null,
"default": 0,
"sort_field": "Due",
"sort_order": "asc",
"global_role": null,
"global_user": null,
"is_home_view": 1,
"order": 1,
"columns": [
{ "id": 301, "task_view_id": 12, "column": "Task Name", "order": 1, "filter": null }
]
},
"taskList": []
}
| Field | Type | Description |
|---|---|---|
homeView |
object | The home board row plus its columns. When no home board exists a synthetic placeholder named "Home View" with id: 0 is returned. |
taskList |
array | Task rows in the same shape as POST /api/task/list, produced with fromHome: true and skipTodo: false. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Non-integer firmId |
Validation error array |
| 401 | No session, or firmId differs from the caller's firm |
{ "message": "These credentials do not match our records." } |
Returns the caller's currently open tasks with their captured time, for the timer/open-task widget.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; scoped to the caller's firm. |
| Content type | application/json |
Example request
curl -s 'https://api.example.com/api/task/getOpenTask/list' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK — an array.
[
{
"id": 10482,
"task_id": 10482,
"name": "Reconcile bank accounts",
"enc_id": "b2FkOTk...",
"companyName": "Acme Holdings",
"capture_time": 3600,
"lastLogged": "2026-10-01 09:00:00",
"disposition_status": ""
}
]
| Field | Type | Description |
|---|---|---|
id / task_id |
number | The task id, duplicated for compatibility with earlier clients. |
enc_id |
string | Encrypted task id. |
capture_time |
number | Total captured seconds. |
lastLogged |
string | null | Formatted timestamp of the most recent time entry. |
disposition_status |
string | Raw stored status, "" when unset. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Returns
[]when thetasksorcompanytable is unavailable, rather than erroring.
The Task Audit Log: paginated, filtered audit rows, optionally accompanied by the dropdown option sets the filter bar needs.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Company scope mirrors the task list: global admin → all firms; firm user/admin → whole firm; mapped user → mapped companies only. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
offset |
number | No | Integer, minimum value 0; default 0 |
Row offset. |
limit |
number | No | Integer, minimum value 1; default 30 |
Page size. |
sort_field |
string | No | String; one of Date, Time, User, Client, Name, Due Date, Action — anything else falls back to audit-created-at |
Sort column. |
sort_order |
string | No | One of asc, desc, ASC, DESC, ""; defaults to DESC (only an explicit asc sorts ascending) |
Sort direction. |
filters |
object[] | No | Array; each item is validated against the entry fields below | Per-column filters. |
filters[].column |
string | Yes (within an entry) | String; one of Date, User, Client, Name, Due Date, Action, Original Value, New Value |
Filter column. |
filters[].filter |
string | null | No | String; blank values are skipped | Filter value. |
includeInactiveOptions |
number | No | Integer; 1 includes inactive users/companies |
Include inactive rows and dropdown entries. |
taskId |
number | No | Integer | Restrict to one task's audit trail. |
fetchDropDown |
boolean | No | Boolean | When true, wrap the list in an envelope that also carries the filter dropdown data. |
Filter semantics: Date accepts a preset name, a Custom JSON payload ({"preset":"Custom","start":"YYYY-MM-DD","end":"YYYY-MM-DD"}), or a single date. User and Client accept a numeric id (preferred) or a display name. Name and Original Value / New Value are LIKE substring matches. Action is an exact match.
Example request
curl -s -X POST 'https://api.example.com/api/task/audit/list' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"offset": 0,
"limit": 30,
"sort_field": "Date",
"sort_order": "desc",
"filters": [{ "column": "Action", "filter": "Updated" }],
"fetchDropDown": true
}'
Success response — 200 OK
With fetchDropDown: true:
{
"dropDownData": {
"users": [{ "id": 88, "name": "Doe, Jane", "deleted": 0 }],
"clients": [{ "id": 412, "name": "Acme Holdings", "deleted": 0 }],
"actions": ["Created", "Updated", "Deleted"]
},
"listData": [
{
"id": 55210,
"task_id": 10482,
"audit_date": "10-01-2026",
"audit_time": "09:14 AM",
"user": "Doe, Jane",
"user_deleted": 0,
"client": "Acme Holdings",
"name": "Reconcile bank accounts",
"due_date": "10-15-2026",
"action": "Updated",
"original_data": { "Status": "Open" },
"new_data": { "Status": "In Progress" }
}
]
}
Without fetchDropDown, the response is the bare listData array.
| Field | Type | Description |
|---|---|---|
listData[].audit_date / audit_time |
string | Pre-formatted MM-DD-YYYY and hh:mm AM/PM. |
listData[].original_data / new_data |
object | null | Normalised key/value diff; a non-JSON string is wrapped as { "value": "..." }. |
dropDownData.actions |
string[] | Always exactly ["Created","Updated","Deleted"]. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The response shape changes with
fetchDropDown: an envelope whentrue, a plain array when absent/false. Clients must branch on the flag they sent. - The
Actiondropdown is fixed to Created/Updated/Deleted. Audit rows with other actions (Completed, Deferred, …) exist and are returned by the list, but are not filterable. - With
includeInactiveOptionsoff, the main list is gated onusers.active = 1andcompany.deleted = 0; the dropdown additionally foldsusers.deleted_atinto itsdeletedflag.
Lists the Client Info company resources (shared reference links) for a company.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: the caller must be able to view the company's Client Info resources; otherwise 403. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
companyId |
string | Yes | Company id; coerced to a number by this endpoint. |
Example request
curl -s 'https://api.example.com/api/task/company/resource/412' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"companyResource": [
{
"id": 88,
"cr_id": 88,
"name": "Client portal",
"description": "Client portal",
"link": "https://portal.example.com",
"order": 1
}
]
}
| Field | Type | Description |
|---|---|---|
companyResource[].id / cr_id |
number | The same id under both keys (earlier clients read cr_id). |
companyResource[].name / description |
string | The same text under both keys. |
companyResource[].link |
string | URL. |
companyResource[].order |
number | Display order. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Client Info resource access for the company | { "message": "You do not have permission to access client info resources for this company." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Returns
{ "companyResource": [] }— without running the permission check — whencompanyIdis falsy or thecompany_resourcestable is absent. - Rows are filtered on both the
deletedflag and thedeleted_atcolumn, so a row marked deleted by either is excluded.
Saves the full set of Client Info company resources for a company.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: 403 when the caller cannot modify the company's Client Info, and 403 again when the company is inactive (Client Info is read-only for inactive companies). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company the resources belong to. |
companyResource |
object[] | Yes | Array; elements have the shape below but are not individually validated | Complete resource list. |
companyResource[].cr_id |
number | null | No | — | Existing row id; absent/0 inserts a new row. |
companyResource[].name |
string | No | — | Display name. |
companyResource[].link |
string | No | — | URL; must be a valid link. |
companyResource[].order |
number | No | — | Display order. |
Example request
curl -s -X POST 'https://api.example.com/api/task/company/resource/all' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"companyId": 412,
"companyResource": [
{ "cr_id": 88, "name": "Client portal", "link": "https://portal.example.com", "order": 1 },
{ "name": "Shared drive", "link": "https://drive.example.com", "order": 2 }
]
}'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. No message is returned. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | One invalid link | { "error": true, "message": "Please enter a valid Link for \"<name>\"." } |
| 200 | Several invalid links | { "error": true, "message": "Please enter a valid Link for: <name>, <name>." } |
| 200 | The company_resources table is absent |
{ "error": true, "message": "Company resources table not found." } |
| 400 | Missing companyId / companyResource; company row missing |
Validation error array, or { "message": "Company not found." } |
| 403 | No edit permission, or the company is inactive | { "message": "You do not have permission to modify client info for this company." } / { "message": "Inactive companies are read-only in Client Info." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Rows where both
nameandlinkare blank are skipped entirely — neither validated nor persisted. - Updating an existing row also clears its
deletedflag where that column exists (an undelete side effect).
Deletes one Client Info company resource.
| Module | Tasks & Time > Tasks |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: the caller must be able to modify Client Info for the resource's owning company. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Resource id; coerced to a number by this endpoint. |
Example request
curl -s -X DELETE 'https://api.example.com/api/task/company/resource/88' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Missing/zero id | { "error": true, "message": "Invalid resource id." } |
| 200 | The row's company cannot be resolved | { "error": true, "message": "Resource not found." } |
| 200 | The company_resources table is absent |
{ "error": false } (treated as a no-op success) |
| 403 | No edit permission, or the company is inactive | Forbidden message as above |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Soft-deletes by setting
deleted = 1(plusupdated_at/deleted_atwhen those columns exist). Only when the table has nodeletedcolumn does it fall back to a hardDELETE.
11.2 Task Board
Task Boards ("task views") are saved column/filter/sort configurations over the task list. A board is either personal (owned by one user) or firm-wide; a firm-wide board stored with no firm id is a global board and only a Global Master System Administrator may create, edit or remove it. Per-user state (default board, pinned board) is stored separately from the board itself, so two users can pin and default different boards from the same shared set. This subsection comprises 6 routes.
Returns the boards visible to the caller, optionally together with the task list rendered through the default board.
| Module | Tasks & Time > Task Board |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: requesting a firmId other than the caller's own throws 401 unless the caller is a global admin. A firm-less Global Master System Administrator is pinned to firm 0 (the global board set). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | No | Integer; defaults to the caller's firm | Firm whose boards to load. 0 selects the global board set. |
isList |
boolean | No | Boolean | When true, also run the default board's filters and return the resulting task list. |
shouldFirm |
boolean | No | Boolean | Forces firm scoping: a zero/absent firmId falls back to the caller's own firm instead of the global set. |
Example request
curl -s -X POST 'https://api.example.com/api/task/board/view' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "firmId": 45, "isList": true }'
Success response — 200 OK
With isList: true:
{
"taskList": [],
"data": [
{
"id": 12,
"name": "Full View",
"priority": null,
"pinned": 1,
"firm_id": 45,
"user_id": null,
"default": 1,
"sort_field": "Due",
"sort_order": "asc",
"global_role": null,
"global_user": null,
"is_home_view": 0,
"order": 1,
"type": "firm",
"columns": [
{ "id": 301, "task_view_id": 12, "column": "Task Name", "order": 1, "filter": null }
]
}
],
"globalFirm": false
}
Without isList, the response is the bare boards array (the data value above).
| Field | Type | Description |
|---|---|---|
data[].type |
string | "personal" when the board has a user_id, otherwise "firm". |
data[].pinned |
number | The caller's pin state from task_pinned_boards, falling back to the board's own value. |
data[].default |
number | 1 on exactly one board: the caller's task_board_default, else the board flagged default = 1, else the first board. |
data[].columns[].filter |
string | null | Saved filter, with inactive-option tokens stripped. |
taskList |
array | Only when isList: true — task rows in the POST /api/task/list shape, run with limit: 30, offset: 0 and the default board's filters, sort and global Role/User filter. |
globalFirm |
boolean | true when the caller was steered to the global board set because they are not a global (or, for a firm-scoped request, firm) admin. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No session, or a non-global-admin requested another firm's boards | { "message": "These credentials do not match our records." } |
| 400 | Request validation failure | Validation error array |
Notes
- The response shape changes with
isList: an envelope whentrue, a plain array otherwise. - With
isList: truebut no boards at all, the response is{ "taskList": [], "data": [], "globalFirm": <bool> }. - Calling this endpoint has a side effect: the firm's built-in system boards are seeded if they do not yet exist (skipped for firm
0). - Default resolution uses no name-based heuristic and never auto-creates a personal default.
Creates a new board or replaces an existing one, including its full column list.
| Module | Tasks & Time > Task Board |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: writing a global board (no firm id and a non-personal type) requires a global admin, otherwise 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Integer | Board id to update. Absent or 0 creates a new board. |
name |
string | Yes | String | Board name; must be unique within its scope. |
firmId |
number | Yes | Integer; a value <= 0 is stored as NULL (global) |
Owning firm. |
type |
string | Yes | One of personal, firm, global |
Board scope. 'global' is normalised to 'firm' before storage; globality is expressed by a null firm id. |
columns |
object[] | Yes | Array; each item is validated against the entry fields below | Ordered column list. |
columns[].name |
string | Yes | String | Display column name. |
columns[].filter |
string | null | No | String | Saved filter for that column. |
sortField |
string | null | No | String | Default sort column. |
sortOrder |
string | null | No | String | Default sort direction. |
globalRole |
string | null | No | String | Board-level Role global filter. |
globalUser |
string | null | No | String | Board-level User global filter. |
pinned |
number | No | Integer | Accepted by the request body but not persisted here — use POST /api/task/board/updatePin. |
Example request
curl -s -X POST 'https://api.example.com/api/task/board/addEditBoard' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"name": "Overdue work",
"firmId": 45,
"type": "personal",
"sortField": "Due",
"sortOrder": "asc",
"columns": [
{ "name": "Task Name" },
{ "name": "Company" },
{ "name": "Progress", "filter": "Overdue" }
]
}'
Success response — 200 OK
{
"error": false,
"message": "Task view added successfully.",
"board": {
"id": 19,
"name": "Overdue work",
"firm_id": 45,
"user_id": 88,
"sort_field": "Due",
"sort_order": "asc",
"global_role": null,
"global_user": null,
"pinned": 0,
"type": "personal",
"columns": [{ "id": 512, "task_view_id": 19, "column": "Task Name", "order": 1, "filter": null }]
},
"data": {
"id": 19,
"name": "Overdue work",
"global_role": null,
"global_user": null,
"pinned": 0,
"table": { "columns": [{ "name": "Task Name" }] }
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task view added successfully." or "Task view updated successfully." |
board |
object | The stored board row plus its persisted columns and derived type. |
data |
object | A trimmed echo, retained for compatibility with earlier clients, carrying the submitted columns under table.columns. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | A board of the same name already exists in the same scope | { "error": true, "message": "<name> already exist in the Task View." } |
| 200 | Insert produced no id, or the board could not be re-read | { "error": true, "message": "Something went wrong." } |
| 400 | Missing/invalid name, firmId, type or columns |
Validation error array |
| 401 | No session, or a non-global admin attempted a global board | { "message": "These credentials do not match our records." } |
Notes
- Updating a board replaces its columns wholesale: all existing
task_columnrows for the board are soft-deleted (deleted_atstamped) and the submitted list is re-inserted with fresh ids. - A personal board stores the caller's
user_id; a firm board storesuser_id = NULL. - New boards receive
order = MAX(order) + 1computed across all non-deleted boards,pinned = 0,default = 0andis_home_view = 0. - Uniqueness is scope-aware: a firm board collides only with same-named firm boards for that firm; a personal board only with the caller's own.
Soft-deletes a board and its columns.
| Module | Tasks & Time > Task Board |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: removing a global board (null/zero firm id and no owning user) requires a global admin, otherwise 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer | Board id. |
index |
number | No | Integer | Client-side list index; not used by this endpoint. |
colName |
string | No | String | Not used by this endpoint. |
firm_id |
number | No | Integer | Not used by this endpoint. |
Example request
curl -s -X POST 'https://api.example.com/api/task/board/removeBoard' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "id": 19 }'
Success response — 200 OK
{ "error": false, "message": "Task view deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task view deleted successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Board id does not resolve | { "error": true, "message": "Task view not found." } |
| 400 | Missing/non-integer id |
Validation error array |
| 401 | No session, or a non-global admin attempted to remove a global board | { "message": "These credentials do not match our records." } |
Notes
- Both
task_boardand itstask_columnrows are stamped with the samedeleted_at; nothing is hard-deleted. index,colNameandfirm_idbelong to the shared board-id request body used by other board routes and are ignored here.
Sets — or clears — the caller's personal default board.
| Module | Tasks & Time > Task Board |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement — the default is stored per user in task_board_default, so it never affects other users. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer; a value <= 0 clears the caller's default |
Board to make default. |
index, colName, firm_id |
number/string | No | Integer / String | Not used by this endpoint. |
Example request
curl -s -X POST 'https://api.example.com/api/task/board/default' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "id": 19 }'
Success response — 200 OK
{ "error": false, "message": "Task board set as defaulted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task board set as defaulted successfully.", or "Task board default removed successfully." when id <= 0. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Board id does not resolve | { "error": true, "message": "Task view not found." } |
| 400 | id missing or non-integer |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Passing
id: 0(or any non-positive value) deletes the caller'stask_board_defaultrow entirely; the "default" then falls back to the board flaggeddefault = 1, or the first board. - Exactly one
task_board_defaultrow is kept per user — this endpoint updates in place when one already exists.
Pins or unpins a board for the calling user.
| Module | Tasks & Time > Task Board |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement — pin state is stored per user in task_pinned_boards. |
| Content type | application/json |
Request body (extends the shared board-id request body)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer | Board id. |
pinned |
number | Yes | Normalised server-side: true/"true" → 1, false/"false" → 0, any other value is converted to a number (absent becomes 0). Any non-zero value is stored as 1. |
Pin state. |
index, colName, firm_id |
number/string | No | Integer / String | Not used by this endpoint. |
Example request
curl -s -X POST 'https://api.example.com/api/task/board/updatePin' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "id": 19, "pinned": true }'
Success response — 200 OK
{ "error": false, "message": "Pin updated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Pin updated successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Board id does not resolve | { "error": true, "message": "Task view not found." } |
| 400 | Missing id / pinned |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Server-side normalisation means the endpoint accepts both a boolean and a
0/1value forpinned.
Re-orders boards to match the submitted sequence.
| Module | Tasks & Time > Task Board |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Note that ordering is stored on task_board itself, so this affects every user who can see the boards, not just the caller. |
| Content type | application/json |
Request body — a bare JSON array of objects with the fields below
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
| (root) | object[] | Yes | The request body itself is an array, not an object | Boards in their new display order. |
[].id |
number | Yes | Integer | Board id. Entries whose id resolves to 0 are skipped. |
Example request
curl -s -X POST 'https://api.example.com/api/task/board/reorderBoards' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '[{ "id": 19 }, { "id": 12 }, { "id": 7 }]'
Success response — 200 OK
{ "error": false, "message": "Task Board reordered successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Task Board reordered successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Body is not an array, or is an empty array | { "error": true, "message": "No boards to reorder." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
orderis assigned as the 1-based array index, so the submitted list must contain every board the user wants ordered — omitted boards keep their previousordervalue and may collide.- Only non-deleted boards are updated.
11.3 Calendar
The Calendar shows each user's tasks laid out by date and supports drag-and-drop to reschedule, reorder within a day, or reassign to another user. This subsection comprises 2 routes.
Returns the calendar grid for a date range: tasks grouped by user and day, plus the user picker and the caller's permission to view other users.
| Module | Tasks & Time > Calendar |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side: a firm admin, or a user whose timesheet config has view_others, sees every firm user; a user with authorized sees themselves plus everyone who submits to them as approver; everyone else sees only themselves. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
start |
string | Yes | String | Range start date (compared as DATE(...), so YYYY-MM-DD). |
end |
string | Yes | String | Range end date, inclusive. |
firstTime |
boolean | No | Boolean | First-load hint from the client. |
userId |
string | No | String | Encrypted user id to display. Defaults to the caller. |
calendarFormat |
string | No | One of day, week, month |
Requested view granularity. |
isShowAllUsers |
boolean | No | Boolean | When true, return tasks for every permitted user instead of just the selected one. |
Example request
curl -s -X POST 'https://api.example.com/api/task/calendar' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "start": "2026-10-01", "end": "2026-10-31", "calendarFormat": "month", "isShowAllUsers": false }'
Success response — 200 OK
{
"tasks": {
"dXNlcjg4...": {
"2026-10-15": [
{
"id": 10482,
"enc_id": "b2FkOTk...",
"name": "Reconcile bank accounts",
"company": "Acme Holdings",
"estimated_time": "2:00",
"date": "2026-10-15"
}
]
}
},
"userOptions": [
{ "name": "Doe, Jane", "enc_id": "dXNlcjg4...", "deleted": 0, "active": 1 }
],
"allowViewUsers": true,
"userId": "dXNlcjg4..."
}
| Field | Type | Description |
|---|---|---|
tasks |
object | Two-level map: encrypted assigned-user id → ISO date (YYYY-MM-DD) → array of task cards. Empty object when no users resolve. |
tasks[user][date][].enc_id |
string | Encrypted task id, used as the drag payload for POST /api/task/calendar/position. |
userOptions[] |
array | Permitted users, each { name, enc_id, deleted, active }. name prefers lf_name. |
allowViewUsers |
boolean | true when the caller is a firm admin or has view_others/authorized on their timesheet config — the client shows the user picker only then. |
userId |
string | Encrypted id of the calling user, used as the picker's default selection. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing start / end, or calendarFormat outside the allowed set |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- A task appears on
calendar_datewhen one is set, otherwise ondue(COALESCE(calendar_date, due)), and only within the requested range. - A task is eligible when it is not deleted, has a due date, is not a ToDo (
is_todo = 0), is not a timesheet entry (timesheet_entry = 0), and itsdisposition_statusis notCOMPLETEDorSKIPPED. The company must belong to the caller's firm. - Grouping is keyed by full ISO date (
YYYY-MM-DD), so week views that straddle a month boundary cannot collide on the day number. - Date values are built from local date components, so a positive server UTC offset cannot roll dates back a day.
Applies a drag: reschedules a task to a new day, optionally reassigns it to another user, and persists the per-day ordering of the affected columns.
| Module | Tasks & Time > Calendar |
| Authentication | Required — authenticated session cookie |
| Authorization | Enforced server-side when reassigning: the target user must have task access to the task's company and be an active, non-deleted user. There is no admin bypass for this check. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String | Encrypted task id being dragged. |
from |
object | Yes | Nested object; its fields are validated individually | The source day. |
from.date |
string | Yes | String | Source date. |
from.list |
object[] | No | Array; each item is validated against the entry fields below | The source day's remaining tasks in order. |
to |
object | Yes | Nested object; its fields are validated individually | The destination day. |
to.date |
string | Yes | String | Destination date; written to tasks.calendar_date. |
to.list |
object[] | No | Same shape as from.list |
The destination day's tasks in order. |
*.list[].order |
number | Yes | Integer | Position within the day. |
*.list[].id |
string | Yes | String | Encrypted task id. Entries that fail to decrypt are dropped. |
isSame |
boolean | No | Boolean | true when source and destination are the same day — the from list is then not written again. |
userId |
string | No | String | Encrypted id of the user to reassign the task to. Omit to keep the current assignee. |
dropDataArr |
unknown[] | No | Array | Opaque client state, echoed back only on the noAccess path; never persisted. |
Example request
curl -s -X POST 'https://api.example.com/api/task/calendar/position' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"id": "b2FkOTk...",
"from": { "date": "2026-10-15", "list": [{ "order": 1, "id": "YWJjMTIz..." }] },
"to": { "date": "2026-10-16", "list": [{ "order": 1, "id": "b2FkOTk..." }] },
"isSame": false,
"userId": "dXNlcjkx..."
}'
Success response — 200 OK
{ "success": true }
When the target user lacks access to the task's company, the call still returns 200 with a recovery payload:
{
"noAccess": true,
"data": {
"id": "b2FkOTk...",
"from": { "date": "2026-10-15", "list": [] },
"to": { "date": "2026-10-16", "list": [] },
"isSame": false,
"userId": "dXNlcjkx...",
"dropDataArr": [],
"company": { "id": 412, "name": "Acme Holdings" },
"fromUser": "dXNlcjg4...",
"toUser": "dXNlcjkx..."
}
}
| Field | Type | Description |
|---|---|---|
success |
boolean | true when the move was applied. |
noAccess |
boolean | Present instead of success when the target user cannot be assigned. |
data |
object | Echo of the submitted drag payload plus company, fromUser and toUser, so the client can restore the pre-drag UI and explain the refusal. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | to.date or from.date missing, id fails to decrypt, or the task row is not found |
{ "success": false, "message": "Something went wrong" } |
| 200 | Target user lacks company access | { "noAccess": true, "data": { ... } } |
| 400 | Missing id / from / to |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Reassignment is skipped entirely when the target user is already the assignee.
- Ordering is stored in
task_calendaras a JSONpositionsarray keyed by(user_id, date), upserted per day. - Both the destination and source day orderings are written under the post-move owner, even when the drag crosses users.
- The
fromlist is only rewritten whenisSameisfalseor absent.
11.4 Projects
Projects group a client's work under a service line and billing arrangement, and carry the monthly budget plus the service-role allocation that drives invoicing. This subsection also covers the Excel import/export round-trip for bulk project setup and the budget configuration endpoints. This subsection comprises 19 routes.
Unless noted otherwise, the create/update/delete/status/import routes require global admin or firm admin rights and then confirm that the target company is reachable: a global admin may target any firm's company; everyone else is restricted to their own firm.
Returns the service line, billing, invoicing and fixed-fee facts for one project — used to auto-fill the Add/Edit Task form once a project is chosen.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No admin gate. The project's company must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
project_id |
number | Yes | Integer | Project to inspect. |
company_id |
number | No | Integer | Company to use for the fixed-fee lookup; defaults to the project's own company. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/based/data' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "project_id": 91, "company_id": 412 }'
Success response — 200 OK
{
"serviceLine": { "id": 4, "name": "Bookkeeping", "firm_id": 45 },
"billing": { "id": 1, "name": "Standard" },
"invoicing": { "id": 1, "name": "Hourly" },
"fixedFee": false
}
| Field | Type | Description |
|---|---|---|
serviceLine |
object | null | The project's service line, or null when unset. |
billing |
object | null | The project's invoice detail, or null. |
invoicing |
object | { id: 1, name: "Hourly" } or { id: 2, name: "Fixed Price" }. |
fixedFee |
boolean | true when at least one invoice_price_list row exists for this company/project pair. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Project not found or soft-deleted | { "message": "Invalid project." } |
| 400 | Missing/non-integer project_id |
Validation error array |
| 401/403 | Project's company is outside the caller's firm | Raised by the company firm check |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
The Projects grid query: a paginated, filtered project list plus the dropdown option sets the page needs.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No admin gate — this is the read path. Company scope follows the caller's access: global admin → every firm; firm user/admin → all firm companies; mapped user → only user_company_mapping companies. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
offset |
number | No | Integer; default 0 |
Row offset. |
limit |
number | No | Integer; default 100 |
Page size. |
filter |
object | No | Nested object; its fields are validated individually | Column filters. |
filter.name |
string | No | String | Project name — LIKE %value%. |
filter.company |
string | No | String | Company name — LIKE %value%. |
filter.serviceLine |
string | No | String | Service line name — LIKE %value%. |
filter.billing |
string | No | String | Invoice detail — matched as an id (p.billing = Number(value)). |
filter.invoicing |
string | No | String | Invoicing type — matched as an id (p.invoicing = Number(value)). |
filter.status |
string | No | String | Accepted but not applied to the result set. |
firm_id |
number | No | Integer | Accepted but not applied; the caller's own firm is always used. |
includeInactive |
boolean | No | Boolean | Include inactive companies and projects (active_status <> 1). |
listOnly |
boolean | No | Boolean | Return only projectList, skipping the dropdown queries. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/getProjectData' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "offset": 0, "limit": 100, "filter": { "company": "Acme" }, "includeInactive": false }'
Success response — 200 OK
{
"dropDownValues": {
"status": [],
"serviceRole": [{ "id": 12, "role": "Accountant" }],
"serviceLine": [{ "id": 4, "name": "Bookkeeping", "firm_id": 45 }],
"billing": [{ "id": 1, "name": "Standard" }],
"project": [{ "id": 91, "company_id": 412, "name": "Monthly Close", "monthly_budget": 2500, "monthly_minimum": 500 }]
},
"projectList": [
{
"id": 91,
"company_id": 412,
"company_name": "Acme Holdings",
"name": "Monthly Close",
"service_line_id": 4,
"service_line": "Bookkeeping",
"billing_id": 1,
"billing": "Standard",
"status_id": null,
"status": null,
"active_status": 1,
"is_primary_budget": 1,
"invoicing_id": 1,
"invoicing": "Hourly",
"monthly_budget": 2500,
"monthly_minimum": 500,
"count": 0,
"task_id": 3,
"enc_id": "cHJvajkx...",
"enc_cid": "Y29tcDQxMg...",
"primary_budget_present": 1,
"comments_count": 2
}
]
}
| Field | Type | Description |
|---|---|---|
projectList[].invoicing |
string | "Hourly" when invoicing = 1, otherwise "Fixed Price". |
projectList[].count |
number | Number of invoice_price_list rows (fixed-price entries) for the project. |
projectList[].task_id |
number | Open task count for the project, despite the name — used to block inactivation. |
projectList[].enc_id / enc_cid |
string | Encrypted project and company ids. |
projectList[].primary_budget_present |
number | 1 when any project of the same company in this list is the primary budget — powers the "only one primary budget per company" guard. |
projectList[].comments_count |
number | Number of per-period comment entries. |
projectList[].status / status_id |
null | Always null; the columns are selected as literals. |
dropDownValues.status |
array | Always []. |
dropDownValues.serviceLine |
array | Either the firm's own service lines or the global set — an exclusive choice driven by firms.use_service_line, never a union. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- With
listOnly: truethe response is{ "projectList": [...] }only — nodropDownValues. - When the caller's scope resolves to zero companies, the response is the full envelope with every array empty.
- No total-count field is returned; paging is driven by
offset/limit. Results are ordered by company name then project name.
Creates a project.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | Global or firm admin only; the company must be reachable by the caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firm_id |
number | Yes | Integer; a falsy value is stored as NULL |
Owning firm. |
company_id |
number | Yes | Integer | Client company. |
name |
string | Yes | String, must not be empty; trimmed before storage | Project name; must be unique per company (case- and whitespace-insensitive). |
service_line |
number | Yes | Integer | Service line id. |
billing |
number | Yes | Integer | Invoice detail id. |
invoicing |
number | Yes | Integer | 1 Hourly, 2 Fixed Price. |
monthly_budget |
number | No | Numeric (numeric strings are coerced) (no Integer, so decimals are allowed); defaults to 0 |
Monthly budget amount. |
monthly_minimum |
number | No | Numeric (numeric strings are coerced); defaults to 0 |
Monthly minimum amount. |
status |
number | No | Integer | Accepted but not applied on create; new projects are always inserted with active_status = 1. |
is_primary_budget |
number | No | Integer | Marks the project as its company's primary budget. |
Example request
curl -s -X PUT 'https://api.example.com/api/task/project' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"firm_id": 45,
"company_id": 412,
"name": "Monthly Close",
"service_line": 4,
"billing": 1,
"invoicing": 1,
"monthly_budget": 2500,
"monthly_minimum": 500
}'
Success response — 200 OK
{
"error": false,
"message": "Project created successfully.",
"data": {
"id": 91,
"company_id": 412,
"company_name": "Acme Holdings",
"name": "Monthly Close",
"service_line_id": 4,
"service_line": "Bookkeeping",
"billing_id": 1,
"billing": "Standard",
"active_status": 1,
"invoicing_id": 1,
"invoicing": "Hourly",
"monthly_budget": 2500,
"monthly_minimum": 500,
"enc_id": "cHJvajkx...",
"enc_cid": "Y29tcDQxMg...",
"count": 0
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Project created successfully." |
data |
object | null | The created project re-read with its joined labels, plus enc_id, enc_cid and count: 0. null if the re-read finds nothing. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | A project of the same name already exists for the company | { "error": true, "message": "<name> already exist in the Projects." } |
| 400 | Company not found (global-admin path) | { "message": "Company not found." } |
| 400 | Request validation failure | Validation error array |
| 401/403 | Caller is not a firm/global admin, or the company is outside their firm | Raised by the project management access check |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- This endpoint uses
PUT, matching the task-create convention. - Firm or global admin rights are enforced server-side, not only in the user interface.
Updates a project.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | Global or firm admin. The project itself is additionally verified to belong to the caller's firm before any mutation. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Project id; coerced to a number by this endpoint. |
Request body (same fields as PUT /api/task/project)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
| (all project-save fields) | See PUT /api/task/project |
status is meaningful here; use POST /api/task/project/active/status for the dedicated activate/inactivate action. |
Example request
curl -s -X PATCH 'https://api.example.com/api/task/project/91' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"firm_id": 45,
"company_id": 412,
"name": "Monthly Close (US)",
"service_line": 4,
"billing": 1,
"invoicing": 1
}'
Success response — 200 OK
{
"error": false,
"message": "Project Updated Successfully",
"data": {
"id": 91,
"company_id": 412,
"company_name": "Acme Holdings",
"name": "Monthly Close (US)",
"service_line_id": 4,
"service_line": "Bookkeeping",
"billing_id": 1,
"billing": "Standard",
"active_status": 1,
"invoicing_id": 1,
"invoicing": "Hourly",
"monthly_budget": 2500,
"monthly_minimum": 500,
"count": 0,
"enc_id": "cHJvajkx...",
"enc_cid": "Y29tcDQxMg..."
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Project Updated Successfully". |
data |
object | null | The refreshed project row with joined labels and encrypted ids. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Project not found, or a same-named project already exists for the company | { "error": true, "message": "..." } |
| 400 | Company not found (global-admin path) | { "message": "Company not found." } |
| 400 | Request validation failure | Validation error array |
| 401/403 | Caller is not a firm/global admin, or the project/company is outside their firm | Raised by the project management access and firm checks |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Renaming a project rewrites saved Task List "Project" column filters, so board filters keep matching after the rename.
Soft-deletes a project, appending (deleted) to its name.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | Global or firm admin. Non-global admins are additionally restricted to projects whose company belongs to their firm. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Project id; coerced to a number. |
Example request
curl -s -X DELETE 'https://api.example.com/api/task/project/91' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Project deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Project deleted successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Project not found, already deleted, or outside the caller's firm | { "error": true, "message": "Project not found." } |
| 200 | The project is active and still has open tasks | { "error": true, "message": "The project was unable to be inactive. This project has N open task(s)." } |
| 401/403 | Caller is not a firm/global admin | Raised by the project management access check |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The open-task guard counts tasks that are not deleted, not timesheet entries, not email-derived, and whose
disposition_statusis neither completed nor skipped (or is null with recent activity). - The stored name has any existing
(inactive)/(deleted)suffix stripped before(deleted)is appended, so repeated operations do not stack suffixes. - Saved Task List "Project" filters are re-synced afterwards, as on rename.
Activates or inactivates a project.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | Global or firm admin; non-global admins are firm-scoped. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer | Project id. |
active_status |
number | Yes | Integer; 1 activates, any other value inactivates |
New status. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/active/status' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "id": 91, "active_status": 0 }'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. No message is returned. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Project not found, deleted, or outside the caller's firm | { "error": true, "message": "Project not found." } |
| 200 | Inactivating an active project that still has open tasks | { "error": true, "message": "The project was unable to be inactive. This project has N open task(s)." } |
| 400 | Missing id / active_status |
Validation error array |
| 401/403 | Caller is not a firm/global admin | Raised by the project management access check |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Inactivating appends
(inactive)to the project name; activating strips it. Existing(inactive)/(deleted)suffixes are removed first. - Saved Task List "Project" filters are re-synced after the rename.
Returns a project's per-period comments, guaranteeing an entry exists for the current month.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No admin gate; the project must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
projectId |
number | Yes | Integer | Project id. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/comment' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "projectId": 91 }'
Success response — 200 OK
{
"error": false,
"title": "Comments",
"comments": [
{ "month": "September 2026", "comment": "" },
{ "month": "August 2026", "comment": "Budget raised for year-end." }
],
"periodLabel": "August 2026"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
title |
string | Always "Comments". |
comments |
array | { month, comment } entries sorted newest first. A blank entry for the current month is inserted when absent. |
periodLabel |
string | Month label derived from the project's updated_at. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Project not found, deleted, or outside the caller's firm | { "error": true, "message": "Project not found." } |
| 400 | Missing projectId |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Saves a project's per-period comments.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No admin gate; the project must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
projectId |
number | Yes | Integer | Project id. |
comments |
object[] | No (preferred) | Array; elements shaped { month: string; comment: string } but not individually validated |
The full edited per-period array. |
comment |
string | No | String | A single comment string, treated as the current month's entry. Used only when comments is absent. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/comment/save' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"projectId": 91,
"comments": [
{ "month": "September 2026", "comment": "On track." },
{ "month": "August 2026", "comment": "Budget raised for year-end." }
]
}'
Success response — 200 OK
{ "error": false, "message": "Comment saved successfully.", "periodLabel": "September 2026" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Comment saved successfully." |
periodLabel |
string | Month label for the save timestamp. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Project not found or outside the caller's firm | { "error": true, "message": "Project not found." } |
| 200 | Any submitted period's comment is blank after trimming | { "error": true, "message": "Please enter your comment." } |
| 400 | Missing projectId |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The whole array is replaced, not merged — clients must send every period they want to keep, including the ones they did not edit.
- The blank check applies to every submitted period, not just the current month.
comment(singular) is retained for compatibility with earlier clients that send a single string;commentsis the canonical field.
Generates the Project Import Excel template and returns a one-time download link.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No admin gate on this route; a firm_id other than the caller's own is rejected. |
| Content type | application/json (the workbook itself is fetched separately as a binary download) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firm_id |
number | Yes | Integer; a non-zero value must equal the caller's resolved firm | Firm whose reference data seeds the template. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/download/file' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "firm_id": 45 }'
Success response — 200 OK
{ "redirect": "/api/task/project/download/generated/9f1c2b7e-6d4a-4f2b-9a10-3e5c8d7b1f22" }
| Field | Type | Description |
|---|---|---|
redirect |
string | Relative URL of the generated file; fetch it with GET /api/task/project/download/generated/:token. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firm_id is non-zero and differs from the caller's firm |
{ "message": "Invalid firm context." } |
| 400 | Missing/non-integer firm_id |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The workbook contains three sheets: Project (the entry sheet), Existing Data (the firm's current projects) and Available Data (the valid Company / Service Line / Invoice Detail / Invoicing values).
- The Available Data sheet is written transposed — each category is a column header with its values listed downward; the other two sheets use normal orientation.
- Column widths are set to 23 for columns A–O on every sheet, and row 1 is bolded on all three (centre-aligned only on Project).
- The generated workbook is held as temporary server-side storage and referenced by a single-use token. The download request must be served by the same application instance that produced the file, so multi-instance deployments must preserve instance affinity for these routes. See Deployment & Operational Considerations.
Uploads a filled-in Project Import template, validates every row, imports the valid ones and reports the rest.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | A firm_id other than the caller's own is rejected. |
| Content type | multipart/form-data |
Request body — multipart form
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
file |
file | Yes | Sent as the file part of the multipart request; no size or MIME validation is applied by this endpoint |
The filled-in .xlsx template. |
firm_id |
number | Yes | Integer; must equal the caller's firm when non-zero | Firm context. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/import/file' \
-b 'genie.sid=<session-cookie>' \
-F 'firm_id=45' \
-F 'file=@"Project Import Template.xlsx"'
Success response — 200 OK
{
"error": false,
"header": { "project": "Project", "company": "Company" },
"excelData": [],
"defaultVal": { "company": [], "serviceLine": [], "billing": [], "invoicing": [] },
"errorData": [],
"message": "Projects are imported successfully",
"downloadLink": "#"
}
When some rows fail:
{
"error": true,
"header": { "project": "Project", "company": "Company" },
"excelData": [{ "project": "Payroll", "company": 0, "reason": "Invalid Company" }],
"defaultVal": { "company": [], "serviceLine": [], "billing": [], "invoicing": [] },
"errorData": [["Payroll", "Invalid Company"]],
"message": "The following projects are failed to import due to invalid input or missing fields",
"downloadLink": "/api/task/project/download/generated/<token>"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | true when at least one row failed. Valid rows are still imported. |
header |
object | Column key → display header map for rendering the error grid. |
excelData |
array | The failed rows. When more than 20 rows failed this is errorData instead of the raw rows. |
defaultVal |
object | Reference option sets (company, serviceLine, billing, invoicing) for re-rendering the pickers. |
errorData |
array | Row/reason pairs for the generated error workbook. |
message |
string | Success or failure summary. |
downloadLink |
string | "#" on full success; otherwise a one-time link to the generated error workbook. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firm_id is non-zero and differs from the caller's firm |
{ "message": "Invalid firm context." } |
| 400 | Missing firm_id |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- This is a partial-success endpoint: valid rows are written immediately even when others fail, and each row is inserted or updated as appropriate. Re-uploading the same file will therefore update, not duplicate, the already-imported rows.
- The error workbook is made available through the same single-use token mechanism as the template download.
Saves a set of reviewed/corrected import rows without re-uploading a file.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | Global or firm admin — import is an admin action. A per-row menu-81 company access check runs in addition. A firm_id other than the caller's own is rejected. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firm_id |
number | Yes | Integer; must equal the caller's firm when non-zero | Firm context. |
projects |
object[] | Yes | Array; each item is validated against the row fields below | Rows to import. |
projects[].id |
number | No | Integer | Existing project id (update rather than insert). |
projects[].project |
string | Yes | String | Project name. |
projects[].company |
number | Yes | Integer | Company id. |
projects[].serviceLine |
number | Yes | Integer | Service line id. |
projects[].billing |
number | Yes | Integer | Invoice detail id. |
projects[].invoicing |
number | Yes | Integer | 1 Hourly, 2 Fixed Price. |
projects[].monthly_budget |
number | No | Numeric (numeric strings are coerced); must be finite and >= 0 |
Monthly budget. |
projects[].monthly_minimum |
number | No | Numeric (numeric strings are coerced); must be finite and >= 0 |
Monthly minimum. |
projects[].reason |
string | No | String | Failure reason echoed back on rejected rows. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/import/save' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"firm_id": 45,
"projects": [
{ "project": "Payroll", "company": 412, "serviceLine": 4, "billing": 1, "invoicing": 1, "monthly_budget": 800 }
]
}'
Success response — 200 OK
{ "error": false, "message": "Projects are imported successfully", "projects": [], "errorCollection": [] }
| Field | Type | Description |
|---|---|---|
error |
boolean | true when any row was rejected. |
message |
string | Success text, or "Some projects could not be imported. Please fix the highlighted rows." |
projects |
array | The rejected rows, each carrying a reason string listing its issues. |
errorCollection |
string[] | Flat list of every issue encountered, for a summary banner. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | One or more rows invalid (bad budget/minimum, or "Don't access with this company" when the caller has no menu-81 access to that company) |
The failure envelope above |
| 400 | firm_id mismatch |
{ "message": "Invalid firm context." } |
| 400 | Request validation failure | Validation error array |
| 401/403 | Caller is not a firm/global admin | Raised by the admin access check |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Like the file import, this is a partial-success endpoint: valid rows are persisted even when others are rejected.
Streams a previously generated project template or import-error workbook.
| Module | Tasks & Time > Projects |
| Authentication | Not required — possession of the single-use token is the only credential; this is the only route in this section that does not require a session |
| Authorization | No role requirement; the single-use token is the only credential |
| Content type | binary download (Content-Disposition: attachment) |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The UUID returned in the redirect / downloadLink field of a generate or import call. |
Example request
curl -s -OJ 'https://api.example.com/api/task/project/download/generated/9f1c2b7e-6d4a-4f2b-9a10-3e5c8d7b1f22'
Success response — 200 OK
Binary .xlsx stream, sent as an attachment named Project Import Template.xlsx (or the error workbook's own name).
Response body: binary file download — no JSON body.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 404 | Token unknown, already consumed, or expired | { "error": true, "message": "File not found." } |
Notes
- The token is single-use: it is invalidated before the stream starts, and the temporary server-side file is removed once the download completes.
- The download must be served by the same application instance that generated the file, so multi-instance deployments must preserve instance affinity for these routes. See Deployment & Operational Considerations.
- Because the route is unauthenticated, the token should be treated as a bearer credential and not logged or shared.
Returns the firm's default budget configuration data: service roles with their default percentages, service lines, and the projects the defaults can be applied to.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; scoped to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
serviceRoleUser |
boolean | No | Normalised server-side: true, 1 and "1" map to true; everything else maps to false |
Switches to the service-role-user variant of the payload. |
serviceLine |
number | No | Integer; 0 means "all service lines" |
Restrict projects to one service line. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/budget/default' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "serviceLine": 4 }'
Success response — 200 OK
{
"error": false,
"dropDownValues": {
"project": [
{
"id": 91,
"name": "Monthly Close",
"service_line": 4,
"company_id": 412,
"monthly_budget": 2500,
"monthly_minimum": 500,
"company_name": "Acme Holdings",
"project_budget_id": 77,
"company": { "name": "Acme Holdings" },
"project_budget": { "id": 77 }
}
],
"serviceLine": [{ "id": 4, "name": "Bookkeeping" }],
"serviceLineData": 4,
"serviceRole": [{ "id": 12, "role": "Accountant", "percentage": "40.00" }]
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
dropDownValues.project |
array | Active, non-deleted projects for active firm companies, optionally filtered by service line, each annotated with its latest budget allocation. |
dropDownValues.serviceLine |
array | Firm service lines. |
dropDownValues.serviceLineData |
number | Echo of the requested service line id. |
dropDownValues.serviceRole |
array | Firm service roles with their default percentages. |
dropDownValues.serviceUserRole |
object | Present instead of the above on the serviceRoleUser: true variant. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Service roles use the
deleted IN (0, 2)convention (0and2are both active states;1is deleted and3inactive) and are ordered byTRIM(role). - The role scope is exclusive to the firm: global roles are not included, because a firm gets its own row as soon as it customises a role and including both would produce duplicates.
Returns the Primary Budget grid for one project: its monthly budget/minimum plus the per-service-role price, default percentage, custom percentage and monthly hours.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the project must belong to a company in the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company id. |
projectId |
number | Yes | Integer | Project id. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/budget/servicerole' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "companyId": 412, "projectId": 91 }'
Success response — 200 OK
{
"error": false,
"primaryBudgetData": {
"companyId": 412,
"projectId": 91,
"budgetMonthlyBudget": "2500.00",
"budgetMonthlyMinimum": "500.00"
},
"overRidePricing": true,
"isFirmAdmin": true,
"serviceRole": [
{
"id": 12,
"service_role_id": 12,
"role": "Accountant",
"price_per_hour": "145.00",
"percentage": "40.00",
"custom_percentage": "35.00",
"monthly_hours_budget": "6.00"
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
primaryBudgetData |
object | The project's monthly budget and minimum, formatted to 2 decimals. |
overRidePricing |
boolean | The company's override_price flag — whether Price Per Hour may be edited here. |
isFirmAdmin |
boolean | The caller's firm-admin flag. The client enables the Price Per Hour inputs only when both this and overRidePricing are true. |
serviceRole[] |
array | One row per firm service role, all money/percentage values as 2-decimal strings. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | companyId or projectId missing/zero |
{ "error": true, "message": "Company and Project are required." } |
| 200 | Project not found for that company in the caller's firm | { "error": true, "message": "Project not found." } |
| 400 | Missing companyId / projectId |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Once a project has any override rows saved, a role without its own override row reports
custom_percentage: "0.00"— it does not silently fall back to the default percentage. This prevents the Custom (%) total inflating past 100. percentage(the Default column) is resolved from the project's own allocation first, falling back to the firm default — not from the Configure Default Budget page's numbers.
Returns the per-role override grid (custom percentage and monthly hours) for one project.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; firm-scoped. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company id. |
projectId |
number | Yes | Integer | Project id. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/budget/override' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "companyId": 412, "projectId": 91 }'
Success response — 200 OK
{
"error": false,
"projectBudgetOverride": [
{
"serviceRoleId": 12,
"encId": "cm9sZTEy...",
"monthlyHoursBudget": "6.00",
"customPercentage": "35.00",
"defaultPercentage": "40.00"
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
projectBudgetOverride[].encId |
string | Encrypted service-role id. |
projectBudgetOverride[].monthlyHoursBudget / customPercentage / defaultPercentage |
string | 2-decimal strings. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | companyId or projectId missing/zero |
{ "error": true, "message": "Company and Project are required." } |
| 400 | Missing companyId / projectId |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The same "any override rows exist ⇒ missing roles read 0.00" rule as the service-role grid applies here.
Saves a project's primary monthly budget and its per-service-role allocation, transactionally.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the project must belong to the caller's firm. Price Per Hour rows are only rewritten when the request sets overRidePricing. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
primaryBudgetData |
object | Yes | Object; its inner fields have the shapes below but are not individually validated | Project identity and budget amounts. |
primaryBudgetData.companyId |
number | Yes | — | Company id; must be non-zero. |
primaryBudgetData.projectId |
number | Yes | — | Project id; must be non-zero. |
primaryBudgetData.budgetMonthlyBudget |
number | string | No | — | Monthly budget. |
primaryBudgetData.budgetMonthlyMinimum |
number | string | No | — | Monthly minimum. |
serviceRole |
object[] | Yes | Array (element fields not individually validated) | Per-role allocation rows. |
serviceRole[].id / .service_role_id |
number | No | — | Service role id (either key is accepted). |
serviceRole[].role |
string | No | — | Role name. |
serviceRole[].price_per_hour |
number | string | No | — | Hourly price; written only when overRidePricing is set. |
serviceRole[].percentage / .custom_percentage |
number | string | No | — | Default and custom percentages. |
serviceRole[].monthly_hours_budget |
number | string | No | — | Monthly hours for the role. |
overRidePricing |
boolean | No | Normalised server-side: true, 1 and "1" map to true; everything else maps to false |
Whether to also rewrite the company's service-team Price Per Hour rows. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/save/budget' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"primaryBudgetData": {
"companyId": 412, "projectId": 91,
"budgetMonthlyBudget": 2500, "budgetMonthlyMinimum": 500
},
"serviceRole": [
{ "service_role_id": 12, "role": "Accountant", "custom_percentage": 35, "monthly_hours_budget": 6, "price_per_hour": 145 }
],
"overRidePricing": true
}'
Success response — 200 OK
{ "error": false, "message": "Project budget saved successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Project budget saved successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | companyId or projectId missing/zero |
{ "error": true, "message": "Company and Project are required." } |
| 200 | Project not found for that company in the caller's firm | { "error": true, "message": "Project not found." } |
| 400 | Missing primaryBudgetData / serviceRole |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The allocation header and every per-role row are written inside a single transaction, so a mid-loop failure cannot leave some roles updated and others stale.
overRidePricingis the company'soverride_priceflag (returned byPOST /api/task/project/budget/servicerole). Sending it when the company does not allow price override would rewrite Price Per Hour rows it should not touch, so clients must echo the value the server gave them.
Saves the firm's default budget allocation, or one of the two per-project override variants — the branch is chosen by type.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; firm-scoped. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
type |
string | Yes | One of default, monthlyBudget, serviceTeam |
default saves the firm default (optionally applying it to many projects); monthlyBudget and serviceTeam take the override-save branch. |
serviceRoleData |
object | No | Object | Map of service-role id → percentage (either a number or { percentage }). Values must be finite and >= 0; anything else becomes 0.00. For type: 'default' the total must be 100 % (±0.005). |
isApplied |
boolean | No | Normalised server-side: true, 1 and "1" map to true; everything else maps to false |
Apply the default to projects as well as storing it. |
selectedType |
string | No | One of all, selected |
Whether the apply targets all companies or the listed ones. |
serviceLine |
number | No | Integer; required (non-zero) for type: 'default' |
Service line the default belongs to. |
projectId |
object[] | No | Array; elements shaped { company, project } |
Explicit apply targets. |
companyId |
string | No | String | Company scope for the apply. |
overrideProjectId |
number | No | Integer | Target project for the override branch. |
monthly_price_budget |
number | No | Numeric (numeric strings are coerced) | Override monthly price budget. |
budget |
number | No | Numeric (numeric strings are coerced) | Override budget amount. |
allocationData |
object[] | No | Array; elements shaped { serviceRoleId, monthlyHoursBudget, customPercentage } |
Per-role override values. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/budget/save/servicerole' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{
"type": "default",
"serviceLine": 4,
"serviceRoleData": { "12": 40, "15": 60 },
"isApplied": true,
"selectedType": "selected",
"projectId": [{ "company": 412, "project": 91 }]
}'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. No message is returned on the default path. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | type: 'default' with no serviceLine |
{ "error": true, "message": "Service line is required." } |
| 200 | Percentages do not total 100 % | { "error": true, "message": "Please Update Default values as 100 %." } |
| 200 | isApplied with selectedType: 'all' and a price-per-hour validation failure |
The validation envelope from POST /api/task/project/budget/validate |
| 200 | The default_budget_mapping table lacks an allocation reference column |
{ "error": true, "message": "Budget mapping table is missing allocation reference." } |
| 400 | type missing or outside the allowed set |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The route name says "servicerole" but it is the firm default budget save; the per-project primary budget is
POST /api/task/project/save/budget. type: 'monthlyBudget'andtype: 'serviceTeam'both take the per-project override-save branch.- The allocation header upsert and every per-role
default_budget_mappingupsert run in one transaction.
Checks that every service role referenced by a budget allocation has a usable Price Per Hour before the allocation is applied.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; firm-scoped. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
serviceRoleData |
object | Yes | Object | Map of service-role id → percentage, as sent to the save endpoint. |
allCompany |
boolean | No | Normalised server-side: true, 1 and "1" map to true; everything else maps to false |
Validate across every company in the firm. |
companyId |
number | number[] | No | No type restriction is applied, so either a single id or an array is accepted | Companies to validate. Ignored when allCompany is true. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/budget/validate' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "serviceRoleData": { "12": 40, "15": 60 }, "companyId": [412, 413] }'
Success response — 200 OK
{ "error": false, "data": [] }
| Field | Type | Description |
|---|---|---|
error |
boolean | true when at least one company/role pair fails the price check. |
data |
array | The offending company/role rows; empty on success. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Validation failures found | { "error": true, "data": [ ... ] } |
| 400 | serviceRoleData missing or not an object |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
allCompany: true(or omittingcompanyIdentirely) validates without a company restriction; otherwise only positive ids are used.- The same validation runs implicitly inside
POST /api/task/project/budget/save/servicerolewhenisAppliedis set withselectedType: 'all'; this endpoint exists so the UI can pre-flight the apply.
Turns a list of budget-apply error messages into a downloadable CSV.
| Module | Tasks & Time > Projects |
| Authentication | Required — authenticated session cookie |
| Authorization | No role requirement. |
| Content type | application/json (the CSV is embedded as a data: URI) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
export |
unknown[] | Yes | Array; elements may be of any type | Error lines. A string is used as-is; a nested array is flattened to its first (or first-of-first) element. |
Example request
curl -s -X POST 'https://api.example.com/api/task/project/budget/error/export' \
-b 'genie.sid=<session-cookie>' \
-H 'Content-Type: application/json' \
-d '{ "export": ["Acme Holdings - Accountant has no price per hour", "Beta LLC - CA has no price per hour"] }'
Success response — 200 OK
{ "redirect": "data:text/csv;charset=utf-8,Project%20Budget%20Apply%20to%20Clients%20Errors%0A..." }
| Field | Type | Description |
|---|---|---|
redirect |
string | data:text/csv URI, URL-encoded, that the client navigates to in order to download. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | export missing or not an array |
Validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The CSV always begins with the header line
Project Budget Apply to Clients Errors, followed by one escaped line per entry. - No file name is supplied by the API; the browser derives one from the
data:URI.
11.5 Task & Time Templates
Path parameters accept either a plain numeric id or the application's encrypted id form, and responses
hand back encrypted ids under encrypt_id / template_id.
Lists template groups for the Global or Firm Templates tab, with optional service-line / task-type
filtering, plus the firm checkbox state that drives the "Use Firm Task/Project Templates" toggle.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Any authenticated user, but a non-global-admin may only request their own firm: firmId/firm_id must equal the caller's firm, else 401 |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | No | Integer (coerced) | Target firm. Falls back to firm_id, then the caller's firm. |
firm_id |
number | No | Integer (coerced) | Alternative spelling of firmId; accepted and treated identically. |
type |
number | No | Integer (coerced) | Template kind discriminator (template_groups.type). Applied only when > 0. |
fromTemplate |
boolean | No | Boolean | "Apply template" flow: scopes to the firm's own groups when firms.use_task = 1, otherwise to global groups. |
isFirmTemplate |
boolean | No | Boolean | Firm tab. A global admin sees every firm's groups; anyone else sees only their own firm's. |
filter |
object | No | Validated nested object | Optional post-filter. |
filter.serviceLine |
array | No | Array of { id?, name? } objects |
Keeps only groups containing a template whose settings use one of these service lines. |
filter.taskType |
array | No | Array of { id?, name? } objects |
Keeps only groups containing a template whose settings use one of these task types. |
Example request
curl -X POST 'https://api.example.com/api/task/template/groupList' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":12,"type":1,"isFirmTemplate":true,"filter":{"serviceLine":[{"id":4}]}}'
Success response — 200 OK
{
"templateGroups": [
{
"id": 31,
"name": "Monthly Close",
"type": 1,
"reference_date": "2026-01-31",
"reference_note": "Period end",
"firm_id": 12,
"is_global": 0,
"encrypt_id": { "id": "aE9xR2..." }
}
],
"checkBoxData": {
"use_task": 1,
"use_project": 0,
"use_service_line": 0,
"task": false,
"project": true
}
}
| Field | Type | Description |
|---|---|---|
templateGroups[] |
array | template_groups rows (id, name, type, reference_date, reference_note, firm_id, is_global) sorted by name. |
templateGroups[].encrypt_id.id |
string | Encrypted group id to use in subsequent path parameters. |
checkBoxData |
object | The firm's firms usage flags. |
checkBoxData.task |
boolean | true when the firm has no task groups and no task templates yet (so the checkbox may still trigger the first-time clone). |
checkBoxData.project |
boolean | Same, for project groups/templates. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | validation error message array |
| 401 | No/expired session, or non-global-admin requesting another firm | { "message": "These credentials do not match our records." } |
Notes
- The
fromTemplatebranch readsfirms.use_taskfor the resolved firm; if that lookup throws it falls
back to0, i.e. the global catalogue. - On the Firm tab a Global Master System Administrator sees every firm's groups
(firm_id IS NOT NULL AND firm_id != 0).
Returns one template group together with the templates included in it — the "included templates" panel.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Non-global-admins may only read a group belonging to their own firm (global groups are readable by all) |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Template group id — plain numeric or encrypted form. |
Example request
curl 'https://api.example.com/api/task/template/group/included/31' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"templateGroups": {
"id": 31,
"name": "Monthly Close",
"type": 1,
"reference_date": "2026-01-31",
"reference_note": "Period end",
"firm_id": 12,
"is_global": 0,
"encrypt_id": { "id": "aE9xR2..." },
"templates_array": [{ "template_id": 88 }],
"templates": [{ "id": 88, "name": "Bank Reconciliation" }]
},
"templates": [{ "id": 88, "name": "Bank Reconciliation" }]
}
| Field | Type | Description |
|---|---|---|
templateGroups |
object | null | The group, or null when the id cannot be decoded. |
templateGroups.templates_array[] |
array | { template_id } pairs, the shape the editor binds to. |
templates[] |
array | The same template rows, hoisted to the top level for convenience. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No/expired session, or group belongs to another firm and caller is not a global admin | { "message": "These credentials do not match our records." } |
Notes
- An unresolvable id is not an error: this endpoint returns
{ "templateGroups": null, "templates": [] }.
Backs the Edit Template Group page: the group plus both sides of the template picker (already selected
and still available).
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Non-global-admins may only read a group belonging to their own firm |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Template group id — plain numeric or encrypted form. |
Example request
curl 'https://api.example.com/api/task/template/edit/group/aE9xR2...' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"getGroupData": {
"id": 31,
"name": "Monthly Close",
"type": 1,
"reference_date": "2026-01-31",
"reference_note": "Period end",
"firm_id": 12,
"encrypt_id": { "id": "aE9xR2..." },
"templates_array": [{ "template_id": 88 }]
},
"selected_template": [{ "id": 88, "name": "Bank Reconciliation" }],
"available_template": [{ "id": 91, "name": "Payroll Journal" }]
}
| Field | Type | Description |
|---|---|---|
getGroupData |
object | null | The template_groups row plus encrypt_id and templates_array. null when the id is unresolvable or the group does not exist. |
selected_template[] |
array | Templates currently mapped to the group. |
available_template[] |
array | Templates in scope that are not yet mapped. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No/expired session, or cross-firm access without global admin | { "message": "These credentials do not match our records." } |
Returns a template group and the templates it contains — the read-only "apply this group" view used when
creating tasks from a group.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; no firm or admin restriction is applied to this read |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Template group id — plain numeric or encrypted form. |
Example request
curl 'https://api.example.com/api/task/template/group/based/template/31' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"group": {
"id": 31,
"name": "Monthly Close",
"type": 1,
"reference_date": "2026-01-31",
"reference_note": "Period end",
"encrypt_id": { "id": "aE9xR2..." }
},
"template": [{ "id": 88, "name": "Bank Reconciliation" }]
}
| Field | Type | Description |
|---|---|---|
group |
object | null | The group row plus encrypt_id; null when not found. |
template[] |
array | Templates mapped to the group. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- Returns
{ "group": null, "template": [] }rather than an error when the group cannot be resolved.
Creates a template group (or updates one when id is supplied in the body), including its template
membership.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Global rows (firm_id 0/NULL): global admin only. Firm rows: that firm's firm admin only (firm_admin = 1 and the row's firm equals the caller's firm) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Integer (coerced) | Present ⇒ update. Absent ⇒ create. |
firmId |
number | No | Integer (coerced) | Target firm. Optional: when it is omitted, the group's own firm_id is read from the database and used instead. |
name |
string | Yes | String | Group name; must be unique within the scope. |
type |
number | Yes | Integer (coerced) | Group kind discriminator. |
referenceDate |
string | No | String | Reference date stored on the group. |
referenceNote |
string | No | String | Free-text note. |
templates |
number[] | No | Array | Template ids to map into the group. |
Example request
curl -X PUT 'https://api.example.com/api/task/template/groupList' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Monthly Close","type":1,"firmId":12,"referenceDate":"2026-01-31","templates":[88,91]}'
Success response — 200 OK
{
"error": false,
"message": "Template Group created successfully.",
"template_id": "aE9xR2...",
"type": 1,
"firm_id": 12
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Template Group created successfully. or ... updated successfully. |
template_id |
string | Encrypted id of the saved group. |
type |
number | Echo of the submitted type. |
firm_id |
number | Resolved target firm (0 for a global group). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Duplicate name in scope | { "error": true, "message": "<name> already exist in the Template Groups." } |
| 400 | Missing name or type |
validation error message array |
| 401 | Cross-firm write, non-firm-admin on a firm row, or non-global-admin on a global row | { "message": "These credentials do not match our records." } |
Notes
- On update with
firmIdomitted or0, the target firm is looked up from the existing
template_groups.firm_idrow before the permission check runs. templatesreplaces the group's membership.
Updates an existing template group. Identical to the PUT above except the id comes from the path.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Global rows: global admin. Firm rows: that firm's firm admin |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Template group id. Used as the id of the record to update. |
Request body, exactly as for PUT /api/task/template/groupList. Any id in the body is overwritten
by the path parameter.
Example request
curl -X PATCH 'https://api.example.com/api/task/template/groupList/31' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Monthly Close (Revised)","type":1,"templates":[88]}'
Success response — 200 OK
{
"error": false,
"message": "Template Group updated successfully.",
"template_id": "aE9xR2...",
"type": 1,
"firm_id": 12
}
Response fields as for PUT /api/task/template/groupList.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Duplicate name in scope | { "error": true, "message": "<name> already exist in the Template Groups." } |
| 400 | Missing name or type |
validation error message array |
| 401 | Scope/role violation | { "message": "These credentials do not match our records." } |
Notes
- The raw path segment is used as the record id without further validation, so a non-numeric path segment
is passed through as a string.
Soft-deletes a template group.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Global groups: global admin only. Firm groups: that firm's firm admin only |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Template group id — plain numeric or encrypted form. |
Example request
curl -X DELETE 'https://api.example.com/api/task/template/groupList/31' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Template Group deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Id cannot be decoded | { "error": true, "message": "Template Group not found." } |
| 401 | Scope/role violation | { "message": "These credentials do not match our records." } |
Notes
- Soft delete:
deleted_atis stamped; rows are not physically removed.
Clones an existing template group under a new name, optionally re-selecting which templates it contains.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Determined by the source group's scope: global source ⇒ global admin; firm source ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer (coerced) | Source template group id. |
name |
string | Yes | String | Name for the clone; must not already exist. |
reference_date |
string | No | String | Reference date for the clone. |
reference_note |
string | No | String | Note for the clone. |
templates |
array | No | Array of { id: number } objects; id is a required integer |
Templates to map into the clone. |
Example request
curl -X POST 'https://api.example.com/api/task/template/cloneTemplateGroup' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id":31,"name":"Monthly Close FY27","reference_date":"2027-01-31","templates":[{"id":88}]}'
Success response — 200 OK
{
"error": false,
"message": "Template Group Cloned successfully.",
"data": {
"id": 44,
"name": "Monthly Close FY27",
"type": 1,
"reference_date": "2027-01-31",
"reference_note": null,
"encrypt_id": { "id": "bK2mV8..." }
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
data.id |
number | New group id. |
data.type |
number | Inherited from the source group. |
data.encrypt_id.id |
string | Encrypted new group id. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Source group missing | { "error": true, "message": "Template Group not found." } |
| 200 | Name collision | { "error": true, "group_error": true, "message": "<name> already exist." } |
| 400 | Missing id/name |
validation error message array |
| 401 | Scope/role violation on the source group | { "message": "These credentials do not match our records." } |
Lists individual templates for the Global or Firm Templates tab, with the same scoping and filtering
semantics as the group list.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Non-global-admins may only request their own firm, else 401 |
| Content type | application/json |
Request body — the identical field set to the group list: firmId, firm_id, type, fromTemplate,
isFirmTemplate, filter.serviceLine, filter.taskType. See POST /api/task/template/groupList for the
full table.
Example request
curl -X POST 'https://api.example.com/api/task/template/templateList' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":12,"type":1,"isFirmTemplate":true}'
Success response — 200 OK
{
"templateList": [
{ "id": 88, "name": "Bank Reconciliation", "type": 1, "firm_id": 12 }
]
}
| Field | Type | Description |
|---|---|---|
templateList[] |
array | Distinct template rows: id, name, type, firm_id, ordered by name. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | validation error message array |
| 401 | No/expired session, or non-global-admin requesting another firm | { "message": "These credentials do not match our records." } |
Notes
- Service-line / task-type filters join
template_settings, so a template with no settings row is
excluded once any filter is supplied.
Creates a template (or updates one when id is supplied).
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Global templates: global admin only. Firm templates: that firm's firm admin only |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Integer (coerced) | Present ⇒ update. |
name |
string | Yes | String | Template name; unique within scope. |
type |
number | Yes | Integer (coerced) | Template kind discriminator. |
firmId |
number | No | Integer (coerced) | Target firm; omitted/0 means the global catalogue. |
Example request
curl -X PUT 'https://api.example.com/api/task/template/templateList' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Bank Reconciliation","type":1,"firmId":12}'
Success response — 200 OK
{
"error": false,
"message": "Template created successfully.",
"template_id": "cQ7zL4..."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Template created successfully. or Template updated successfully. |
template_id |
string | Encrypted id of the saved template. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Duplicate name in scope | { "error": true, "message": "<name> already exist in the Templates." } |
| 400 | Missing name or type |
validation error message array |
| 401 | Scope/role violation | { "message": "These credentials do not match our records." } |
Notes
- The same save contract also backs the Tasks area's "make task into template" flow.
Updates an existing template; the id comes from the path.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Global templates: global admin. Firm templates: that firm's firm admin |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Template id, interpreted as a number. A non-numeric value resolves to no id, so the request is treated as a create. |
Request body, as for PUT /api/task/template/templateList.
Example request
curl -X PATCH 'https://api.example.com/api/task/template/templateList/88' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Bank Reconciliation (Monthly)","type":1,"firmId":12}'
Success response — 200 OK
{
"error": false,
"message": "Template updated successfully.",
"template_id": "cQ7zL4..."
}
Response fields as for PUT /api/task/template/templateList.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Duplicate name in scope | { "error": true, "message": "<name> already exist in the Templates." } |
| 400 | Missing name or type |
validation error message array |
| 401 | Scope/role violation | { "message": "These credentials do not match our records." } |
Notes
- Only a plain numeric path id maps to an update; an encrypted id in this path creates a new template
instead.
Soft-deletes a template, its settings and its group mappings — with a two-step confirmation when the
template is still mapped to a group.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Global templates: global admin only. Firm templates: that firm's firm admin only |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Template id. Plain numeric = first pass (may return the confirmation prompt); encrypted = confirmed delete. |
Example request
# First pass
curl -X DELETE 'https://api.example.com/api/task/template/templateList/88' -b 'genie.sid=<session-cookie>'
# Confirmed pass, using the encrypted id returned above
curl -X DELETE 'https://api.example.com/api/task/template/templateList/cQ7zL4...' -b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"message": "Template deleted successfully.",
"template_id": "cQ7zL4..."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success; true when the confirmation prompt is returned. |
message |
string | Confirmation text, or the "mapped with other template groups" prompt. |
template_id |
string | Encrypted template id — resend it in the path to confirm. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Id cannot be decoded | { "error": true, "message": "Template not found." } |
| 200 | Template is mapped to a group and the path id was plain numeric | { "error": true, "message": "This template has been mapped with other template groups. Do you still want to delete it? By confirming, it will remove from its respective template groups as well.", "template_id": "<encrypted>" } |
| 401 | Scope/role violation | { "message": "These credentials do not match our records." } |
Notes
- The confirmation prompt is returned only when the template is mapped to at least one group and the
path id was plain numeric; an encrypted id bypasses the prompt. - On confirmation,
template,template_settingsandtemplate_groups_templatesrows are all soft-deleted.
Clones a single template (and its settings) under a new name.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Determined by the source template's scope: global ⇒ global admin; firm ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer (coerced) | Source template id. |
name |
string | Yes | String | Name for the clone. |
Example request
curl -X POST 'https://api.example.com/api/task/template/cloneTemplate' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id":88,"name":"Bank Reconciliation (Copy)"}'
Success response — 200 OK
{
"error": false,
"message": "Template Cloned successfully.",
"data": { "id": 102, "name": "Bank Reconciliation (Copy)" }
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
data.id |
number | New template id. |
data.name |
string | Echo of the requested name. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Source template missing | { "error": true, "message": "Template not found." } |
| 200 | Name collision | { "error": true, "message": "<name> already exist in the Templates." } |
| 400 | Missing id/name |
validation error message array |
| 401 | Scope/role violation on the source template | { "message": "These credentials do not match our records." } |
Loads the Template Settings editor: the saved settings for a template plus every dropdown's option set.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Non-global-admins may only request their own firm (firmId must equal the caller's firm) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | No | Integer (coerced) | Target firm; defaults to the caller's firm. |
templateId |
number | No | Integer (coerced) | Template whose settings to load. |
sLine |
number | No | Integer (coerced) | Service line to scope the task-type / task-status option lists to. |
fromTemplate |
boolean | No | Boolean | Signals the "apply template" flow rather than the editor. |
Example request
curl -X POST 'https://api.example.com/api/task/template/template-setting' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":12,"templateId":88,"sLine":4}'
Success response — 200 OK
{
"defaultValues": {
"serviceLine": [{ "id": 4, "name": "Bookkeeping" }],
"taskType": [{ "id": 7, "name": "Recurring" }],
"priority": [{ "id": 2, "name": "High" }],
"billing": [{ "id": 1, "name": "Billable" }],
"tags": [{ "id": 5, "name": "Month End" }],
"assignRoles": [{ "id": 3, "role": "Bookkeeper" }],
"serviceRole": [{ "id": 3, "role": "Bookkeeper" }],
"taskStatus": [{ "id": 9, "name": "In Progress" }]
},
"template": { "id": 88, "name": "Bank Reconciliation", "type": 1, "firm_id": 12 },
"templateSettings": { "template_id": 88, "task_type": 7, "service_line": 4, "priority": 2 },
"steps": [{ "name": "Download statement", "description": "", "action_link": "" }],
"stepHaveData": true,
"recurrence": { "id": 14, "template_id": 88, "cron_string": "0 0 1 * *" }
}
| Field | Type | Description |
|---|---|---|
defaultValues |
object | Option lists for every dropdown in the editor. assignRoles and serviceRole carry the same list under two keys. |
template |
object | The template row. |
templateSettings |
object | The enriched template_settings row. |
steps[] |
array | template_steps rows. |
stepHaveData |
boolean | steps.length > 0. |
recurrence |
object | null | The template's recurrence row, if any. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Request validation failure | validation error message array |
| 401 | No/expired session, or non-global-admin requesting another firm | { "message": "These credentials do not match our records." } |
Notes
- Service-role options are additionally narrowed to either the firm's own role catalogue or the global
one, according tofirms.use_service_role. - Option names containing
(deleted)or(inactive)are filtered out of the dropdown lists.
Saves the Template Settings editor: the settings row, its steps and its recurrence.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Determined by the target template's scope: global template ⇒ global admin; firm template ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
template_id |
number | Yes | Integer (coerced) | Template these settings belong to. |
task_type |
unknown | No | Optional (untyped) | Task type — id or { id } object. |
priority |
unknown | No | Optional (untyped) | Priority. |
assinged_role |
unknown | No | Optional (untyped) | Assigned role. The field name is spelled exactly as shown and must be sent that way. |
requires_review |
unknown | No | Optional (untyped) | Review-required flag. |
service_line |
unknown | No | Optional (untyped) | Service line. |
module |
unknown | No | Optional (untyped) | Owning module. |
billing |
unknown | No | Optional (untyped) | Billing type. |
tags |
(number|string)[] | No | Array | Tag ids or names. |
status |
unknown | No | Optional (untyped) | Status value. |
estimated_time |
unknown | No | Optional (untyped) | Estimated time. |
service_role |
unknown | No | Optional (untyped) | Service role. |
steps |
array | No | Array of step objects | Step list; each step has optional name, description, action_link (all String) and deleted_at (untyped). |
task_status |
unknown | No | Optional (untyped) | Task status. |
relative_due_date |
unknown | No | Optional (untyped) | Relative due-date rule. |
due |
string | No | String | Due-date expression. |
invoicing |
unknown | No | Optional (untyped) | Invoicing type. |
due_time |
string | No | String | Due time. |
recurrence |
object | No | Recurrence object | See below. |
Recurrence object: id? (int), template_id? (int), cron_string? (string), start_on?
(string), end_on? (string), weekend_rule? (string), holiday_rule? (string), holiday_calendar?
(untyped) — all optional.
Example request
curl -X PUT 'https://api.example.com/api/task/template/template-setting' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"template_id": 88,
"service_line": 4,
"task_type": 7,
"priority": 2,
"tags": [5],
"steps": [{ "name": "Download statement", "description": "From the bank portal" }],
"recurrence": { "cron_string": "0 0 1 * *", "start_on": "2026-02-01", "weekend_rule": "next" }
}'
Success response — 200 OK
{
"error": false,
"message": "Template Settings created successfully.",
"template_id": "dR3nY9..."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Template Settings created successfully. or ... updated successfully. |
template_id |
string | Encrypted id of the saved template_settings row. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | template_id resolves to nothing |
{ "error": true, "message": "Template is required." } |
| 400 | Missing template_id |
validation error message array |
| 401 | Scope/role violation on the target template | { "message": "These credentials do not match our records." } |
Notes
- Several fields accept either a scalar id or a
{ id, name }object, depending on the control that
produced the value; both forms are normalised. - Steps are replaced wholesale;
deleted_aton a step marks it removed.
Returns the task-type and task-status option lists for one service line — the dependent-dropdown refresh
in the Template Settings editor.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | The resolved firm must equal the caller's firm, else 401 |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
serviceLineId |
number | Yes | Integer (coerced) | Service line whose dependent options to load. |
firmId |
number | No | Integer (coerced) | Defaults to the caller's firm. |
Example request
curl -X POST 'https://api.example.com/api/task/template/template-setting/service-line/options' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"serviceLineId":4,"firmId":12}'
Success response — 200 OK
{
"taskType": [{ "id": 7, "name": "Recurring" }],
"taskStatus": [{ "id": 9, "name": "In Progress" }]
}
| Field | Type | Description |
|---|---|---|
taskType[] |
array | Task types belonging to the service line. |
taskStatus[] |
array | Statuses belonging to the service line. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing serviceLineId |
validation error message array |
| 401 | Requested firm is not the caller's firm | { "message": "These credentials do not match our records." } |
Notes
- A falsy
serviceLineId(e.g.0) returns{ "taskType": [], "taskStatus": [] }rather than an error.
Soft-deletes one step from a template, addressed by its ordinal position.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Global template ⇒ global admin; firm template ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
order |
number | Yes | Integer (coerced) | The step's order value within the template. |
template_id |
number | Yes | Integer (coerced) | Owning template id. |
Example request
curl -X POST 'https://api.example.com/api/task/template/removeStep' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"template_id":88,"order":2}'
Success response — 200 OK
{ "error": false, "message": "Step deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing order or template_id |
validation error message array |
| 401 | Scope/role violation on the owning template | { "message": "These credentials do not match our records." } |
Notes
- Deleting a step whose
orderdoes not exist is a no-op that still returns success.
The "Use Firm Task/Project Templates" first-time switch: copies the whole global template catalogue into
the firm, or (when turning the flag off) soft-deletes the firm's cloned set.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | firm_id must equal the caller's firm and the caller must be a firm admin (firm_admin = 1) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
use_firm |
number | Yes | Integer (coerced) | 1 = clone global templates into the firm; anything else = remove the firm's cloned set. |
type |
number | Yes | Integer (coerced) | Template kind to clone/remove. |
firm_id |
number | Yes | Integer (coerced) | Target firm; must be the caller's own. |
Example request
curl -X POST 'https://api.example.com/api/task/template/clone' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"use_firm":1,"type":1,"firm_id":12}'
Success response — 200 OK
{ "error": false, "message": "Global Templates are cloned successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Global Templates are cloned successfully. when use_firm = 1, Clone Templates are removed successfully. otherwise. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing use_firm, type or firm_id |
validation error message array |
| 401 | firm_id is not the caller's firm, or caller is not a firm admin |
{ "message": "These credentials do not match our records." } |
Notes
- This is a destructive toggle:
use_firm ≠ 1soft-deletes the firm'stemplate_groups,templateand
template_settingsrows for the giventype. That is why it is firm-admin-only, even for a Global
Master System Administrator withoutfirm_admin. - The front end calls this route only when turning the checkbox on for the very first time; every
other toggle goes toPOST /api/task/firminstead.
Returns the firm's template usage flags and whether the firm already has task/project data — the state the
"Use Firm Templates" checkboxes render from.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | firm_id must equal the caller's firm, else 401 |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firm_id |
number | Yes | Integer (coerced) | Target firm; must be the caller's own. |
Example request
curl -X POST 'https://api.example.com/api/task/template/firm/checkBoxData' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firm_id":12}'
Success response — 200 OK
{
"use_task": 1,
"use_project": 0,
"use_service_line": 0,
"use_service_role": 1,
"use_timesheet": 1,
"use_service_line_approver": 0,
"clone_task_config": 1,
"close_deadline_config": 0,
"task": false,
"project": true
}
| Field | Type | Description |
|---|---|---|
use_task, use_project, use_service_line, use_service_role, use_timesheet, use_service_line_approver, clone_task_config, close_deadline_config |
number | The firms row's flags. All default to 0 when no firm row exists. |
task |
boolean | true when the firm has no task template data yet (first-time clone still possible). |
project |
boolean | true when the firm has no project template data yet. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing firm_id |
validation error message array |
| 401 | firm_id is not the caller's firm |
{ "message": "These credentials do not match our records." } |
Notes
- The
firm_idcomparison is strict, against the already-coerced numeric value.
Flips the firm's template usage flags. This path is also documented in the Configure subsection, and that entry is the canonical one — see the note below.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | firmId must equal the caller's firm and the caller must be a firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
templateId |
number | Yes | Integer (coerced) | Which flag to write: 1 ⇒ firms.use_task, 2 ⇒ firms.use_project, anything else ⇒ firms.use_service_line. |
firmId |
number | Yes | Integer (coerced) | Target firm; must be the caller's own. |
value |
number | Yes | Integer (coerced) | Truthy ⇒ stored as 1, falsy ⇒ 0. |
Example request
curl -X POST 'https://api.example.com/api/task/firm' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"templateId":1,"firmId":12,"value":0}'
Success response — 200 OK
{ "error": false, "message": "Record updated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false when at least one firms row was updated. |
message |
string | Record updated successfully. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | UPDATE matched no firms row |
{ "error": true, "message": "failed" } |
| 400 | Missing templateId, firmId or value |
validation error message array |
| 401 | firmId is not the caller's firm, or caller is not a firm admin |
{ "message": "These credentials do not match our records." } |
Notes
- Duplicate path. The same path is declared in the Configure subsection, and that entry is the one that serves requests to
/api/task/firm. The two behave almost identically; the Configure entry additionally acceptscloneDoneand resolves the targetfirmsrow first. Clients should integrate against the Configure entry and treat this one as documentation of the shared path only. - This endpoint exists so that unchecking "Use Firm Task Templates" has a call of its own; the
destructive/api/task/template/cloneroute is used only for the first-time enable.
Creates real tasks from a template group's templates — the "apply template group" action. Takes a raw
JSON array, one element per task to create.
| Module | Tasks & Time > Task & Time Templates |
| Authentication | Required — authenticated session cookie |
| Authorization | Per company: the company must belong to the caller's firm and the caller must hold ACCESS_STANDARD (3) or ACCESS_ADMIN (4) on TASK_TIME_MENU_ID (81) for that company; global and firm admins bypass the access-level check |
| Content type | application/json (top-level array) |
Request body — a JSON array validated item by item. Unknown properties are not stripped from array
items on this route, so every nested field (steps, resources, recurrence, …) reaches the endpoint
intact.
Only two fields per item are validated:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company |
any | Yes | Must be present — may be a scalar id or a nested { id, name } object |
Target company. |
name |
string | No | String | Task name. |
Because unknown properties are preserved, the following additional keys are also read and applied to
the created task — each accepts a scalar id or a nested { id } object:
| Key | Description |
|---|---|
priority |
Priority. |
assigned |
Assigned role. |
assignedName |
Assigned user; must have access to company or the request is rejected. |
serviceLine |
Service line. |
project |
Project. |
type |
Task type. |
billing |
Billing type. |
estimatedTime |
Estimated time (string). |
requireReview |
Coerced to a boolean. |
assignedReviewer |
Reviewer role. |
assignedReviewerName |
Reviewer user; must have access to company. |
due |
Due date (string). |
status |
Task status. |
tags |
Tag list. |
invoicing |
Invoicing type; defaults to 1. |
timeNotes |
Time notes (string). |
steps |
Written as the new task's steps. |
resources |
Written as the new task's resources. |
recurrence |
Written as the new task's recurrence. |
Example request
curl -X POST 'https://api.example.com/api/task/template/from/group' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '[
{
"company": { "id": 501, "name": "Acme Ltd" },
"name": "Bank Reconciliation",
"serviceLine": { "id": 4 },
"type": { "id": 7 },
"assignedName": { "id": 88 },
"due": "2026-02-15",
"invoicing": 1,
"steps": [{ "name": "Download statement" }]
}
]'
Success response — 200 OK
{ "error": false, "message": "Task created successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false when every task was created. |
message |
string | Task created successfully. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Empty array | { "error": true, "message": "No template task payload found." } |
| 200 | Per-row validation problems (missing company, assignee/reviewer without company access) | { "error": true, "message": "<msg1><br><msg2>" } — messages joined with <br> |
| 200 | Task creation reports a failure | The creation result { error: true, message, taskId? } is returned unchanged |
| 400 | An array element fails item validation (e.g. no company key at all) |
validation error message array |
| 401 | No session, company outside the caller's firm, or insufficient Tasks & Time access on that company | { "message": "These credentials do not match our records." } |
Notes
- Validation runs as a pre-pass over every row before any task is created; if any row fails, nothing is
created. Creation itself is not transactional across rows — a failure on row n returns immediately,
leaving rows1..n-1created. - The assignee and reviewer access checks are performed by this endpoint itself and are not repeated
during task creation. - Task names are stored exactly as submitted, with no trimming and no fallback value.
11.6 Configure
Configure's permission model. Every mutating endpoint applies one of two rules, so that a hand-built
request cannot bypass the restrictions the UI enforces:
- Payload-scope rule — used by endpoints that carry a firm id in the payload.
firmId <= 0means
global configuration and requires the Global Master System Administrator role; a positivefirmId
must equal the caller's own firm and the caller must be a firm admin. The rule also resolves the SQL
firm predicate: global rows are(firm_id IS NULL OR firm_id = 0), firm rows arefirm_id = ?. - Row-scope rule — used by endpoints addressed by row id only. The row's own
firm_iddecides
which restriction applies:NULL/0⇒ Global Master System Administrator; otherwise that firm's firm
admin.service_line_statusrows carry nofirm_idof their own, so their owningservice_line's firm
is used.
is_active semantics. In priority, service_line and task_type, is_active = 1 is written when
inactivating a row and is_active = 0 when reactivating. Read the column as "is inactive".
Loads the whole Tasks & Time Configure page in one call: priorities, billing types, service lines with
their task types, service lines with their statuses, and the firm's Home View column order.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Read-only: any authenticated user, but a non-zero firm must be the caller's own firm, else 401 |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firm |
number | Yes | Integer (coerced) | 0 ⇒ the Global tab; a firm id ⇒ that firm's tab (must be the caller's own). |
from |
string | No | String | Caller-supplied origin hint. |
Example request
curl -X POST 'https://api.example.com/api/task/configs/serviceline/group' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firm":12}'
Success response — 200 OK
{
"billing": [{ "id": 1, "name": "Billable" }],
"priority": [
{ "id": 2, "name": "High", "order": 1, "high_priority": 1, "assigned_id": 14, "is_active": 0 }
],
"servicelineList": [
{ "id": 4, "name": "Bookkeeping", "order": 1, "assigned_id": null, "is_active": 0,
"child": [{ "id": 7, "name": "Recurring" }] }
],
"servicelineStatus": [
{ "id": 4, "name": "Bookkeeping", "order": 1, "assigned_id": null, "is_active": 0,
"child": [{ "id": 9, "name": "In Progress" }] }
],
"homeViwColumns": [
{ "id": 55, "task_view_id": 3, "column": "company", "order": 1, "filter": null }
]
}
| Field | Type | Description |
|---|---|---|
billing[] |
array | Billing type lookup rows. |
priority[] |
array | Priorities with high_priority flag and assigned_id (non-null when the priority is in use, blocking deletion). |
servicelineList[] |
array | Service lines, each with child[] = its task types. |
servicelineStatus[] |
array | The same service lines, each with child[] = its statuses. |
homeViwColumns[] |
array | task_column rows for the firm's is_home_view board, ordered by order. Empty array when firm = 0. The response key is spelled exactly as shown. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing firm |
validation error message array |
| 401 | No session, or firm is a firm other than the caller's |
{ "message": "These credentials do not match our records." } |
Reports whether the firm is set to use its own configuration and whether the global-to-firm clone has
already been run — the state of the "Use Firm Settings" checkbox.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; no admin restriction is applied to this read |
| Content type | application/json |
Request body (firm required, from optional). See above.
Example request
curl -X POST 'https://api.example.com/api/task/configs/clone/status' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firm":12}'
Success response — 200 OK
{ "error": false, "clone": 1, "cloneDone": 1 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false when the firm row was found. |
clone |
number | firms.use_service_line — whether the firm runs on its own configuration. |
cloneDone |
number | firms.clone_task_config — whether the global catalogue has already been copied in. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Firm row not found | { "error": true, "message": "Firm not found", "clone": 0, "cloneDone": 0 } |
| 400 | Missing firm |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Copies the global priorities, service lines, task types and statuses into the firm — the "Use Firm
Settings" first-run action.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Firm admin (firm_admin = 1) of the requested firm; the firm must be the caller's own |
| Content type | application/json |
Request body (firm required, from optional).
Example request
curl -X POST 'https://api.example.com/api/task/configs/serviceline/clone' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firm":12}'
Success response — 200 OK
{ "error": false, "message": "Global Configuration are Cloned Successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | firm is 0/absent, or not the caller's firm |
{ "error": true, "message": "Firm not found" } |
| 400 | Missing firm |
validation error message array |
| 401 | Caller is not a firm admin | { "message": "These credentials do not match our records." } |
Notes
- The clone is idempotent per row: each global row is inserted only when a row with the same name does not
already exist in the firm's scope.
Marks one priority as the firm's (or the global) "high priority", clearing the flag on all others in the
same scope.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Payload-scope rule: firm = 0 ⇒ Global Master System Administrator; firm > 0 ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer (coerced) | Priority to flag as high. |
firm |
number | Yes | Integer (coerced) | 0 ⇒ global scope, otherwise the caller's firm. |
Example request
curl -X POST 'https://api.example.com/api/task/configs/set/priority/high' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id":2,"firm":12}'
Success response — 200 OK
{ "error": false, "message": "Priority updated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing id or firm |
validation error message array |
| 401 | Not a Global Master System Administrator (global scope), or not that firm's firm admin | { "message": "These credentials do not match our records." } |
Notes
- Two statements run: clear
high_priorityacross the scope, then set it onidwithin the same scope
— an id outside the scope silently sets nothing while still returning success.
Inactivates a priority after repointing everything that used it to a replacement priority.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the old priority's own firm_id: global row ⇒ Global Master System Administrator, firm row ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
oldPriorityId |
number | Yes | Integer (coerced) | Priority being inactivated. |
priorityId |
number | Yes | Integer (coerced) | Replacement priority to repoint tasks and template settings to. |
Example request
curl -X POST 'https://api.example.com/api/task/configs/priority/inactive' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"oldPriorityId":2,"priorityId":3}'
Success response — 200 OK
{ "error": false, "message": "Priority inactivated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing either id | validation error message array |
| 401 | Old priority does not exist, or role/scope violation | { "message": "These credentials do not match our records." } |
Notes
- Side effects, in order:
tasks.priorityis repointed for all live tasks;template_settings.priorityis
repointed; the old priority row is then setis_active = 1(inactive). - A missing old priority is reported as 401, not 404.
Inactivates a service line, repointing tasks to a replacement service line, task type and task status.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the old service line's firm_id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
oldServiceLineId |
number | Yes | Integer (coerced) | Service line being inactivated. |
serviceLineId |
number | Yes | Integer (coerced) | Replacement service line. |
taskTypeId |
number | Yes | Integer (coerced) | Replacement task type (must belong to the replacement service line). |
taskStatusId |
number | Yes | Integer (coerced) | Replacement task status. |
Example request
curl -X POST 'https://api.example.com/api/task/config/service-line/inactive' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"oldServiceLineId":4,"serviceLineId":5,"taskTypeId":11,"taskStatusId":14}'
Success response — 200 OK
{ "error": false, "message": "Service Line inactivated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Any of the four ids missing | validation error message array |
| 401 | Old service line does not exist, or role/scope violation | { "message": "These credentials do not match our records." } |
Notes
- Repoints live tasks (service line, task type and status together, since type and status are service-line
scoped) and template settings, then marks the old service line inactive.
Inactivates a task type after repointing everything that used it.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the old task type's firm_id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
oldTaskTypeId |
number | Yes | Integer (coerced) | Task type being inactivated. |
taskTypeId |
number | Yes | Integer (coerced) | Replacement task type. |
Example request
curl -X POST 'https://api.example.com/api/task/type/inactive' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"oldTaskTypeId":7,"taskTypeId":11}'
Success response — 200 OK
{ "error": false, "message": "Task Type inactivated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing either id | validation error message array |
| 401 | Old task type does not exist, or role/scope violation | { "message": "These credentials do not match our records." } |
Persists the drag-and-drop ordering of priorities.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Per row: the row-scope rule, applied to that priority's firm_id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
array | Yes | Array of { id, order } objects |
The new ordering. |
data[].id |
number | Yes | Integer (coerced) | Priority id. |
data[].order |
number | Yes | Integer (coerced) | New order value. |
from |
string | No | String | Ignored by this endpoint (see POST /api/task/config/change/order). |
index |
number | No | Integer (coerced) | Accepted but not used. |
Example request
curl -X POST 'https://api.example.com/api/task/change/priority/order' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"data":[{"id":2,"order":1},{"id":3,"order":2}]}'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data missing or malformed |
validation error message array |
| 401 | Any listed row fails the per-row scope/role check | { "message": "These credentials do not match our records." } |
Notes
- Rows are processed one at a time with no transaction: a 401 on item n leaves items
1..n-1already
reordered. An id that matches no row is skipped silently.
Persists the drag-and-drop ordering of service lines, task types or statuses, selected by from.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Per row: the row-scope rule, applied to that row's firm_id (for a status, the owning service line's firm) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
array | Yes | Array of { id: int, order: int } objects |
The new ordering. |
from |
string | No | String. Compared lower-cased: tasktype ⇒ task_type, status ⇒ service_line_status, anything else (including omitted) ⇒ service_line |
Which table to reorder. |
index |
number | No | Integer (coerced) | Accepted but not used. |
Example request
curl -X POST 'https://api.example.com/api/task/config/change/order' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"from":"taskType","data":[{"id":7,"order":1},{"id":11,"order":2}]}'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data missing or malformed |
validation error message array |
| 401 | Any listed row fails the per-row scope/role check | { "message": "These credentials do not match our records." } |
Notes
- The scope and role check is applied to every row individually, so a caller can reorder only the rows
their own scope permits.
Replaces the firm's Home View column list and ordering, creating the Home View board if it does not exist.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Firm admin of the caller's own firm |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
columns |
array | Yes | Array of column objects (see the two rows below) | Full replacement column list. |
columns[].column |
string | Yes | String | Column key stored in task_column.column. |
columns[].order |
number | Yes | Integer (coerced) | Display order. |
firmId |
number | No | Integer (coerced) | Defaults to the caller's firm; must equal it when supplied. |
Example request
curl -X POST 'https://api.example.com/api/task/home/order' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":12,"columns":[{"column":"company","order":1},{"column":"due","order":2}]}'
Success response — 200 OK
{ "error": false, "message": "Home view column(s) are updated sucessfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. The message string is spelled exactly as shown, including sucessfully. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Home View board could neither be found nor created | { "error": true, "message": "Unable to save Home View order." } |
| 400 | columns missing or malformed |
validation error message array |
| 401 | firmId is not the caller's firm, or caller is not a firm admin |
{ "message": "These credentials do not match our records." } |
Notes
- Destructive replace: all existing
task_columnrows for the board are soft-deleted, then the submitted
list is inserted. Submitting an emptycolumnsarray clears the Home View. - A missing Home View board is created as a
task_boardrow namedHome Viewwithuser_id = 0and
is_home_view = 1.
Adds a configuration value. Only colName = "priority" is implemented.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Payload-scope rule: firmId = 0 ⇒ Global Master System Administrator; firmId > 0 ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
colName |
string | Yes | String. Only "priority" is supported |
Which configuration list to add to. |
colVal |
string | Yes | String | The new value's name (trimmed). |
firmId |
number | Yes | Integer (coerced) | 0 ⇒ global scope. |
order |
number | No | Integer (coerced) | Explicit order; otherwise appended. |
Example request
curl -X POST 'https://api.example.com/api/task/config/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"colName":"priority","colVal":"Critical","firmId":12}'
Success response — 200 OK
{
"error": false,
"message": "Priority created successfully.",
"data": { "id": 21, "name": "Critical", "is_active": 0, "order": 4, "high_priority": 0 }
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
data.id |
number | New priority id. |
data.is_active |
number | Always 0 (active) for a new row. |
data.order |
number | Assigned order. |
data.high_priority |
number | Always 0 for a new row. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | colName !== "priority" |
{ "error": true, "message": "Only priority add is supported in this POC." } |
| 200 | colVal blank after trim |
{ "error": true, "message": "Priority name is required." } |
| 200 | Name already exists in scope (case-insensitive) | { "error": true, "message": "<name> already exist in the Priority." } |
| 400 | Missing colName, colVal or firmId |
validation error message array |
| 401 | Not a Global Master System Administrator (global scope), or not that firm's firm admin | { "message": "These credentials do not match our records." } |
Notes
- The role check runs before the
colNamecheck, so an unauthorised caller gets 401 even for an
unsupportedcolName.
Creates or updates a service line.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Payload-scope rule: firmId = 0 ⇒ Global Master System Administrator; firmId > 0 ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Integer (coerced) | Present ⇒ update. |
name |
string | Yes | String | Service line name (trimmed); unique in scope. |
isGlobalFirm |
number | Yes | Integer (coerced) | Global/firm discriminator carried by the editor. |
firmId |
number | Yes | Integer (coerced) | 0 ⇒ global scope. |
order |
number | No | Integer (coerced) | Explicit order; otherwise appended. |
Example request
curl -X PUT 'https://api.example.com/api/task/configs/serviceline' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Payroll","isGlobalFirm":1,"firmId":12}'
Success response — 200 OK
{
"error": false,
"message": "Service Line created successfully.",
"id": 33,
"name": "Payroll",
"order": 5,
"is_active": 0
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Service Line created successfully. or Service Line updated successfully. |
id |
number | Saved row id. |
name |
string | Trimmed name. |
order |
number | Assigned order. |
is_active |
number | 0. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | name blank after trim |
{ "error": true, "message": "Service Line name is required." } |
| 200 | Name already exists in scope | { "error": true, "message": "<name> already exist in the Service Line." } |
| 400 | Missing name, isGlobalFirm or firmId |
validation error message array |
| 401 | Not a Global Master System Administrator (global scope), or not that firm's firm admin | { "message": "These credentials do not match our records." } |
Creates or updates a task type.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Payload-scope rule: firmId = 0 ⇒ Global Master System Administrator; firmId > 0 ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Integer (coerced) | Present ⇒ update. |
name |
string | Yes | String | Task type name (trimmed); unique in scope. |
type |
number | Yes | Integer (coerced) | Owning service line id (task_type.type). |
firmId |
number | Yes | Integer (coerced) | 0 ⇒ global scope. |
Example request
curl -X PUT 'https://api.example.com/api/task/type' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Ad hoc","type":4,"firmId":12}'
Success response — 200 OK
{
"error": false,
"message": "Task Type created successfully.",
"id": 42,
"name": "Ad hoc",
"type": 4,
"order": 3,
"is_active": 0
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Task Type created successfully. or Task Type updated successfully. |
id |
number | Saved row id. |
type |
number | Echo of the submitted type. |
order |
number | Assigned order. |
is_active |
number | 0. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | name blank after trim |
{ "error": true, "message": "Task Type name is required." } |
| 200 | Name already exists in scope | { "error": true, "message": "<name> already exist in the Task Types." } |
| 400 | Missing name, type or firmId |
validation error message array |
| 401 | Not a Global Master System Administrator (global scope), or not that firm's firm admin | { "message": "These credentials do not match our records." } |
Updates a task type; the id comes from the path.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Payload-scope rule — as for PUT /api/task/type |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task type id; interpreted as a number. |
Request body, as for PUT /api/task/type. Any id in the body is overwritten by the path parameter.
Example request
curl -X PATCH 'https://api.example.com/api/task/type/42' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Ad hoc (revised)","type":4,"firmId":12}'
Success response — 200 OK
{
"error": false,
"message": "Task Type updated successfully.",
"id": 42,
"name": "Ad hoc (revised)",
"type": 4,
"order": 3,
"is_active": 0
}
Response fields as for PUT /api/task/type.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Blank name / duplicate name | { "error": true, "message": "..." } |
| 400 | Missing name, type or firmId |
validation error message array |
| 401 | Scope/role violation | { "message": "These credentials do not match our records." } |
Notes
- A non-numeric path id does not match any row.
Deletes or (in)activates a task type, depending on the status segment.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the task type's own firm_id |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task type id; interpreted as a number. |
status |
string | Yes | Interpreted as a number. 1 ⇒ soft delete (deleted_at). 2 ⇒ set is_active = 0 (activate). Any other value ⇒ set is_active = 1 (inactivate). |
Example request
curl -X DELETE 'https://api.example.com/api/task/type/42/1' -b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Task Type deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
message |
string | Same message regardless of which branch ran. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Task type not found, or role/scope violation | { "message": "These credentials do not match our records." } |
Creates or updates a task status under a service line.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the owning service line's firm_id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Integer (coerced) | Present ⇒ update. |
name |
string | Yes | String | Status name (trimmed); unique within the service line. |
service_line_id |
number | Yes | Integer (coerced) | Owning service line — also decides the permission scope. |
status |
number | No | Integer (coerced) | Status flag persisted on the row. |
order |
number | No | Integer (coerced) | Explicit order; otherwise appended. |
Example request
curl -X PUT 'https://api.example.com/api/task/status' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Awaiting Client","service_line_id":4,"status":1}'
Success response — 200 OK
{
"error": false,
"message": "Task Status created successfully.",
"id": 61,
"name": "Awaiting Client",
"status": 1,
"service_line_id": 4,
"order": 6
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Task Status created successfully. or Task Status updated successfully. |
id |
number | Saved row id. |
status |
number | Persisted status flag. |
service_line_id |
number | Echo of the submitted service line. |
order |
number | Assigned order. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | name blank after trim |
{ "error": true, "message": "Status name is required." } |
| 200 | Name already exists under that service line | { "error": true, "message": "<name> already exist in Task Status." } |
| 400 | Missing name or service_line_id |
validation error message array |
| 401 | Service line not found, or role/scope violation | { "message": "These credentials do not match our records." } |
Updates a task status; the id comes from the path.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the owning service line's firm_id |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task status id; interpreted as a number. |
Request body, as for PUT /api/task/status.
Example request
curl -X PATCH 'https://api.example.com/api/task/status/61' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"name":"Awaiting Client Docs","service_line_id":4}'
Success response — 200 OK
{
"error": false,
"message": "Task Status updated successfully.",
"id": 61,
"name": "Awaiting Client Docs",
"status": 1,
"service_line_id": 4,
"order": 6
}
Response fields as for PUT /api/task/status.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Blank name / duplicate name | { "error": true, "message": "..." } |
| 400 | Missing name or service_line_id |
validation error message array |
| 401 | Service line not found, or role/scope violation | { "message": "These credentials do not match our records." } |
Soft-deletes a task status.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the owning service line's firm_id |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Task status id; interpreted as a number. |
Example request
curl -X DELETE 'https://api.example.com/api/task/status/61' -b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Task Status deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Status not found, or role/scope violation | { "message": "These credentials do not match our records." } |
Notes
- Where the
deleted_atcolumn is not available, the row is marked removed by setting
service_line_status.status = 0instead.
Renames a task status.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the owning service line's firm_id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | Yes | Integer (coerced) | Task status id. |
name |
string | Yes | String | New name (trimmed before writing). |
Example request
curl -X POST 'https://api.example.com/api/task/manage/status' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"id":61,"name":"Awaiting Client Docs"}'
Success response — 200 OK
{ "error": false, "message": "Task Status updated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing id or name |
validation error message array |
| 401 | Status not found, or role/scope violation | { "message": "These credentials do not match our records." } |
Notes
- No duplicate-name check on this path (unlike
PUT /api/task/status).
Inactivates a task status after repointing tasks to a replacement status.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the old status's owning service line's firm_id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
oldTaskStatusId |
number | Yes | Integer (coerced) | Status being inactivated. |
taskStatusId |
number | Yes | Integer (coerced) | Replacement status. |
Example request
curl -X POST 'https://api.example.com/api/task/status/inactive' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"oldTaskStatusId":61,"taskStatusId":9}'
Success response — 200 OK
{ "error": false, "message": "Task Status inactivated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing either id | validation error message array |
| 401 | Old status not found, or role/scope violation | { "message": "These credentials do not match our records." } |
Notes
- Repoints
tasks.statuson live tasks before inactivating the status row.
Deletes or (in)activates a service line, depending on the deleted segment.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the service line's own firm_id |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Service line id; interpreted as a number. |
deleted |
string | Yes | Interpreted as a number. 1 ⇒ soft delete (deleted_at). 2 ⇒ set is_active = 0 (activate). Any other value ⇒ set is_active = 1 (inactivate). |
Example request
curl -X DELETE 'https://api.example.com/api/task/configs/serviceline/4/1' -b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Service Line deleted successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
message |
string | Same message regardless of which branch ran. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Service line not found, or role/scope violation | { "message": "These credentials do not match our records." } |
Renames a configuration value. Only colName = "priority" is implemented.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Payload-scope rule: firmId = 0 ⇒ Global Master System Administrator; firmId > 0 ⇒ that firm's firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
colName |
string | Yes | String. Only "priority" is supported |
Which configuration list. |
colVal |
string | Yes | String | New name (trimmed). |
firmId |
number | Yes | Integer (coerced) | 0 ⇒ global scope. |
id |
number | Yes | Integer (coerced) | Row to rename. |
Example request
curl -X POST 'https://api.example.com/api/task/config/update' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"colName":"priority","colVal":"Urgent","firmId":12,"id":21}'
Success response — 200 OK
{ "error": false, "message": "Priority updated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | colName !== "priority" |
{ "error": true, "message": "Only priority update is supported in this POC." } |
| 200 | colVal blank after trim |
{ "error": true, "message": "Priority name is required." } |
| 200 | Name already exists in scope | { "error": true, "message": "<name> already exist in the Priority." } |
| 400 | Missing any of colName, colVal, firmId, id |
validation error message array |
| 401 | Not a Global Master System Administrator (global scope), or not that firm's firm admin | { "message": "These credentials do not match our records." } |
Deletes or (in)activates a configuration value. Only colName = "priority" is implemented.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Row-scope rule, applied to the priority row's own firm_id |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
colName |
string | Yes | String. Only "priority" is supported |
Which configuration list. |
colVal |
number | Yes | Integer (coerced) | The row id to act on (despite the name). |
deleted |
number | Yes | Integer (coerced) | 1 ⇒ soft delete. 2 ⇒ set is_active = 0 (activate). Any other value ⇒ is_active = 1 (inactivate). |
Example request
curl -X POST 'https://api.example.com/api/task/config/delete' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"colName":"priority","colVal":21,"deleted":1}'
Success response — 200 OK
{ "error": false, "message": "Priority deleted sucessfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. The message string is spelled exactly as shown, including sucessfully. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | colName !== "priority" |
{ "error": true, "message": "Only priority delete is supported in this POC." } |
| 400 | Missing any of colName, colVal, deleted |
validation error message array |
| 401 | Priority row not found, or role/scope violation | { "message": "These credentials do not match our records." } |
Repoints one task's lookup columns after a configuration value it referenced was inactivated — the
"alternate selection" modal.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the task must be visible to the caller, otherwise the endpoint reports "Task not found." |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
taskId |
string | Yes | String — plain numeric or encrypted id | Task to update. |
updateArray |
array | Yes | Array of { name, value } objects |
Column/value pairs to write. |
updateArray[].name |
string | Yes | String | Column name. Silently ignored unless it is one of priority, project, service_line, billing, invoicing, type, assigned_roles, assigned_name, reviewer, reviewer_name and the column exists on tasks. |
updateArray[].value |
any | Yes | Must be present | Written as a number, or as null when the value is not numeric. |
Example request
curl -X POST 'https://api.example.com/api/task/update-alternate-type' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"taskId":"eL5wT1...","updateArray":[{"name":"service_line","value":5},{"name":"type","value":11}]}'
Success response — 200 OK
{
"error": false,
"message": "Alternate Data Updated successfully.",
"statusOptionUpdate": true,
"statusOption": [{ "id": 14, "name": "In Progress" }]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
statusOptionUpdate |
boolean | true when service_line was among the updated columns, meaning the client must reload the status dropdown. |
statusOption[] |
array | The replacement status options for the new service line; [] when service_line was not changed. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | taskId cannot be decoded, or the task is not visible to the caller |
{ "error": true, "message": "Task not found." } |
| 200 | No entry in updateArray maps to an allowed, existing column |
{ "error": true, "message": "No alternate values to update." } |
| 400 | Missing taskId or updateArray |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- Changing
service_linealso nullstasks.status, because statuses are service-line scoped. - Anything not in the allow-list is dropped without comment — the endpoint cannot be used to write
arbitrarytaskscolumns.
Flips a firm-level usage flag (use_task / use_project / use_service_line), optionally recording that the configuration clone has been done. This is the canonical entry for /api/task/firm — the same path also appears in the Task & Time Templates subsection.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | firmId must equal the caller's firm and the caller must be a firm admin |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
templateId |
number | Yes | Integer (coerced) | Which flag: 1 ⇒ firms.use_task, 2 ⇒ firms.use_project, anything else ⇒ firms.use_service_line. |
firmId |
number | Yes | Integer (coerced) | Target firm; must be the caller's own. |
value |
number | Yes | Integer (coerced) | Written to the flag column as-is. |
cloneDone |
boolean | No | Boolean | When present (a real boolean), also writes firms.clone_task_config = 1/0. |
Example request
curl -X POST 'https://api.example.com/api/task/firm' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"templateId":1,"firmId":12,"value":1,"cloneDone":true}'
Success response — 200 OK
{ "error": false, "message": "Record updated successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No firms row matches user_id = firmId OR id = firmId |
{ "error": true, "message": "Firm configuration not found." } |
| 400 | Missing templateId, firmId or value |
validation error message array |
| 401 | firmId is not the caller's firm, or caller is not a firm admin |
{ "message": "These credentials do not match our records." } |
Notes
- Duplicate path. The Task & Time Templates subsection documents the same path; this entry is the canonical one and serves
every request to/api/task/firm. Compared with the Templates variant, this one acceptscloneDone,
resolves the targetfirmsrow id before updating, and writesvalueexactly as supplied rather than
normalising it to0/1. firmIdis compared strictly against the caller's own firm id.
Loads the Utilization configuration tab: the firm's service-role targets, its users' overrides, and the
current user's own role assignment.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | firmId must be non-zero and equal the caller's firm; no admin flag required for the read |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | Yes | Integer (coerced) | Target firm; must be the caller's own and non-zero. |
type |
string | No | String + One of role, user. Defaults to 'role' |
Which tab to populate; 'user' additionally loads firmUsers. |
Example request
curl -X POST 'https://api.example.com/api/task/utilization/config' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":12,"type":"user"}'
Success response — 200 OK
{
"defaultServiceRoles": [
{ "id": 3, "role": "Bookkeeper", "target_utilization": 75, "target_bill_week": 30, "target_bill_day": 6 }
],
"firmUsers": [
{
"id": 88,
"lf_name": "Doe, Jane",
"override_total_hours": 40,
"override_target_utilization": 80,
"override_target_bill_week": 32,
"override_target_bill_day": 6.4
}
],
"loginUserRole": 3
}
| Field | Type | Description |
|---|---|---|
defaultServiceRoles[] |
array | Service roles in scope with their utilization targets (utilization_config_role values falling back to service_roles columns, then 0). |
firmUsers[] |
array | Firm users with their utilization_config_user overrides. Populated only for type = 'user'; otherwise []. Derived override_target_bill_week/_day are nulled when both stored overrides are null, so a cleared override renders blank rather than 0.00. |
loginUserRole |
number | null | The calling user's own utilization_config_user.role_id, or null. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing firmId, or type outside role/user |
validation error message array |
| 401 | firmId is 0 or not the caller's firm |
{ "message": "These credentials do not match our records." } |
Notes
- Role scoping is exclusive and follows
firms.use_service_role:1⇒ the firm's own roles,
otherwise the global catalogue — never a union of both. service_rolesrows withdeleted IN (0, 2)are included; both values denote an active role.
Saves one utilization row — either a role-level target or a user-level override.
| Module | Tasks & Time > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | firmId must be non-zero and equal the caller's firm |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | Yes | Integer (coerced) | Target firm; must be the caller's own. |
type |
string | Yes | String + One of role, user |
Which row kind to save. |
roleId |
number | No | Integer (coerced) | Required in practice when type = 'role'; also stored alongside a user row. |
userId |
number | No | Integer (coerced) | Required in practice when type = 'user'. |
value |
string | No | String | The target utilization value for a role row. |
override_total_hours |
number | string | null | No | Optional; not numerically coerced | User override for weekly hours. A blank string or null is preserved and means "no override", which applies the 40-hour default; it is never stored as an explicit 0. |
override_target_utilization |
number | string | null | No | Optional; not numerically coerced | User override for target utilization %. A blank value means "no override", which is treated as 0%. |
override_target_bill_week |
number | No | Number (coerced) | Derived weekly billable target. |
override_target_bill_day |
number | No | Number (coerced) | Derived daily billable target. |
Example request
curl -X POST 'https://api.example.com/api/task/utilization/config/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":12,"type":"user","userId":88,"roleId":3,"override_total_hours":40,"override_target_utilization":80}'
Success response — 200 OK (user branch)
{
"firm_id": 12,
"user_id": 88,
"role_id": 3,
"override_total_hours": 40,
"override_target_utilization": 80,
"override_target_bill_week": 32,
"override_target_bill_day": 6.4
}
Success response — 200 OK (role branch)
{
"firm_id": 12,
"role_id": 3,
"target_utilization": 75,
"target_bill_week": 30,
"target_bill_day": 6
}
| Field | Type | Description |
|---|---|---|
firm_id |
number | Echo of the requested firm. |
user_id |
number | User branch only. |
role_id |
number | The saved/derived role id. |
override_total_hours / override_target_utilization |
number | null | null when the caller sent a blank override. |
override_target_bill_week / override_target_bill_day |
number | null | Derived values, rounded to 2 decimals; null when both stored overrides are blank. |
target_utilization / target_bill_week / target_bill_day |
number | Role branch only; derived values rounded to 2 decimals. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | type = 'user' with no usable userId |
{ "error": true, "message": "User is required." } |
| 200 | type = 'role' with no usable roleId |
{ "error": true, "message": "Role is required." } |
| 400 | Missing firmId or type, or type outside role/user |
validation error message array |
| 401 | firmId is 0 or not the caller's firm |
{ "message": "These credentials do not match our records." } |
11.7 Reports (Reports Builder)
This subsection covers report list management and placement, report definitions, live preview, XLSX export
and calculated-column evaluation.
Enumerations used across this subsection
| Enum | Values | Where enforced |
|---|---|---|
Report module / type (list) |
task, time, project, invoice, profitability, company, custom |
The module field of POST /api/tat/reports/list — restricted value set |
| Placement | global, firm, personal |
The placement field of the clone, share and save requests — restricted value set |
| Reorder bucket | global, firm, personal, favorite, shared |
The type field of POST /api/tat/reports/update/order — restricted value set |
| List action | fav, unfav, delete |
The action field of POST /api/tat/reports/update — restricted value set |
| Standard report | monthly_budget, mc_section_status, billable_hours, fp_profitability |
The type field of the standard report preview and export requests — restricted value set |
Note that the type field of the clone, share, getcolumn and save requests is a plain string with no
restricted value set — the module vocabulary above is enforced only on the module field of the report
list request.
Column detail structure — one entry per column on a report, persisted to
reports_builder_column_details. Every field is optional; an entry whose columnId is blank is dropped.
| Field | Type | Validation | Persisted column | Meaning |
|---|---|---|---|---|
columnId |
string | String | column_id |
The reports_builder_column id, or a synthetic id for a custom/calculated column (Company reports use company_info_<id> / company_contacts_<id>; calculated columns use their own generated id). |
coumnType |
string | String | coumn_type |
normal (a real SQL column) or anything else (a calculated/formula column). Defaults to normal. The persisted column name is spelled exactly as shown. |
order |
number | Integer | order |
Display order; defaults to the array index + 1. |
sort |
string | String | sort |
Saved sort direction for the column. |
sumType |
string | String | sum_type |
Saved aggregation choice. Persisted but not what drives the preview's subtotals — see the row-group note below. |
format |
any (JSON) | untyped | format |
Presentation flags. The exporter reads is_bold, is_italic, is_underline, is_double_underline and is_box. |
displayName |
string | String | display_name |
Column heading; defaults to columnId. |
is_display |
boolean | untyped | is_display |
Presentation-only filter; defaults to true. A hidden column is still fetched, so it can still be filtered on, grouped by and subtotalled. |
filters |
any (JSON) | untyped | filters |
The saved column filter — see the filter structure below. |
customFilters |
any (JSON) | untyped | custom_filters |
Additional saved filter payload. |
condFormat |
any (JSON) | untyped | cond_format |
Conditional-formatting rules. This is the field that can make a save payload very large. |
formula |
any (JSON) | untyped | formula |
For a calculated column: { formula: "<expression>", output: "<field type>", decimal: <n> }. |
decimal |
number | Integer | decimal |
Decimal places applied to the rendered value; defaults to 0. |
isHeaderFilter |
any | untyped | is_header_filter |
Stored as an integer 0/1 (never JSON-encoded), or NULL when omitted. |
Row-group structure — { column_id?: string }. The rowGroup field of a save request is
mapped to a plain string array and stored JSON-encoded in reports_builder_list.row_group. Row groups
drive the grouped preview; when a request asks for neededData: 'details' the grouping is skipped and
detail rows are returned instead. Row-group subtotal columns are derived from each column's field_type
being one of number, time or percentage.
Filter structure — a saved/inline filter entry is
{ field_name, field_type, filter: { input, option } }, where option is either a string or
{ value: "<op>" }. Recognised operators:
| Operator | Behaviour |
|---|---|
equal |
Numeric equality when both sides parse as numbers, otherwise string equality. |
not_equal |
Inverse of equal. |
contains |
String includes. |
starts_with |
String startsWith. |
ends_with |
String endsWith. |
greater / greater_equal_to |
Numeric only; false when either side is non-numeric. |
lower / lower_equal_to |
Numeric only; false when either side is non-numeric. |
field_type of jsonstring or link is skipped entirely. A boolean field is the one type that does
not require an option — only input (0/1) — matching the preview sidebar's boolean editor. An
absent or empty option on any other type means the filter is ignored (not "match nothing").
Formula (calculated column) grammar — formulas are parsed by a dedicated expression parser and are
never evaluated as executable code. Operands are bare field names resolved against the row; the editor
writes them wrapped in parentheses, e.g. (billable_time) / (total_reported_time).
- Operators, lowest to highest precedence:
||,&&, comparisons (==,!=,>=,<=,>,<),
+-,*/%,^(right-associative), unary-+!. - Functions:
DATEDIFF(a, b),ABS,ROUND(1 or 2 args),CEIL,FLOOR,SQRT,POW,MIN,
MAX,IF(cond, then, else). Names are case-insensitive; any other function name throws. - Literals: numbers, single- or double-quoted strings. Identifiers may be written
$nameorname. DATEDIFF: returns''if either operand is empty or unparseable; returns whole seconds when
the column's output type istime, otherwise whole days; always an absolute value.- Failure semantics: an unknown variable, a division (or modulo) by zero, unbalanced parentheses,
trailing tokens or any other parse/eval failure makes the entire cell evaluate to0— never a
partial result. - Output: always a string.
percentageoutput multiplies by 100. Decimals come from the formula's
decimal(only applied when> 0and the result is numeric), and are formatted with fixed decimals,
no thousands separator and halves rounded away from zero.decimalis clamped to0..100. A
non-numeric string result passes through unformatted. - Results are written to the row under both
calc_<column detail id>and the column's own
column_id, so a later formula can reference an earlier calculated column by name. For row-group
summary rows the variables are rebuilt from the__SUBTOTAL__<field>keys first.
Standard preview payload — every report engine returns the same envelope, which is then decorated
with calculated columns and decimal places:
| Field | Type | Description |
|---|---|---|
columnDetail[] |
array | Detail rows. |
columnSummary[] |
array | Deepest row-group summary rows (empty for a plain detail request). |
colSummarySubTotal |
array | object | Per-level row-group subtotals, keyed by level when grouping is active. |
rowGroupColumns[] |
array | Field names the result is grouped by. |
neededData |
string | details on the detail path. |
name, type, globalType, placement |
string | Report identity. |
columnList[] |
array | { column_id, display_name, field_name, field_type, order, format, cond_format, filters } per displayed column. |
total_record |
number | Row count of the returned set. |
reportTotal |
array | object | Report-wide totals (object on the subtotal-only path). |
reportRowGroupTotal |
array | object | Grand total across row groups. |
Returns every report the caller can see for one module, bucketed by placement, plus the user list used by
the Share dialog.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement; visibility is scoped in SQL to global reports, the caller's firm's reports, the caller's own personal reports and reports shared with the caller |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
module |
string | Yes | String + One of task, time, project, invoice, profitability, company, custom |
Which report module's list to return. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/list' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"module":"task"}'
Success response — 200 OK
{
"error": false,
"reports": {
"global": [{ "id": 5, "name": "Open Tasks", "type": "task", "placement": "global", "order": 1, "encryptId": "fM8kP3..." }],
"firm": [],
"personal": [{ "id": 12, "name": "My Overdue", "type": "task", "placement": "personal", "order": 1, "encryptId": "gN9lQ4..." }],
"favorite": [{ "id": 5, "name": "Open Tasks", "order": 1, "encryptId": "fM8kP3..." }],
"shared": []
},
"roleId": 1,
"firmAdmin": 0,
"userList": [
{ "id": 88, "user_name": "jdoe", "lf_name": "Doe, Jane", "first_name": "Jane", "last_name": "Doe",
"email": "jane@example.com", "firm_admin": 0, "firm_id": 12, "role": 6, "user_type": 1, "active": 1 }
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
reports |
object | Five buckets — global, firm, personal, favorite, shared — each sorted by order. Every row is the reports_builder_list row plus encryptId. A favourited report appears in both its placement bucket and favorite. |
roleId |
1 | "" |
1 for a global admin, empty string otherwise. This is a flag, not a role id; the UI gates Global-report management on it. |
firmAdmin |
number | The caller's users.firm_admin value, returned directly. |
userList[] |
array | Active, non-deleted users in the caller's firm excluding the caller — the Share dialog's candidate list. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | module missing or outside the allowed set |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Tells the client whether the current user may see profitability reports.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | Reads the caller's own timesheet_config.view_profitablity_reports flag |
| Content type | application/json |
Example request
curl 'https://api.example.com/api/tat/reports/profitability/access' -b 'genie.sid=<session-cookie>'
Success response — 200 OK
true
| Field | Type | Description |
|---|---|---|
| (body) | boolean | A bare JSON boolean — true when the caller's timesheet_config.view_profitablity_reports equals 1, false otherwise (including when no config row exists). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- The response is a scalar, not an envelope — unlike every other Reports endpoint.
- The column name
view_profitablity_reportsis spelled exactly as shown.
Favourites, un-favourites or deletes a report from the list screen.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | fav/unfav: any authenticated user (favourites are per-user). delete: global reports (user_id IS NULL) require a global admin; firm reports (user_id = firmId) require a firm admin or global admin; personal reports require the owner |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
action |
string | Yes | String + One of fav, unfav, delete |
What to do. |
reportId |
string | Yes | String — plain numeric or encrypted id | Target report. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/update' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"action":"fav","reportId":"fM8kP3..."}'
Success response — 200 OK
{ "error": false, "msg": "Report marked as favorite successfully" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
msg |
string | Report marked as favorite successfully, This Report has been already marked as favorites, Report removed from favorite successfully, or Report deleted successfully. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Report not found | { "error": true, "msg": "Report not found." } |
| 200 | Caller may not delete this report's placement | { "error": true, "msg": "Not allowed to delete this report." } |
| 400 | reportId cannot be decoded |
{ "statusCode": 400, "message": "Invalid report id", "error": "Bad Request" } |
| 400 | action outside the allowed set |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- Favouriting assigns the next
orderwithin the report's own module, so favourites keep a per-module
ordering. Un-favouriting setsis_fav = 0, order = 0rather than deleting the row.
Copies a report — its list row and all of its column details — under a new name and placement.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | placement: "global" requires a global admin; placement: "firm" requires a firm admin or global admin; placement: "personal" is open |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportId |
string | Yes | String — plain numeric or encrypted id | Source report. |
name |
string | Yes | String | Name for the clone. |
placement |
string | Yes | String + One of global, firm, personal |
Where the clone lives. |
type |
string | Yes | String (no Restricted value set) | Fallback module; the source report's own type wins when present. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/clone' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"reportId":"fM8kP3...","name":"Open Tasks (Copy)","placement":"personal","type":"task"}'
Success response — 200 OK
{ "error": false, "id": "hP1sR6...", "message": "Report cloned successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
id |
string | Encrypted id of the new report. |
message |
string | Confirmation text. Note this endpoint uses message, not msg. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Source report missing | { "error": true, "msg": "Report not found." } |
| 200 | placement: "global" without global admin |
{ "error": true, "msg": "Not allowed to place this report globally." } |
| 200 | placement: "firm" without firm/global admin |
{ "error": true, "msg": "Not allowed to place this report at the firm level." } |
| 200 | Name already used in the target placement | { "error": true, "msg": "Report name already Exist. Please provide another name" } |
| 400 | reportId cannot be decoded |
{ "statusCode": 400, "message": "Invalid report id", "error": "Bad Request" } |
| 400 | Missing/invalid fields | validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- The duplicate-name check excludes the source report, so cloning under the same name into the same
placement is permitted. global_type,descriptionandrow_groupare carried over from the source.row_groupis stored as a JSON string.
Shares a report with a set of users in the caller's firm.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No placement role gate on this route; recipients outside the caller's firm are silently skipped |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportId |
string | Yes | String — plain numeric or encrypted id | Report to share. |
name |
string | Yes | String | Report name carried by the dialog. |
placement |
string | Yes | String + One of global, firm, personal |
Placement carried by the dialog. |
type |
string | Yes | String (no Restricted value set) | Module carried by the dialog. |
isShared |
number[] | Yes | Array + Numeric (numeric strings are coerced) | User ids to share with. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/share' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"reportId":"fM8kP3...","name":"Open Tasks","placement":"personal","type":"task","isShared":[88,91]}'
Success response — 200 OK
{ "error": false, "msg": "Report shared successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
msg |
string | Report shared successfully., or Report shared successfully. N selected user(s) were skipped because they are outside your firm. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | isShared empty |
{ "error": true, "msg": "Please select users to share this report." } |
| 200 | The report is already shared with one of the selected users | { "error": true, "msg": "Selected report is already shared to one of these users." } |
| 200 | Report missing | { "error": true, "msg": "Report not found." } |
| 200 | Every selected user is outside the caller's firm | { "error": true, "msg": "None of the selected users can be shared with." } |
| 400 | reportId cannot be decoded |
{ "statusCode": 400, "message": "Invalid report id", "error": "Bad Request" } |
| 400 | Missing/invalid fields | validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- Recipients outside the caller's firm are dropped rather than rejected; the count appears in
msg. - Shared reports show up in the recipient's
reports.sharedbucket fromPOST /api/tat/reports/list,
ordered byreports_builder_shared_list.order.
Persists drag-and-drop ordering within one bucket of the report list.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement. favorite/shared writes are scoped to the caller's own rows; global/firm/personal update reports_builder_list by id only |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
type |
string | Yes | String + One of global, firm, personal, favorite, shared |
Which bucket is being reordered. |
data |
array | Yes | Array of { id, order } objects |
The new ordering. |
data[].id |
number | Yes | Integer (coerced) | Report id (plain numeric — not the encrypted form). |
data[].order |
number | Yes | Integer (coerced) | New order value. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/update/order' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"type":"personal","data":[{"id":12,"order":1},{"id":18,"order":2}]}'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | type outside the allowed set, or data malformed |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- For
global/firm/personalthe update carries no owner predicate: rows are matched by id alone,
so global rows stored withuser_id = 0rather thanNULLstill reorder correctly.
Loads the report editor: the report's saved columns and row groups, plus the full catalogue of columns
available for its type.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement; the report must be visible to the caller |
| Content type | application/json |
Request body — all fields optional
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportId |
string | No | String — plain numeric or encrypted id | Existing report to load. Omit (with report_id also omitted) for the new-report case. |
report_id |
string | No | String | Alternative spelling; used when reportId is absent. |
type |
string | No | String | Module, used to pick the column catalogue when no report id is supplied. Overwritten by the report's own type when a report is loaded. |
placement |
string | No | String | Placement for the new-report case. |
search_val |
string | No | String | Filters the available-column catalogue by display_name LIKE %search_val%. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/details' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"reportId":"fM8kP3...","search_val":"hours"}'
Success response — 200 OK
{
"name": "Open Tasks",
"type": "task",
"placement": "global",
"columns": [
{ "id": 210, "column_id": 210, "field_name": "company_name", "display_name": "Company",
"field_type": "text", "type": "task", "default_values": null }
],
"selectedColumns": [
{ "id": 900, "report_id": 5, "column_id": "210", "coumn_type": "normal", "order": 1,
"display_name": "Company", "is_display": 1, "sort": null, "sum_type": null,
"format": null, "filters": null, "custom_filters": null, "cond_format": null,
"formula": null, "decimal": 0, "is_header_filter": null }
],
"rowGroup": [],
"shared": 0,
"globalType": "",
"error": false
}
| Field | Type | Description |
|---|---|---|
name, type, placement, globalType |
string | Report identity (empty strings for a new report). |
columns[] |
array | Available columns: reports_builder_column rows for the type (each with column_id aliased from id), merged with the report's calculated columns (column_id, display_name, field_name = the column id, field_type from the formula's output, default_values: null), sorted by display_name. |
selectedColumns[] |
array | The report's reports_builder_column_details rows, ordered by order. |
rowGroup[] |
array | The subset of selectedColumns named in the report's row_group, reversed. |
shared |
number | 1 when the report is shared with the caller, else 0. |
error |
boolean | false on success. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | reportId resolves to a report the caller cannot see |
{ "error": true, "msg": "Report not found" } |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- The available-column catalogue is filtered by
globalType:utilizationandtimeComparerestrict to
the matchingsub_type; a plaintimereport excludes
billable_hour,non_billable_hour,no_charge_hour,fp_time_sheet_hour,fp_time_sheet_amount; a
plainprofitabilityreport excludespweek,pmonth,reported_hours,client_billable_hours.
Returns the default value sets used to populate filter dropdowns in the report editor.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement; results are scoped to the caller's firm |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
type |
string | Yes | String (no Restricted value set) | Report module; selects which reports_builder_column dropdown defaults to include. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/getcolumn' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"type":"task"}'
Success response — 200 OK
{
"error": false,
"columnDefaultValues": {
"company": ["Acme Ltd", "Beta Inc"],
"users": ["Doe, Jane"],
"user": ["Doe, Jane"],
"assigned_name": ["Doe, Jane", "Unassigned"],
"status": ["In Progress", "No Status"],
"tags": ["Month End", "No Tags"],
"item_type": ["Service", "Expense", "Product"]
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
columnDefaultValues |
object | Map of field name → de-duplicated, blank-stripped option list. users and user carry the same list; assigned_name appends Unassigned; status appends No Status; tags appends No Tags; item_type is the fixed list ["Service","Expense","Product"]. Any reports_builder_column row of field_type = 'dropdown' with default_values for this type adds/overrides its own entry. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing type |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Returns the report's reference date column and the date window the preview should open with.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportId |
string | Yes | String — plain numeric or encrypted id | Report whose date defaults to compute. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/getdate' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"reportId":"fM8kP3..."}'
Success response — 200 OK
{
"doesDateNeed": true,
"type": "",
"displayName": "Due Date",
"fieldName": "due",
"filterText": "Range from 01/01/2026 to 01/31/2026",
"dateColumns": [
{ "displayName": "Completion Date", "fieldName": "completion_date",
"start": "2026-01-01", "end": "2026-01-31",
"filterText": "Range from 01/01/2026 to 01/31/2026", "isFirst": true }
],
"start": "2026-01-01",
"end": "2026-01-31",
"isFirst": false,
"haveProjectActualBillable": false
}
| Field | Type | Description |
|---|---|---|
doesDateNeed |
boolean | Whether the preview needs a date window at all. Forced true for a Project report that selects actual_billable_hours. |
type |
string | invoiceBudget when the report's global_type is invoiceBudget, else "". |
displayName |
string | Label for the date control; Actual Billable Hours Date on the Project/actual-billable path. |
fieldName |
string | The reference date column. |
filterText |
string | Human-readable Range from MM/DD/YYYY to MM/DD/YYYY. |
dateColumns[] |
array | Every date column on the report with its own derived range and isFirst flag (isFirst marks completion_date). |
start, end |
string | The derived YYYY-MM-DD window. |
haveProjectActualBillable |
boolean | Whether the report selects actual_billable_hours on a Project report. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Report not found | { "error": true } |
| 400 | reportId cannot be decoded |
{ "statusCode": 400, "message": "Invalid report id", "error": "Bad Request" } |
| 400 | Missing reportId |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- The window is derived from the column's saved filter: a
greater/loweroperator shifts one end by a
day, a two-ended operator orders the pair, and a single value collapses tostart === end.
Creates or updates a report definition — the list row, its row groups and its full column-detail set.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | placement: "global" requires a global admin; placement: "firm" requires a firm admin or global admin; personal is open. On update the report must also be visible to the caller |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportId |
string | No | String — plain numeric or encrypted id | Present ⇒ update. |
report_id |
string | No | String | Alternative spelling; used when reportId is absent. |
name |
string | Yes | String | Report name; unique within (type, placement, owner). |
type |
string | Yes | String (no Restricted value set) | Report module. |
placement |
string | Yes | String + One of global, firm, personal |
Where the report lives. |
details |
array | No | Array of column detail objects | The column definitions — see the column detail structure table above. |
rowGroup |
array | No | Array of { column_id? } objects |
Row-group column ids, stored JSON-encoded on the report. |
isShared |
any | No | untyped | Accepted but not used on this path. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"reportId": "fM8kP3...",
"name": "Open Tasks",
"type": "task",
"placement": "global",
"rowGroup": [{ "column_id": "210" }],
"details": [
{ "columnId": "210", "coumnType": "normal", "order": 1, "displayName": "Company", "is_display": true },
{ "columnId": "calc_1", "coumnType": "calculated", "order": 2, "displayName": "Utilisation",
"decimal": 2,
"formula": { "formula": "(billable_time) / (total_reported_time)", "output": "percentage", "decimal": 2 } }
]
}'
Success response — 200 OK
{
"reportId": "fM8kP3...",
"isNewReport": false,
"error": false,
"msg": "Reports data has been saved successfully"
}
| Field | Type | Description |
|---|---|---|
reportId |
string | Encrypted id of the saved report. |
isNewReport |
boolean | true when a new reports_builder_list row was inserted. |
error |
boolean | false on success. |
msg |
string | Confirmation text. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | placement: "global" without global admin |
{ "error": true, "msg": "Not allowed to place this report globally." } |
| 200 | placement: "firm" without firm/global admin |
{ "error": true, "msg": "Not allowed to place this report at the firm level." } |
| 200 | Name already used in the target placement | { "error": true, "msg": "Report name already Exist. Please provide another name" } |
| 200 | Update targets a report the caller cannot see | { "error": true, "msg": "Report not found" } |
| 400 | Missing name, type or placement, or placement outside the allowed set |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
| 413 | Request body exceeds 20 MB | request entity too large |
Notes
- This is the endpoint the 20 MB body limit exists for. A column carrying a per-value
conditional-formatting rule for every distinct value (Company or Project columns with hundreds of
entries) can produce a very large payload, so the request body limit for JSON and form-encoded bodies is
raised to 20 MB. - Column sync is a soft-delete-and-upsert: details whose
column_idis not in the payload are
soft-deleted; each remainingcolumn_idupdates its live row, or inserts a new one. Re-adding a
previously removed column creates a new row rather than reviving the soft-deleted one, so its old
configuration and id do not come back. format,filters,custom_filters,cond_formatandformulaare JSON-encoded on write.
is_header_filteris an integer column and is coerced to0/1/NULL, never JSON.placementalso determines the stored owner (reports_builder_list.user_id): global reports store
NULL, firm reports store the firm id, personal reports store the user id.
Runs a saved report and returns rows, row-group summaries and totals — the main reporting call.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement; the report must be visible to the caller, and all data is firm-scoped (a Global Master System Administrator with no firm of their own gets an all-firms scope) |
| Content type | application/json |
Request body — all fields optional
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportId |
string | No | String — plain numeric or encrypted id | Report to run. Required in practice: without it (and without report_id) the call returns Report not found. |
report_id |
string | No | String | Alternative spelling; used when reportId is absent. |
globalFilter |
object | No | Validated nested object { role?: string, user?: string } (both String) |
Cross-report role/user narrowing. Both must be set for the company narrowing to apply. |
filters |
any | No | untyped | Inline filter list (same structure as saved column filters). |
inputDate / input_date |
any | No | untyped | The header date window, typically { start, end }. |
needSubtotal |
boolean | No | untyped | Request the totals-only pass. Ignored for invoiceBudget reports. |
neededData |
string | No | untyped | details requests the flat detail rows and suppresses row grouping. |
expandBy |
any | No | untyped | { rowGroupFieldName: value } pairs pinning a detail fetch to one group combination; used together with neededData: 'details'. |
paginate |
any | No | untyped | { page?, limit?, offset? }. limit is clamped to 1..100000, default 200. |
export |
boolean | No | untyped | Marks the call as an export pass. |
includeInactiveData |
boolean | No | untyped | Include inactive records. |
sorting |
any | No | untyped | { field_name?, direction? }. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/preview' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"reportId": "fM8kP3...",
"inputDate": { "start": "2026-01-01", "end": "2026-01-31" },
"globalFilter": { "role": "3", "user": "88" },
"sorting": { "field_name": "due", "direction": "asc" },
"paginate": { "page": 1, "limit": 200 },
"includeInactiveData": false
}'
Success response — 200 OK
{
"columnDetail": [{ "company_name": "Acme Ltd", "due": "2026-01-15", "billable_time": 3600 }],
"columnSummary": [],
"colSummarySubTotal": [],
"rowGroupColumns": [],
"neededData": "details",
"name": "Open Tasks",
"type": "task",
"globalType": "",
"placement": "global",
"columnList": [
{ "column_id": "company_name", "display_name": "Company", "field_name": "company_name",
"field_type": "text", "order": 0, "format": null, "cond_format": null, "filters": null }
],
"total_record": 1,
"reportTotal": [],
"reportRowGroupTotal": []
}
Response fields as in the Standard preview payload table above. When needSubtotal is requested the
response collapses to { "reportTotal": { ... } }.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No usable report id, or report not visible | { "error": true, "msg": "Report not found" } |
| 200 | Report has no columns | { "error": true, "msg": "There is no columns in this report" } |
| 200 | A row-group query fails | { "error": true, "msg": "Failed to build task row groups.", "debug": "<driver message>" } |
| 200 | Any other failure inside the preview | { "error": true, "msg": "Failed to build the report preview.", "debug": "<message>" } |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- Non-HTTP failures are returned as a readable JSON envelope with a
debugfield and are logged
server-side. Real HTTP errors (notably the 401) are returned unchanged, so client-side session-expiry
handling still fires. - The report's
typeandglobal_typeselect a dedicated engine:invoice(non-budget),invoice+
invoiceBudget,time,time+timeCompare,project,company,profitability+utilization
(computed by dedicated database routines),
profitability, and a generic Task engine as the fallback. - Hidden columns (
is_display = 0) are still fetched, so they can be filtered on, grouped by and
subtotalled —is_displayis applied as a presentation filter only. - Calculated columns are evaluated after the query, on both detail rows and row-group summary rows.
BIGINTvalues are returned as numbers where they fit in a JavaScript number and as strings where they
do not, so a large integer can never break response serialisation.
Generates an XLSX workbook for a report and returns a one-time download link.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | Same as preview — the report must be visible to the caller. The generated token is bound to the calling user |
| Content type | application/json (the workbook itself is fetched separately) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportId |
string | Yes | String — plain numeric or encrypted id | Report to export. |
inputDate |
any | No | untyped | The header date window, forwarded to the preview. |
filters |
any | No | untyped | Inline filters, forwarded to the preview. |
globalFilter |
object | No | untyped; same shape as the preview's globalFilter |
Forwarded so the workbook matches the on-screen grid. |
sorting |
any | No | untyped | Forwarded so the workbook matches the on-screen order. |
includeInactiveData |
boolean | No | untyped | Forwarded. |
exportName |
string | No | untyped | File name for the download. Sanitised by removing \ / # ? " %; falls back to the report name, then report. |
exportWithSummary |
boolean | No | untyped | Switches to the recursive row-group hierarchy export. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/make/export' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"reportId": "fM8kP3...",
"inputDate": { "start": "2026-01-01", "end": "2026-01-31" },
"globalFilter": { "role": "3", "user": "88" },
"sorting": { "field_name": "due", "direction": "asc" },
"includeInactiveData": false,
"exportName": "Open Tasks January",
"exportWithSummary": true
}'
Success response — 200 OK
{
"error": false,
"message": "Export generated.",
"redirect": "/api/tat/reports/export/generated/4f3c2b1a-9d8e-4c7b-a6f5-0e1d2c3b4a59"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Export generated. |
redirect |
string | Relative URL of the one-time download — GET /api/tat/reports/export/generated/:token. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | The underlying preview failed | { "error": true, "msg": "<preview msg>" }, or { "error": true, "msg": "Failed to generate export." } |
| 400 | Missing reportId |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- This re-runs
POST /api/tat/reports/previewwith the same payload plus
paginate: { page: 1, limit: 100000 },export: trueandneededData: 'details', so a row-grouped
report exports real detail rows rather than the grouped-summary early return. A separate pass fetches
reportTotal, and — forexportWithSummary— the row-group summary levels. - The workbook opens with a header block (Report Name / Generated On / Date Range) before the column
header row. Per-columnformatflags (is_bold,is_italic,is_underline,is_double_underline,
is_box) are applied as cell styles; column widths are auto-sized to at least 20 characters. - The generated workbook is held as temporary server-side storage and referenced by a single-use UUID
token recorded together with the owning user id and a creation timestamp; expired entries are swept on
each export. The download request must be served by the same application instance that produced the
export, so multi-instance deployments must preserve instance affinity for these routes. See
Deployment & Operational Considerations.
Streams a previously generated export workbook. One-time, session-bound.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie; token ownership is verified against the session |
| Authorization | The token must belong to the user who generated it |
| Content type | Binary download — application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, with a Content-Disposition attachment filename. JSON on error |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The UUID from the redirect URL returned by POST /api/tat/reports/make/export (or by the standard-report export). |
Example request
curl -OJ 'https://api.example.com/api/tat/reports/export/generated/4f3c2b1a-9d8e-4c7b-a6f5-0e1d2c3b4a59' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
The XLSX file itself, named from the export's exportName (or the report name), e.g.
Open Tasks January.xlsx. There is no JSON body.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 404 | Token unknown, already used, or swept as expired | { "error": true, "message": "File not found." } |
| 403 | No valid session, or the token belongs to a different user | { "error": true, "message": "Not allowed to download this export." } |
Notes
- The download requires a valid session and is served only to the user who generated the token; a shared
or forwarded link is of no use to anyone else. - The download is genuinely one-time: the token is invalidated before streaming begins, and the
temporary file is removed once the download completes. A second request for the same token returns
404. - Responses on this route are written directly to the HTTP response, so a 403 or 404 here is returned as
the JSON body shown above rather than through the standard error envelope.
Returns the option lists for the Custom tab's pre-built ("standard") report filters.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement; scoping is resolved server-side from the session, never from the payload |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
any | No | untyped, optional | Accepted but ignored — the firm is resolved from the session. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/standard/report/dropdown' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{}'
Success response — 200 OK
{
"error": false,
"userOptions": [{ "id": 88, "lf_name": "Doe, Jane" }],
"roleOptions": [{ "id": 3, "role": "Bookkeeper" }],
"companyGroupOptions": [{ "id": 2, "name": "Retail Clients" }],
"companyOptions": [{ "id": 501, "name": "Acme Ltd" }]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
userOptions[] |
array | Firm users. Only deleted_at IS NULL is applied; no deleted or active filtering. |
roleOptions[] |
array | Service roles in scope. |
companyGroupOptions[] |
array | Company groups in scope. |
companyOptions[] |
array | Companies in scope. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
- Firm scoping is three-way: a real firm id scopes to that firm; a Global Master System Administrator with
no firm of their own sees everything; a firm-less non-admin sees nothing (AND firm_id = 0).
Runs one of the four pre-built standard reports.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement; the firm is resolved from the session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
type |
string | Yes | String + One of monthly_budget, mc_section_status, billable_hours, fp_profitability |
Which standard report to run. |
filter |
object | No | Validated nested object | Report-specific filters — see below. |
isPivot |
boolean | No | untyped | Request the pivoted layout. |
firmId |
any | No | untyped | Accepted but not used; the firm is resolved from the session. |
Filter object — every field optional, grouped by the report that uses it:
| Field | Type | Validation | Used by | Description |
|---|---|---|---|---|
startMonth |
string | String | monthly_budget |
Whole-month picker, start. |
endMonth |
string | String | monthly_budget |
Whole-month picker, end. |
company |
any | untyped | shared | Company filter. |
role |
any | untyped | shared | Single role filter (independent of user). |
user |
any | untyped | shared | Single user filter (independent of role). |
companyGroup |
any | untyped | shared | Company group filter. |
asOnDate |
string | String | mc_section_status |
"As of" date, start-of-month; selects the mc_status snapshot. |
rolesToDisplay |
any | untyped | mc_section_status |
Role ids that each get their own assigned-user column — distinct from role. |
includeNonPostingClients |
any | untyped | shared | Include non-posting clients. |
includeInactiveClients |
any | untyped | shared | Include inactive clients. |
reportDate |
string | String | billable_hours |
Single "as of" date; the trailing 1/4/13-week and YTD windows are computed backwards from it. |
companyIdArray |
any | untyped | billable_hours |
Multi-select company ids. |
isExclude |
any | untyped | billable_hours |
When true, companyIdArray becomes a NOT IN filter instead of IN. |
startDate |
string | String | fp_profitability |
Date-range start (distinct from the whole-month startMonth). |
endDate |
string | String | fp_profitability |
Date-range end. |
globalRole |
any | untyped | fp_profitability |
Role half of the collapsible Role/User pair; fp_profitability requires both or neither. |
globalUser |
any | untyped | fp_profitability |
User half of that pair. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/standard/report/preview' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"type": "monthly_budget",
"filter": { "startMonth": "2026-01-01", "endMonth": "2026-03-01", "role": 3, "user": 88 },
"isPivot": false
}'
Success response — 200 OK
{
"error": false,
"data": [
{
"company": "Acme Ltd",
"project": "Bookkeeping",
"role": "Bookkeeper",
"price": 1500,
"task_user": "Doe, Jane",
"task_name": "Bank Reconciliation",
"task_actual_billable_hour": 12.5,
"task_estimate_hour": 10,
"task_over_under_hour": 2.5,
"task_over_under_amount": 250,
"task_over_under_percentage": 25,
"budget_hours": 40,
"actual_billable_hour": 38,
"over_under_hour": -2,
"budget": 6000,
"actual_amount": 5700,
"over_under_amount": -300,
"over_under_percentage": -5,
"haveExpandData": false
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
data[] |
array | Report rows. The example shows monthly_budget's row shape (the one the exporter enumerates). The row shapes for mc_section_status, billable_hours and fp_profitability are produced by their own engines and are not enumerated here. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | monthly_budget without both months |
{ "error": true, "msg": "Please select a Start and End month." } |
| 200 | A recognised type that is not wired up in this environment |
{ "error": true, "msg": "This standard report is not yet available in this environment." } |
| 400 | type missing or outside the allowed set |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
fp_profitabilityrequiresglobalRoleandglobalUserboth or neither;role/useron the other
reports are independent single filters.monthly_budgetmonths are parsed as UTC from the first 10 characters of the supplied value.
Generates an XLSX workbook for one of the standard reports and returns a one-time download link.
| Module | Tasks & Time > Reports (Reports Builder) |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement; the firm is resolved from the session and the download token is bound to the calling user |
| Content type | application/json (the workbook is fetched separately) |
Request body — the same fields as the standard report preview (type, filter, isPivot, firmId),
plus:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
exportName |
string | No | String | File name and the Report Name header cell. Defaults to Monthly Budget v Actual by Client in the header and report in the file name. Non-[\w-] characters become _ in the file name. |
company |
string | No | String | Display label written into the workbook's Client header cell (- when absent). |
role |
string | No | String | Display label for the Role header cell. |
user |
string | No | String | Display label for the User header cell. |
Example request
curl -X POST 'https://api.example.com/api/tat/reports/standard/report/export' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"type": "monthly_budget",
"filter": { "startMonth": "2026-01-01", "endMonth": "2026-03-01" },
"exportName": "Monthly Budget Q1",
"company": "Acme Ltd",
"role": "Bookkeeper",
"user": "Doe, Jane"
}'
Success response — 200 OK
{
"error": false,
"redirect": "/api/tat/reports/export/generated/7b6a5c4d-3e2f-41a0-9b8c-7d6e5f4a3b21"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
redirect |
string | Relative URL of the one-time download — GET /api/tat/reports/export/generated/:token. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | The underlying preview failed (e.g. missing months) | The preview's own envelope, e.g. { "error": true, "msg": "Please select a Start and End month." } |
| 200 | A recognised type that is not wired up in this environment |
{ "error": true, "msg": "This standard report is not yet available in this environment." } |
| 400 | type missing or outside the allowed set |
validation error message array |
| 401 | No/expired session | { "message": "These credentials do not match our records." } |
Notes
mc_section_status,billable_hoursandfp_profitabilityeach have their own dedicated export builder;
monthly_budgetis assembled inline.- The
monthly_budgetworkbook opens with Report Name / Start Month / End Month / Client / Role / User /
Generated On / Generated By rows, then a blank row, then the column header row. A leadingUsercolumn
is inserted only whenfilter.roleorfilter.userwas supplied. A row withhaveExpandDatarenders
its role as<role> (All). - The download reuses exactly the same token plumbing as
POST /api/tat/reports/make/export— see
GET /api/tat/reports/export/generated/:tokenfor the one-time, session-bound retrieval semantics.
11.8 Invoicing
The Invoicing module turns approved timesheet hours and fixed-price contract lines into invoices and credit memos, routes them through an approver chain, and posts the approved batches into QuickBooks Online (QBO). It is organised into five areas — Create Invoice, Configure, Post to GL, Timesheet Details and Invoicing Audit Log. Every route in this section is served under the /api prefix, so every path below is /api/<method path>.
Invoice lifecycle. Billable work accumulates in the un-invoiced queue (time_history rows with billable time, plus fp_uninvoice_queue rows for fixed-price recurrences). Selecting queue rows creates batch_invoice rows in Preview status with a child invoice row and invoice_details lines. Routing a preview for approval moves it to Invoice and seeds invoice_approvers plus approver to-dos. Each approver records a disposition; when every approver has approved, the batch becomes Approve (or Non Posting when the invoice carries no billable lines) and is assigned a batch_no (a YmdHis timestamp string). A rejection sets Rejected; Unapprove / Re Submit reset the batch to Invoice. Approved batches are posted to QuickBooks, becoming Posted on success or Post Failed (with error_msg) on failure; unposting sets Unposted. Editing an invoice that is already Approve / Posted / Post Failed / Unposted / Non Posting unposts it in QBO, clears every approver's disposition and returns it to Invoice for a fresh approval pass.
Status values persisted on batch_invoice.status: Preview, Invoice, Re Submit, Approve, Rejected, Non Posting, Posted, Post Failed, Unposted. UnInvoice and New are request-only pseudo-statuses accepted by POST /api/invoicing/batch/list — they are not stored. The Post to GL grid additionally derives a per-batch display status with the precedence Post Failed > Approved > Posted > Unposted > No Approver(s) > Approval Pending.
Type enums. batch_invoice.type: 1 = Invoice, 2 = Credit Memo. Line-item item_type / time_history.invoicing: 1 = Hourly, 2 = Fixed Price (returned to clients as {id, name} pairs). Line-item item_detail / time_history.billing: 1 = Billable, 2 = No Charge, 3 = Non-Billable.
Approval model. Approvers are resolved per batch in tiers: an explicit company-scoped Invoice Approver assignment (role id 13) wins outright; otherwise the firm's default approvers-by-service-role (invoice_default_approver) apply, narrowed to the service roles that actually appear on the invoice's lines; a service-line approver overlay (invoice_default_sl_approver / company-scoped equivalents, role id 15 Invoice Approver By Service Line) is merged on top, gated by the firm's use_service_line_approver flag with a per-company override. Role ids used by this module: InvoiceApprover = 13, InvoiceManager = 14, InvoiceApproverByServiceLine = 15, FirmInternalAccountingManager = 17, GlobalMasterSystemAdministrator = 1.
Authentication and permissions. Every endpoint reads the session cookie and resolves the caller, returning 401 with {"message":"These credentials do not match our records."} when session.login.userId is missing or session.login.state !== 'authenticated'. Role enforcement is applied per endpoint and is documented with each endpoint below. Global request validation strips body properties that are not part of the documented request schema and applies type coercion, so a 400 Bad Request is returned for any validation violation.
QuickBooks Online side effects. The endpoints that reach QBO are: POST /api/invoicing/post-to-gl/post (create or delete invoices/credit memos), POST /api/invoicing/bulk/action (Post, UnPost and — via the delete cascade — Delete), POST /api/invoicing/preview/delete and POST /api/invoicing/preview/update (the latter deletes the QBO invoice when an already-posted invoice is edited). All other endpoints only read cached QBO metadata (qbo_items, qbo_customer, qbo_terms, class/location) or write local rows.
11.8.1 Create Invoice
Returns every dropdown/reference list the Create Invoice and Ad Hoc Invoice screens need in one payload — companies, company groups, projects, service lines and roles, users, the firm's QBO customer/item/term/class/location lists, and the firm's invoice configuration.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Companies are scoped by role: GlobalMasterSystemAdministrator (1) sees every company in every firm, a firm user sees their firm's, a mapped user sees their user_company_mapping set. Inactive companies are kept. |
| Content type | application/json |
Example request
curl -X GET 'https://api.example.com/api/invoicing/fetch/dropdown' \
-H 'Accept: application/json' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"companies": [{ "id": 41, "name": "Northwind Manufacturing", "deleted": 0 }],
"companyGroups": [{ "id": 7, "group": "Midwest Group" }],
"customerList": [{ "id": 12, "qbo_id": "58", "name": "Northwind Manufacturing", "term": 3 }],
"firmAccountingMangers": [214],
"invoiceBudgetReport": "eyJpdiI6...",
"invoiceConfig": {
"invoiceStartDate": "2026-01-01",
"todoDueDate": 5,
"ignoreTimesheetDate": null,
"invoiceMessage": "Thank you for your business.",
"invoiceStatement": "Please remit within 30 days.",
"paymentTerms": null,
"qboInvoiceDate": 0,
"isInvoiceManager": false
},
"invoiceManagers": [188, 214],
"itemList": [{ "id": 3, "qbo_id": "45", "name": "Consulting", "description": "Consulting services", "price": 150 }],
"itemListFixed": [{ "id": 9, "qbo_id": "61", "name": "Monthly Retainer", "description": "", "price": 2500 }],
"projects": [{ "id": 88, "name": "FY26 Audit", "company_id": 41, "billing": 1, "active_status": 1, "service_line": 4, "invoicing": 1 }],
"qboCategoriesConfig": { "track_class": true, "assign_class": 1, "track_location": false },
"qboClass": [{ "id": 2, "qbo_id": "200000000001445721", "cls_name": "Advisory" }],
"qboLocation": [],
"serviceLines": [{ "id": 4, "name": "Tax", "is_active": 0 }],
"serviceRoles": [{ "id": 6, "role": "Senior Accountant", "price_per_hour": 150 }],
"termList": [{ "id": 1, "qbo_id": "3", "name": "Net 30" }],
"users": [{ "id": 188, "name": "Alvarez, Dana" }]
}
| Field | Type | Description |
|---|---|---|
companies |
array | Companies in the caller's scope (id, name, deleted). |
companyGroups |
array | Company groups available for filtering. |
customerList |
array | Cached QBO customers for the firm's internal company. |
firmAccountingMangers |
number[] | User ids holding FirmInternalAccountingManager (17) in this firm. |
invoiceBudgetReport |
string | Encrypted id of the firm's invoice budget report. |
invoiceConfig |
object | The firm's invoice_configure row. |
invoiceManagers |
number[] | User ids holding InvoiceManager (14). |
itemList / itemListFixed |
array | Cached QBO items for hourly / fixed-price selection. |
projects |
array | Projects with billing / service-line / invoicing flags. |
qboCategoriesConfig |
object | track_class, assign_class, track_location from the QBO company preferences. |
qboClass / qboLocation |
array | Cached QBO classes and locations (qbo_id kept as a string — ids can be 18 digits). |
serviceLines |
array | Non-deleted service lines for the firm (or global), with is_active. |
serviceRoles |
array | Non-deleted service roles with price_per_hour. |
termList |
array | Cached QBO payment terms. |
users |
array | Firm users as {id, name}. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session, or the user row is missing/deleted | {"message":"These credentials do not match our records."} |
Notes
- QBO ids (
qbo_id, class ids) are returned as strings throughout; QuickBooks class/customer/location ids can be 18-digit values that lose precision if coerced to JavaScript numbers. - Reads cached QBO metadata only — no live QuickBooks call.
The single list endpoint behind the Create Invoice screen. Depending on status it returns the un-invoiced queue, creates preview invoices from the queue, or lists existing batch_invoice rows.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Company scope is role-derived (inactive companies retained); any companies sent in the body are intersected with that scope. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
status |
string | Yes | String | UnInvoice returns the grouped un-invoiced queue; New creates preview invoices from the queue and then lists them as Preview; Invoice lists the whole post-preview status set (Invoice, Re Submit, Approve, Posted, Post Failed, Unposted, Rejected, Non Posting); any other value is matched literally against batch_invoice.status. Empty defaults to Preview. |
filter |
object | No | Optional — untyped | Free-form filter bag (see Notes for the keys actually read). |
page |
integer | No | Numeric (numeric strings are coerced), integer | Omit or 0 for an unpaginated result; any > 0 value returns a 30-row window. |
sortBy |
string | No | String | Column to sort by. |
orderBy |
string | No | String | Sort direction. |
companies |
object[] | No | Array | Company selections; only each element's id is read, then intersected with the caller's scope. |
batchInvoiceId |
string | No | String | Encrypted batch_invoice.id used to narrow the list to a single invoice. |
ignoreWarning |
integer | No | Numeric (numeric strings are coerced), integer | 1 bypasses the not-approved-timesheet warning on the UnInvoice path. Forced on for New. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/batch/list' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"status": "Preview",
"filter": { "invoiceByServiceLine": true, "date": "2026-08-31" },
"companies": [{ "id": 41 }]
}'
Success response — 201 Created
[
{
"invoice": {
"id": 9012,
"customer_qbo": "58",
"customer_email": "ap@northwind.example",
"customer_cc": "",
"customer_bcc": "",
"customer_address": "100 Mill Rd, Springfield",
"terms": "3",
"class_qbo": "200000000001445721",
"location_qbo": "",
"tax_rate": 0,
"message_invoice": "Thank you for your business.",
"message_statement": "",
"details": [
{
"id": 55011, "item_detail": 1, "item_type": 1, "item_description": "August advisory",
"item_name": "Consulting", "item_project": 88, "item_service_line": 4, "item_user": 188,
"item_qbo": "45", "quantity": 12.5, "rate": 150, "amount": 1875, "is_taxable": 0,
"from": "Timesheet", "invoice_date": null,
"project_name": "FY26 Audit", "service_line_name": "Tax", "user_name": "Alvarez, Dana"
}
]
},
"id": 4471,
"invoice_id": 9012,
"company_id": 41,
"company": { "id": 41, "name": "Northwind Manufacturing", "deleted": 0 },
"date": "2026-08-31",
"due_date": "2026-09-30",
"invoice_amount": 1875,
"tax_amount": 0,
"amount": 1875,
"invoice_no": "INV-2096",
"batch_no": null,
"company_group": { "id": 7, "group": "Midwest Group" },
"status": "Preview",
"error_msg": null,
"type": "Invoice",
"encId": "eyJpdiI6...",
"invoice_approvers": [],
"resolve_pending": 0
}
]
| Field | Type | Description |
|---|---|---|
invoice |
object | Present only when the batch has an invoice row; carries the header plus details line items. QBO reference ids are strings. |
id |
number | batch_invoice.id. |
invoice_id |
number | invoice.id (omitted when 0). |
company / company_group |
object | Resolved company and company-group labels. |
invoice_amount / tax_amount / amount |
number | Falls back to the computed line total when the stored value is 0. |
status |
string | Persisted batch_invoice.status. |
type |
string | Credit Memo when batch_invoice.type = 2, otherwise Invoice. |
encId |
string | Encrypted batch_invoice.id. |
invoice_approvers |
array | Approvers with their latest disposition. |
resolve_pending |
number | Count of approvers who have not yet dispositioned. |
The UnInvoice path returns a different, grouped shape (one row per company, optionally split by service line and/or project) with status: "UnInvoice", type: "Invoice", a details array of raw queue rows, and the resolved invoice header fields (customer_qbo, customer_email, customer_address, class_qbo, location_qbo, terms, message_invoice, message_statement).
When the not-approved-timesheet warning fires, the endpoint instead returns the warning payload:
{ "showWarning": true, "warningData": [{ "user_id": 188, "user": "Alvarez, Dana", "week": "2026-W34", "status": "Unsubmit" }] }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | status missing or not a string |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 201 | Preview creation found a queue line with no QBO product mapping | {"error":true,"message":"Some invoice(s) don't have Mapping with Products."} |
| 201 | One or more previews failed to create | {"error":true,"message":"Failed to create N invoice(s): <company>: <reason> \| ..."} |
Notes
filteris a free-form object. The keys read by this endpoint are:amount,approver,batchNo,company,companyGroup,companyGroupName,companyName,date,dueDate,excludeFilter,invoiceByFixedPrice,invoiceByProject,invoiceByServiceLine,invoiceDate,invoiceNo,invoiceStartDate,itemType,paymentTerms,project,serviceLine,startDate,status,throughDate,toDate.status: "New"is a write operation: it createsPreviewbatch invoices from the un-invoiced queue (in chunks of 50 companies) before listing them.status: "UnInvoice"is read-only.- The not-approved-timesheet warning is evaluated only when the request includes hourly item types and
ignoreWarningis not set; it is force-bypassed forstatus: "New". invoiceByServiceLine/invoiceByProjectchange the grouping of theUnInvoiceresult as well as the shape of previews created from it.
Lists the batch invoices awaiting approval that are attached to a given to-do, for the Home/To-do invoice modal.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; no company scope is applied — rows are reached only through the to-do's own invoice_todo_mapping rows. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
todoId |
integer | No | Numeric (numeric strings are coerced), integer | tasks.id of the to-do. Omitted or 0 returns []. |
offset |
integer | No | Numeric (numeric strings are coerced), integer | Accepted by the request schema; not read by this endpoint. |
limit |
integer | No | Numeric (numeric strings are coerced), integer | Accepted by the request schema; not read by this endpoint. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/todo/pending/approval-list' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "todoId": 30712 }'
Success response — 201 Created
[
{
"id": 4471,
"encId": "eyJpdiI6...",
"invoice_no": "INV-2096",
"company_id": 41,
"company_name": "Northwind Manufacturing",
"date": "2026-08-31",
"due_date": "2026-09-30",
"amount": 1875,
"status": "Invoice",
"invoice_approvers": [{ "user_id": 214, "name": "Brooks, Lee", "status": "Approve" }]
}
]
| Field | Type | Description |
|---|---|---|
id / encId |
number / string | batch_invoice.id and its encrypted form. |
date / due_date |
string | ISO YYYY-MM-DD, empty string when null. |
invoice_approvers |
array | Every non-deleted approver with the status of that user's latest invoice_disposition row ('' when none). Approve renders a green check, Reject a red cross. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- Resolution is via
invoice_todo_mapping.task_id → batch_invoice_id, not by scanninginvoice_approversfor the current user. - Rows are ordered by
batch_invoice.due_dateascending, theniddescending. - Archived approver users (
users.deleted = 1) are excluded frominvoice_approvers.
Converts explicitly selected un-invoiced queue rows into Preview batch invoices.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | InvoiceManager (14) required. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
object[] | Yes | Array | Un-invoiced queue rows, in the shape returned by POST /api/invoicing/batch/list with status: "UnInvoice" (each details element). |
filter |
object | No | Optional — untyped | Same filter bag as the batch list; invoiceByServiceLine / invoiceByProject / invoiceByFixedPrice control how the queue rows are split into invoices. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/preview/un-invoice-to-preview' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"data": [
{ "company_id": 41, "item_detail": 1, "item_type": 1, "item_qbo": 45,
"item_description": "August advisory", "quantity": 12.5, "rate": 150,
"amount": 1875, "is_taxable": 0, "time_history_ids": [77120, 77121] }
],
"filter": { "invoiceByServiceLine": true }
}'
Success response — 201 Created
{ "error": false, "message": "Preview Invoice(s) created successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Human-readable result. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data is not an array |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is not an InvoiceManager |
{"message":"You are not authorized to perform this invoice action."} |
| 201 | data normalised to an empty set |
{"error":true,"message":"No uninvoiced queue items selected."} |
Notes
- Creates
batch_invoice(statusPreview) +invoice+invoice_detailsrows and marks the consumedtime_historyrows as previewed / consumes the matchingfp_uninvoice_queuerows. - No QuickBooks call is made.
Removes lines from the un-invoiced queue without invoicing them: hourly lines have their billable time moved to non-billable, fixed-price lines are marked excluded.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | InvoiceManager (14) required. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
object[] | Yes | Array | Queue rows to remove. Elements are not individually validated; this endpoint reads from, item_type, time_history_id, invoice_price_list_id (or invoicePriceListId) and invoice_date (or invoiceDate). |
fromPreview |
boolean | No | Boolean | Accepted by the request schema; not read by this endpoint. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/queue/delete' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "data": [ { "time_history_id": 77120, "item_type": 1, "from": "Timesheet" } ] }'
Success response — 201 Created
{ "error": false, "message": "Queue line updated successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data is not an array |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is not an InvoiceManager |
{"message":"You are not authorized to perform this invoice action."} |
| 201 | data empty |
{"error":true,"message":"No queue line selected."} |
Notes
- A row whose
fromcontains "fixed" (case-insensitive) or whoseitem_typeis2inserts anfp_uninvoice_queuerow withis_excluded = 1, removing that occurrence from the queue. - Any other row with a
time_history_idhas itsnon_billable_timeincreased by itsbillable_time, andbillable_timeset to0— the hours stay in the timesheet but leave the billable queue.
Returns the users/weeks whose timesheets are not yet approved for the companies in scope — the blocking warning shown before previewing invoices.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Any companyIdArray sent in the body is intersected with the caller's role-derived company scope; an empty array falls back to the whole scope. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
filter |
object | No | Optional — untyped | Filter bag; the date window and week filters are read from it. |
pagination |
object | No | Optional — untyped | Accepted by the request schema; not read by this endpoint. |
companyIdArray |
integer[] | No | Array, numeric (numeric strings are coerced) | Companies to check; intersected with the caller's scope. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/queue/not-approved/timesheet/list' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "companyIdArray": [41, 42], "filter": { "date": "2026-08-31" } }'
Success response — 201 Created
{
"showWarning": true,
"warningData": [
{ "user_id": 188, "user": "Alvarez, Dana", "week": "2026-W34", "status": "Unsubmit" }
]
}
| Field | Type | Description |
|---|---|---|
showWarning |
boolean | true when warningData is non-empty. |
warningData[].user_id |
number | time_history.user_id. |
warningData[].user |
string | users.lf_name. |
warningData[].week |
string | YYYY-Www, the Monday-anchored work week. |
warningData[].status |
string | Latest timesheet_disposition_status.status for that user/week; defaults to Unsubmit. Weeks already Approve/Approved are excluded. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- Returns
{ "showWarning": false, "warningData": [] }when the caller has no companies in scope or thetime_historytable is absent. - The week string is computed from
time_history.created_at(falling back todate), matching the queue's own week derivation so the warning and the queue never disagree.
Resolves the description, QBO class and rate to apply to an invoice line when a product/item is selected.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the GL product mapping lookup is scoped to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
productId |
integer | No | Numeric (numeric strings are coerced), integer | Alternative field name for the QuickBooks item id, accepted for compatibility with earlier clients; used only when itemQbo is absent. |
itemQbo |
integer | No | Numeric (numeric strings are coerced), integer | QBO item id to look up. |
serviceRoleId |
integer | No | Numeric (numeric strings are coerced), integer | Service role to price from, when no GL mapping override supplies one. |
companyId |
integer | No | Numeric (numeric strings are coerced), integer | Company used for the service_role_users rate override. |
description |
string | No | String | Fallback description echoed back when no firm-level mapping exists. |
classId |
string | No | String | Fallback QBO class id (string — class ids can be 18 digits). |
price |
number | No | Numeric (numeric strings are coerced), number | Fallback rate echoed back when no rate can be resolved. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/product/config' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "itemQbo": 45, "companyId": 41, "serviceRoleId": 6, "description": "Consulting services", "classId": "200000000001445721", "price": 150 }'
Success response — 201 Created
{ "description": "Consulting services", "class": "200000000001445721", "rate": 165 }
| Field | Type | Description |
|---|---|---|
description |
string | Mapping's product_description, else the cached qbo_items description, else the posted description. |
class |
string | Mapping's class, else the cached item's class id, else the posted classId. Exact string — never numerically coerced. |
rate |
number | service_role_users.price_per_hour for the company+role (only when the override's user still exists), else service_roles.price_per_hour, else the posted price. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- The firm-level override lives in
invoicing_product_mapping(keyed byfirm_id+product); when no row exists, the endpoint echoes back the fields the client already sent rather than returning blanks. - Reads cached QBO item metadata only; no live QuickBooks call.
Deletes preview (or any) invoices with a full cascade: restores hours and fixed-price occurrences to the queue, removes approver to-dos, and voids the QuickBooks invoice when one exists.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | InvoiceManager (14) required. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
object[] | Yes | Array | Rows to delete. Elements are not individually validated; this endpoint reads batchInvoiceId and invoiceId. When invoiceId is missing, 0, or equal to batchInvoiceId, it is resolved from the batch. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/preview/delete' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "data": [ { "batchInvoiceId": 4471, "invoiceId": 9012 } ] }'
Success response — 201 Created
{ "error": false, "message": "Invoice(s)/Credit Memo(s) deleted successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data is not an array |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is not an InvoiceManager |
{"message":"You are not authorized to perform this invoice action."} |
| 201 | Nothing was deleted | {"error":true,"message":"Something went wrong."} |
Notes
- Reaches QuickBooks Online. For each row that carries a
qbo_invoice_id, the invoice (or credit memo, whentype = 2) is deleted or voided in the firm's QBO realm before the local rows are soft-deleted. - Cascade order: remove
invoice_todo_mappingrows (and any orphaned parent task) → write aDeletedaudit row with a readable snapshot → restore line items to the un-invoiced queue (invoice_time_history_mappingdeleted andtime_history.previewedreset; fixed-price lines re-queued intofp_uninvoice_queue) → delete in QBO → soft-deletebatch_invoice,invoiceandinvoice_details. - The audit snapshot is captured before the soft-delete, because it reads the rows being removed.
Saves an edited invoice: header fields, line items, dates and amounts. Editing an already-approved or posted invoice unposts it and returns it to the approval queue.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session — any authenticated user may save an invoice they can address by id. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
object | Yes | Nested objects validated | The invoice payload. |
data — invoice payload
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
integer | Yes | Numeric (numeric strings are coerced), integer | batch_invoice.id. |
date |
string | No | String | Invoice date. Rejected when present but blank. |
due_date |
string | No | String | Due date. Rejected when present but blank. |
invoice_amount |
number | No | Numeric (numeric strings are coerced) | Pre-tax total written to batch_invoice. |
tax_amount |
number | No | Numeric (numeric strings are coerced) | Tax total. |
amount |
number | No | Numeric (numeric strings are coerced) | Grand total. |
lineItems |
object[] | No | Array, each item validated | Full line-item set. Omitting the key leaves lines untouched; sending it rewrites the set line by line (see Notes). |
invoice |
object | No | Nested objects validated | Invoice header. |
notMoveQueueItems |
integer[] | No | Array, numeric (numeric strings are coerced) | invoice_details.id values the user chose not to return to the un-invoiced queue when removed. |
data.invoice — invoice header (every field optional; only fields actually present are persisted)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
integer | No | Numeric (numeric strings are coerced), integer | invoice.id. |
customer_qbo |
string | No | String | QBO customer id, kept as a string. Rejected when present but blank or "0". |
customer_email / customer_cc / customer_bcc / customer_address |
string | No | String | Customer contact fields. |
terms |
string | No | String | QBO payment-term id, kept as a string. Rejected when present but blank or "0". |
class_qbo / location_qbo |
string | No | String | QBO class / location ids, kept as strings. |
tax_rate |
number | No | Numeric (numeric strings are coerced), number | Tax rate applied to the invoice. |
message_invoice / message_statement |
string | No | String | Invoice and statement messages. |
data.lineItems[] — line item (all fields optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
integer | No | Numeric (numeric strings are coerced), integer | Existing invoice_details.id; absent/unknown means a new line. |
item_detail |
integer | No | Numeric (numeric strings are coerced), integer | 1 Billable, 2 No Charge, 3 Non-Billable. |
item_type |
integer | No | Numeric (numeric strings are coerced), integer | 1 Hourly, 2 Fixed Price. |
item_qbo |
integer | No | Numeric (numeric strings are coerced), integer | QBO item id, accepted as a number on this field. |
item_description |
string | No | String | Line description. |
item_project / item_service_line / item_user |
integer | No | Numeric (numeric strings are coerced), integer | Project / service line / user attribution. |
quantity / rate / amount |
number | No | Numeric (numeric strings are coerced) | Line quantity, unit rate and extended amount. |
is_taxable |
integer | No | Numeric (numeric strings are coerced), integer | 0 / 1. |
class_qbo |
string | No | String | Line-level QBO class id (string). |
location_qbo |
integer | No | Numeric (numeric strings are coerced), integer | Line-level QBO location id. |
from |
string | No | String | Provenance, e.g. Timesheet, Fixed Price. On an existing line, omitting it preserves the stored value. |
invoice_date |
string | No | String | Occurrence date for fixed-price lines. |
invoice_price_list_id |
integer | No | Numeric (numeric strings are coerced), integer | Source invoice_price_list.id for fixed-price lines. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/preview/update' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"data": {
"id": 4471,
"date": "2026-08-31",
"due_date": "2026-09-30",
"invoice_amount": 1875, "tax_amount": 0, "amount": 1875,
"invoice": { "id": 9012, "customer_qbo": "58", "terms": "3", "class_qbo": "200000000001445721", "tax_rate": 0 },
"lineItems": [
{ "id": 55011, "item_detail": 1, "item_type": 1, "item_qbo": 45,
"item_description": "August advisory", "quantity": 12.5, "rate": 150,
"amount": 1875, "is_taxable": 0 }
],
"notMoveQueueItems": []
}
}'
Success response — 201 Created
{ "error": false, "message": "Invoice updated successfully.", "data": null }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Result message. |
data |
null | Always null; the field is retained on the response shape for compatibility with earlier clients. Callers re-fetch the list themselves. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data.id missing or not an integer, or a nested field fails validation |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 201 | data.id is 0 or the batch_invoice table is absent |
{"error":true,"message":"Invoice preview not found."} |
| 201 | invoice.customer_qbo present but blank / "0" |
{"error":true,"message":"The Customer Field is required."} |
| 201 | invoice.terms present but blank / "0" |
{"error":true,"message":"The Terms Field is required."} |
| 201 | date present but blank |
{"error":true,"message":"The Invoice Date Field is required."} |
| 201 | due_date present but blank |
{"error":true,"message":"The Due Date Field is required."} |
Notes
- Reaches QuickBooks Online. When the invoice's current status is
Approve,Posted,Post Failed,UnpostedorNon Posting, the edit unposts it: aPostedinvoice is deleted or voided in QBO, every approver's disposition is cleared, and the batch is reset toInvoicefor a fresh approval pass. - Header validation runs before any mutation, so a rejected payload has no side effects.
- Line handling is per line: lines matched by
idare updated in place (preserving theirinvoice_time_history_mapping), unknown ids are inserted, and removed lines are returned to the un-invoiced queue unless listed innotMoveQueueItems. - Approver to-do due dates are recomputed after the save.
- Audit rows are written only for genuine changes — a header row when any header field changed, plus one row per changed/added/removed line; a true no-op edit writes nothing.
- Blank QBO reference ids are persisted as
NULL, never as the empty string (the underlying columns are integer).
Routes one or more preview invoices for approval: seeds approvers, generates approver to-dos and sets the batch status to Invoice.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | InvoiceManager (14) required. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
ids |
integer[] | Yes | Array, numeric (numeric strings are coerced) | batch_invoice.id values to route. Non-positive values are dropped. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/preview/route/approval' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "ids": [4471, 4472] }'
Success response — 201 Created
{ "error": false, "message": "Invoiced successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | ids is not an array |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is not an InvoiceManager |
{"message":"You are not authorized to perform this invoice action."} |
| 201 | ids resolves to an empty set |
{"error":true,"message":"No invoices selected for approval routing."} |
Notes
- Approvers are seeded only for batches that have none yet; routing a batch that already has approvers leaves them unchanged.
- Writes one
Updatedaudit row per invoice recording the status transition{status: <old>} → {status: "Invoice"}. - Sets
batch_invoice.status = 'Invoice'andinvoiced_by = <caller>.batch_nois not assigned here; it is assigned later, at approval time.
Records an approver's decision on an invoice (approve / reject / unapprove / re-submit) and recomputes the batch status.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The role recorded on the audit row is resolved from the acting user's approver role on that invoice (InvoiceApprover / InvoiceApproverByServiceLine), except Re Submit, which is always recorded as InvoiceManager (14). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
batchInvoiceId |
integer | Yes | Numeric (numeric strings are coerced), integer | batch_invoice.id. |
userId |
integer | Yes | Numeric (numeric strings are coerced), integer | The approver the disposition is recorded against (may differ from the acting user, who is stored as action_by). |
status |
string | Yes | One of Approve, Reject, Unapprove, Re Submit |
The disposition. |
reason |
string | No | String | Free-text reason; required by the UI for Reject / Unapprove / Re Submit, where it also becomes an invoice comment. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/preview/disposition' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "batchInvoiceId": 4471, "userId": 214, "status": "Approve" }'
Success response — 201 Created
{
"error": false,
"message": "Disposition updated successfully.",
"status": "Approve",
"batch_no": "20260901103012"
}
| Field | Type | Description |
|---|---|---|
status |
string | The new batch_invoice.status. |
batch_no |
string | null | The assigned posting batch number — only non-null when the new status is Approve. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | status outside the allowed set, or batchInvoiceId / userId not integers |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 201 | batchInvoiceId or userId is 0 |
{"error":true,"message":"Invalid disposition payload."} |
Notes
- Status transitions:
Reject→Rejected.Approve→Approveonce every approver has approved and the invoice has at least one billable line,Non Postingwhen every approver has approved but no billable lines remain, otherwiseInvoice.UnapproveandRe Submit→Invoice, and both clearis_dispositionfor all approvers on the batch. ApproveandRejectremove the disposing approver's to-do;Unapproveregenerates the approver to-dos.Reject,UnapproveandRe Submitopen (or reuse) a comment thread and store thereasonas an invoice comment.- Reaching
Approveassignsbatch_no(aYmdHistimestamp), which is what makes the batch visible to Post to GL. - Audit rows: one primary row with the action mapped to
Approved/Unapproved/Rejected/Resubmitted, plus a secondaryUpdatedrow documenting the invoice entering the batch (on firstbatch_noassignment) or leaving it (onReject/Unapprovewhen abatch_noexisted). - No QuickBooks call is made.
Adds a quick comment to an invoice and records a Commented audit entry.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The invoice's company must belong to the caller's firm. The audit role_id is resolved from the caller's role on that company. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
batchInvoiceId |
integer | Yes | Numeric (numeric strings are coerced), integer | batch_invoice.id. |
comment |
string | Yes | String | Comment text; rejected when it trims to empty. |
assignTo |
integer[] | No | Array, numeric (numeric strings are coerced) | Accepted by the request schema; not read by this endpoint. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/preview/post/comment' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "batchInvoiceId": 4471, "comment": "Confirmed rate with the client." }'
Success response — 201 Created
{ "error": false, "message": "Comment created successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | batchInvoiceId not an integer or comment not a string |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 401 | The invoice's company is outside the caller's firm | {"message":"These credentials do not match our records."} |
| 201 | batchInvoiceId is 0 |
{"error":true,"message":"Batch invoice id is required."} |
| 201 | The invoice has no resolvable company | {"error":true,"message":"Invoice not found."} |
| 201 | comment trims to empty |
{"error":true,"message":"Comment is required."} |
Notes
- The comment is written directly to the invoice comment table; it does not use the shared open-comment thread mechanism, which covers only the Monthly Close and Accounting modules.
- Writes an audit row with
action: "Commented"andoriginal_data: { "Comment": "<text>" }.
Two-way lookup between a Genie company and its mapped QuickBooks customer — used by the Ad Hoc Invoice screen to fill the counterpart field when either side is picked.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. QBO lookups are scoped to the caller's firm-internal company. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
integer | Yes | Numeric (numeric strings are coerced), integer | A company.id when field = "company", or a QBO customer id when field = "customer". |
field |
string | Yes | One of company, customer |
Which side of the pair is being supplied. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/fetch/custorcomp' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "id": 41, "field": "company" }'
Success response — 201 Created
{
"customer": { "company_id": 41, "customer": 58, "term": 3, "class": "200000000001445721", "location": 0 },
"customerList": { "id": 12, "qbo_id": "58", "name": "Northwind Manufacturing", "email": "ap@northwind.example" }
}
| Field | Type | Description |
|---|---|---|
customer |
object | null | Returned when field = "company" — the invoicing_customer_mapping row for that company. |
company |
object | null | Returned when field = "customer" — the GL customer mapping matched by QBO customer id within the caller's firm. |
customerList |
object | null | The cached qbo_customer row for the resolved customer id, or null when it cannot be resolved. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | field outside company / customer, or id not an integer |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- The response key differs by direction:
customer+customerListforfield: "company",company+customerListforfield: "customer". - Reads cached QBO customer data only; no live QuickBooks call.
Creates an ad-hoc invoice or credit memo directly, bypassing the un-invoiced queue. The new document is created in Invoice status with approvers and to-dos already seeded.
| Module | Tasks & Time > Invoicing > Create Invoice |
| Authentication | Required — authenticated session cookie |
| Authorization | InvoiceManager (14) required. companyId must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
type |
integer | Yes | Numeric (numeric strings are coerced), integer | batch_invoice.type — 1 = Invoice, 2 = Credit Memo. |
companyId |
integer | Yes | Numeric (numeric strings are coerced), integer | Billed company; must belong to the caller's firm. |
customerQbo |
string | No | String | QBO customer id, kept as a string (18-digit safe). Blank is stored as 0. |
customerAddress / customerEmail / customerCc / customerBcc |
string | No | String | Customer contact fields. |
terms |
string | No | String | QBO payment-term id as a string; blank stored as NULL. |
date |
string | Yes | String | Invoice date. |
dueDate |
string | No | String | Due date; defaults to date when omitted. |
classQbo |
string | No | String | QBO class id as a string; blank stored as NULL. |
locationQbo |
string | No | String | QBO location id as a string; blank stored as 0. |
messageInvoice |
string | No | String | Message on invoice; falls back to the firm's configured invoice message when omitted. |
messageStatement |
string | No | String | Message on statement; falls back to the firm's configured statement message. |
taxRate |
number | No | Numeric (numeric strings are coerced), number | Tax rate; defaults to 0. |
invoiceAmount |
number | Yes | Numeric (numeric strings are coerced), number | Pre-tax total. |
taxAmount |
number | Yes | Numeric (numeric strings are coerced), number | Tax total. |
amount |
number | Yes | Numeric (numeric strings are coerced), number | Grand total. |
lineItems |
object[] | Yes | Array, each item validated | Line items (see below). |
lineItems[] — line item
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
item_detail |
integer | Yes | Numeric (numeric strings are coerced), integer | 1 Billable, 2 No Charge, 3 Non-Billable. |
item_type |
integer | Yes | Numeric (numeric strings are coerced), integer | 1 Hourly, 2 Fixed Price. |
item_qbo |
integer | Yes | Numeric (numeric strings are coerced), integer | QBO item id. |
project_id |
integer | No | Numeric (numeric strings are coerced), integer | Project attribution; stored as 0 when omitted. |
service_line_id |
integer | No | Numeric (numeric strings are coerced), integer | Service-line attribution; stored as 0 when omitted. |
item_description |
string | Yes | String | Line description. |
quantity |
number | Yes | Numeric (numeric strings are coerced), number | Quantity. |
rate |
number | Yes | Numeric (numeric strings are coerced), number | Unit rate. |
is_taxable |
integer | Yes | Numeric (numeric strings are coerced), integer | 0 / 1. |
class_qbo |
string | No | String | Line-level QBO class id (string). |
user_id |
integer | No | Numeric (numeric strings are coerced), integer | User attribution; stored as 0 when omitted. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/adhocinvoice/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"type": 1,
"companyId": 41,
"customerQbo": "58",
"terms": "3",
"date": "2026-09-01",
"dueDate": "2026-10-01",
"classQbo": "200000000001445721",
"taxRate": 0,
"invoiceAmount": 500, "taxAmount": 0, "amount": 500,
"lineItems": [
{ "item_detail": 1, "item_type": 1, "item_qbo": 45,
"item_description": "Ad hoc advisory", "quantity": 2, "rate": 250, "is_taxable": 0 }
]
}'
Success response — 201 Created
{ "error": false, "message": "Invoice Created/Updated Successfully.", "id": 4489 }
| Field | Type | Description |
|---|---|---|
id |
number | The newly created batch_invoice.id. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Any required field missing or of the wrong type | Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 401 | companyId outside the caller's firm |
{"message":"These credentials do not match our records."} |
| 403 | Caller is not an InvoiceManager |
{"message":"You are not authorized to perform this invoice action."} |
| 201 | batch_invoice / invoice / invoice_details tables unavailable |
{"error":true,"message":"Invoicing tables are unavailable."} |
| 201 | batch_invoice insert failed |
{"error":true,"message":"Failed to create ad-hoc invoice."} |
| 201 | invoice insert failed |
{"error":true,"message":"Failed to create invoice payload."} |
Notes
- A credit memo is only ever raised from this endpoint (
type: 2); everything created from the un-invoiced queue is an invoice. - The invoice number is generated server-side; each line's
amountis recomputed asquantity × raterounded to 2 decimals rather than trusting a client-supplied total. - The company's company group is resolved and stored on the batch.
- Because the document is created directly in
Invoicestatus, approvers are seeded and approver to-dos generated immediately — there is no separate route-for-approval step. - Writes a
Createdaudit row with a readable snapshot (customer / address / terms / dates / line-item count). - No QuickBooks call is made at save time; the document reaches QBO only via Post to GL.
11.8.2 Configure
Loads an Invoicing Configure screen's data set. The from discriminator selects which screen: the default (managers, users, messages, dates), the Invoice Approver screen, or the Service-Line-based Approver screen.
| Module | Tasks & Time > Invoicing > Configure > General |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The body's firmId is ignored; the firm is resolved from the caller's own users.firm_id (falling back to 0 = the global configuration set), so a caller can never read another firm's configuration. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Required by the request schema but overridden server-side with the caller's own firm id. |
from |
string | No | String | invoiceApprover, slBasedInvoiceApprover, or omitted/other for the default configuration payload. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12, "from": "invoiceApprover" }'
Success response — 201 Created
Default payload (no from):
{
"invoiceManagers": [{ "id": 188, "name": "Alvarez, Dana" }],
"firmUsers": [{ "id": 214, "name": "Brooks, Lee" }],
"firmAllUsers": [{ "id": 188, "name": "Alvarez, Dana" }, { "id": 214, "name": "Brooks, Lee" }],
"serviceRoles": [{ "id": 6, "role": "Senior Accountant" }],
"serviceLines": [{ "id": 4, "name": "Tax" }],
"qboCategoriesConfig": { "track_class": true, "assign_class": 1, "track_location": false },
"invoiceDate": "2026-01-01",
"todoDueDate": 5,
"ignoreTimesheetDate": null,
"invoiceMessage": "Thank you for your business.",
"invoiceStatement": "Please remit within 30 days."
}
from: "invoiceApprover" returns { serviceRoles, defaultApprover } where defaultApprover is { service_role_id, user_id }[] from invoice_default_approver.
from: "slBasedInvoiceApprover" returns { serviceLines, serviceRoles, defaultSLApprover } where defaultSLApprover is { service_line_id, service_role_id, user_id }[] from invoice_default_sl_approver.
| Field | Type | Description |
|---|---|---|
invoiceManagers |
array | Firm users holding InvoiceManager (14). |
firmUsers |
array | Firm users not holding InvoiceManager. |
firmAllUsers |
array | All non-deleted firm users. |
serviceRoles |
array | Service roles with deleted IN (0, 2) for the firm or global. |
serviceLines |
array | Active (is_active = 0), non-deleted service lines for the firm or global. |
invoiceDate |
string | null | The firm's invoice start date. |
todoDueDate |
number | Relative day offset for approver to-do due dates. |
ignoreTimesheetDate |
string | null | Cut-off date below which the not-approved-timesheet warning is suppressed. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId missing or not an integer |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- A firm-less Global Master System Administrator resolves to firm
0and therefore edits the global configuration row set — there is no firm picker on these screens. - Returns
{ invoiceManagers: [], firmUsers: [], firmAllUsers: [], serviceRoles: [], serviceLines: [] }when theuserstable is unavailable.
Sets the firm's Invoice Managers (role 14) by assigning and de-assigning the role.
| Module | Tasks & Time > Invoicing > Configure > General |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required, and firmId must equal the caller's own resolved firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
managers |
object[] | Yes | Array | Selected managers. Elements are not individually validated; this endpoint reads id (falling back to user_id). |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-invoice-manager' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12, "managers": [{ "id": 188 }, { "id": 214 }] }'
Success response — 201 Created
{ "error": false, "message": "Invoice Managers Created/Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId or managers missing/mistyped |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin, or firmId ≠ the caller's firm |
{"message":"You do not have permission to update invoice managers."} |
Notes
- Performs a keyed upsert with revive: users no longer selected have their
assign_user_rolesrow soft-deleted; selected users have an existing row revived (deleted_at = NULL) or a new row inserted withcompany_id = NULLandrole_from = NULL. - Only users belonging to the caller's firm are considered for de-assignment.
Sets the firm's default invoice approvers per service role, then re-resolves approvers on every un-approved invoice in the firm.
| Module | Tasks & Time > Invoicing > Configure > Approvers |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required, and firmId must equal the caller's own resolved firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
defaultApprover |
object[] | Yes | Array, each item validated | Approver assignments. |
defaultApprover[].service_role_id |
integer | Yes | Numeric (numeric strings are coerced), integer | Service role the approver covers. |
defaultApprover[].user_id |
integer | Yes | Numeric (numeric strings are coerced), integer | Approving user. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-invoice-approvers' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12, "defaultApprover": [ { "service_role_id": 6, "user_id": 214 } ] }'
Success response — 201 Created
{ "error": false, "message": "Invoice Approver Created/Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing/mistyped firmId or a malformed defaultApprover row |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin, or firmId ≠ the caller's firm |
{"message":"You do not have permission to update this invoicing configuration."} |
Notes
- After persisting, the change is propagated synchronously to existing un-approved invoices, wrapped in a
queue_manageraudit row that is marked complete (1) on success or failed (2) on error; an error is re-thrown after the audit row is closed.
Sets the firm's default approvers per service line + service role, then re-resolves approvers on existing un-approved invoices.
| Module | Tasks & Time > Invoicing > Configure > Approvers |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required, and firmId must equal the caller's own resolved firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
defaultSLApprover |
object[] | Yes | Array, each item validated | Service-line approver assignments. |
defaultSLApprover[].service_line_id |
integer | Yes | Numeric (numeric strings are coerced), integer | Service line covered. |
defaultSLApprover[].service_role_id |
integer | Yes | Numeric (numeric strings are coerced), integer | Service role covered. |
defaultSLApprover[].user_id |
integer | Yes | Numeric (numeric strings are coerced), integer | Approving user. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-sl-approvers' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12, "defaultSLApprover": [ { "service_line_id": 4, "service_role_id": 6, "user_id": 214 } ] }'
Success response — 201 Created
{ "error": false, "message": "Invoice ServiceLineBased Approver Created/Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing/mistyped firmId or a malformed defaultSLApprover row |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin, or firmId ≠ the caller's firm |
{"message":"You do not have permission to update this invoicing configuration."} |
Notes
- Service-line approvers are only applied to an invoice when the firm's
use_service_line_approverflag is on (per-company override:cs_use_service_line_invoice_approver.invoice_approver). - Same synchronous re-resolve +
queue_manageraudit lifecycle assave-invoice-approvers.
One endpoint serving three mutually exclusive date settings: the invoice start date, the approver to-do due-date offset, and the ignore-not-approved-timesheet cut-off.
| Module | Tasks & Time > Invoicing > Configure > General |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required. The request body carries no firmId, so the firm is always the caller's own. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
date |
string | Yes | String | The value being saved. A date string for the start-date and ignore-timesheet modes; a numeric string day offset when isTodoDate is true. |
isTodoDate |
boolean | No | Boolean | true saves date into invoice_configure.todo_due_date as an integer offset. |
ignoreTimesheetDate |
boolean | No | Boolean | Present (either value) selects the ignore-timesheet cut-off mode, writing invoice_configure.ignore_timesheet_date. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-date' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "date": "2026-01-01" }'
Success response — 201 Created
{ "error": false, "message": "Invoice Date Updated Successfully." }
| Condition | message |
|---|---|
ignoreTimesheetDate present |
Ignore Not Approved Timesheet Date Updated Successfully. |
isTodoDate present (and ignoreTimesheetDate absent) |
Todo Due Date Updated Successfully. |
| Neither present | Invoice Date Updated Successfully. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | date missing or not a string |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin | {"message":"You do not have permission to update this invoicing configuration."} |
Notes
- The three modes are strictly exclusive — exactly one column is written per call, so saving the ignore-timesheet cut-off can never overwrite the invoice start date.
todo_due_dateis coerced with truncation toward zero, falling back to0for a non-numeric value.- The row is created on first save (
invoice_configureinsert keyed onfirm_id), updated thereafter. - Saving the invoice date does not push a live notification update; the notification badge refreshes on the user's next poll.
Saves the firm's default "Message on Invoice" and "Message on Statement" text.
| Module | Tasks & Time > Invoicing > Configure > General |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required. No firmId is accepted in the request body — the firm is always the caller's own. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
message |
string | No | String | Default message on invoice. Only written when the key is present. |
statement |
string | No | String | Default message on statement. Only written when the key is present. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-message' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "message": "Thank you for your business.", "statement": "Please remit within 30 days." }'
Success response — 201 Created
{ "error": false, "message": "Invoice Message/Statement Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | message or statement present but not a string |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin | {"message":"You do not have permission to update this invoicing configuration."} |
Notes
- Omitting a field leaves the stored value untouched; sending an empty string clears it.
- These values are the fallback used when an ad-hoc invoice is saved without explicit messages.
Loads the mapping data for one of three Configure screens, selected by from: GL Product/Service mapping, Labor Items, or Service-Line Class Mapping.
| Module | Tasks & Time > Invoicing > Configure > Services / Labor / Service-Line Class Mapping |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The body's firmId is ignored; the caller's own users.firm_id is used (falling back to 0). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Required by the request schema but overridden server-side with the caller's firm. |
from |
string | Yes | String | glProductMapping, laborItem or slClassMapping. Any other value returns {}. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/gl-product' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12, "from": "glProductMapping" }'
Success response — 201 Created
{
"serviceRoles": [
{ "id": 6, "role": "Senior Accountant", "product": "45", "product_description": "Consulting services", "class": "200000000001445721", "location": 0 }
],
"qboProducts": [
{ "id": 3, "qbo_id": "45", "name": "Consulting", "description": "Consulting services", "price": 150, "class_id": "200000000001445721", "type": "Service",
"qboClass": { "id": 2, "qbo_id": "200000000001445721", "cls_name": "Advisory" } }
],
"qboClass": [{ "id": 2, "qbo_id": "200000000001445721", "cls_name": "Advisory" }],
"qboLocation": []
}
from |
Response shape |
|---|---|
glProductMapping |
{ serviceRoles, qboProducts, qboClass, qboLocation } — each qboProducts entry is enriched with its resolved qboClass object (or null). |
laborItem |
{ qboProducts } — each entry enriched with qboClass plus laborData ({ id, item_id, company_id, labor }, {} when unmapped). |
slClassMapping |
{ slClassMapping, qboClass } — slClassMapping is { service_line_id, name, class_qbo }[], class_qbo defaulting to "0". |
| anything else | {} |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId or from missing/mistyped |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- When the firm has
use_service_roledisabled, service roles fall back to the global set (firm_id = 0); whenuse_service_lineis disabled, service lines fall back to the global set (firm_id IS NULL). - QBO item and class ids are returned as exact strings.
- Reads cached QBO metadata only; no live QuickBooks call.
Loads the GL Customer mapping grid — each of the firm's companies with its mapped QBO customer, payment term, class and location, plus the dropdown option lists.
| Module | Tasks & Time > Invoicing > Configure > Customers |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The body's firmId is ignored; the caller's own users.firm_id is used. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Required by the request schema but overridden server-side. |
filter |
object[] | No | Array, each item validated | "Filter by Company" selections. |
filter[].id |
integer | No | Numeric (numeric strings are coerced), integer | company.id to keep in the grid. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/gl-customer' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12, "filter": [ { "id": 41 } ] }'
Success response — 201 Created
{
"data": [
{ "id": 41, "name": "Northwind Manufacturing", "override_price": 0,
"customer": 58, "term": 3, "class": "200000000001445721", "location": 0 }
],
"dropdownOptions": {
"companyOptions": [{ "id": 41, "name": "Northwind Manufacturing" }, { "id": 42, "name": "Contoso Ltd" }],
"customerOption": [{ "id": 12, "qbo_id": "58", "name": "Northwind Manufacturing", "term": 3 }],
"termsOptions": [{ "id": 1, "qbo_id": "3", "name": "Net 30" }],
"classOptions": [{ "id": 0, "name": "--Select--", "cls_name": "--Select--", "qbo_id": "0" }],
"locationOptions": [{ "id": 0, "name": "--Select--", "cls_name": "--Select--", "qbo_id": "0" }]
}
}
| Field | Type | Description |
|---|---|---|
data |
array | The grid rows — narrowed by filter. class is an exact string; customer, term, location are numeric ids (0 when unmapped). |
dropdownOptions.companyOptions |
array | Always the full company list, regardless of the applied filter, so more companies can be added to the filter. |
dropdownOptions.customerOption / termsOptions |
array | Cached QBO customers and payment terms for the firm's internal company. |
dropdownOptions.classOptions / locationOptions |
array | Cached QBO classes/locations, each prefixed with a --Select-- sentinel (id: 0, qbo_id: "0"). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId missing/mistyped, or a malformed filter element |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- Returns empty
dataand empty option lists when thecompanytable is unavailable. - Reads cached QBO metadata only; no live QuickBooks call.
Saves the firm's GL Product/Service mapping — which QBO item, description, class and location each service role bills to.
| Module | Tasks & Time > Invoicing > Configure > Services |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required, and firmId must equal the caller's own resolved firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
data |
object[] | Yes | Array | Mapping rows. Elements are not individually validated; this endpoint reads id (falling back to service_role_id, then role_id), product, product_description, class and location. Rows with no resolvable service role id are skipped. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-gl-product' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"firmId": 12,
"data": [ { "id": 6, "product": "45", "product_description": "Consulting services", "class": "200000000001445721", "location": 0 } ]
}'
Success response — 201 Created
{ "error": false, "message": "Invoice GL Product/Service Mapping Created/Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId or data missing/mistyped |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin, or firmId ≠ the caller's firm |
{"message":"You do not have permission to update this invoicing configuration."} |
Notes
- Upsert keyed on
(firm_id, service_role_id)ininvoicing_product_mapping; an existing soft-deleted row is revived (deleted_at = NULL). productandclassare persisted as exact strings — QBO item and class ids can exceed JavaScript's safe integer range, and coercing them produces ids QuickBooks rejects with "Invalid Reference Id".
Flags which QBO items count as labor for the firm's internal company.
| Module | Tasks & Time > Invoicing > Configure > Labor |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required, and firmId must equal the caller's own resolved firm. Rows are additionally filtered to companies belonging to that firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
data |
object[] | Yes | Array | Labor-item rows. Elements are not individually validated; this endpoint reads company_id (used for the firm-scope check), qbo_id (falling back to item_id), and the labor flag from laborData.labor (falling back to labor). Rows with no item_id or no company_id are skipped. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-labor-items' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12, "data": [ { "company_id": 41, "qbo_id": 45, "laborData": { "labor": 1 } } ] }'
Success response — 201 Created
{ "error": false, "message": "Labor Items Created/Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId or data missing/mistyped |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin, or firmId ≠ the caller's firm |
{"message":"You do not have permission to update this invoicing configuration."} |
Notes
- Rows whose
company_idis not one of the caller's firm's companies are silently dropped before persisting —labor_itemshas no firm column, so this check is what prevents cross-firm writes. - Upsert keyed on
(item_id, company_id);laboris normalised to0/1.
Saves the GL Customer mapping — which QBO customer, payment term, class and location each company invoices to.
| Module | Tasks & Time > Invoicing > Configure > Customers |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required. The request body carries no firmId, so the firm is always the caller's own; rows are additionally filtered to that firm's companies. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
object[] | Yes | Array | Mapping rows. Elements are not individually validated; this endpoint reads id (falling back to company_id), customer, term, class and location. Rows with no resolvable company id are skipped. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-gl-customer' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "data": [ { "id": 41, "customer": 58, "term": 3, "class": "200000000001445721", "location": 0 } ] }'
Success response — 201 Created
{ "error": false, "message": "Invoice GL Customer Mapping Created/Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data is not an array |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin | {"message":"You do not have permission to update this invoicing configuration."} |
Notes
- Rows whose company lies outside the caller's firm are silently dropped —
invoicing_customer_mappingkeys purely oncompany_idand has no firm column. - Upsert keyed on
company_id;classis stored as an exact string,customer/term/locationas integers.
Saves the Service-Line → QBO Class mapping for the firm.
| Module | Tasks & Time > Invoicing > Configure > Service-Line Class Mapping |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required, and firmId must equal the caller's own resolved firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
mappingData |
object[] | Yes | Array | Mapping rows. Elements are not individually validated; this endpoint reads service_line_id (falling back to id) and class_qbo. Rows with no service line id are skipped. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/save-sl-class' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12, "mappingData": [ { "service_line_id": 4, "class_qbo": "200000000001445721" } ] }'
Success response — 201 Created
{ "error": false, "message": "Invoice ServiceLine Mapping Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId or mappingData missing/mistyped |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin, or firmId ≠ the caller's firm |
{"message":"You do not have permission to update this invoicing configuration."} |
Notes
- Upsert keyed on
(service_line_id, firm_id)in the resolved service-line-class mapping table;class_qbois stored as an exact string.
Generates the GL Customer Mapping Excel template (a blank mapping sheet plus an "Available Data" reference sheet) and returns a one-shot download link.
| Module | Tasks & Time > Invoicing > Configure > Customers |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required, and firmId must equal the caller's own resolved firm. |
| Content type | application/json (the link it returns serves .xlsx) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/export/file' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12 }'
Success response — 201 Created
{ "redirect": "/api/task/invoicing/config/export/file/generated/3f7c1a90-6a2b-4d55-9c0e-2f1b8a44e211" }
| Field | Type | Description |
|---|---|---|
redirect |
string | Relative URL of the generated file; the token is single-use. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId missing or not an integer |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin, or firmId ≠ the caller's firm |
{"message":"You do not have permission to update this invoicing configuration."} |
Notes
- Workbook contains two sheets: GL Customer Mapping (header row only:
Company,GL Customer Name,Class,Location) and Available Data (four parallel value columns of valid names). - Payment Terms is absent from the template; it is derived from the chosen customer on import.
- The generated workbook is held as temporary server-side storage and referenced by a single-use token. The download request must be served by the same application instance that produced the export, so multi-instance deployments must preserve instance affinity for these routes; see Deployment & Operational Considerations. The file is deleted after the first successful download.
Imports a completed GL Customer Mapping workbook. Valid rows are saved even when other rows fail.
| Module | Tasks & Time > Invoicing > Configure > Customers |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin required, and firmId must equal the caller's own resolved firm. |
| Content type | multipart/form-data (the upload is sent in the file part) |
Request body — multipart form
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
file |
file | Yes | Extension must be .xlsx or .xlx |
The completed workbook. |
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/import/file' \
-b 'genie.sid=<session-cookie>' \
-F 'firmId=12' \
-F 'file=@GL Customer Mapping Template.xlsx'
Success response — 201 Created
{
"error": false,
"message": "GL mapping has been imported Successfully",
"headArray": ["Company", "GL Customer Name", "Class", "Location", "Reason"],
"errorArray": [],
"downloadLink": "#"
}
Partial failure:
{
"error": true,
"message": "There are some failure to import GL mapping data",
"headArray": ["Company", "GL Customer Name", "Class", "Location", "Reason"],
"errorArray": [["Acme Widgets", "Unknown Customer", "", "", "Invalid Values for customer"]],
"downloadLink": "/api/task/invoicing/config/export/file/generated/9a3e..."
}
| Field | Type | Description |
|---|---|---|
headArray |
string[] | Column headers for the on-screen error grid. |
errorArray |
array[] | One row per hard-failed import row, with a trailing reason cell. |
downloadLink |
string | Link to a regenerated workbook pre-filled with the failed rows, or "#" when everything imported. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId missing or not an integer |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is neither global nor firm admin, or firmId ≠ the caller's firm |
{"message":"You do not have permission to update this invoicing configuration."} |
| 201 | No file uploaded | {"error":true,"message":"Please upload an Excel file."} |
| 201 | Extension not .xlsx / .xlx |
{"error":true,"message":"Please provide Excel sheet format supported file."} |
| 201 | Sheet empty or header-only | {"error":true,"message":"Empty Excel Sheet.Please put on some data to import."} |
| 201 | Header row does not match | {"error":true,"message":"GL Customer Mapping import format is wrong."} |
Notes
- Company and GL Customer Name are hard validations — an unrecognised value fails the row. Class and Location are soft: an unknown name is flagged in the downloadable error workbook but the row still imports with that field unset.
- Payment Terms is derived from the matched customer's QBO term, never read from the sheet.
- A trailing
Reasoncolumn (present on a re-downloaded error sheet) is stripped before header validation, so an error workbook can be corrected and re-uploaded directly.
Streams a previously generated Excel template (GL Customer Mapping or Fixed Price Invoicing) and deletes it.
| Module | Tasks & Time > Invoicing > Configure > Customers |
| Authentication | Not required — possession of the single-use token is the only credential |
| Authorization | No role requirement |
| Content type | Binary download (.xlsx attachment) |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The UUID returned in the redirect of an export endpoint. |
Example request
curl -X GET 'https://api.example.com/api/task/invoicing/config/export/file/generated/3f7c1a90-6a2b-4d55-9c0e-2f1b8a44e211' \
-o 'GL Customer Mapping Template.xlsx'
Success response — 200 OK
Binary .xlsx body with a Content-Disposition attachment filename of either GL Customer Mapping Template.xlsx or Fixed Price Invoicing Template.xlsx, depending on which export created it.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 404 | Token unknown or already consumed | {"error":true,"message":"File not found."} |
Notes
- The token is invalidated before streaming begins, so each link works exactly once; the temporary file is removed once the download completes.
- Tokens are held in temporary server-side storage: they do not survive an application restart, and the download must be served by the same application instance that produced the export. Multi-instance deployments must preserve instance affinity for these routes; see Deployment & Operational Considerations.
Loads the fixed-price item list for a project, together with the QBO product list needed to edit it.
| Module | Tasks & Time > Invoicing > Configure > Fixed Price |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The decrypted clientId company must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
clientId |
string | Yes | String | AES-encrypted company.id (a plain numeric string is also accepted); decrypted server-side. |
projectId |
string | Yes | String | AES-encrypted project.id, same handling. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/fixed-price' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "clientId": "eyJpdiI6...", "projectId": "eyJpdiI6..." }'
Success response — 201 Created
{
"company": { "id": 5, "name": "Expert Solutions LLP", "qbo_connection": 1, "encId": "eyJpdiI6..." },
"project": { "id": 88, "name": "FY26 Audit", "company_id": 41 },
"projectCompany": { "id": 41, "name": "Northwind Manufacturing" },
"qboInvoiceData": [
{ "id": 9, "qbo_id": "61", "name": "Monthly Retainer", "description": "Retainer", "price": 2500 }
],
"invoiceList": [
{ "id": 301, "client_id": 41, "project_id": 88, "item_qbo": "61", "item_name": "Monthly Retainer",
"description": "Retainer", "amount": 2500, "quantity": 1,
"start_date": "2026-01-01", "end_date": "2026-12-31",
"frequency": "monthly", "comments": "", "order": 0, "is_manual_desc": 0 }
]
}
| Field | Type | Description |
|---|---|---|
company |
object | null | The firm-internal company (the QBO-connected one), with qbo_connection and its encrypted id — used for the "reconnect QuickBooks" banner. Not the project's client. |
project |
object | null | The project, looked up by id alone. |
projectCompany |
object | null | The project's actual client company — what the UI labels "Company". |
qboInvoiceData |
array | Cached QBO products, excluding type = "group" items; qbo_id is an exact string. |
invoiceList |
array | The project's invoice_price_list rows. description is refreshed from the current QBO item description unless is_manual_desc = 1. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | clientId or projectId missing / not strings |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 401 | The decrypted client company is outside the caller's firm | {"message":"These credentials do not match our records."} |
Notes
- Returns
{ company: null, project: null, projectCompany: null, qboInvoiceData: [], invoiceList: [] }wheninvoice_price_listis unavailable. - Reads cached QBO product data only; no live QuickBooks call.
Inserts or updates the fixed-price item lines for a project.
| Module | Tasks & Time > Invoicing > Configure > Fixed Price |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The decrypted clientId company must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
clientId |
string | Yes | String | AES-encrypted (or plain) company.id. |
projectId |
string | Yes | String | AES-encrypted (or plain) project.id. |
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Written to invoice_price_list.firm_id when that column exists. |
invoicePriceList |
object[] | Yes | Array | Line rows. Elements are not individually validated; this endpoint reads id (0/absent = insert), item_qbo, item_name, description, amount, quantity, start_date, end_date, frequency, comments, order, is_manual_desc. |
Example request
curl -X POST 'https://api.example.com/api/task/invoicing/config/fixed-price/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"clientId": "eyJpdiI6...", "projectId": "eyJpdiI6...", "firmId": 12,
"invoicePriceList": [
{ "id": 0, "item_qbo": 61, "item_name": "Monthly Retainer", "description": "Retainer",
"amount": 2500, "quantity": 1, "start_date": "2026-01-01", "end_date": "2026-12-31",
"frequency": "monthly", "comments": "", "order": 0, "is_manual_desc": 0 }
]
}'
Success response — 201 Created
{
"error": false,
"message": "Fixed price items saved successfully.",
"invoice_price_list": [{ "id": 301, "order": 0 }]
}
| Field | Type | Description |
|---|---|---|
invoice_price_list |
array | The project's saved rows as {id, order}, so the client can patch newly assigned ids in place by order without a full reload. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Any required field missing or mistyped | Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 401 | The decrypted client company is outside the caller's firm | {"message":"These credentials do not match our records."} |
| 201 | invoice_price_list table unavailable |
{"error":true,"message":"Fixed price table is unavailable."} |
Notes
- The extended total column, where present, is written as
amount × quantity. - Rows with
id > 0are updated; everything else is inserted. - This configures the recurrence source; the actual invoice lines are produced later by the un-invoiced queue.
Soft-deletes a single fixed-price item line.
| Module | Tasks & Time > Invoicing > Configure > Fixed Price |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The row's client_id company must belong to the caller's firm; this is checked before the row is deleted. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | invoice_price_list.id, either plain numeric or AES-encrypted. |
Example request
curl -X DELETE 'https://api.example.com/api/task/invoicing/config/fixed-price/301' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Fixed price item deleted successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 401 | The row's client company is outside the caller's firm | {"message":"These credentials do not match our records."} |
| 200 | id does not decode to a positive integer |
{"error":true,"message":"Invalid fixed price id."} |
| 200 | The row does not exist or has no client_id |
{"error":true,"message":"Fixed price item not found."} |
Notes
- Soft delete only (
deleted_atis set); historical invoices referencing the price-list row are unaffected. - The firm-ownership check applies to every request: a row whose client company lies outside the caller's firm cannot be deleted by supplying its id.
Generates the Fixed Price Invoicing Excel template for the firm — a blank entry sheet plus "Existing Data" and "Available Data" reference sheets — and returns a one-shot download link.
| Module | Tasks & Time > Invoicing > Configure > Fixed Price |
| Authentication | Required — authenticated session cookie |
| Authorization | Firm-ownership check only — firmId must equal the caller's own resolved firm. No admin requirement, because this route is reached from the Project Fixed Price Items page, which is open to any Tasks & Time user. |
| Content type | application/json (the link it returns serves .xlsx) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
Example request
curl -X POST 'https://api.example.com/api/task/project/fixed/download/file' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "firmId": 12 }'
Success response — 201 Created
{ "redirect": "/api/task/invoicing/config/export/file/generated/8b1d4c22-0f6e-4a71-b3d2-77c9e51a6f04" }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId missing or not an integer |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | firmId ≠ the caller's own firm |
{"message":"Invalid firm context."} |
Notes
- Template header (sheet Fixed Price Invoicing):
Company Name,Project,Item Name,Item Description,Price (EA),Quantity,Recurrence Start date (mm/dd/yyyy),Recurrence type,Repeat every,Repeat until,Weekend rule,Rules,Comments. - The Existing Data sheet appends an extra
Internel Id (Do not edit/delete)column; the blank entry sheet does not carry it. - Weekend rule values map to the labels
Leave As Is,Prior Business day,Next Business day,Skip day. - The download is served by
GET /api/task/invoicing/config/export/file/generated/:token.
Imports a completed Fixed Price Invoicing workbook, inserting new price-list rows and updating existing ones by their internal id.
| Module | Tasks & Time > Invoicing > Configure > Fixed Price |
| Authentication | Required — authenticated session cookie |
| Authorization | Firm-ownership check only — firmId must equal the caller's own resolved firm. No admin requirement (same reasoning as the download route). |
| Content type | multipart/form-data (the upload is sent in the file part) |
Request body — multipart form
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
file |
file | Yes | Extension must be .xlsx, .xls or .xlx |
The completed workbook. |
firmId |
integer | Yes | Numeric (numeric strings are coerced), integer | Must match the caller's own firm id. |
Example request
curl -X POST 'https://api.example.com/api/task/project/fixed/import/file' \
-b 'genie.sid=<session-cookie>' \
-F 'firmId=12' \
-F 'file=@Fixed Price Invoicing Template.xlsx'
Success response — 201 Created
{ "error": false, "message": "Fixed price template imported successfully (14 row(s))." }
Partial failure:
{
"error": true,
"message": "The following values are failed to import due to missing fields",
"header": ["Company Name", "Project", "Item Name", "Item Description", "Price (EA)", "Quantity",
"Recurrence Start date (mm/dd/yyyy)", "Recurrence type", "Repeat every", "Repeat until",
"Weekend rule", "Rules", "Comments", "Reason"],
"errorData": [["Acme Widgets", "Unknown Project", "Retainer", "", "2500", "1", "", "", "", "", "", "", "", "Project not found"]],
"downloadLink": "/api/task/invoicing/config/export/file/generated/1c9f..."
}
| Field | Type | Description |
|---|---|---|
header |
string[] | Column headers for the error grid; Internel Id (Do not edit/delete) is included only for an update-mode import, and Reason is always last. |
errorData |
array[] | The failed rows with a trailing reason cell. |
downloadLink |
string | Regenerated three-sheet workbook with the failed rows pre-filled for correction and re-upload. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId missing or not an integer |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | firmId ≠ the caller's own firm |
{"message":"Invalid firm context."} |
| 201 | No file uploaded | {"error":true,"message":"Please upload an Excel file."} |
| 201 | Extension not .xlsx / .xls / .xlx |
{"error":true,"message":"Please provide Excel sheet format supported file."} |
| 201 | Sheet empty or header-only | {"error":true,"message":"Empty Excel Sheet.Please put on some data to import."} |
| 201 | Header row has missing or extra columns | {"error":true,"message":"Fixed Price Invoicing import Format is wrong."} |
Notes
- Company and project names are resolved against the caller's firm only.
- A row carrying an
Internel Idvalue updates thatinvoice_price_listrow (and revives it if soft-deleted); a row without one is appended after the project's current maximumorder. is_manual_descis set to1when the sheet's Item Description differs from the current QBO item description, so a later reload will not overwrite the manual text.- Partial import: every valid row is saved even when other rows fail.
11.8.3 Post to GL
Returns the Post to GL grid — one row per posting batch, with its derived status, invoice counts and any QuickBooks error messages.
| Module | Tasks & Time > Invoicing > Post to GL |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Company scope is role-derived: GlobalMasterSystemAdministrator (1) sees every company in every firm, a firm user their firm's, a mapped user their user_company_mapping set. |
| Content type | application/json |
Example request
curl -X GET 'https://api.example.com/api/invoicing/post-to-gl/get' \
-H 'Accept: application/json' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
[
{
"batch_no": "20260901103012",
"no_of_invoice": 3,
"status": "Approved",
"posted": "0/3",
"allStatus": ["Approve"],
"error_msg": "",
"noApprovers": 0,
"type": "Invoice",
"credit_memo_count": 0
}
]
| Field | Type | Description |
|---|---|---|
batch_no |
string | Posting batch number (YmdHis). Grid is ordered by this, descending. |
no_of_invoice |
number | Invoices in the batch. |
status |
string | Derived display status: Post Failed > Approved > Posted > Unposted > No Approver(s) > Approval Pending. |
posted |
string | "<posted count>/<total>". |
allStatus |
string[] | The distinct persisted batch_invoice.status values in the batch. |
error_msg |
string | Distinct error_msg values joined by ,\n — one per line for the popover. |
noApprovers |
number | 1 when the batch has no approvers configured (never 1 when any invoice is Post Failed). |
type |
string | Credit Memo only when every invoice in the batch is a credit memo; otherwise Invoice. |
credit_memo_count |
number | Count of credit memos in the batch. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- Every load first re-derives the persisted status of batches in
Approve/Invoicefrom the live approver dispositions: a batch whose approvers have all approved becomesApprove, otherwiseInvoice. Batches with no approvers are left unchanged — that is the case the grid surfaces asNo Approver(s). - Returns
[]whenbatch_invoiceis unavailable or the caller has no companies in scope. - The derived status is computed strictly from the persisted statuses, never from a leftover
error_msg(anerror_msgcan survive on a still-Postedinvoice whose unpost attempt failed).
Posts an entire batch to QuickBooks Online, or unposts it. This is the primary QuickBooks write path.
| Module | Tasks & Time > Invoicing > Post to GL |
| Authentication | Required — authenticated session cookie |
| Authorization | FirmInternalAccountingManager (17) for the caller's firm required. Additionally, every invoice carrying the given batch_no must be inside the caller's company scope. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
batchNo |
string | Yes | String, must not be empty | The posting batch number. Whitespace-only values are rejected at the boundary. |
fromUnPost |
boolean | No | Boolean | true unposts the batch (deletes/voids each posted invoice in QBO and sets Unposted) instead of posting it. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/post-to-gl/post' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "batchNo": "20260901103012" }'
Success response — 201 Created
{ "error": false, "message": "Invoice(s) posted successfully." }
Unpost returns { "error": false, "message": "Invoice(s) unposted successfully." }.
| Field | Type | Description |
|---|---|---|
error |
boolean | true when any invoice in the batch failed. |
message |
string | Success text, or the de-duplicated failure messages joined by ,. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | batchNo missing or empty |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Caller is not a FirmInternalAccountingManager for their firm |
{"message":"You are not authorized to post or unpost invoices."} |
| 403 | Any invoice in the batch belongs to a company outside the caller's scope | {"message":"This batch is outside your access scope."} |
| 201 | batchNo trims to empty |
{"error":true,"message":"Batch number is required."} |
| 201 | The firm has no QuickBooks connection | {"error":true,"message":"Please connect your QuickBooks account before posting."} |
| 201 | The batch is already locked by another post/unpost | {"error":true,"message":"This batch is already being posted. Please wait for it to finish."} |
| 201 | No invoice in the batch is in a postable state | {"error":true,"message":"This batch has invoice(s) pending approval and cannot be posted yet."} |
| 201 | A line's QBO item id is not in the connected company's item list | {"error":true,"message":"Invoice <no>: item id(s) <ids> not found in your connected QuickBooks company's item list. Please re-select the item on this line and try again."} |
| 201 | The invoice's payment term is not in the connected company | {"error":true,"message":"Invoice <no>: the payment term on this invoice was not found in your connected QuickBooks company (it may have been renamed, deleted, or belong to a different QuickBooks connection). Please re-select the Terms on this invoice and try again."} |
| 201 | The invoice contains only non-billable items | {"error":true,"message":"Unable to Post the invoice with only Non billable items."} |
| 201 | QuickBooks rejected the create/delete call | {"error":true,"message":"<QBO error> [diag: ...]"} |
Notes
- Reaches QuickBooks Online — always live. There is no dry-run mode; every call has a real QBO effect, which is why the higher
FirmInternalAccountingManagerpermission is always required. - Postable statuses:
Approve,Unposted,Post Failed(repost), plusInvoiceonly when the batch has no approvers. AnInvoicebatch with pending approvers is never postable. - Both post and unpost take the same batch-level lock (
invoice_lock, actionbatchPosting), so a second click, a concurrent unpost, or an unpost racing an in-flight post is rejected rather than duplicated in QuickBooks. - Before calling QBO, item ids and payment-term ids are pre-flight verified live against QuickBooks, falling back to the local
qbo_items/qbo_termscache only when the live check cannot run — so a stale cache surfaces a named, actionable message instead of QuickBooks' generic "Invalid Reference Id" fault. - On success each invoice is updated to
Postedwith itsqbo_invoice_id,invoice_no(QBODocNumber) andinvoiced_by, and aPostedaudit row is written. On failure the invoice becomesPost Failedwitherror_msgand no audit row is written. - Unpost only touches invoices currently in
Postedstatus and carrying aqbo_invoice_id; a row that was never posted is skipped entirely (no status flip, no audit row). Successful unposts clearqbo_invoice_idanderror_msg, setUnposted, and write anUnPostedaudit row. - Credit memos (
type = 2) are created and deleted through the QBO credit-memo endpoints rather than the invoice endpoints.
Applies Post, UnPost or Delete to an explicit set of invoice ids from the invoice list.
| Module | Tasks & Time > Invoicing > Post to GL |
| Authentication | Required — authenticated session cookie |
| Authorization | Post / UnPost require FirmInternalAccountingManager (17); Delete requires InvoiceManager (14). All three additionally require every id to be inside the caller's company scope. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
status |
string | Yes | One of Post, UnPost, Delete |
The action to apply. |
invoiceIds |
integer[] | Yes | Array, numeric (numeric strings are coerced), each item must be an integer | batch_invoice.id values. Empty arrays and non-integer entries are rejected at the boundary. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/bulk/action' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "status": "Post", "invoiceIds": [4471, 4472] }'
Success response — 201 Created
{ "error": false, "message": "Invoice(s) posted successfully." }
status |
Success message |
|---|---|
Post |
Invoice(s) posted successfully. |
UnPost |
Invoice(s) unposted successfully. |
Delete |
Invoice(s)/Credit Memo(s) deleted successfully. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | status outside the allowed set, or invoiceIds empty / containing non-integers |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 403 | Post / UnPost by a non-FirmInternalAccountingManager |
{"message":"You are not authorized to post or unpost invoices."} |
| 403 | Delete by a non-InvoiceManager |
{"message":"You are not authorized to perform this invoice action."} |
| 403 | Any id belongs to a company outside the caller's scope | {"message":"One or more of the selected invoices is outside your access scope."} |
| 201 | invoiceIds filtered down to nothing |
{"error":true,"message":"No invoices selected."} |
| 201 | Post / UnPost with no QuickBooks connection |
{"error":true,"message":"Please connect your QuickBooks account before posting."} / ... before unposting. |
| 201 | Post with no invoice in a postable state |
{"error":true,"message":"No invoices found for Approved Status."} |
| 201 | UnPost with no invoice currently Posted |
{"error":true,"message":"No invoices found for Posted Status."} |
| 201 | Delete deleted nothing |
{"error":true,"message":"Something went wrong."} |
| 201 | QuickBooks rejected a create/delete | Same per-invoice messages as POST /api/invoicing/post-to-gl/post |
Notes
- Reaches QuickBooks Online for all three actions:
Postcreates the invoices/credit memos,UnPostdeletes/voids them, andDeleteruns the full delete cascade which also voids any posted QBO document. Postfilters the supplied ids down to postable ones using the same rule as batch posting (Approve/Unposted/Post Failed, orInvoicewith no approvers) before building QBO payloads.- Unlike batch posting, this endpoint takes no batch lock — it operates on explicit invoice ids rather than a batch number.
- The company-scope check fails closed: the scope set always contains at least a
0sentinel, so an unscoped caller can never match rows.
11.8.4 Timesheet Details
Returns the timesheet entries behind a set of invoice line items, for the Timesheet Details modal on an invoice.
| Module | Tasks & Time > Invoicing > Timesheet Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The returned serviceRoles list is scoped to the caller's firm; the timesheet rows themselves are reached through the supplied invoice_details ids. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
invoiceDetails |
integer[] | Yes | Array, numeric (numeric strings are coerced) | invoice_details.id values whose mapped timesheet entries should be returned. Non-positive values are dropped. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/timesheet/details' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "invoiceDetails": [55011, 55012] }'
Success response — 201 Created
{
"mappingData": [
{
"invoice_info": [
{ "item_name": "Consulting", "item_detail": 1, "item_description": "August advisory",
"quantity": 6.25, "price": 150, "priceRate": 937.5, "taxable": "No", "taxRate": 0, "tax": 0 }
],
"time_history_id": 77120,
"user_id": 188,
"week": "2026-W34",
"task_id": 30712,
"company_id": 41,
"name": "Quarterly review",
"project": 88, "project_id": 88,
"serviceLine": 4, "service_line_id": 4,
"role": 6, "role_id": 6,
"invoiceDetails": 1, "invoice_detail_id": 1,
"invoicing": "Hourly", "invoicing_id": 1,
"user_name": "Alvarez, Dana",
"date": "2026-08-20",
"time": "09:15:00",
"reportedTime": 22500, "billableTime": 22500, "noChargeTime": 0, "nonBillableTime": 0,
"timeNotes": "Quarterly review", "internalNotes": "",
"validationError": false, "changeTaskData": false, "moveToUnInvoice": false,
"haveBillableTime": true, "throughDate": null,
"invoice_id": 9012, "batch_invoice_id": 4471
}
],
"serviceRoles": [{ "id": 6, "role": "Senior Accountant", "deleted": 0 }]
}
| Field | Type | Description |
|---|---|---|
mappingData |
array | One row per unique time_history_id. |
mappingData[].invoice_info |
array | One entry per associated invoice_details line: item_name (resolved via qbo_items), item_detail, item_description, quantity, price, priceRate, taxable ("Yes"/"No"), taxRate, tax. |
mappingData[].invoicing |
string | Hourly or Fixed Price, derived from invoicing_id (1 / 2). |
mappingData[].reportedTime / billableTime / noChargeTime / nonBillableTime |
number | Seconds. |
mappingData[].week |
string | ISO week (YYYY-Www, Monday-start). |
mappingData[].invoice_id / batch_invoice_id |
number | invoice.id and batch_invoice.id the entry belongs to. |
serviceRoles |
array | Firm service roles for the role picker, excluding archived roles (deleted NOT IN (0,2)). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | invoiceDetails is not an array |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- Line quantity is computed with the shared rule: a Fixed Price line bills billable + no-charge + non-billable time; an Hourly line bills only the bucket matching its
item_detail. Seconds are rounded to whole minutes (a non-zero value ≤ 30 s becomes a 1-minute floor) before conversion to hours. item_nameis resolved from the cachedqbo_itemsdata, not from a stored column oninvoice_details.
Saves edits made in the Timesheet Details modal: writes the grid back to time_history, optionally returns entries to the un-invoiced queue, rebuilds the invoice's timesheet lines and recomputes the batch totals.
| Module | Tasks & Time > Invoicing > Timesheet Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session — any authenticated user may save. The firm used for line rebuilding is the caller's own users.firm_id. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
timesheetDetails |
object[] | Yes | Array | Edited rows. Elements are not individually validated; the keys read are those the read endpoint returns (see below). |
invoiceId |
integer | Yes | Numeric (numeric strings are coerced), integer | batch_invoice.id (not invoice.id, which is supplied per row as invoice_id). |
oldTimesheetData |
object[] | No | Array | Accepted by the request schema; not read by this endpoint. |
Keys read from each timesheetDetails[] element: time_history_id (required — rows without it are skipped), invoice_id, project_id, name, service_line_id, role_id, invoice_detail_id, invoicing_id, reportedTime, billableTime, noChargeTime, nonBillableTime, timeNotes, internalNotes, date, time, throughDate, changeTaskData, moveToUnInvoice.
Example request
curl -X POST 'https://api.example.com/api/invoicing/timesheet/details/edit' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"invoiceId": 4471,
"timesheetDetails": [
{ "time_history_id": 77120, "invoice_id": 9012, "project_id": 88, "name": "Quarterly review",
"service_line_id": 4, "role_id": 6, "invoice_detail_id": 1, "invoicing_id": 1,
"reportedTime": 22500, "billableTime": 18000, "noChargeTime": 4500, "nonBillableTime": 0,
"timeNotes": "Quarterly review", "internalNotes": "",
"date": "2026-08-20", "time": "09:15:00", "moveToUnInvoice": false }
]
}'
Success response — 201 Created
{
"error": false,
"message": "Timesheet and Invoice Updated Successfully.",
"invoiceData": { "id": 4471, "invoice_no": "INV-2096", "status": "Preview", "amount": 1800 }
}
| Field | Type | Description |
|---|---|---|
invoiceData |
object | null | The refreshed batch invoice, so the caller can re-render without a second round trip. null when invoiceId is 0. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | timesheetDetails not an array or invoiceId not an integer |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 201 | time_history table unavailable |
{"error":true,"message":"Timesheet table is unavailable."} |
Notes
- Rows are keyed by
time_history_id, not byid—idis a 1-based display index supplied by the client, not a stored key. - The edited task label is written to
time_history.nameonly; the underlyingtasks.nameis left unchanged, so an invoice edit cannot rename the task elsewhere in the application. changeTaskData: truemarks the entry as invoice-sourced (time_history.timesheet_entry = 2).created_atis rewritten as<date> <time>from the row's own values, so a date edit cannot silently shift the time of day.- Through-date eviction: when the edited
datemoves past the row'sthroughDate, the entry is detached from the invoice entirely (mappings removed, sole line item soft-deleted) and the move-to-queue step is skipped. moveToUnInvoice: truedetaches the mappings and setstime_history.previewed = 0, returning the hours to the un-invoiced queue.- After the row loop, every
from = 'Timesheet'line on the invoice is torn down and rebuilt from the newtime_historystate, thenbatch_invoice.invoice_amount/tax_amount/amountare recomputed. - No QuickBooks call is made.
Turns the rows currently shown in the Timesheet Details grid into a CSV payload for client-side download.
| Module | Tasks & Time > Invoicing > Timesheet Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the payload is formatted from the request body only. |
| Content type | application/json (CSV is returned as a string field, not as a file) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
exportName |
string | No | String | Explicit file name; when blank a name is derived. |
invoiceNo |
string | No | String | Used in the derived file name. |
data |
object[] | Yes | Array | Rows to export. Elements are not individually validated — the union of all keys across all rows becomes the CSV header, in first-seen order. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/timesheet/details/export' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"invoiceNo": "INV-2096",
"data": [ { "User": "Alvarez, Dana", "Date": "2026-08-20", "Billable": "05:00" } ]
}'
Success response — 201 Created
{
"error": false,
"fileName": "INV-2096-timesheet-details-2026-09-01.csv",
"mimeType": "text/csv",
"content": "\"User\",\"Date\",\"Billable\"\n\"Alvarez, Dana\",\"2026-08-20\",\"05:00\""
}
| Field | Type | Description |
|---|---|---|
fileName |
string | exportName when supplied, otherwise <invoiceNo or "invoice">-timesheet-details-<YYYY-MM-DD>.csv. |
mimeType |
string | Always text/csv. |
content |
string | The full CSV document; every value is double-quoted with " escaped as "". |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | data is not an array |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
| 201 | data is empty |
{"error":true,"message":"No data available for export."} |
Notes
- Purely a formatter — no database read. The exported content is exactly what the client sends, so the CSV always matches what the user sees on screen.
- The response is JSON containing the CSV as a string; the client is responsible for triggering the download.
11.8.5 Invoicing Audit Log
Returns the Invoicing Audit Log — every recorded invoice action with the user, role, invoice number and before/after values — with filtering, sorting and paging.
| Module | Tasks & Time > Invoicing > Invoicing Audit Log |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Rows are restricted to invoices whose company is in the caller's role-derived scope. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
offset |
integer | No | Numeric (numeric strings are coerced), integer | Row offset; values ≤ 0 are treated as 0. |
limit |
integer | No | Numeric (numeric strings are coerced), integer | Page size; values ≤ 0 fall back to 30. |
sort_field |
string | No | String | One of Date, Time, User, User Role, Invoice No, Action. Anything else sorts by date. |
sort_order |
string | No | String | ASC (case-insensitive) or anything else for DESC. |
filters |
object[] | No | Array, each item validated | Column filters. |
filters[].column |
string | Yes (within a filter) | String | User, User Role, Invoice No, Action, Date, Original Value, New Value. Unknown columns are ignored. |
filters[].filter |
any | No | Optional — untyped | The filter value; see Notes for per-column semantics. |
fetchDropDown |
boolean | No | Boolean | true wraps the list in an object that also carries the filter dropdown data. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/audit/list' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"offset": 0, "limit": 30,
"sort_field": "Date", "sort_order": "DESC",
"filters": [
{ "column": "Action", "filter": "Posted" },
{ "column": "Date", "filter": "Custom/2026-08-01/2026-08-31" }
]
}'
Success response — 201 Created
[
{
"action": "Posted",
"original_data": {
"customer": "Northwind Manufacturing",
"customer_email": "ap@northwind.example",
"customer_cc": "-", "customer_bcc": "-",
"address": "100 Mill Rd, Springfield",
"terms": "Net 30",
"invoiceDate": "08/31/2026",
"dueDate": "09/30/2026",
"LineItem": "1 Line items Posted.",
"class": "Advisory"
},
"new_data": { "qbo_invoice_id": "1042", "invoice_no": "1042", "response": { } },
"created_at": "2026-09-01T10:31:12.000Z",
"user_name": "Brooks, Lee",
"role_name": "Firm Internal Accounting Manager",
"invoice_no": "INV-2096",
"audit_date": "09/01/2026",
"audit_time": "10:31 AM"
}
]
With fetchDropDown: true the response becomes { "dropDownData": { "users": [...], "userRoles": [...] }, "listData": [...] }.
| Field | Type | Description |
|---|---|---|
action |
string | The audit verb: Created, Updated, Commented, Approved, Unapproved, Rejected, Resubmitted, Posted, UnPosted, Deleted. |
original_data / new_data |
object | null | Parsed JSON blobs. Unparseable text is wrapped as {"value": "<text>"}. |
created_at |
string | null | Raw timestamp. |
audit_date / audit_time |
string | Formatted date and time for display. |
user_name / role_name |
string | Acting user and their role at the time; empty string when unresolved. |
invoice_no |
string | The invoice's number. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | A malformed filters element, or a mistyped scalar |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- Returns
[](or{dropDownData:{users:[],userRoles:[]}, listData:[]}whenfetchDropDownis set) if the audit tables are unavailable or the caller has no companies in scope. - Filter semantics:
UserandUser Rolematch on numeric id (values ≤ 0 ignored).Invoice Nois normalised on both sides —#and whitespace are stripped — so a value typed as displayed (#INV - 2096) still matches the storedINV-2096.Actionmatches exactly, exceptunposted/UnPosted, which is matched case-insensitively.Dateaccepts a named preset, an object{startDate, endDate}, or the string formCustom/<from>/<to>.Original ValueandNew ValueareLIKEsubstring matches on the raw JSON text. - Sorting by
Timealways yieldscreated_at DESC, regardless ofsort_order. - The per-row
idis not included in the response. - Audit writes are non-fatal: a failed audit insert is logged but never blocks the financial action it accompanies.
Returns just the filter dropdown data for the Invoicing Audit Log — the firm's users and the roles actually assigned within it.
| Module | Tasks & Time > Invoicing > Invoicing Audit Log |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Requires at least one company in the caller's scope; the lists themselves are scoped by the caller's raw users.firm_id (a firm-less Global Master System Administrator gets every user). |
| Content type | application/json |
Request body (the same schema as the list endpoint; no field is read by this endpoint)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
offset |
integer | No | Numeric (numeric strings are coerced), integer | Accepted; not read. |
limit |
integer | No | Numeric (numeric strings are coerced), integer | Accepted; not read. |
sort_field |
string | No | String | Accepted; not read. |
sort_order |
string | No | String | Accepted; not read. |
filters |
object[] | No | Array, each item validated | Accepted; not read. |
fetchDropDown |
boolean | No | Boolean | Accepted; not read. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/audit/dropdownData' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{}'
Success response — 201 Created
{
"users": [
{ "id": 188, "name": "Alvarez, Dana" },
{ "id": 214, "name": "Brooks, Lee" }
],
"userRoles": [
{ "id": 14, "name": "Invoice Manager", "role": { "id": 14, "role_name": "Invoice Manager" } }
]
}
| Field | Type | Description |
|---|---|---|
users |
array | Non-deleted users in the caller's firm, ordered by last name. |
userRoles |
array | Distinct roles actually assigned to those users, each duplicated into a nested role object for compatibility with earlier clients. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | A malformed filters element |
Standard request validation error |
| 401 | No authenticated session | {"message":"These credentials do not match our records."} |
Notes
- Returns
{ "users": [], "userRoles": [] }when the audit tables are unavailable or the caller has no companies in scope. - The user list is scoped by firm, not by the company scope — a firm-less Global Master System Administrator receives every user.
- Roles are read from
assign_user_roles, so only roles actually in use appear in the filter.
11.9 Timesheet
The Timesheet module is the time-capture and time-approval half of Tasks & Time. It covers four screens:
- Timesheet Entry — the Daily/Weekly grid where a user records time against a company, project and task, splits that time into Billable / No Charge / Non Billable buckets, and drives the disposition (submit / approve / reject / void) workflow.
- Timesheet Reports — the per-week roster of users and their disposition status, plus the Excel export in four different shapes.
- Timesheet Configure — the per-firm grid that assigns each user an approver, a cost rate, a full/part-time flag and the report-visibility permission flags, plus the firm-level "use timesheet" master switch.
- Timesheet Audit Log — the read-only trail of Created / Updated / Deleted timesheet rows.
Every route in this section is served under /api, so each path below begins with /api/.
11.9.1 Timesheet Conventions
Week string. Every timesheet week is identified by an ISO-8601-style week string YYYY-Www (e.g. 2026-W36), normalised server-side:
- The accepted input pattern is
^(\d{4})-W(\d{1,2})$, case-insensitive. The week number is clamped to1..53and zero-padded to two digits on output. - Any missing, empty or unparseable value silently falls back to the current week — no validation error is raised. A client that sends a malformed week gets data for today's week, not an error.
Week range (Saturday → Friday). A week string is converted into a date range by first locating the ISO Monday of that week (anchored on Jan 4), then shifting back two days to Saturday and forward six days to Friday. A timesheet week therefore runs Saturday to Friday, and "Week Ending" everywhere in this module means that Friday. Both ends are returned as YYYY-MM-DD strings in UTC terms.
Date → week. A date is snapped to a week string as follows: weekend dates shift forward to the following Monday first (Saturday +2 days, Sunday +1 day) before the ISO week is taken, so a weekend entry belongs to the Sat–Fri window that starts on that weekend, not the one that ends on it.
Second/hour rounding. All stored times are integer seconds. The following rounding is applied on read and before allocation:
| Input | Result |
|---|---|
0, non-finite |
0 |
1 … 30 seconds |
60 (rounds up to one minute — never rounds a non-zero positive to zero) |
> 30 seconds |
Rounded to the nearest whole minute |
| negative | Rounded to the nearest whole minute, keeping the sign; 0 if the magnitude rounds to zero minutes |
Hours are derived as seconds / 3600 rounded to 2 decimals for the utilisation strip, and as HH:mm (with the rounding above re-applied first) in Excel exports.
Allocation invariant. Allocation rounds capture_time, reported_time, billable_time, no_charge_time and non_billable_time, then forces billable + no_charge + non_billable === reported by pushing the difference into the bucket named by invoice_detail_id (1 → billable, 2 → no charge, 3 → non billable; anything else → billable). Rows that still fail the equality are flagged validationError: 1. On the read path this is persisted back to time_history (a self-healing UPDATE); on the export path persistence is skipped.
Date formats in payloads.
| Context | Format | Example |
|---|---|---|
Entry row date, request date, week ranges |
YYYY-MM-DD |
2026-09-04 |
Audit Log week_ending, timesheet_date, audit_date; Reports week label |
MM-DD-YYYY |
09-04-2026 |
Audit Log audit_time |
hh:mm AM/PM |
02:35 PM |
| Excel export "Week Ending" / row Date cells | MM/DD/YYYY |
09/04/2026 |
| Excel export "Generated On" | MM/DD/YYYY hh:mm:AM in America/New_York |
09/01/2026 10:15:AM |
| Audit Log filter values (Week Ending / Timesheet Date) | MM-DD-YYYY or YYYY-MM-DD (both accepted) |
09-04-2026 |
Approval / disposition flow. A week's disposition is a row in the disposition table keyed by (user_id, week); the latest row (highest id) wins. Valid statuses are SUBMIT, UNSUBMIT, APPROVE, UNAPPROVE, REJECT, VOID, UNVOID; a user with no row at all reads as UNSUBMIT. Actor rules are enforced server-side by POST /api/task/timesheet/weekly/status:
| Transition | Who may perform it |
|---|---|
SUBMIT / UNSUBMIT |
The sheet's owner, or a "special user" |
APPROVE / UNAPPROVE / REJECT |
The target's configured approver, or a special user |
VOID / UNVOID |
Special users only |
A special user is a Global Master System Administrator (role id 1, resolved from assigned roles — not only the users.global_admin column), a firm admin (users.firm_admin = 1), or both.
Approver relationship. The approver link is not driven by the TimesheetApprover role (role id 16), which is not used anywhere in this module. The relationship is a row in timesheet_config where user_id = <target> and approver_id IN (<caller>, <target>) (the second value covers the self-approver edge case). The check is one-way: being approved by someone does not grant rights over them.
Utilisation targets. The Entry endpoints return a utilTableData strip built from a single per-day billable target: the user's utilization_config_user.override_target_bill_day if > 0, else utilization_config_role.target_bill_day for that row's role_id scoped to the firm, else 0. The five row targets are that per-day figure multiplied by [1, 5, 5, 20, 65] for ['Today', 'This Wk', 'Last Wk', 'Last 4 Wks', 'Last 13 Wks']. Rolling windows are derived as follows: This Wk is the Sat–Fri window containing the anchor date; Last 4 Wks and Last 13 Wks end at last week's Friday and exclude the current, in-progress week.
Authentication. Every endpoint reads the session identified by the genie.sid cookie (session state is stored in MySQL) and resolves the authenticated user from it. The request is rejected with 401 unless session.login.userId is set and session.login.state === 'authenticated' and a users row with that id and deleted = 0 exists. No endpoint in this module carries a route-level role requirement; all authorisation is applied within the endpoint logic.
Validation. Global request validation is applied to every endpoint. Unknown body properties are silently stripped; a failing validation rule yields 400 with the standard validation error array.
Save to Google Sheet / Drive. POST /api/send/drive does not belong to this module; it is served by the Google Drive integration and backed by the drive_config table. Its contract: not linked → { error: true, message, url: <consent url> }; linked → { error: false, sendType, accessToken, picker }. The in-module export endpoint (POST /api/task/timesheet/weekly/export) is what produces the workbook that is subsequently handed to Drive.
11.9.2 Timesheet Entry
Loads the dropdown catalogues for the Daily/Weekly timesheet grid — selectable users, companies, projects (grouped by company), billing types, service lines and service roles. It returns no time entries; the grid rows come from POST /api/task/timesheet/filter.
| Module | Tasks & Time > Timesheet > Timesheet Entry |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. The user dropdown is scoped to self + approvees unless the caller is a Global Master System Administrator (role id 1), a firm admin, or has timesheet_config.view_others = 1. The company/project dropdowns are scoped separately (Global Company Administrator roles 1/3/4 → all companies; firm admin → own firm; everyone else → companies with non-NoAccess Task & Time access). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
week |
string | No | Optional, string | Week string YYYY-Www. Anything unparseable or omitted falls back to the current week — no error. |
Example request
curl -X POST https://api.example.com/api/task/timesheet/weekly \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"week":"2026-W36"}'
Success response — 200 OK
{
"loginUserId": 237,
"dropdownValues": {
"userOptions": [
{
"id": 237,
"name": "Byfield, Kadian",
"full_name": "Byfield, Kadian",
"firm_admin": 0,
"first_name": "Kadian",
"last_name": "Byfield",
"firm_id": 32,
"active": 1,
"deleted": 0,
"enc_id": "eyJpdiI6..."
}
],
"companyOptions": [
{ "id": 415, "name": "Northwind Foods LLC", "deleted": 0 }
],
"projectOptions": {
"415": [
{
"id": 9022,
"name": "Monthly Close",
"company_id": 415,
"company_name": "Northwind Foods LLC",
"service_line": 4,
"invoicing": 1,
"billing": 1
}
]
},
"billingOptions": [
{ "id": 1, "name": "Billable" },
{ "id": 2, "name": "No Charge" },
{ "id": 3, "name": "Non Billable" }
],
"serviceLineOptions": [
{ "id": 4, "name": "Accounting", "firm_id": 32, "is_active": 1, "deleted_at": null }
],
"serviceRoleOptions": [
{ "id": 11, "name": "Bookkeeper", "role": "Bookkeeper", "order": 2, "price": 0, "icon": "", "deleted": 2 }
]
}
}
| Field | Type | Description |
|---|---|---|
loginUserId |
number | The authenticated caller's user id. |
dropdownValues.userOptions[] |
object[] | Selectable users. name/full_name are users.lf_name trimmed, falling back to a formatted first/last name. enc_id is the encrypted user id. Sorted by name. |
dropdownValues.companyOptions[] |
object[] | id, name, deleted (company.deleted is a 3-value enum: 0 active, 1 inactive, 2 deleted). Only deleted = 0/NULL, active = 1/NULL, deleted_at IS NULL rows are returned. |
dropdownValues.projectOptions |
object | Map of company_id → array of project objects (id, name, company_id, company_name, service_line, invoicing, billing). |
dropdownValues.billingOptions[] |
object[] | Fixed list: 1 Billable, 2 No Charge, 3 Non Billable. Maps to invoice_detail_id. |
dropdownValues.serviceLineOptions[] |
object[] | Scoped to the firm's own service_line rows when firms.use_service_line = 1, else to the global set (firm_id = 0). Soft-deleted lines are kept and their name is suffixed " (deleted)" with is_active forced to 0, so history referencing them still renders. Ordered by order ASC. |
dropdownValues.serviceRoleOptions[] |
object[] | Scoped exclusively (never additively): the firm's own roles when use_service_role = 1, the global catalogue otherwise. deleted is a 4-value enum where 0 and 2 are both live states. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | week present but not a string |
Standard validation error array |
| 401 | No session, state !== 'authenticated', or no matching non-deleted users row |
{ "message": "These credentials do not match our records." } |
Notes
- Daily and Weekly Timesheet share this single endpoint; one call feeds both screens' dropdowns.
- A firm-less Global Master System Administrator gets every user across all firms in
userOptions, because firm scoping would key them to a firm id that matches nobody. - Users who are deleted or inactive are dropped from
userOptionsunless they have actualtime_historyrows in the selected week, so a week containing a since-deactivated user's history does not silently lose that user. view_others = 1widens the user dropdown but never grants edit rights — seePOST /api/task/timesheet/filter'sviewOnly.
Returns one user's timesheet rows for a week (or a single day), together with the edit-permission flags, the week's disposition status, and the utilisation strip. This is the main read for the Daily/Weekly grid.
| Module | Tasks & Time > Timesheet > Timesheet Entry |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. A Global Master System Administrator (role id 1) may load any firm's user; everyone else is restricted to their own firm. Edit rights (viewOnly) require self, a special user (Global Master System Administrator or firm admin), or an approver-of-target timesheet_config row. Row visibility is additionally company-scoped. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user |
number | Yes | Numeric (numeric strings are coerced), integer | Target user id whose sheet is being read. |
week |
string | No | Optional, string | Week string YYYY-Www. If omitted, the week is derived from date; if both are absent, the current week is used. |
date |
string | No | Optional, string | A single YYYY-MM-DD date. When present the query narrows to that one day (Daily view) and the utilisation strip switches to daily mode. |
Example request
curl -X POST https://api.example.com/api/task/timesheet/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"user":237,"week":"2026-W36"}'
Success response — 200 OK
{
"configData": {
"approver_id": 32,
"authorized": 0,
"submit": 1,
"user_id": 237,
"view_others": 0,
"cost_per_hour": "85.00",
"time_status": 1
},
"specialUser": false,
"viewOnly": false,
"allowAddEntry": true,
"timeSheetData": [
{
"id": 884213,
"user_id": 237,
"task_id": 55120,
"company_id": 415,
"project_id": 9022,
"name": "Reconcile bank feed",
"service_line_id": 4,
"assigned_role_id": 11,
"invoice_detail_id": 1,
"invoicing_id": 1,
"date": "2026-09-02",
"capture_time": 5400,
"reported_time": 5400,
"billable_time": 5400,
"no_charge_time": 0,
"non_billable_time": 0,
"time_notes": "Cleared September items",
"internal_notes": "",
"company": "Northwind Foods LLC",
"project": "Monthly Close",
"service_line": "Accounting",
"assigned_role": "Bookkeeper",
"invoice_detail": "Billable",
"invoicing": "Hourly",
"estimated_time": "02:00",
"manualEntry": 1,
"invoiceEntry": 0,
"is_email": 0,
"email_id": 0,
"validationError": 0,
"previewed": 0,
"isCompleted": 0,
"encTaskId": "eyJpdiI6...",
"task_id_copy": "copy_55120"
}
],
"allowedTimesheetConfig": 1,
"status": "UNSUBMIT",
"reason": "",
"fromApproved": false,
"userSelect": { "id": 237, "name": "Byfield, Kadian" },
"utilTableData": {
"resData": [
{
"name": "This Wk",
"start_date": "2026-08-29",
"end_date": "2026-09-04",
"billable_hours": "18.50",
"target": "40.00"
}
],
"horizantalData": {
"head": ["", "Today", "This Wk", "Last Wk", "Last 4 Wks", "Last 13 Wks"],
"body": [
["Billable Hours", "1.50", "18.50", "36.00", "142.00", "451.25"],
["Target", "8.00", "40.00", "40.00", "160.00", "520.00"]
]
},
"totals": {
"capture_time": 5400,
"reported_time": 5400,
"no_charge_time": 0,
"non_billable_time": 0
}
},
"weekEnding": "2026-09-04"
}
| Field | Type | Description |
|---|---|---|
configData |
object | The target user's timesheet_config row, defaulted when absent: approver_id (0), authorized (0), submit (1), view_others (0), cost_per_hour ("0.00", a string), time_status (1 = Full Time, 2 = Part Time). |
specialUser |
boolean | Caller is a Global Master System Administrator or a firm admin of the resolved firm. |
viewOnly |
boolean | true when the caller may not edit this sheet. Inverse of the self / special-user / approver-of-target rule. |
allowAddEntry |
boolean | true when the caller is a special user, or the target user's firm has timesheets enabled, and the sheet is not view-only. Note this follows the target's firm setting, not the viewer's. |
timeSheetData[] |
object[] | The week's (or day's) rows, ordered created_at DESC, id DESC. Times are integer seconds, already rounded and re-allocated. capture_time is abs(end_time - start_time); reported_time is override_time when set, else capture_time. invoice_detail / invoicing are derived labels. manualEntry is 1 when timesheet_entry = 1; invoiceEntry is 1 when timesheet_entry = 2. validationError is 1 when the buckets do not sum to reported_time. task_id_copy is "copy_" + task_id. |
allowedTimesheetConfig |
number | Always 1. No per-user check narrows this value. |
status |
string | Latest disposition for the week, uppercased; "UNSUBMIT" when no row exists. |
reason |
string | Rejection reason from the latest disposition row, "" if none. |
fromApproved |
boolean | status === "APPROVE". When true, the approved snapshot rows (is_approved = 1) are returned instead of the working rows, and no company allow-list is applied. |
userSelect |
object | { id, name } of the resolved target user. |
utilTableData |
object | resData[] (per-window name, start_date, end_date, billable_hours, target — the last two are 2-dp strings), horizantalData (a head row plus Billable Hours / Target body rows — note the spelling), and totals (raw second sums over the returned rows). |
weekEnding |
string | The week's Friday, YYYY-MM-DD. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Target user id resolves to no accessible user | { "error": true, "message": "User not found." } (a 200, not a 404) |
| 400 | user missing or not coercible to an integer |
Standard validation error array |
| 401 | No session / not authenticated / user row missing | { "message": "These credentials do not match our records." } |
Notes
- Side effect on a read. Allocation runs before the response is built and writes rounded/reallocated
billable_time,no_charge_time,non_billable_time(and a re-syncedservice_line) back totime_historyfor any row whose values changed. A failure of that UPDATE is suppressed and never fails the read. - The
"Today"row is included inutilTableDataonly when the viewed week contains the real current date; in Daily mode it is always included. Last 4 WksandLast 13 Wksbillable sums are computed per row with second rounding applied before summing, not as a raw SQLSUM().- Non-approved rows are filtered by the caller's company allow-list, which includes soft-deleted companies (
deleted IN (0, 1)) for firm users, so entries against a soft-deleted company remain visible in the grid as well as on invoices. view_others = 1widens which users the caller may load but never setsviewOnlytofalse.
Persists a batch of timesheet rows for one user — creating manual entries, updating existing rows, and soft-deleting removed ones — and writes an audit record for each change. Returns the recalculated utilisation strip.
| Module | Tasks & Time > Timesheet > Timesheet Entry |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. The caller must be the target user, a special user (Global Master System Administrator, role id 1 or firm admin), or the target's configured approver. Failure returns a 200 body with permission: true, not a 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
object[] | Yes | Array, each item validated | The rows to persist. Processed in reverse array order. |
userId |
number | Yes | Numeric (numeric strings are coerced), integer | The user whose sheet is being written. Also the permission target. |
week |
string | No | Optional, string | Week string YYYY-Www. If absent, derived from date. |
date |
string | No | Optional, string | YYYY-MM-DD; presence switches the returned utilisation strip to daily mode. |
initialData |
Record<string, unknown>[] |
No | Optional, array | The pre-edit snapshot of the same rows, keyed by id. Used only to compute the Updated audit diff — a row absent here produces no Updated audit entry. |
Each element of data — every field is optional:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Optional, numeric (numeric strings are coerced), integer | time_history.id. Absent/0 ⇒ a new manual task + history row is created. |
user_id |
number | No | Optional int | Row owner. |
task_id |
number | No | Optional int | tasks.id. On a delete this is the task that is soft-deleted; on a create it is echoed back as manual_id. |
task_id_copy |
string | No | Optional, string | Client-side row key ("copy_" + task_id). Not persisted. |
company_id |
number | No | Optional int | |
project_id |
number | No | Optional int | |
name |
string | No | Optional, string | Task name. |
service_line_id |
number | No | Optional int | |
assigned_role_id |
number | No | Optional int | |
invoice_detail_id |
number | No | Optional int | Billing type: 1 Billable, 2 No Charge, 3 Non Billable. Drives which bucket absorbs an allocation difference. |
invoicing_id |
number | No | Optional int | 1 Hourly, 2 Fixed Price. |
capture_time |
number | No | Optional int | Seconds. |
reported_time |
number | No | Optional int | Seconds. |
billable_time |
number | No | Optional int | Seconds. |
no_charge_time |
number | No | Optional int | Seconds. |
non_billable_time |
number | No | Optional int | Seconds. |
date |
string | No | Optional, string | YYYY-MM-DD for the row. |
time_notes |
string | No | Optional, string | |
internal_notes |
string | No | Optional, string | |
manualEntry |
number | No | Optional int | |
invoiceEntry |
number | No | Optional int | |
deleted_at |
unknown | No | Optional (no type validator) | Truthy ⇒ delete. Any truthy value combined with task_id > 0 soft-deletes the task and writes a Deleted audit row; the entry is then skipped. |
Example request
curl -X POST https://api.example.com/api/task/timesheet/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"userId": 237,
"week": "2026-W36",
"data": [
{
"id": 884213,
"task_id": 55120,
"company_id": 415,
"project_id": 9022,
"name": "Reconcile bank feed",
"service_line_id": 4,
"assigned_role_id": 11,
"invoice_detail_id": 1,
"invoicing_id": 1,
"date": "2026-09-02",
"capture_time": 5400,
"reported_time": 5400,
"billable_time": 5400,
"no_charge_time": 0,
"non_billable_time": 0,
"time_notes": "Cleared September items"
}
],
"initialData": [
{ "id": 884213, "reported_time": 3600, "billable_time": 3600 }
]
}'
Success response — 200 OK
{
"error": false,
"message": "Timesheet updated successfully.",
"data": [
{ "manual_id": -3, "id": 884590, "task_id": 55344 }
],
"utilTableData": {
"resData": [],
"horizantalData": { "head": [], "body": [] },
"totals": {
"capture_time": 5400,
"reported_time": 5400,
"no_charge_time": 0,
"non_billable_time": 0
}
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Timesheet updated successfully." |
data[] |
object[] | One entry per newly created row only: manual_id echoes the client's provisional task_id, id is the new time_history.id, task_id the new tasks.id. Empty when nothing was created. |
utilTableData |
object | Same shape as in POST /api/task/timesheet/filter, recomputed from the rows just written. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Caller may not edit the userId user's sheet |
{ "error": true, "permission": true, "message": "You don't have permission to update this user's timesheet." } |
| 200 | Any exception while writing (task/history/audit) | { "error": true, "message": "<original error message>", "data": [ ...rows created before the failure... ] } |
| 400 | data not an array, userId not an integer, or a nested row fails validation |
Standard validation error array |
| 401 | No session / not authenticated / user row missing | { "message": "These credentials do not match our records." } |
Notes
- Not atomic. Task, history and audit writes are separate statements — there is no enclosing transaction. A mid-batch failure leaves earlier rows committed; the rows created before the failure are returned in
dataso the client can reconcile. - Per-entry branching, in order:
deleted_attruthy andtask_id > 0→ soft-delete +Deletedaudit; noid→ create task + history +Createdaudit; otherwise → update history +Updatedaudit (only when the diff againstinitialDatais non-empty). - Audit payloads carry a fixed 14-column display snapshot:
company,project,name,service_line,assigned_role,invoice_detail,invoicing,date,capture_time,reported_time,billable_time,no_charge_time,non_billable_time,time_notes.CreatedandDeletedstore the full snapshot inoriginal_datawithnew_datanull;Updatedstores only the changed keys, symmetrically in both. - The diff only compares keys present on both the previous and current payloads, so a field merely missing from one side is not reported as a change.
week/dateare used only to computeweek_endingfor the audit rows and to anchor the returned utilisation strip; they do not filter what is written.
Records a disposition transition (submit / unsubmit / approve / unapprove / reject / void / unvoid) for one user's week, applies the approval flags to that week's time_history rows, and fires the notification email.
| Module | Tasks & Time > Timesheet > Timesheet Entry |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. Special users (Global Master System Administrator role id 1, or firm admin) may perform any transition. Otherwise — SUBMIT/UNSUBMIT require user_id to be the caller's own id; APPROVE/UNAPPROVE/REJECT require the caller to be the target's timesheet_config.approver_id; VOID/UNVOID are refused. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
user_id |
number | Yes | Numeric (numeric strings are coerced), integer | The user whose week is being dispositioned. |
week |
string | Yes | String | Week string YYYY-Www. Normalised (falls back to the current week if unparseable). |
status |
string | Yes | String; uppercased then checked against SUBMIT, UNSUBMIT, APPROVE, UNAPPROVE, REJECT, VOID, UNVOID |
The transition. Case-insensitive on input. |
reason |
string | No | Optional, string | Required in practice when status = REJECT — a blank/whitespace reason throws 400. |
data |
Record<string, unknown>[] |
No | Optional, array | The grid rows. When present and status is SUBMIT or APPROVE, each row is validated (see Errors). Ignored for all other statuses. |
Row keys read from data during SUBMIT/APPROVE validation: company_id, project_id, name, service_line_id, assigned_role_id, invoice_detail_id, invoicing_id, date, reported_time, billable_time, no_charge_time, non_billable_time.
Example request
curl -X POST https://api.example.com/api/task/timesheet/weekly/status \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"user_id": 237,
"week": "2026-W36",
"status": "SUBMIT",
"data": [
{
"company_id": 415,
"project_id": 9022,
"name": "Reconcile bank feed",
"service_line_id": 4,
"assigned_role_id": 11,
"invoice_detail_id": 1,
"invoicing_id": 1,
"date": "2026-09-02",
"reported_time": 5400,
"billable_time": 5400,
"no_charge_time": 0,
"non_billable_time": 0
}
]
}'
Success response — 200 OK
{
"error": false,
"message": "Time Sheet status created successfully."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Time Sheet status created successfully." |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Caller is not an allowed actor for this transition | { "error": true, "permission": true, "message": "You don't have permission to change this timesheet's status." } |
| 400 | status not in the allowed set |
Message: "Invalid disposition status." |
| 400 | status = REJECT with a blank/whitespace reason |
Message: "The Reason field is required." |
| 400 | SUBMIT/APPROVE and any data row is missing a required field |
Message: "Please fill required fields." |
| 400 | SUBMIT/APPROVE and any data row has reported_time !== billable_time + no_charge_time + non_billable_time |
Message: "Allocated Time is not equal to the Reported time for some Tasks." |
| 400 | user_id, week or status missing / wrong type |
Standard validation error array |
| 401 | No session / not authenticated / user row missing | { "message": "These credentials do not match our records." } |
Notes
- Validation order matters: permission is checked before the
REJECTreason and thedatarow checks, so an unauthorised caller always gets thepermissionbody rather than a400. - On
APPROVE, everytime_historyrow in the week's Sat–Fri range for that user getsawaiting_approval = 0,is_approved = 1,approved_billable_time = 1(each applied only if the column exists). - On
UNAPPROVE,is_approved = 0andapproved_billable_time/approved_no_charge_time/approved_non_billable_timeare set toNULL. VOID,UNVOID,SUBMIT,UNSUBMITandREJECTwrite a disposition row only — they do not touchtime_history.- A new disposition row is appended (never updated in place); reads take the highest
idfor(user_id, week). A failure to store the row is suppressed to keep the endpoint resilient across schema variants, so a status call can report success even when the row was not stored. - Email side effect, fire-and-forget.
SUBMITemails the target's approver ("Timesheet approval required");REJECTemails the submitter ("Timesheet Approval - Rejected", including the reason). The email is dispatched asynchronously, so mail-server latency or failure never delays or fails the disposition save; failures are logged as warnings only. No email is sent for the other five statuses. No real-time websocket notification is emitted.
11.9.3 Timesheet Reports
Returns the user dropdown for the Timesheet Reports screen for a given week.
| Module | Tasks & Time > Timesheet > Timesheet Reports |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. The list is narrowed to self + approvees unless the caller is a Global Master System Administrator (role id 1), a firm admin, or has timesheet_config.view_others = 1. A Global Master System Administrator with no firm of their own receives every user across all firms. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
week |
string | Yes | String | Week string YYYY-Www. Normalised; an unparseable value silently becomes the current week. |
Example request
curl -X POST https://api.example.com/api/task/timesheet/user/list \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"week":"2026-W36"}'
Success response — 200 OK
[
{
"id": 237,
"name": "Byfield, Kadian",
"full_name": "Byfield, Kadian",
"firm_admin": 0,
"first_name": "Kadian",
"last_name": "Byfield",
"firm_id": 32,
"active": 1,
"deleted": 0,
"enc_id": "eyJpdiI6..."
}
]
| Field | Type | Description |
|---|---|---|
| (root) | object[] | A bare array — not wrapped in an envelope. Sorted by name. |
id |
number | User id. |
name / full_name |
string | users.lf_name trimmed, falling back to a formatted first/last name. |
firm_admin |
number | 1 when the user is their firm's admin. |
first_name / last_name |
string | null | Raw name columns. |
firm_id |
number | The user's firm. |
active |
number | 1 active, 0 inactive. |
deleted |
number | Hard-delete flag; 0 = present. |
enc_id |
string | Encrypted user id, for URLs that must not expose the raw id. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | week missing or not a string |
Standard validation error array |
| 401 | No session / not authenticated / user row missing | { "message": "These credentials do not match our records." } |
Notes
- Unlike
POST /api/task/timesheet/weekly, this call always includes history users: users withtime_historyrows in the week are added to the list even if they were not in the base firm query, so a week's history never lacks its author. - Deleted/inactive users are still dropped unless they have history in the selected week.
lf_nameis trimmed before it is returned, because some stored records carry leading or trailing whitespace.
Returns the Timesheet Reports roster: one row per candidate user for the week, with their disposition status and whether the caller may open their sheet.
| Module | Tasks & Time > Timesheet > Timesheet Reports |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. Row candidates are every entitled user intersected with users who have a live timesheet_config row; the per-row viewTimesheet flag is narrowed to self + approvees unless the caller is a Global Master System Administrator (role id 1), a firm admin, or has view_others = 1. The viewHierarchy flag requires users.firm_admin = 1 or timesheet_config.authorized = 1. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
week |
string | Yes | String | Week string YYYY-Www. Not clamped — future weeks are accepted, so the list and the export agree. |
status |
string | Yes | String; matched case-insensitively (see mapping below) | Disposition filter. |
hierarchy |
boolean | No | Optional, boolean | true restricts rows to the users the caller directly approves ("Reporting Hierarchy" mode). |
fromMounted |
boolean | No | Optional, boolean | true adds viewHierarchy to the response (the screen's initial load). |
status mapping, matched on the lowercased, trimmed value:
| Accepted values | Statuses included |
|---|---|
"", all, all timesheets |
UNSUBMIT, SUBMIT, APPROVE, UNAPPROVE, REJECT, VOID, UNVOID |
submitted, submitted timesheets |
SUBMIT |
open, unsubmitted/unapproved/unvoided timesheets, unsubmit |
UNSUBMIT, UNAPPROVE, UNVOID |
rejected, rejected timesheets |
REJECT |
approved, approved timesheets |
APPROVE |
pending, timesheets pending approval |
SUBMIT |
voided, voided timesheets |
VOID |
| anything else | the uppercased input, matched literally |
Example request
curl -X POST https://api.example.com/api/task/timesheet/weekly/list \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"week":"2026-W36","status":"All Timesheets","hierarchy":false,"fromMounted":true}'
Success response — 200 OK
{
"time_sheet_list": [
{
"user": {
"id": 237,
"name": "Byfield, Kadian",
"full_name": "Byfield, Kadian",
"firm_admin": 0,
"first_name": "Kadian",
"last_name": "Byfield",
"firm_id": 32,
"active": 1,
"deleted": 0,
"enc_id": "eyJpdiI6...",
"disposition_status": "SUBMIT"
},
"week": "09-04-2026",
"status": "SUBMIT",
"viewTimesheet": true
}
],
"viewHierarchy": true
}
| Field | Type | Description |
|---|---|---|
time_sheet_list[] |
object[] | One row per included user. |
time_sheet_list[].user |
object | The POST /api/task/timesheet/user/list user object plus disposition_status (a duplicate of the sibling status). |
time_sheet_list[].week |
string | Week-ending label, MM-DD-YYYY. |
time_sheet_list[].status |
string | The week's disposition, uppercased; "UNSUBMIT" when synthesised. |
time_sheet_list[].viewTimesheet |
boolean | Whether the caller may open ("eye" icon) that user's sheet. Computed independently of hierarchy mode. |
viewHierarchy |
boolean | Present only when fromMounted is true. Whether the caller may use the Reporting Hierarchy toggle. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | week or status missing / not a string, or hierarchy / fromMounted not booleans |
Standard validation error array |
| 401 | No session / not authenticated / user row missing | { "message": "These credentials do not match our records." } |
Notes
- Row inclusion is a two-stage gate. A user with a real disposition row for the week is included whenever the filter matches, regardless of active/deleted status. A user with no disposition row is synthesised as
UNSUBMITonly when all three hold: theirtimesheet_config.submitis truthy, the filter includesUNSUBMIT, and they are either active or havetime_historyrows in that week. - Candidates are intersected with users who have a live (non soft-deleted)
timesheet_configrow: a user never added via Timesheet Configure never appears here at all, however active they are. - Hierarchy mode swaps the candidate set to the caller's direct reports (themselves drawn from non-deleted
timesheet_configrows) and swaps thesubmitmap to the approver-scoped one. - Hierarchy is a display filter, not a narrower security boundary — the export endpoint re-derives the wider non-hierarchy ceiling.
- A firm-less Global Master System Administrator is exempted from the firm clause on both the candidate list and the
timesheet_configintersection, since firm resolution would otherwise substitute their own user id and empty the report.
Builds a styled .xlsx workbook of timesheet data and returns it as a base64 data: URI in the JSON body. Four different payload shapes select four different workbook layouts.
| Module | Tasks & Time > Timesheet > Timesheet Reports |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. For Shapes 2 and 3 only, userArray is filtered against the caller's entitlement ceiling (every firm user when the caller is a Global Master System Administrator / firm admin / has view_others; otherwise self + direct reports). Unauthorised ids are silently dropped and a warning is logged. Shapes 1 and 4 render client-supplied rows without a server-side re-check. |
| Content type | application/json — the workbook is embedded as a data: URI, not streamed as a binary download. |
Request body. Every field is optional; the combination present selects the shape.
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
exportName |
string | No | Optional, string | Report title. Defaults to "Timesheet Report". |
week |
string | No | Optional, string | Week string YYYY-Www, used only for the "Week Ending" header line and (Shapes 2/3) the server-side entry fetch. Unparseable values are echoed verbatim; an empty value yields "". |
fromReport |
unknown | No | Optional (no type validator) | Truthy ⇒ Shape 1 (report list). |
data |
Record<string, unknown>[] |
No | Optional, array | Shape 1: the report rows ({ week, status, user: { full_name \| name } }). Shape 4: the grid rows; nested arrays are flattened one level. |
userArray |
Record<string, unknown>[] |
No | Optional, array | Non-empty ⇒ Shape 2 or 3. Each element must carry an id; entries are then fetched server-side for those users. |
withSummary |
unknown | No | Optional (no type validator) | With userArray: truthy ⇒ Shape 3 (summary), falsy ⇒ Shape 2 (details). |
user |
Record<string, unknown> |
No | Optional (no type validator) | Shape 4: only user.name is read, for the "User" header line. |
status |
unknown | No | Optional (no type validator) | Shape 4: stringified into the "Disposition Status" header line. |
capture |
unknown | No | Optional (no type validator) | Shape 4: "Total Time Capture" header value, in seconds, rendered as HH:mm. |
reported |
unknown | No | Optional (no type validator) | Shape 4: "Total Time Reported" header value, in seconds, rendered as HH:mm. |
Shape selection, evaluated in order: fromReport truthy → Shape 1; else userArray non-empty → Shape 3 if withSummary truthy, otherwise Shape 2; else → Shape 4.
Shape 4 reads these keys from each row of data: company, project, name, service_line, assigned_role, invoice_detail, invoicing, date, capture_time, reported_time, estimated_time, billable_time, no_charge_time, non_billable_time, time_notes.
Example request (Shape 2 — details for selected users)
curl -X POST https://api.example.com/api/task/timesheet/weekly/export \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"exportName": "Weekly Timesheet Report",
"week": "2026-W36",
"userArray": [{ "id": 237 }, { "id": 32 }],
"withSummary": false
}'
Success response — 200 OK
{
"error": false,
"redirect": "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,UEsDBBQABgAIAAAAIQ...",
"exportName": "Weekly Timesheet Report",
"total": 42
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on this path. |
redirect |
string | The complete workbook as a base64 data: URI. The client turns this into a download. |
exportName |
string | Echo of the resolved report name. |
total |
number | Number of rows in the sheet including the header/meta rows. |
Workbook layouts (the sheet tab is always named Worksheet):
| Shape | Trigger | Meta rows | Column headers |
|---|---|---|---|
| 1 — Report list | fromReport truthy |
Report Name, Week Ending, Generated On, Generated By | Week Ending, User, Status |
| 2 — Details | userArray non-empty, withSummary falsy |
Report Name, Week Ending, Generated On, Generated By | User, Company, Project, Task Name, Service Line, Role, Task Type, Invoicing Details, Invoicing, Date, Time Capture, Time Reported, Difference, Billable Time, No Charge Time, Non Billable Time, Time Notes |
| 3 — Summary | userArray non-empty, withSummary truthy |
Report Name, Week Ending, Generated On, Generated By, Total Time Capture, Total Time Reported | User, Company, Project, Time Capture, Time Reported, Billable Time, No Charge Time, Non Billable Time |
| 4 — Single-user weekly | neither of the above | Report Name, User, Week Ending, Generated On, Generated By, Disposition Status, Total Time Capture, Total Time Reported | Company, Project, Task Name, Service Line, Role, Invoicing Details, Invoicing, Date, Time Capture, Time Reported, Estimated Time, Billable Time, No Charge Time, Non Billable Time, Time Notes |
Shape 1 maps raw statuses to past-tense labels: SUBMIT→Submitted, UNSUBMIT→Unsubmitted, APPROVE→Approved, UNAPPROVE→Unapproved, REJECT→Rejected, VOID→Voided, UNVOID→Unvoided. An unrecognised status is written through unchanged.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | exportName / week not strings, or data / userArray not arrays |
Standard validation error array |
| 401 | No session / not authenticated / user row missing | { "message": "These credentials do not match our records." } |
No other error path exists — an empty result set produces a workbook containing a merged, centred --No Record Found-- placeholder row rather than an error.
Notes
- Shapes 2 and 3 are authorised server-side. Ids outside the caller's entitlement ceiling are dropped from the export silently (the caller still gets a
200), with a server-side warning recording how many were removed. - All time cells are seconds converted to
HH:mm(with second rounding re-applied first). Negative values are prefixed-and coloured red (FF0000). Conversion is anchored on a per-shape "anchor column" — a row whose anchor cell is not a finite number is left untouched. - Header rows are bold 12pt; Shape 3 additionally shades group rows
E3E3E3, bolds total rows, and applies hairline top borders. Column widths are auto-approximated tomin(longest cell + 2, 60)characters. - "Generated On" is always formatted in America/New_York, independent of the server's own timezone.
- Exports never write to
time_history: on the export path allocation runs with persistence disabled, so downloading a report cannot mutate stored time. - To push the result to Google Drive/Sheets the client calls
POST /api/send/drive(see Conventions) — that route does not belong to this module.
11.9.4 Timesheet Configure
Loads the Timesheet Configuration grid for one firm: every visible firm user with their approver, permission flags, cost rate and full/part-time status, plus the dropdown option sets and the firm's "use timesheet" master switch.
| Module | Tasks & Time > Timesheet > Timesheet Configure |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. Reading the caller's own firm is always allowed; reading another firm requires Global Master System Administrator (role id 1) or Global User Administrator (role id 2, resolved by name as well as id). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | Yes | Numeric (numeric strings are coerced), integer | The firm to load. Note timesheet_config.firm_id keys on firms.user_id, not firms.id; the firm lookup accepts either (WHERE user_id = ? OR id = ?). |
Example request
curl -X POST https://api.example.com/api/task/timesheet/config \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":32}'
Success response — 200 OK
{
"allowUser": true,
"userOptions": [
{ "id": 237, "name": "Byfield, Kadian" },
{ "id": 32, "name": "Alvarez, Mia" }
],
"configData": [
{
"id": 4471,
"firm_id": 32,
"user_id": 237,
"approver_id": 32,
"submit": 1,
"authorized": 0,
"view_others": 0,
"view_invoice_reports": 1,
"view_profitablity_reports": 2,
"cost_per_hour": 85,
"time_status": 1,
"created_at": "2026-02-11T09:14:02.000Z",
"updated_at": "2026-08-30T16:02:55.000Z",
"deleted_at": null,
"user_name": "Byfield, Kadian"
},
{
"user_id": 32,
"user_name": "Alvarez, Mia",
"approver_id": 0,
"submit": 1,
"authorized": 0,
"view_others": 0,
"view_invoice_reports": 0,
"view_profitablity_reports": 0,
"cost_per_hour": "0.00",
"time_status": 1
}
],
"firmAdmin": 32,
"dropdownValues": {
"viewInvoiceReportsOptions": [
{ "id": 1, "name": "All" },
{ "id": 2, "name": "My Company Groups" }
],
"viewProfitabilityReportsOptions": [
{ "id": 0, "name": "Select" },
{ "id": 2, "name": "No" },
{ "id": 1, "name": "Yes" }
],
"timeStatusOptions": [
{ "id": 1, "name": "Full Time" },
{ "id": 2, "name": "Part Time" }
]
}
}
| Field | Type | Description |
|---|---|---|
allowUser |
boolean | firms.use_timesheet === 1 — the firm-level master switch. |
userOptions[] |
object[] | { id, name } for every visible firm user. This is also the pool the Approver dropdown draws from — the same source as configData, so a user can never appear in one and not the other. |
configData[] |
object[] | One entry per visible user, in the same order as userOptions. A user with a timesheet_config row gets that row returned with all of its stored columns (so cost_per_hour arrives as a number) plus user_name. A user without one gets a synthetic default: approver_id: 0, submit: 1, authorized: 0, view_others: 0, view_invoice_reports: 0, view_profitablity_reports: 0, cost_per_hour: "0.00" (a string), time_status: 1 — and no id. |
firmAdmin |
number | The id of the first visible user with firm_admin = 1, or 0. |
dropdownValues.viewInvoiceReportsOptions[] |
object[] | 1 All, 2 My Company Groups. |
dropdownValues.viewProfitabilityReportsOptions[] |
object[] | 0 Select (placeholder), 2 No, 1 Yes. Note that 2 means No, not Yes. |
dropdownValues.timeStatusOptions[] |
object[] | 1 Full Time, 2 Part Time. |
Note the DB column is spelled view_profitablity_reports (missing the second i) and the response preserves that spelling; the dropdown key is spelled correctly (viewProfitabilityReportsOptions).
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | firmId missing or not coercible to an integer |
Standard validation error array |
| 401 | Not authenticated, or firmId is another firm and the caller is neither a Global Master System Administrator nor a Global User Administrator |
{ "message": "These credentials do not match our records." } |
Notes
- Visible users are
usersrows withfirm_id = firmId,COALESCE(deleted,0) = 0,deleted_at IS NULLandCOALESCE(active,1) = 1, orderedlf_name, first_name, last_name. Inactive users are therefore excluded from both the grid and the Approver dropdown. Bothdeleted = 0anddeleted_at IS NULLare applied because the two flags are independent in this schema and either alone lets rows through. cost_per_houris typed differently between the twoconfigDatabranches (a number for a stored row, a string for a default). The save endpoint normalises the value to a string on receipt — see below.
Returns every non-deleted firm as { id, name }, for the Global Timesheet Configuration screen's firm selector.
| Module | Tasks & Time > Timesheet > Timesheet Configure |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Restricted. Requires Global Master System Administrator (role id 1) or Global User Administrator (role id 2) — the same gate as the screen it feeds. Everyone else gets 401. |
| Content type | application/json |
This endpoint takes no path parameters, no query parameters and no request body.
Example request
curl -X GET https://api.example.com/api/task/timesheet/firm/dropdown \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
[
{ "id": 32, "name": "Acme Accounting Partners" },
{ "id": 118, "name": "Beacon CPA Group" }
]
| Field | Type | Description |
|---|---|---|
| (root) | object[] | A bare array, ordered by firm name ascending. |
id |
number | firms.user_id, not firms.id — this is the value timesheet_config.firm_id holds and the value POST /api/task/timesheet/config expects as firmId. |
name |
string | firms.account_name (falling back to firms.name if the column is absent). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Not authenticated, or the caller is neither a Global Master System Administrator nor a Global User Administrator | { "message": "These credentials do not match our records." } |
Notes
- The
idsemantics are load-bearing: the value returned here isfirms.user_idand must be sent back asfirmId. - The deleted filter adapts to the schema:
COALESCE(deleted,0) = 0if the column exists, elsedeleted_at IS NULL, else no filter. - This is the only
GETroute in the Timesheet module.
Saves the Timesheet Configuration grid. Two mutually exclusive modes: toggling the firm's use_timesheet master switch, or rewriting the per-user configuration rows.
| Module | Tasks & Time > Timesheet > Timesheet Configure |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Restricted. A Global Master System Administrator (role id 1) may save any firm. Anyone else must be that firm's firm admin (users.firm_admin = 1) and be saving their own firm. Everyone else gets 401. Note this is stricter than the read gate — a Global User Administrator may read but not save. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | No | Optional, numeric (numeric strings are coerced), integer | Target firm (keyed on firms.user_id). Defaults to the caller's own resolved firm id. |
colName |
string | No | Optional, string | Presence selects toggle mode. The only supported value is "use_timesheet"; anything else is rejected. |
colValue |
number | No | Optional, numeric (numeric strings are coerced), integer | The new firms.use_timesheet value. Defaults to 0 when omitted in toggle mode. |
data |
object[] | No | Optional, array, each item validated | Grid rows. Used only when colName is absent. |
Each element of data:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
number | No | Optional, numeric (numeric strings are coerced), integer | Existing timesheet_config.id. Not used for matching — the upsert re-looks-up by (firm_id, user_id). |
user_id |
number | Yes | Numeric (numeric strings are coerced), integer | The configured user. |
approver_id |
number | Yes | Numeric (numeric strings are coerced), integer | Who approves this user's timesheets. 0 means none. |
submit |
number | Yes | Numeric (numeric strings are coerced), integer | Whether the user is offered in the Reports roster when they have no disposition row. 0 suppresses the synthesised UNSUBMIT row. |
authorized |
number | Yes | Numeric (numeric strings are coerced), integer | Grants the Reporting Hierarchy toggle on Timesheet Reports. |
view_others |
number | Yes | Numeric (numeric strings are coerced), integer | Widens the user dropdowns to every firm user. Does not grant edit rights. |
view_invoice_reports |
number | Yes | Numeric (numeric strings are coerced), integer | 1 All, 2 My Company Groups. |
view_profitablity_reports |
number | Yes | Numeric (numeric strings are coerced), integer | 0 unset/Select, 2 No, 1 Yes. Note the column spelling. |
cost_per_hour |
string | Yes | Normalised to a string server-side, then validated as a string | Cost rate. A numeric value is accepted and converted to a string, so a row loaded from POST /api/task/timesheet/config can be posted back unchanged. null and undefined are not converted and are rejected with 400. |
time_status |
number | Yes | Numeric (numeric strings are coerced), integer | 1 Full Time, 2 Part Time. |
Example request (grid save)
curl -X POST https://api.example.com/api/task/timesheet/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"firmId": 32,
"data": [
{
"id": 4471,
"user_id": 237,
"approver_id": 32,
"submit": 1,
"authorized": 0,
"view_others": 0,
"view_invoice_reports": 1,
"view_profitablity_reports": 2,
"cost_per_hour": "85.00",
"time_status": 1
}
]
}'
Example request (master-switch toggle)
curl -X POST https://api.example.com/api/task/timesheet/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":32,"colName":"use_timesheet","colValue":1}'
Success response — 200 OK
{
"error": false,
"message": "Timesheet Configuration updated successfully."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | "Timesheet Configuration updated successfully." — identical for both modes. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | colName present but not "use_timesheet" |
{ "error": true, "message": "Unsupported timesheet setting." } |
| 200 | Toggle mode and no firms row matches firmId by user_id or id |
{ "error": true, "message": "Firm not found." } |
| 400 | A data row is missing a required field, or cost_per_hour is null/undefined |
Standard validation error array |
| 401 | Not authenticated, or the caller is not a Global Master System Administrator and is not the firm admin of firmId |
{ "message": "These credentials do not match our records." } |
Notes
- Authorisation is enforced server-side, independently of whether the client renders the grid as editable. A caller who is neither a Global Master System Administrator nor the firm admin of
firmIdcannot rewrite approvers, permission flags or cost rates by calling this endpoint directly. - Toggle mode resolves the firm row by
user_idfirst, thenidas a fallback, then updates by primary key. A combineduser_id = ? OR id = ?lookup is not used, because it can resolve to the wrong firm. - Grid mode is a scoped wipe-and-resave: every
timesheet_configrow for the firm whoseuser_idis in the currently visible user set is soft-deleted (deleted_at = now()), then each posted row is upserted by(firm_id, user_id)withdeleted_at = NULL. The scoping matters because the grid hides inactive users: an unscoped wipe would discard their cost rate, approver and permissions with nothing left to restore on reactivation. - Rows are written one statement at a time with no enclosing transaction.
- Sending
colNamealongsidedatameansdatais ignored — the toggle branch returns before the grid branch runs. - Sending neither
colNamenordatastill performs the scoped wipe (soft-deleting every visible user's config) and then returns success with nothing re-inserted.
11.9.5 Timesheet Audit Log
Returns a paged, filtered, sorted list of timesheet audit records — the Created / Updated / Deleted trail written by POST /api/task/timesheet/save. Optionally returns the filter dropdown data in the same call.
| Module | Tasks & Time > Timesheet > Timesheet Audit Log |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. Rows are scoped to the caller's company allow-list — a Global Master System Administrator (role id 1) gets no company restriction; a mapped (non-firm) user is limited to their user_company_mapping companies; a firm user/admin is limited to company.firm_id = <their firm>. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
offset |
number | No | Optional, numeric (numeric strings are coerced), integer | SQL OFFSET. Defaults to 0. |
limit |
number | No | Optional, numeric (numeric strings are coerced), integer | SQL LIMIT. Defaults to 30. No upper bound is enforced. |
sort_field |
string | No | Optional, string | One of Date, User, Week Ending, Timesheet Date, Action. Anything else (including Time) falls back to tad.created_at. |
sort_order |
string | No | Optional, string | asc or desc (case-insensitive); anything else is treated as desc. Ignored — forced to DESC — whenever sort_field is not one of the five recognised values. |
filters |
object[] | No | Optional, array, each item validated | Column filters, AND-combined. |
fetchDropDown |
boolean | No | Optional, boolean | true wraps the result and includes the dropdown data (see Success response). |
includeInactiveOptions |
number | No | Optional, numeric (numeric strings are coerced), integer | 1 includes rows authored by inactive users (drops the u.active = 1 clause). Any other value keeps them hidden. |
Each element of filters:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
column |
string | Yes | String | Which column to filter. Recognised: Date, User, Week Ending, Timesheet Date, Action, Original Value, New Value. An empty or unrecognised value is skipped. |
filter |
unknown | No | Optional (no type validator) | The filter value; its accepted form depends on column. |
Per-column filter semantics:
column |
Accepted filter |
Applied as |
|---|---|---|
Date |
A preset string: Today, Yesterday, This Week, Last Week, Next Week, This Month, Last Month, Next Month — all resolved in America/New_York (weeks run Mon–Sun here, unlike the Sat–Fri timesheet week). Or "Custom/<start>/<end>". Or an object { startDate, endDate }. |
DATE(tad.created_at) BETWEEN ? AND ? |
User |
A numeric user id (not a display name). Non-numeric or <= 0 is skipped. |
tad.user_id = ? (or tad.created_by = ?) |
Week Ending |
MM-DD-YYYY or YYYY-MM-DD |
DATE(tad.week_ending) = ? |
Timesheet Date |
MM-DD-YYYY or YYYY-MM-DD |
DATE(th.created_at) = ? |
Action |
A non-empty string, e.g. Created, Updated, Deleted |
tad.action = ? (exact) |
Original Value |
A non-empty string | tad.original_data LIKE '%…%' |
New Value |
A non-empty string | tad.new_data LIKE '%…%' |
Example request
curl -X POST https://api.example.com/api/task/timesheet/audit/list \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"offset": 0,
"limit": 30,
"sort_field": "Date",
"sort_order": "desc",
"includeInactiveOptions": 0,
"filters": [
{ "column": "Date", "filter": "This Month" },
{ "column": "User", "filter": 237 },
{ "column": "Action", "filter": "Updated" }
]
}'
Success response — 200 OK
When fetchDropDown is falsy or absent, the body is a bare array:
[
{
"id": 90211,
"user_id": 237,
"user_name": "Byfield, Kadian",
"task_name": "Reconcile bank feed",
"week_ending": "09-04-2026",
"timesheet_date": "09-02-2026",
"action": "Updated",
"original_data": { "reported_time": 3600, "billable_time": 3600 },
"new_data": { "reported_time": 5400, "billable_time": 5400 },
"audit_date": "09-02-2026",
"audit_time": "02:35 PM",
"created_at": "2026-09-02T18:35:11.000Z"
}
]
When fetchDropDown is true, the same array is wrapped:
{
"dropDownData": { "users": [ { "id": 237, "name": "Byfield, Kadian", "deleted": 0, "active": 1 } ] },
"listData": [ { "id": 90211, "...": "..." } ]
}
| Field | Type | Description |
|---|---|---|
id |
number | timesheet_audit_details.id. |
user_id |
number | Author of the change (0 when the join found nobody). |
user_name |
string | users.lf_name, falling back to first_name + " " + last_name. "" when unresolved. |
task_name |
string | time_history.name — the task the audited row belongs to. Needed because an Updated payload carries only changed columns. |
week_ending |
string | MM-DD-YYYY, or "". |
timesheet_date |
string | MM-DD-YYYY derived from time_history.created_at, or "". |
action |
string | Created, Updated or Deleted as written by the save endpoint. |
original_data |
object | string | The stored JSON, parsed; left as the raw string if it does not parse. For Created/Deleted this is the full 14-column snapshot; for Updated, only the changed keys' previous values. |
new_data |
object | string | null | Same treatment. null for Created/Deleted. |
audit_date |
string | MM-DD-YYYY of tad.created_at. |
audit_time |
string | hh:mm AM/PM of tad.created_at. |
created_at |
string | The raw tad.created_at value as returned by the driver. |
dropDownData.users[] |
object[] | Only when fetchDropDown is true — identical to POST /api/task/timesheet/audit/dropdownData's payload. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | offset/limit/includeInactiveOptions not integers, sort_field/sort_order not strings, filters not an array, or a filter element lacking column |
Standard validation error array |
| 401 | No session / not authenticated / user row missing | { "message": "These credentials do not match our records." } |
Notes
- The response has two different top-level shapes depending on
fetchDropDown— a bare array, or{ dropDownData, listData }. Clients must branch on the flag they sent. - No total-count field is returned; paging is offset/limit only.
- The audit table is joined to
time_historyviatime_history_id(with atimesheet_idvariant tolerated) using an INNER JOIN, so an audit row whosetime_historyrow no longer exists does not appear at all. - The schema is probed at runtime: a missing
user_id,created_by,week_ending,deleted_ator link column degrades the query gracefully rather than erroring. If thetimesheet_audit_detailstable does not exist at all, an empty array is returned. - The
Usersort orders byusers.user_name, while the column displays and filters onlf_name. - The company allow-list is applied only when the
time_historylink column exists; without it no company restriction is emitted.
Returns the user list that populates the Audit Log's "User" filter dropdown.
| Module | Tasks & Time > Timesheet > Timesheet Audit Log |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. The firm clause is applied only when the caller's own users.firm_id > 0 — a firm-less Global Master System Administrator therefore lists every user in the system. |
| Content type | application/json |
Request body, the same body schema as POST /api/task/timesheet/audit/list. Only one field is read by this endpoint:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
includeInactiveOptions |
number | No | Optional, numeric (numeric strings are coerced), integer | 1 includes soft-deleted users (drops the deleted_at IS NULL clause). Any other value keeps them hidden. |
offset, limit, sort_field, sort_order, filters and fetchDropDown are accepted by the request schema (and so pass validation) but do not affect this endpoint's result.
Example request
curl -X POST https://api.example.com/api/task/timesheet/audit/dropdownData \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"includeInactiveOptions":0}'
Success response — 200 OK
{
"users": [
{ "id": 32, "name": "Alvarez, Mia", "deleted": 0, "active": 1 },
{ "id": 237, "name": "Byfield, Kadian", "deleted": 0, "active": 1 }
]
}
| Field | Type | Description |
|---|---|---|
users[] |
object[] | Ordered by lf_name, then first_name, then last_name. |
users[].id |
number | User id — this is the value the Audit Log's User filter expects. |
users[].name |
string | users.lf_name, falling back to a formatted first/last name. |
users[].deleted |
number | The hard-delete int flag. Returned as data, not filtered on. |
users[].active |
number | 1 active, 0 inactive. Returned as data, not filtered on. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | includeInactiveOptions not coercible to an integer (or another body field has the wrong type) |
Standard validation error array |
| 401 | No session / not authenticated / user row missing | { "message": "These credentials do not match our records." } |
Notes
- The visibility gate is
users.deleted_atonly; thedeletedint flag is not used here, because the two columns can disagree for a given record. Both flags are still returned as data so the client can render an "(inactive)" suffix. - The
deleted_atcolumn's existence is probed at runtime; if absent, no inactive filter is applied at all. - The same payload is reachable inline by calling
POST /api/task/timesheet/audit/listwithfetchDropDown: true, which saves a round trip on the screen's initial load.
12. Monthly Close
The Monthly Close module drives four screen families: Close Status (a manager-facing roll-up of where every company stands in the current close), Close Details (the nine per-company review sections and their drill-down lists), Firm Configuration (firm-wide period definitions plus the per-section Configure screens), and the supporting Review Assignments, QuickBooks Close Dates and Open Comments surfaces.
Every route in this section is mounted under an /api prefix, so the full path is /api/<route path>. Every route reads the caller's identity from the session (session.login), which requires session.login.userId and session.login.state === 'authenticated'. Permission decisions are not made at the route level; they are evaluated server-side per request. The module's permission menu id is MONTHLY_CLOSE_MENU_ID = 3, which is the menu that per-company user_company_access / user_group_access grants are looked up against; access levels are 1 NoAccess, 2 ViewOnly, 3 Standard, 4 Admin.
Selecting a period and a company scope. Almost every endpoint here takes the period as a month token + year pair in the request body — monthVal/yearVal on most routes, month/year on the Close Status manager tabs and Review Assignments. The pair is collapsed server-side into a YYYY-MM-01 month-start string: the month token is matched case-insensitively by 3-letter prefix (jan…dec), an unmatched token falls back to the current month, and a zero/absent year falls back to the current year. That month-start is the key used against mc_date / as_on_date / comment_date. The company (or company-group) scope is likewise passed per request — companyId on the Close Details and Configure routes, cg_id/cgId (a c_<id> / g_<id> token) plus id and type ('company' | 'group' | '') on the scoped/roll-up routes. Separately, the browser's sticky selection lives in the session and is set through the Session module's POST /api/company/session/period (month/year only, company preserved) and POST /api/company/session (company); see the Session section. Monthly Close's month/year selects post to POST /api/company/session/period so the period carries across screens, but the Monthly Close endpoints themselves never read it — they always use the values in their own request body.
Request validation runs on every route. Unknown body properties are stripped silently (no 400), and numeric fields accept numeric strings, which are coerced before validation.
Errors common to the whole module:
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session, or the user row is missing/deleted | { "message": "These credentials do not match our records." } |
| 401 | The requested companyId does not belong to the caller's firm |
{ "message": "These credentials do not match our records." } |
| 400 | Request validation rejects the body | { statusCode, message: [...], error: "Bad Request" } |
12.1 Close Status
The Close Status screen has two halves: the review-status grid (one node per company group, lazily walked by the client) and the three manager roll-up tabs — Calendar (closeDeadline), Relative (unClosedDeadline) and Completion (completionResults) — each with a drill-down modal and an Excel export.
Returns one company-group node's Close Status rows — that group's own direct companies (paged) plus its direct child-group ids, which the client walks recursively.
| Module | Monthly Close > Close Status |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Results are scoped to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
monthVal |
string | Yes | Must not be empty String | Month token (e.g. Apr), resolved by prefix. |
yearVal |
number | Yes | Must not be empty Integer | Four-digit year. |
cg_id |
string | No | String | Scope token, e.g. g_12 / c_45. Empty falls back to "All Companies" in the firm. |
id |
number | No | Integer | Numeric id of the selected company/group. |
type |
string | No | One of group, company, "" |
Which kind of id id is. |
primary |
number | No | Integer | Filter by primary-accountant user id. |
reviewer |
number | No | Integer | Filter by reviewer user id. |
status |
number | No | Integer | Filter by status code (see the status table under Close Details). 0 = no filter. |
closeDead |
number | No | Integer | Filter by close-deadline day. 0 = no filter. |
sortColumn |
string | No | One of company, primary, reviewer |
Defaults to company. |
sortBy |
string | No | One of asc, desc |
Defaults to asc. |
page |
number | No | Integer | Page index into the group's own companies. |
includeInactive |
boolean | No | Boolean | Include inactive companies in the picker set. |
Example request
curl -X POST https://api.example.com/api/mc/peer/review/status/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"monthVal":"Apr","yearVal":2026,"cg_id":"g_12","id":12,"type":"group","page":0}'
Success response — 201 Created
{
"start_time": "2026-09-01T10:12:33.001Z",
"end_time": "2026-09-01T10:12:33.412Z",
"error": false,
"peerStatusArr": {
"corg": "group",
"data": [
{
"group": "West Region",
"groupCompanyLength": 57,
"isLoopAgain": true,
"currentIndex": 50,
"groupId": 12,
"groupChild": ["g_18", "g_19"],
"company": [
{
"status": "Completed",
"statusCode": 5,
"statusClass": "fa-check text-success animated tada",
"company_name": "Acme Holdings",
"company_id": 401,
"date": "04/30/26",
"close": "15",
"primary": { "id": 88, "name": "Dana Reed", "email": "dana@firm.com" },
"reviewer": [{ "id": 91, "name": "Sam Ortiz", "email": "sam@firm.com" }]
}
]
}
],
"commentCount": { "401": "2/7" },
"commentType": 104,
"monthVal": "Apr",
"yearVal": 2026
}
}
| Field | Type | Description |
|---|---|---|
start_time / end_time |
string | ISO timestamps stamped at response build. |
peerStatusArr.corg |
string | group, company, or the fallback scope type. |
peerStatusArr.data[] |
object[] | One node. isLoopAgain/currentIndex drive the client's paging of this group's own companies; groupChild holds g_<id> tokens for the child groups to request next. |
peerStatusArr.data[].company[] |
object[] | date is the completion date (m/d/yy) or -; close is the close-deadline day as a string; primary is an object ({} when unassigned); reviewer is an array (a single object for company scope). |
peerStatusArr.commentCount |
object | companyId → "<unresolved>/<total>". |
peerStatusArr.commentType |
number | Always 104 (the Close Status comment type). |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- For a group scope this returns exactly one node, not the flattened subtree; the client recursively re-calls this endpoint once per child group to walk the tree.
- An empty scope (
cg_idblank /id0) falls back to every company in the firm. - Close Status data is never filtered by the company's active/deleted state;
includeInactiveonly affects the picker option list.
The Calendar tab: per-manager completed/total counts bucketed by the firm's configured calendar periods (day-of-month ranges), plus the drill-down detail rows behind each bucket.
| Module | Monthly Close > Close Status |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Firm-scoped server-side. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
month |
string | Yes | Must not be empty String | Month token. |
year |
number | Yes | Must not be empty Integer | Four-digit year. |
cgId |
string | No | String | Scope token (g_<id> / c_<id>). |
id |
number | No | Integer | Selected company/group id. |
type |
string | No | One of group, company, "" |
Kind of id. |
includeInactive |
boolean | No | Boolean (string forms are coerced) Boolean | Include inactive companies. |
Example request
curl -X POST https://api.example.com/api/mc/status/manager/closeDeadline \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"month":"Apr","year":2026,"cgId":"g_12","id":12,"type":"group"}'
Success response — 201 Created
{
"data": [
{
"manager_id": 88,
"manager_name": "Dana Reed",
"close_status": [
{ "completed": 4, "total": 6, "percent": 66.67 },
{ "completed": 9, "total": 9, "percent": 100 },
{ "completed": 13, "total": 15, "percent": 86.67 },
{ "completed": 0, "total": 2, "percent": 0 }
]
},
{
"manager_id": "all_manager",
"manager_name": "All Managers",
"close_status": [{ "completed": 26, "total": 32, "percent": 81.25 }]
}
],
"columns": [
{ "order": 1, "type": "period", "text": "1 - 10", "original": "1 - 10" },
{ "order": 2, "type": "total", "text": "Total", "original": "total" },
{ "order": 3, "type": "noDeadline", "text": "Non Closing", "original": "noDeadline" }
],
"report": {
"88": { "1 - 10": [{ "company": "Acme Holdings", "deadline": "10", "primary": "Dana Reed", "reviewer": "Sam Ortiz" }], "total": [], "noDeadline": [] },
"all_manager": { "1 - 10": [], "total": [], "noDeadline": [] }
}
}
| Field | Type | Description |
|---|---|---|
data[] |
object[] | One row per manager, sorted by manager_name, with a trailing all_manager aggregate row. close_status is positionally aligned with columns. |
columns[] |
object[] | Bucket definitions: one per configured calendar period, then total, then noDeadline ("Non Closing"). |
report |
object | managerId → { <column.original>: detailRow[] }, plus an all_manager union. These rows feed the drill-down modal and are what the modal's export posts back. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Deadline days are weekend-adjusted using the firm's
close_deadline_configbefore bucketing. - Each column is evaluated independently, so overlapping configured periods each count the same company.
The Relative tab: per-manager counts of unclosed companies bucketed by how many days past/due the deadline is.
| Module | Monthly Close > Close Status |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Firm-scoped server-side. |
| Content type | application/json |
Request body (identical to closeDeadline above).
Example request
curl -X POST https://api.example.com/api/mc/status/manager/unClosedDeadline \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"month":"Apr","year":2026,"cgId":"g_12","id":12,"type":"group"}'
Success response — 201 Created
{
"data": [
{ "manager_id": 88, "manager_name": "Dana Reed", "un_close": [{ "total": 3 }, { "total": 1 }, { "total": 4 }, { "total": 0 }] },
{ "manager_id": "all_manager", "manager_name": "All Managers", "un_close": [{ "total": 7 }] }
],
"columns": [
{ "order": 1, "type": "period", "text": "1 - 5 Days Past", "original": "past_1_5" },
{ "order": 2, "type": "total", "text": "Total", "original": "total" },
{ "order": 3, "type": "noDeadline", "text": "Non Closing", "original": "noDeadline" }
],
"details": {
"88": { "past_1_5": [{ "company": "Acme Holdings", "deadline": "10", "primary": "Dana Reed", "reviewer": "Sam Ortiz" }], "total": [], "noDeadline": [] },
"all_manager": { "past_1_5": [], "total": [], "noDeadline": [] }
}
}
| Field | Type | Description |
|---|---|---|
data[].un_close[] |
object[] | { total } per column, positionally aligned with columns (buckets + Total + Non Closing). |
details |
object | Same drill-down shape as report on the Calendar tab. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
The Completion tab: per-manager closed-on-time / closed-late / unclosed / no-deadline counts with percentages.
| Module | Monthly Close > Close Status |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Firm-scoped server-side. |
| Content type | application/json |
Request body (identical to closeDeadline above).
Example request
curl -X POST https://api.example.com/api/mc/status/manager/completionResults \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"month":"Apr","year":2026,"cgId":"g_12","id":12,"type":"group"}'
Success response — 201 Created
{
"data": [
{
"manager_id": 88,
"manager_name": "Dana Reed",
"closedOnTime": { "val": 12, "percent": 75 },
"closedLate": { "val": 2, "percent": 12.5 },
"unClosed": { "val": 2, "percent": 12.5 },
"noDeadline": { "val": 1, "percent": 6.25 },
"total": 16
},
{
"manager_id": "all_manager",
"manager_name": "All Managers",
"closedOnTime": { "val": 20, "percent": 71.43 },
"closedLate": { "val": 4, "percent": 14.29 },
"unClosed": { "val": 4, "percent": 14.29 },
"noDeadline": { "val": 2, "percent": 7.14 },
"total": 28
}
],
"details": {
"88": { "closedOnTime": [{ "company": "Acme Holdings", "deadline": "10", "closed": "04/28/26", "primary": "Dana Reed", "reviewer": "Sam Ortiz" }] },
"all_manager": { "closedOnTime": [] }
}
}
| Field | Type | Description |
|---|---|---|
data[] |
object[] | Sorted by manager_name, trailing all_manager aggregate. total excludes noDeadline (it is closedOnTime + closedLate + unClosed). |
details |
object | Drill-down rows keyed by manager then bucket; the Completion tab's rows carry an extra closed (date closed) column. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Generates the drill-down modal export workbook from rows the client posts back, and returns a one-shot download link.
| Module | Monthly Close > Close Status |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body (the scope fields of the tab endpoints above, plus the export fields)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
month |
string | Yes | Must not be empty String | Month token (rendered into the export's "As On Date" header). |
year |
number | Yes | Must not be empty Integer | Year. |
cgId / id / type / includeInactive |
— | No | As on the tab endpoints above | Scope fields. |
tab |
string | No | One of calendar, relative, completion |
Defaults to calendar; picks the report name and sheet layout. |
title |
any | No | none | The modal's bucket title, rendered into the meta header. |
exportName |
string | No | String | User-chosen file name; \ / # ? " % are stripped server-side. Defaults to the report name. |
managerName |
string | No | String | The drill-down modal's manager, rendered into the meta header. |
rows |
object[] | No | none | The modal's visible rows, exported as-is. Non-array values yield an empty sheet body. |
Example request
curl -X POST https://api.example.com/api/mc/status/manager/makeExport \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"month":"Apr","year":2026,"tab":"completion","title":"Closed On Time","managerName":"Dana Reed","exportName":"April completion","rows":[{"company":"Acme Holdings","deadline":"10","closed":"04/28/26","primary":"Dana Reed","reviewer":"Sam Ortiz"}]}'
Success response — 201 Created
{ "error": false, "redirect": "/api/mc/status/manager/download/generated/1f0c2b6e-2b1c-4a2f-9b8e-7f2e2a1d55c3" }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success. |
redirect |
string | Path to GET /api/mc/status/manager/download/generated/:token. Single-use. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Report name is derived from
tab:Closed Status Report(calendar),Unclosed Status Report(relative),Completion Status Report(completion). - The Calendar tab export carries five parallel status columns; the relative and completion tabs export the modal's Company/Deadline/(Date Closed)/Primary/Reviewer table. Internal fields such as
statusCodeare never written to the sheet. - Note the route naming: the drill-down modal export posts to
makeExport, while the grid export posts tomakeIndivudualExport.
Generates the grid export workbook by re-running the requested tab's own query server-side, and returns a one-shot download link. (The route path is spelled exactly as shown and must be requested that way.)
| Module | Monthly Close > Close Status |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body (same fields as makeExport; rows / title / managerName are ignored on this path because the grid is rebuilt server-side).
Example request
curl -X POST https://api.example.com/api/mc/status/manager/makeIndivudualExport \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"month":"Apr","year":2026,"cgId":"g_12","id":12,"type":"group","tab":"calendar","exportName":"April calendar grid"}'
Success response — 201 Created
{ "error": false, "redirect": "/api/mc/status/manager/download/generated/9c4a5b12-7d33-4c0a-b2ad-0f4e6a1b8e77" }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success. |
redirect |
string | Path to the token download route. Single-use. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- The requested tab's own query (
closeDeadline,unClosedDeadlineorcompletionResults) is re-run server-side, so the export always matches what the grid would render for the same filters. - The sheet opens with a Report Name / Report Generated On / As On Date / Generated By meta block, then the grid including its merged title row and blank manager-column header.
Streams a previously generated Close Status export workbook and deletes it.
| Module | Monthly Close > Close Status |
| Authentication | Not required — possession of the single-use token is the only credential (unlike the QuickBooks Close Dates download, which additionally requires a matching session user) |
| Authorization | No role requirement |
| Content type | Binary .xlsx download |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The UUID returned in the redirect of makeExport / makeIndivudualExport. |
Example request
curl -OJ https://api.example.com/api/mc/status/manager/download/generated/9c4a5b12-7d33-4c0a-b2ad-0f4e6a1b8e77
Success response — 200 OK, Content-Disposition: attachment; filename="<exportName>.xlsx", body is the XLSX binary.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 404 | Unknown or already-consumed token | { "error": true, "message": "File not found." } |
Notes
- Single-use: the token is invalidated before streaming begins, and the temporary file is removed once the response finishes.
- The generated workbook is held as temporary server-side storage and referenced by a single-use token. The download request must be served by the same application instance that produced the export, so multi-instance deployments must preserve instance affinity for these routes. This is the export mechanism used throughout the module. See Deployment & Operational Considerations.
12.2 Close Details
Close Details is the per-company close checklist. It comprises exactly nine sections, each with its own review screen, its own comment-module id, and — except Cash Flow Statement — a Configure screen:
section |
Section name | Comment subModule id |
Review route | Configure route |
|---|---|---|---|---|
| 1 | Bank and Card Reconciliations | 59 | /monthly-close/close-details/bank-card/review |
/monthly-close/close-details/bank-card/configure |
| 2 | Accounts Receivable | 53 | /monthly-close/close-details/accounts-receivable/review |
/monthly-close/close-details/accounts-receivable/configure |
| 3 | Accounts Payable | 54 | /monthly-close/close-details/accounts-payable/review |
/monthly-close/close-details/accounts-payable/configure |
| 4 | Balance Sheet General | 62 | /monthly-close/close-details/balance-sheet/review |
/monthly-close/close-details/balance-sheet/configure |
| 5 | Uncharacterized Revenue and Expense | 56 | /monthly-close/close-details/uncharacterized/review |
/monthly-close/close-details/uncharacterized/configure |
| 6 | Payroll | 58 | /monthly-close/close-details/payroll/review |
/monthly-close/close-details/payroll/configure |
| 7 | Income Statement General | 55 | /monthly-close/close-details/income-statement/review |
/monthly-close/close-details/income-statement/configure |
| 8 | Cash Flow Statement | 57 | /monthly-close/close-details/cash-flow/review |
# (no Configure workflow exists) |
| 9 | Other | 60 | /monthly-close/close-details/other/review |
/monthly-close/close-details/other/configure |
The list is rendered in the order 1, 2, 3, 4, 5, 6, 9, 7, 8 — that is, Other sits between Payroll and Income Statement General.
Each section's Comments badge counts a fixed set of mc_comments.type ids, not the section id: 1 → [44,45,46,47]; 2 → [1,2,3,7,8,48,49]; 3 → [4,5,6,9,10,50,51]; 4 → [11,12,13,14,15,16,17,18,19,105,106,107]; 5 → [27,28,29,30,36,37,38,39]; 6 → [40,41,42,43]; 7 → [20,21,22,23,24,25,26]; 8 → [31,32,33,34,35]; 9 → [100].
Persisted mc_status.mc_status values are 1 Skip, 2 Submit, 3 Approve. The display status the API returns per section is:
statusId |
statusName |
Derived from |
|---|---|---|
| 1 | Not started |
no mc_status row and no comments |
| 2 | Skipped |
mc_status = 1 |
| 3 | In Review |
mc_status = 2 |
| 4 | Started |
no status row but the section has comments |
| 5 | Completed / Skip & Completed |
mc_status = 3 (the latter when is_skipped = 1) |
Every write endpoint in this subsection except mc/status/update additionally requires Monthly Close edit rights: the caller's Monthly Close (menu 3) access for the company must be Admin (4) or Standard (3), with a blanket pass for a Global Master System Administrator or a firm admin. ViewOnly (2) and NoAccess (1) are rejected with 403.
The Close Details landing grid: the nine sections with their status, comment counts and the whole-close QA action flags for the selected company and month.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the backend asserts the company belongs to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company to load. |
monthVal |
string | Yes | String | Month token. |
yearVal |
number | Yes | Numeric (numeric strings are coerced) Integer | Year. |
status |
number | No | Integer | Filter the returned sections by display statusId (1–5). 0/absent = all. |
Example request
curl -X POST https://api.example.com/api/mc/monthly/close/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"error": false,
"approveError": "",
"data": {
"viewContent": [
{
"section": 1,
"subModule": 59,
"sectionName": "Bank and Card Reconciliations",
"reviewRoute": "/monthly-close/close-details/bank-card/review",
"configRoute": "/monthly-close/close-details/bank-card/configure",
"statusId": 5,
"statusName": "Completed",
"statusCls": "fa-check text-success animated tada",
"unresolvedCount": 1,
"totalCount": 4,
"module": 103
}
],
"qaStatusArray": {
"canReview": true,
"canApprove": false,
"canSkip": false,
"canUnReview": false,
"canUnApprove": false,
"canUnSkip": false,
"hover_status": "",
"canSubmit": true,
"hasCompleted": true
},
"totalCommentCount": { "unresolvedCount": 3, "totalCount": 12 },
"connection_qbo": 1
}
}
| Field | Type | Description |
|---|---|---|
approveError |
string | Same value as qaStatusArray.hover_status; rendered as the Approve button's tooltip. |
data.viewContent[] |
object[] | One entry per section (filtered by status when given), in MC_CLOSE_DETAILS_ORDER. module: 103 is the Monthly Close comment module id. |
data.qaStatusArray |
object | Whole-close action flags. canReview is the SUBMIT permission (primary accountant); canSkip/canApprove belong to the month's assigned reviewer. canSubmit is an alias of canReview. hasCompleted is true when any section is at statusId 5. |
data.totalCommentCount |
object | Count of every mc_comments row for the company/month — not the sum of the per-section badges. |
data.connection_qbo |
number | 1 when the company has a live QuickBooks connection, else 0. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- The QA flags are computed over all nine sections before the
statusfilter is applied. statusClscarries the icon and colour class tokens the client renders for each status.
Skip / Submit / Approve a single section, or run the corresponding "… All" action across the whole close; also supports reverting each of those.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Firm ownership of companyId is asserted; Monthly Close edit rights are not separately required — eligibility is expressed through the returned can* flags. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company. |
monthVal |
string | Yes | String | Month token. |
yearVal |
number | Yes | Integer | Year. |
mode |
number | Yes | Integer | 1 = Skip, 2 = Submit; any other value is treated as 3 (Approve). |
section |
number | Yes | Integer | Target section (1–9). Ignored when overall is true; values ≤ 0 select nothing. |
overall |
boolean | No | Boolean | Run the whole-close ("… All") variant. |
isRevert |
number | No | Integer | 1 = revert the action (Un-Skip / Un-Submit / Un-Approve). |
Example request
curl -X POST https://api.example.com/api/mc/status/update \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026,"mode":2,"section":4}'
Success response — 201 Created
{
"error": false,
"errorMsg": "",
"canSkip": false,
"canReview": false,
"canApprove": true,
"canUnSkip": false,
"canUnReview": true,
"canUnApprove": false,
"hoverStatus": "",
"canSubmit": false,
"hover_status": ""
}
| Field | Type | Description |
|---|---|---|
error / errorMsg |
boolean / string | false / "" on success. |
can* / hoverStatus |
boolean / string | The recomputed whole-close QA flags, spread into the top level of the response. |
hover_status |
string | Snake_case alias of hoverStatus, carrying the same value. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | mc_status table is absent in this deployment |
{ "error": true, "message": "mc_status table not found." } |
Notes
- The "… All" variants do not apply the mode to all nine sections. Submit All / Skip All target only sections with no
mc_statusrow yet; Approve All targets only sections currently at Skip or Submit; Un-Skip/Un-Submit/Un-Approve All target only the sections currently in that state. - Reverting never hard-deletes: Un-Skip and Un-Submit soft-delete (
deleted = 1), and Un-Approve steps the row back to Skip (whenis_skipped) or Submit, so a re-approved section still remembers it was skipped.
The "Other Monthly Close Items" panel header: task counts, combined comment counts and the two saved checkbox flags for the company and date.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
asOnDate |
string | Yes | String | The close period date. |
Example request
curl -X POST https://api.example.com/api/other/section/details \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"asOnDate":"2026-04-01"}'
Success response — 201 Created
{
"count": {
"totalTask": 14,
"incompleteTask": 3,
"unresolvedCount": 5,
"totalCount": 21,
"moduleId": 60,
"mcUnResolve": 2,
"mcTotal": 9
},
"check": { "other_section_incomplete": 0, "other_section_complete": 1 }
}
| Field | Type | Description |
|---|---|---|
count.totalTask / incompleteTask |
number | Task counts for the company/period. |
count.unresolvedCount / totalCount |
number | Monthly Close plus task comment counts combined. |
count.moduleId |
number | The "Other" comment module id (60 by default). |
count.mcUnResolve / mcTotal |
number | The Monthly Close half of those counts, broken out. |
check |
object | mc_checkbox_data flags for this company/period; both keys always present, defaulting to 0. Written by POST /api/other/section/check. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Loads the Bank & Card Reconciliations review screen: bank accounts, credit cards, undeposited-funds data, balances and the section's QA status.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Returns canEdit so the UI can disable inputs. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company. |
monthVal |
string | Yes | String | Month token. |
yearVal |
number | Yes | Integer | Year. |
active |
number | No | Integer | Active/inactive account filter. |
Example request
curl -X POST https://api.example.com/api/mc/bankcard/review/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026,"active":1}'
Success response — 201 Created
{
"title": "Bank and Card Reconciliations",
"moduleId": 59,
"totalCommentCount": { "unresolvedCount": 1, "totalCount": 4 },
"connection_qbo": 1,
"filterTable": {
"filterBankCard": [
{
"id": 900,
"account_name": "Operating Checking",
"account_num": "1000",
"account_type": "Bank",
"qbo_id": "35",
"company_id": 401,
"active": 1,
"q_id": "35",
"end_date": "2026-04-30",
"is_date_posted": "35",
"encrypt_data": "PVdKa0...",
"comment_count": "0/0"
}
],
"filterCreditCard": [],
"undepositData": [],
"balanceArray": [],
"asOnDate": "2026-04-01",
"monthVal": "Apr",
"yearVal": 2026
},
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true
}
| Field | Type | Description |
|---|---|---|
title |
string | Section heading. |
moduleId |
number | comment_module.module_id for this section (falls back to 59). |
connection_qbo |
number | 1 when QuickBooks is connected. |
filterTable.filterBankCard[] / filterCreditCard[] |
object[] | One row per account. q_id echoes qbo_id when the reconciliation checkbox is ticked ('' when not); is_date_posted encodes the "all transactions posted" checkbox the same way; encrypt_data is the route param for the flagged-list drill-down. Credit-card rows carry an extra statement_date. |
qaStatus |
object | Section-level QA flags (section 1). |
canEdit |
boolean | Whether this user may edit Monthly Close for this company. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Comment counts per row are populated separately; the initial payload ships
"0/0". - The flagged-transaction counts are a second call — see
POST /api/mc/bankcard/flag/allcount.
Phase-2 call for the Bank & Card screen: given every account's qbo_id, returns the flagged-transaction count per account.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company. |
monthVal |
string | Yes | String | Month token. |
yearVal |
number | Yes | Integer | Year. |
qboIds |
(string|number)[] | Yes | Array | Every account qbo_id on the screen. De-duplicated and stringified server-side. |
Example request
curl -X POST https://api.example.com/api/mc/bankcard/flag/allcount \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026,"qboIds":["35","36","91"]}'
Success response — 201 Created
{ "count": { "35": 4, "91": 1 } }
| Field | Type | Description |
|---|---|---|
count |
object | qbo_id → number of flagged transactions. Accounts with zero flags are omitted. {} when qboIds is empty, monthVal/yearVal are missing, or the qbo_bank_cc_data table is absent. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- A transaction is flagged when its age at the month end exceeds the per-transaction-type day threshold. Thresholds resolve per company override → firm-wide global → a built-in default of
10. - Reconciled/cleared rows (
is_clrinR,C) and soft-deleted rows are excluded.
The "View flagged List" drill-down for one bank or credit-card account.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the decoded company is firm-checked. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The encrypt_data value from a Bank & Card review row — the encrypted triple "<qboId>/<companyId>/<asOnDate>". Both URL-encoded and raw forms are accepted. |
Example request
curl -b 'genie.sid=<session-cookie>' \
'https://api.example.com/api/mc/bank/view/reconciliations/PVdKa0hDVGZ3...'
Success response — 200 OK
{
"error": false,
"company_id": 401,
"company_name": "Acme Holdings",
"accountName": "1000 Operating Checking",
"accountType": "Bank",
"is_creditCard": false,
"qbo_id": "35",
"month": "Apr",
"year": 2026,
"monthVal": "Apr",
"yearVal": 2026,
"commentType": 44,
"acceptType": 44,
"subModuleId": 59,
"bankCcData": []
}
| Field | Type | Description |
|---|---|---|
is_creditCard |
boolean | True when the account type is Credit Card. |
commentType / acceptType |
number | 45 for a credit card, 44 for a bank account. |
subModuleId |
number | The Bank and Card Reconciliations comment-module id (default 59). |
bankCcData[] |
object[] | The flagged transactions for this account and month. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | The id cannot be decrypted, or decrypts to fewer than 3 parts, or has an empty qboId/companyId |
{ "error": true, "message": "Invalid list id.", "bankCcData": [] } |
Per-row autosave for the Bank & Card screen: the reconciliation checkbox, the "all transactions posted" checkbox, statement dates, and the undeposited-funds row.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close edit rights on the company: Admin/Standard access, or global/firm admin. Otherwise 403. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company. |
type |
string | Yes | String | 'bank' | 'credit' | 'undep'; not enum-validated. |
month |
string | No | String | Month token. |
year |
number | No | Integer | Year. |
monthVal |
string | No | String | Alias of month. |
yearVal |
number | No | Integer | Alias of year. |
qboId |
string | No | String | The account being saved. |
chkBc |
string | No | String | Holds the account's qbo_id when the reconciliation checkbox is ticked, '' when not. |
chkDate |
string | No | String | Same encoding for the "all transactions posted" checkbox. |
stDate |
string | No | String | Statement start date. |
endDate |
string | No | String | Statement end date. |
undepDate |
string | No | String | Undeposited-funds date. |
undepCheck |
number | No | Integer | Undeposited-funds checkbox value. |
Example request
curl -X POST https://api.example.com/api/mc/save/bank/reconciliations \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026,"type":"bank","qboId":"35","chkBc":"35","chkDate":"","endDate":"2026-04-30"}'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Monthly Close edit rights on the company | { "error": true, "message": "You do not have permission to edit Monthly Close for this company." } |
Notes
- Both key spellings (
month/yearandmonthVal/yearVal) are accepted; the backend coalesces them.
Loads the Accounts Receivable review screen.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canEdit is returned for UI gating. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
monthVal |
string | Yes | String | Month token. |
yearVal |
number | Yes | Numeric (numeric strings are coerced) Integer | Year. |
Example request
curl -X POST https://api.example.com/api/mc/ar/review/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"title": "Account Receivable",
"moduleId": 53,
"connection_qbo": 1,
"totalCommentCount": { "unresolvedCount": 2, "totalCount": 6 },
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true,
"approveError": "",
"report_date": "2026-04-30",
"filterTable": {}
}
| Field | Type | Description |
|---|---|---|
title |
string | Account Receivable. |
moduleId |
number | Resolved from comment_module by submodule name; falls back to 53. |
qaStatus |
object | Section 2 QA flags. |
canEdit |
boolean | Monthly Close edit rights for this company. |
report_date |
string | The AR aging report date. |
filterTable |
object | The AR review buckets (delinquent invoices, credit memos, unapplied payments), each carrying its own encrypted "View List" id. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Loads the Accounts Payable review screen.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canEdit is returned for UI gating. |
| Content type | application/json |
Request body (structurally identical to the AR review filter: companyId, monthVal, yearVal, all required).
Example request
curl -X POST https://api.example.com/api/mc/ap/review/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"title": "Account Payable",
"moduleId": 54,
"connection_qbo": 1,
"totalCommentCount": { "unresolvedCount": 0, "totalCount": 3 },
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true,
"approveError": "",
"report_date": "2026-04-30",
"filterTable": {}
}
Field meanings are the same as the AR filter above, with title Account Payable, moduleId falling back to 54, and qaStatus covering section 3.
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Generic save for the per-section reconciliation attestation checkboxes (e.g. "does AR Aging tie to the Balance Sheet AR balance?").
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close edit rights on the company; otherwise 403. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Integer | Company. |
monthVal |
string | Yes | String | Month token. |
yearVal |
number | Yes | Integer | Year. |
colName |
string | Yes | String | The mc_checkbox_data.col_name discriminator, e.g. ar_balance. Not enum-constrained — each section uses its own name. |
value |
number | Yes | Integer | The stored value (0/1). |
Example request
curl -X POST https://api.example.com/api/mc/checkbox/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026,"colName":"ar_balance","value":1}'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Monthly Close edit rights | { "error": true, "message": "You do not have permission to edit Monthly Close for this company." } |
| 200/201 | mc_checkbox_data table missing |
{ "error": true, "message": "mc_checkbox_data table not found." } |
| 200/201 | mc_checkbox_data lacks the expected columns |
{ "error": true, "message": "mc_checkbox_data has unsupported schema." } |
Notes
- Keyed by
company_id+as_on_date(the resolved month start) +col_name; upserts in place. Shared by AR, AP, Balance Sheet, Income Statement and Payroll.
Paginated transaction list behind one Accounts Receivable review bucket.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the decoded company is firm-checked. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The bucket's encrypted list id, minted by the AR review filter as "<companyId>/<monthEnd>/<type>/<days>". |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
page |
string | No | 1 |
Page number, coerced to a number. An absent value yields page 1; a non-numeric value yields NaN. |
commonId |
string | No | '' |
Pins a deep-linked transaction onto page 1 (excluded from every other page). |
Example request
curl -b 'genie.sid=<session-cookie>' \
'https://api.example.com/api/mc/ar/review/list/PVdKa0hDVGZ3...?page=2&commonId=9931'
Success response — 200 OK
{
"error": false,
"companyId": 401,
"companyName": "Acme Holdings",
"encryptData": "PVdKa0hDVGZ3...",
"type": 1,
"daysOld": 90,
"typeName": "Seriously Delinquent Invoices",
"asOnDate": "2026-04-30",
"monthVal": "Apr",
"yearVal": 2026,
"acceptType": 7,
"commentType": 1,
"subModuleId": 53,
"commonId": "9931",
"invoiceData": [],
"noMoreData": false
}
| Field | Type | Description |
|---|---|---|
type / typeName |
number / string | The bucket. AR uses 1 Seriously Delinquent Invoices, 2 Seriously Delinquent Credit Memo, 5 Delinquent Unapplied Customer Payments. |
acceptType / commentType |
number | Accept/revoke type_id and comment thread type for this bucket — AR: 1→7/1, 2→8/2, 5→49/48. |
daysOld |
number | The age cutoff encoded in the list id. |
commonId |
string | Echo of the pinned transaction id (or ''). |
invoiceData[] |
object[] | The page's transactions. |
noMoreData |
boolean | True when the page returned fewer rows than the page size (end of list). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | The encrypted id cannot be parsed | { "error": true, "message": "Invalid list id.", "invoiceData": [] } |
Notes
- The AR and AP list endpoints return the same response shape; only the bucket metadata differs.
Paginated transaction list behind one Accounts Payable review bucket.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the decoded company is firm-checked. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The bucket's encrypted list id (same shape as AR). |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
page |
string | No | 1 |
Page number. |
commonId |
string | No | '' |
Pins a deep-linked transaction onto page 1. |
Example request
curl -b 'genie.sid=<session-cookie>' \
'https://api.example.com/api/mc/ap/review/list/PVdKa0hDVGZ3...?page=1'
Success response — 200 OK; identical shape to the AR list, with subModuleId falling back to 54 and AP's own bucket metadata:
{
"error": false,
"companyId": 401,
"companyName": "Acme Holdings",
"encryptData": "PVdKa0hDVGZ3...",
"type": 3,
"daysOld": 90,
"typeName": "Seriously Delinquent Bills",
"asOnDate": "2026-04-30",
"monthVal": "Apr",
"yearVal": 2026,
"acceptType": 9,
"commentType": 4,
"subModuleId": 54,
"commonId": "",
"invoiceData": [],
"noMoreData": true
}
| Field | Type | Description |
|---|---|---|
type / typeName |
number / string | AP uses 3 Seriously Delinquent Bills, 4 Seriously Delinquent Vendor Credits, 6 Delinquent Unapplied Vendor Payments. |
acceptType / commentType |
number | AP: 3→9/4, 4→10/5, 6→51/50. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | The encrypted id cannot be parsed | { "error": true, "message": "Invalid list id.", "invoiceData": [] } |
Accept or revoke a single flagged transaction on any section's list screen.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close edit rights on the company identified by cgId; otherwise 403. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
cgId |
number | Yes | Numeric (numeric strings are coerced) Integer | The company id (despite the name) whose row is being accepted. |
monthVal |
string | Yes | String | Month token. |
yearVal |
number | Yes | Integer | Year. |
value |
number | Yes | Integer | The accept/revoke value to store. |
commonId |
string | number | No | No type constraint | The row key — a numeric qbo_id for AR/AP, the base64 enc_bank_id for Bank & Card. |
typeId |
number | No | Integer | The bucket's accept type. |
commonIds |
(string|number)[] | No | Array | Batch form of commonId. |
typeIds |
number[] | No | Array | Batch form of typeId. |
isAllcheckBox |
boolean | No | Boolean | "Select all" mode — the server expands the bucket itself instead of enumerating rows. |
encryptId |
string | No | String | The encrypted list id, required when isAllcheckBox is set. |
isFrom |
string | No | String | Expansion branch for select-all. Supported values are 'AR', 'AP', 'revenue', 'IncomeStatement' and 'BalanceSheet'; the last two load their whole set in one response, so their clients never set this flag. |
commonIdsUnSelected |
(string|number)[] | No | Array | Rows the user un-ticked while select-all was active. |
Example request
curl -X POST https://api.example.com/api/mc/common/accept \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"cgId":401,"monthVal":"Apr","yearVal":2026,"value":1,"commonId":"9931","typeId":7}'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Monthly Close edit rights on the company | { "error": true, "message": "You do not have permission to edit Monthly Close for this company." } |
Notes
- This route and
mc/common/accept/allshare one request contract; the batch route is the single-row route with the scalar ids replaced by arrays.
Batch accept/revoke — the header "select all" action on a list screen.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close edit rights on the company identified by cgId; otherwise 403. |
| Content type | application/json |
Request body (identical to mc/common/accept; this route is where commonIds/typeIds or the isAllcheckBox + encryptId + isFrom + commonIdsUnSelected combination is used).
Example request
curl -X POST https://api.example.com/api/mc/common/accept/all \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"cgId":401,"monthVal":"Apr","yearVal":2026,"value":1,"isAllcheckBox":true,"encryptId":"PVdKa0hDVGZ3...","isFrom":"AR","commonIdsUnSelected":["9940"]}'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Monthly Close edit rights on the company | { "error": true, "message": "You do not have permission to edit Monthly Close for this company." } |
Notes
- Both routes perform the same upsert; the batch variant is exposed on its own path.
Loads the Balance Sheet General review screen.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canEdit is returned for UI gating. |
| Content type | application/json |
Request body (companyId, monthVal, yearVal, all required — the shared review-filter contract).
Example request
curl -X POST https://api.example.com/api/mc/balancesheet/review/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"title": "Balance Sheet General",
"moduleId": 62,
"connection_qbo": 1,
"totalCommentCount": { "unresolvedCount": 4, "totalCount": 11 },
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true,
"approveError": "",
"filterTable": {
"flaggedAccounts": 7,
"gl_account_schedules": {}
}
}
| Field | Type | Description |
|---|---|---|
moduleId |
number | Resolved from comment_module; falls back to 62. |
qaStatus |
object | Section 4 QA flags. |
filterTable.flaggedAccounts |
number | Count of flagged GL accounts. |
filterTable.gl_account_schedules |
object | The GL Account Validation summary that links to the GL Validation screen. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- The screen's six drill-downs each carry an encrypted "View List" id minted as
"<companyId>/<monthStart>/<type>/"— note it uses the month start and ends with a trailing slash with no days component, unlike the AR/AP ids.
One of the six Balance Sheet General "View List" drill-downs.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the decoded company is firm-checked. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Encrypted "<companyId>/<monthStart>/<type>/". type is 1–6. |
Example request
curl -b 'genie.sid=<session-cookie>' \
'https://api.example.com/api/mc/balancesheet/review/list/RG5jNXdIT...'
Success response — 200 OK
{
"error": false,
"companyId": 401,
"companyName": "Acme Holdings",
"encryptData": "RG5jNXdIT...",
"type": 1,
"typeName": "GL Account Variances List",
"asOnDate": "2026-04-01",
"monthVal": "Apr",
"yearVal": 2026,
"commentType": 11,
"acceptType": 17,
"threshold": 0,
"accounts": [],
"accSixMonth": {},
"accSixMonthHeader": [],
"priorVal": 0
}
| Field | Type | Description |
|---|---|---|
type / typeName |
number / string | 1 GL Account Variances List, 2 Accumulated Depreciation and Amortization, 3 Balance Sheet Debit and Credit Balances, 4 Prepaid Expense, 5 Fixed Assets, 6 Deferred Revenue. |
commentType |
number | 1→11, 2→12, 3→14, 4→105, 5→106, 6→107. |
acceptType |
number | 1→17, 2→18, 3→19, 4→105, 5→106, 6→107. |
accounts[] |
object[] | The list rows, each decorated with accepted and checkCommentsCount ("<unresolved>/<total>"). |
accSixMonth / accSixMonthHeader |
object / object[] | The trailing six-month balance buckets and their column headers. |
priorVal |
number | Prior-month / average-of-prior-N-months value. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
priorValis returned for information only.
Loads the Income Statement General review screen.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canEdit is returned for UI gating. |
| Content type | application/json |
Request body (companyId, monthVal, yearVal, all required).
Example request
curl -X POST https://api.example.com/api/mc/incomestatement/review/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"title": "Income Statement General",
"moduleId": 55,
"connection_qbo": 1,
"totalCommentCount": { "unresolvedCount": 0, "totalCount": 2 },
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true,
"approveError": "",
"filterTable": {
"varianceRows": 5,
"chart_of_accounts": {},
"integrity": {}
}
}
| Field | Type | Description |
|---|---|---|
moduleId |
number | Falls back to 55. |
qaStatus |
object | Section 7 QA flags. |
filterTable.varianceRows |
number | Count of flagged GL variance rows. |
filterTable.chart_of_accounts / integrity |
object | The screen's supporting panels (including the gross-margin actual-vs-expected comparison). |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
One of the two Income Statement General "View List" drill-downs.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the decoded company is firm-checked. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Encrypted "<companyId>/<monthStart>/<type>/". type is 1 or 2. |
Example request
curl -b 'genie.sid=<session-cookie>' \
'https://api.example.com/api/mc/incomestatement/review/list/RG5jNXdIT...'
Success response — 200 OK
{
"error": false,
"companyId": 401,
"companyName": "Acme Holdings",
"encryptData": "RG5jNXdIT...",
"type": 1,
"typeName": "GL Account Variances List",
"asOnDate": "2026-04-01",
"monthVal": "Apr",
"yearVal": 2026,
"commentType": 20,
"acceptType": 25,
"accounts": [],
"accSixMonth": {},
"accSixMonthHeader": [],
"priorVal": 0
}
| Field | Type | Description |
|---|---|---|
type / typeName |
number / string | 1 GL Account Variances List, 2 Income Statement Debit and Credit Balances Flagged. |
commentType |
number | 1→20, 2→22. |
acceptType |
number | 1→25, 2→26. |
accounts[] |
object[] | Rows decorated with accepted and checkCommentsCount. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Loads the Payroll review screen.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canEdit is returned for UI gating. |
| Content type | application/json |
Request body (companyId, monthVal, yearVal, all required).
Example request
curl -X POST https://api.example.com/api/mc/pr/review/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"title": "Payroll",
"moduleId": 58,
"connection_qbo": 1,
"totalCommentCount": { "unresolvedCount": 1, "totalCount": 1 },
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true,
"approveError": "",
"filterTable": {
"w2Employees": 24,
"payrollDepartment": 1,
"taxTarget": 12
}
}
| Field | Type | Description |
|---|---|---|
moduleId |
number | Falls back to 58. |
qaStatus |
object | Section 6 QA flags. |
filterTable.w2Employees / payrollDepartment / taxTarget |
number | The configured Payroll expectations this month is checked against (set on the Payroll Configure screen). |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
The Payroll review's single "View List" drill-down (wages plus payroll-tax GL accounts).
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the decoded company is firm-checked. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Encrypted "<companyId>/<monthStart>/<type>/". |
Example request
curl -b 'genie.sid=<session-cookie>' \
'https://api.example.com/api/mc/pr/review/list/RG5jNXdIT...'
Success response — 200 OK
{
"error": false,
"company_id": 401,
"companyId": 401,
"companyName": "Acme Holdings",
"encryptData": "RG5jNXdIT...",
"asOnDate": "2026-04-01",
"monthVal": "Apr",
"yearVal": 2026,
"wagesAccountData": [],
"glAccountData": []
}
| Field | Type | Description |
|---|---|---|
wagesAccountData[] |
object[] | The configured wages accounts and their balances. |
glAccountData[] |
object[] | The configured payroll-tax GL accounts and their balances. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- The account sets come from
mc_payroll_config→mc_payroll_config_accounts, i.e. what the Payroll Configure screen wrote.
Loads the Uncharacterized Revenue and Expense review screen.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canEdit is returned for UI gating. |
| Content type | application/json |
Request body (companyId, monthVal, yearVal, all required).
Example request
curl -X POST https://api.example.com/api/mc/revenue/review/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"title": "Uncharacterized revenue and expense",
"moduleId": 56,
"connection_qbo": 1,
"totalCommentCount": { "unresolvedCount": 2, "totalCount": 5 },
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true,
"approveError": "",
"filterTable": { "classRevenue": 1, "classExpense": 0 }
}
| Field | Type | Description |
|---|---|---|
moduleId |
number | Falls back to 56. |
qaStatus |
object | Section 5 QA flags. |
filterTable.classRevenue / classExpense |
number | The two class-checking toggles from Uncharacterized Configure, plus the four review buckets and their encrypted list ids. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
One of the four Uncharacterized "View List" drill-downs, paginated.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the decoded company is firm-checked. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Encrypted "<companyId>/<monthStart>/<type>/"; type must be 1–4. |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
page |
string | No | 1 |
Page number; values ≤ 0 or non-numeric fall back to 1. |
commonId |
string | No | '' |
The row's enc_bank_id; pins that transaction onto page 1 when the window did not already contain it. |
Example request
curl -b 'genie.sid=<session-cookie>' \
'https://api.example.com/api/mc/revenue/review/list/RG5jNXdIT...?page=1&commonId=ZW5jXzk5Mw=='
Success response — 200 OK
{
"error": false,
"company_id": 401,
"companyId": 401,
"companyName": "Acme Holdings",
"encryptData": "RG5jNXdIT...",
"type": 1,
"typeName": "Uncharacterized and Miscellaneous Revenue",
"asOnDate": "2026-04-01",
"commentDate": "2026-04-01",
"monthVal": "Apr",
"yearVal": 2026,
"commentType": 27,
"acceptType": 36,
"subModuleId": 56,
"bankAccounts": [],
"noMoreData": true
}
| Field | Type | Description |
|---|---|---|
type / typeName |
number / string | 1 Uncharacterized and Miscellaneous Revenue, 2 Uncharacterized and Miscellaneous Expense, 3 Revenue Un-classed, 4 Expense Un-classed. |
commentType |
number | 1→27, 2→28, 3→29, 4→30. |
acceptType |
number | 1→36, 2→37, 3→38, 4→39. |
bankAccounts[] |
object[] | The page's transactions, with the customer-or-vendor name resolved from qbo_customer by cus_or_ven (1 customer, 2 vendor). |
noMoreData |
boolean | True when fewer than 25 rows came back (page size is 25). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Id cannot be parsed, or type is outside 1–4 |
{ "error": true, "message": "Invalid list id.", "bankAccounts": [] } |
Loads the Cash Flow Statement review screen — five checkbox options plus the section QA status.
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canEdit is returned for UI gating. |
| Content type | application/json |
Request body (companyId, monthVal, yearVal, all required).
Example request
curl -X POST https://api.example.com/api/mc/cash/flow/statement/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"title": "Cash Flow Statement",
"moduleId": 57,
"connection_qbo": 1,
"totalCommentCount": { "unresolvedCount": 0, "totalCount": 0 },
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true,
"approveError": "",
"filterTable": { "option_1": 1, "option_2": 0, "option_3": 1, "option_4": 0, "option_5": 0 }
}
| Field | Type | Description |
|---|---|---|
moduleId |
number | Falls back to 57. |
qaStatus |
object | Section 8 QA flags. |
filterTable.option_1 … option_5 |
number | The five saved checkbox values (0/1) for this company and month. Written by POST /api/mc/cash/flow/statement/save (documented under Firm Configuration). |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Cash Flow Statement has no Configure screen; its
configRouteis'#'.
Loads the "Other" section's review checklist for the company and month. (The path segment is spelled reivew, exactly as shown, and must be requested that way.)
| Module | Monthly Close > Close Details |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canEdit is returned for UI gating. |
| Content type | application/json |
Request body (companyId, monthVal, yearVal, all required).
Example request
curl -X POST https://api.example.com/api/mc/filter/other/reivew \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"error": false,
"connection_qbo": 1,
"moduleId": 60,
"totalCommentCount": { "unresolvedCount": 0, "totalCount": 1 },
"qaStatus": {
"canSkip": false, "canReview": true, "canApprove": false,
"canUnSkip": false, "canUnReview": false, "canUnApprove": false,
"hoverStatus": "", "canSubmit": true
},
"canEdit": true,
"approveError": "",
"getOtherReview": [
{ "id": 77, "item_name": "Bank fees reviewed", "type_of_field": 2 }
]
}
| Field | Type | Description |
|---|---|---|
moduleId |
number | Falls back to 60. |
qaStatus |
object | Section 9 QA flags. |
getOtherReview[] |
object[] | The checklist items configured for this company (from mc_other_config) together with this month's saved answers. type_of_field defaults to 2. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Items are defined on the Other Configure screen (
POST /api/mc/other/config/save) and answered throughPOST /api/mc/filter/other/save.
12.3 Open Comments
Two overlapping comment surfaces are served here:
- Monthly Close comments (
mc_comments+mc_comments_assignee) — the per-transaction / per-section threads read and written from a section review screen (mc/comment/*). - Open Comments — the cross-module grid that merges Monthly Close, Accounting, Task and Invoice threads (
comment/module,mc/thread/sort*,thread/*,comment/*,open-comment/edit). Itssourcediscriminator is'mc' | 'accounting' | 'task' | 'invoice', and thread creation branches onmodule: 103 = Monthly Close, 102 = Accounting, 101 = Task (requires a validtaskId), 106 = Invoice (requires a validbatchInvoiceId).
Reads the Monthly Close comment threads for one company, month and comment type (optionally narrowed to a single transaction).
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
month |
number | Yes | Numeric (numeric strings are coerced) Integer | Month as a number. |
year |
number | Yes | Integer | Year. |
companyId |
number | Yes | Integer | Company. |
type |
number | No | Integer | The mc_comments.type to read. |
commonId |
string | number | No | No type constraint | Row key — numeric qbo_id for AR/AP, base64 enc_bank_id for Bank & Card. |
Example request
curl -X POST https://api.example.com/api/mc/comment/view \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"month":4,"year":2026,"companyId":401,"type":1,"commonId":"9931"}'
Success response — 201 Created
{
"error": false,
"finalCommentData": {
"getComment": [
{
"id": 5521,
"thread_id": 880,
"comment": "Confirmed with the client.",
"resolve": 0,
"user_id": 88,
"user_name": "Dana Reed",
"created_at": "2026-04-28T14:02:11.000Z",
"assign_to": [91]
}
],
"assignUsers": [],
"unResolveCount": 1,
"type": 1,
"loginUser": 88
}
}
| Field | Type | Description |
|---|---|---|
finalCommentData.getComment[] |
object[] | Flat comment rows; assign_to is the comma-separated assignee id list parsed into numbers. |
finalCommentData.assignUsers[] |
object[] | Users assignable on this company. |
finalCommentData.unResolveCount |
number | Unresolved comment count. |
finalCommentData.type |
number | The comment type read. |
finalCommentData.loginUser |
number | The caller's user id, so the UI can gate the edit affordance. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- This endpoint reads
mc_commentsonly. The GL Validation sidebar has its own read:POST /api/mc/glvalidation/comments/view.
Builds the Open Comments filter tree — the Module › Submodule › Section hierarchy from comment_module, plus the assignable-user list for the selected company.
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. When companyId > 0 the company must belong to the caller's firm; when 0/absent the tree is built firm-wide. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company whose assignable users are returned. 0 = firm-wide. |
noAccounting |
boolean | No | Boolean | Omit the Accounting module from the tree. |
Example request
curl -X POST https://api.example.com/api/comment/module \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"noAccounting":false}'
Success response — 201 Created
[
{
"name": "Monthly Close",
"value": 103,
"route": "monthly_close",
"assignedUsers": [{ "id": 91, "name": "Sam Ortiz", "email": "sam@firm.com" }],
"child": [
{
"name": "Accounts Receivable",
"value": 53,
"route": "mc_ar_review",
"child": [{ "name": "Seriously Delinquent Invoices", "value": 1, "route": "mc_ar_list" }]
}
]
}
]
| Field | Type | Description |
|---|---|---|
| (root array) | object[] | One entry per module, sorted by name. |
value / route |
number / string | The module row's module_id and deep-link route name. |
assignedUsers[] |
object[] | Users assignable for the requested company (or firm-wide). |
child[] |
object[] | Submodules; each submodule's own child[] holds its sections. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
The Open Comments grid: every comment thread visible to the caller across Monthly Close, Accounting, Task and Invoice, filtered and sorted.
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Per-thread write access is computed and returned as haveAccess (menu 81 Standard/Admin, or global/firm admin; always false for an inactive company). |
| Content type | application/json |
Request body (every field optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | number[] | No | none (untyped) | One company or several. |
module |
number | No | Integer | comment_module.module_id of the module filter. |
subModule |
number | No | Integer | Submodule module id. |
section |
number | No | Integer | Section module id. |
startDate / endDate |
string | No | String | Date-range filter. |
monthlyPeriod |
string | No | String | Close-period filter. |
status |
string | No | String | 'resolved' or 'open' filter the list; other values are ignored. |
hideResolved |
boolean | No | Boolean | Drop resolved threads. |
includeInactive |
boolean | No | Boolean | Include inactive companies. |
assignedToMe |
boolean | No | Boolean | Only threads assigning the caller. |
Example request
curl -X POST https://api.example.com/api/mc/thread/sort \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":[401,402],"module":103,"hideResolved":true,"assignedToMe":false}'
Success response — 201 Created
{
"threads": [
{
"key": "mc:880",
"source": "mc",
"thread_id": 880,
"company_id": 401,
"company_name": "Acme Holdings",
"thread_title": "Confirmed with the client.",
"attributes": "Monthly Close › Accounts Receivable › Seriously Delinquent Invoices",
"route_data": {
"type": "mc",
"id": 9931,
"enc_id": "PVdKa0hEVGZ3...",
"invoice_no": "",
"route": "mc_ar_review"
},
"assignee": "Sam Ortiz",
"assignee_names": ["Sam Ortiz"],
"assignee_ids": [91],
"close_period": "2026-04-01",
"last_comment": "Confirmed with the client.",
"last_comment_date": "4/28/26",
"last_comment_at": "2026-04-28T14:02:11.000Z",
"date_resolved": "",
"resolved": false,
"module_name": "Monthly Close",
"submodule_name": "Accounts Receivable",
"section_name": "Seriously Delinquent Invoices",
"initiator_comments": [],
"responder_comments": [],
"comments": [],
"haveAccess": true
}
],
"counters": { "total": 1, "resolved": 0 },
"currentUserId": 88,
"canWriteAll": false
}
| Field | Type | Description |
|---|---|---|
threads[].key |
string | Stable row identity across the merged sources. |
threads[].thread_title |
string | The initiating comment's text, falling back to the source title or #<attributeId>. |
threads[].route_data |
object | Deep-link descriptor. For a Monthly Close thread tied to a single transaction it also carries the encrypted list-screen id so the grid can jump straight to the row (null for section-level and non-MC threads). |
threads[].comments[] |
object[] | Every comment in the thread; initiator_comments / responder_comments are the same rows split by role. |
threads[].haveAccess |
boolean | Whether this viewer may write on the thread. |
counters |
object | total and resolved counts after filtering. |
currentUserId |
number | Lets the grid gate the edit affordance to the viewer's own comments. |
canWriteAll |
boolean | True for a global/firm admin. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Results are ordered by source group first (
mc→accounting→task→invoice), then most recently commented first within each group. They are not grouped by company.
Returns the users who can be assigned on a new or existing thread for one company and module.
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company is firm-checked. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
module |
number | Yes | Integer | Module id (103 Monthly Close, 102 Accounting, 101 Task, 106 Invoice). |
date |
string | No | String | Close period, used to resolve the month's review assignees. |
Example request
curl -X POST https://api.example.com/api/mc/thread/sort/fetch-users \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"module":103,"date":"2026-04-01"}'
Success response — 201 Created
{
"assignUsers": [{ "id": 91, "name": "Sam Ortiz", "email": "sam@firm.com" }],
"reviewAssignUser": [{ "id": 91, "name": "Sam Ortiz", "email": "sam@firm.com" }]
}
| Field | Type | Description |
|---|---|---|
assignUsers[] |
object[] | Everyone assignable on this company/module. |
reviewAssignUser[] |
object[] | The subset that is this month's recorded primary accountant / reviewer. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Posts a Monthly Close comment (starting a thread, or replying into an existing one).
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close edit rights on the company; otherwise 403. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
month |
number | Yes | Integer | Month as a number. |
year |
number | Yes | Integer | Year. |
companyId |
number | Yes | Integer | Company. |
typeId |
number | Yes | Integer | The mc_comments.type for this thread. |
commentText |
string | Yes | String | The comment body. |
commonListId |
string | number | No | No type constraint | Row key — numeric qbo_id for AR/AP, base64 enc_bank_id for Bank & Card. |
threadId |
number | No | Integer | Reply into this thread; omit to start a new one. |
assignTo |
number[] | No | Array | Assignee user ids. |
Example request
curl -X POST https://api.example.com/api/mc/comment/create \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"month":4,"year":2026,"companyId":401,"typeId":1,"commentText":"Chasing the client.","commonListId":"9931","assignTo":[91]}'
Success response — 201 Created
{ "error": false, "thread_id": 880 }
| Field | Type | Description |
|---|---|---|
thread_id |
number | The thread the comment landed in (newly created when threadId was absent). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Monthly Close edit rights | { "error": true, "message": "You do not have permission to edit Monthly Close for this company." } |
Resolves or un-resolves one or more Monthly Close comments.
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close edit rights on the comment's own company; otherwise 403. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
commentId |
number[] | Yes | Array | The comment ids to update. |
resolveVal |
number | Yes | Numeric (numeric strings are coerced) Integer | The resolve value to write. |
Example request
curl -X POST https://api.example.com/api/mc/comment/resolve \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"commentId":[5521,5522],"resolveVal":1}'
Success response — 201 Created
{ "error": false, "resolveComment": 1 }
| Field | Type | Description |
|---|---|---|
resolveComment |
number | The resolve value now stored. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Monthly Close edit rights on the owning company | { "error": true, "message": "You do not have permission to edit Monthly Close for this company." } |
Notes
- The company is derived from the comment row itself, then permission-checked — the caller does not pass a
companyId.
Edits the text of one Monthly Close comment.
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close edit rights on the comment's own company; otherwise 403. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
commentId |
number | Yes | Numeric (numeric strings are coerced) Integer | The comment to edit. |
comment |
string | Yes | String | The replacement text. |
Example request
curl -X POST https://api.example.com/api/comment/edit \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"commentId":5521,"comment":"Chasing the client (updated)."}'
Success response — 201 Created
{ "error": false, "message": "Comment Updated Successfully" }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Monthly Close edit rights on the owning company | { "error": true, "message": "You do not have permission to edit Monthly Close for this company." } |
Replaces the assignee set on one Monthly Close comment.
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close edit rights on the comment's own company; otherwise 403. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
commentId |
number | Yes | Numeric (numeric strings are coerced) Integer | The comment to reassign. |
assignTo |
number[] | Yes | Array | The new assignee user ids (replaces the existing set). |
Example request
curl -X POST https://api.example.com/api/comment/editAssignUser \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"commentId":5521,"assignTo":[91,88]}'
Success response — 201 Created
{
"error": false,
"message": "Comment Assignee Updated Successfully",
"assignee": [
{
"id": 3301,
"mc_comments_id": 5521,
"assign_to": 91,
"user": { "id": 91, "name": "Sam Ortiz", "email": "sam@firm.com" }
}
]
}
| Field | Type | Description |
|---|---|---|
assignee[] |
object[] | The resulting mc_comments_assignee rows; user is null when the referenced user cannot be resolved. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller lacks Monthly Close edit rights on the owning company | { "error": true, "message": "You do not have permission to edit Monthly Close for this company." } |
Starts a new comment thread from the Open Comments screen (or from the Invoicing/Task comment panels).
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | Monthly Close edit rights are not required on this path; the company is firm-checked, and unauthenticated or foreign-firm requests fail with 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
module |
number | Yes | Integer | 103 Monthly Close, 102 Accounting, 101 Task, 106 Invoice. |
comment |
string | Yes | String | The initiating comment (trimmed before storage). |
submodule |
number | No | Integer | Submodule module id. |
section |
number | No | Integer | Section module id. |
subSection |
string | number | No | No type constraint | Becomes common_list_id; for Bank & Card sections this is the non-numeric enc_bank_id. |
taskId |
number | No | Integer | Required (and > 0) for module: 101. |
batchInvoiceId |
number | No | Integer | Required (and > 0) for module: 106. |
asOnDate |
string | No | String | Close period for the comment. |
assignTo |
number[] | No | Array | Assignee user ids. |
Example request
curl -X POST https://api.example.com/api/thread/create \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"module":103,"submodule":53,"section":1,"subSection":"9931","comment":"New thread","asOnDate":"2026-04-01","assignTo":[91]}'
Success response — 201 Created
{ "error": false, "thread_id": 881, "source": "mc" }
| Field | Type | Description |
|---|---|---|
thread_id |
number | The new thread id. |
source |
string | mc for module 103, accounting for 102, task for 101, invoice for 106. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 201 (body-level) | module is anything other than 103/102, or is 101/106 without a valid parent id |
{ "error": true, "message": "Threads can only be started for Monthly Close or Accounting from Open Comments." } |
Notes
- The rejection above prevents the Open Comments screen from creating orphan Task/Invoice comment rows; the parented Task/Invoice paths remain reachable from their own modules' comment panels.
Replies into an existing thread on any of the four sources.
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company is firm-checked (401 otherwise). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
source |
string | Yes | String; one of 'mc', 'accounting', 'task', 'invoice' (not enum-validated at runtime) |
Which comment table to write. |
threadId |
number | Yes | Numeric (numeric strings are coerced) Integer | Thread to reply into. |
companyId |
number | Yes | Integer | Company. |
comment |
string | Yes | String | The reply text. |
assignTo |
number[] | No | Array | Assignee user ids for the reply. |
Example request
curl -X POST https://api.example.com/api/thread/comment/create \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"source":"mc","threadId":880,"companyId":401,"comment":"Client responded.","assignTo":[88]}'
Success response — 201 Created
{ "error": false }
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Resolves or un-resolves an Open Comments thread (or a single comment within one).
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; access is validated against the caller's task-access scope (401 on failure). |
| Content type | application/json |
Request body (every field optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
module |
number | No | Integer | Module id. |
thread |
number | No | Integer | Thread id to resolve. |
commentId |
number | No | Integer | A single comment id instead of a whole thread. |
companyId |
number | No | Integer | Company. |
section |
number | No | Integer | Section id. |
subsection |
number | No | Integer | Subsection id. |
resolve |
number | No | Integer | Resolve flag. |
resolveVal |
number | No | Integer | Resolve value to write. |
source |
string | No | String; one of 'mc', 'accounting', 'task', 'invoice' |
Which comment table. |
Example request
curl -X POST https://api.example.com/api/comment/resolve \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"source":"mc","thread":880,"companyId":401,"resolveVal":1}'
Success response — 201 Created
{ "error": false, "resolveComment": 1 }
| Field | Type | Description |
|---|---|---|
resolveComment |
number | The resolve value now stored on the thread. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Resolve is stored per comment row but written per thread: every comment in the thread is resolved at once, and a thread counts as open if any of its rows is open.
Edits one comment (and optionally its assignees) from the Open Comments grid, on any source.
| Module | Monthly Close > Open Comments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the caller's access to the owning company is validated (401 otherwise). |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
source |
string | Yes | String; one of 'mc', 'accounting', 'task', 'invoice' |
Which comment table. |
commentId |
number | Yes | Numeric (numeric strings are coerced) Integer | The comment to edit. |
comment |
string | Yes | String | The replacement text. |
assignTo |
number[] | No | Array | Replacement assignee ids. |
Example request
curl -X POST https://api.example.com/api/open-comment/edit \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"source":"mc","commentId":5521,"comment":"Updated from Open Comments.","assignTo":[91]}'
Success response — 201 Created
{ "error": false }
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
12.4 GL Validation
The GL Account Validation screen hangs off Monthly Close › Balance Sheet General › GL Account Validation. For one company and one close month it lists every balance-sheet GL account across three tabs and, per account, shows the Source (where its supporting schedule lives), the Source Balance scraped from that schedule, the QuickBooks GL Balance, and the variance — plus a per-account comment thread count.
Route prefix. These routes sit under mc/ with the rest of Close Details. The GL Validation dashboard and configuration screens are not part of this screen and are not exposed by this API.
Constants that form part of the contract:
| Constant | Values |
|---|---|
Tabs (activeTab) |
asset (account types Other Current Asset, Fixed Asset, Other Asset), liabilities (Other Current Liability, Long Term Liability), equity (Equity). Defaults to asset. |
GL_SOURCE_TYPE (source) |
genie = 1, excel = 2, google = 3, exclude = 4 |
GL_SOURCE_LABEL |
1 Genie, 2 Excel, 3 Google Sheets, 4 Exclude from Validation |
GL_TAG_TYPE (g_tag / tag_type) |
'' = 0, above = 1, Left = 2 (spelled exactly as shown, with a capital "L") |
SHEET_ERROR_CODE / SHEET_ERROR_MESSAGE |
0 Invalid Url, 1 Success, 2 Sheet Missing, 3 Tag Missing, 4 Value Missing, 5 Invalid Tag, 6 Invalid Value |
GL_VALIDATION_COMMENT_TYPE |
50 (accounting_comments.type) |
ACCOUNTING_TYPE_GL_VALIDATION |
6 (accounting_default.type) |
Lists the GL accounts for one tab with their source configuration, scraped source balance, GL balance, variance and comment counts.
| Module | Monthly Close > GL Validation |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
monthVal |
string | No | String | Month token; defaults to the current month when absent or unrecognised. |
yearVal |
number | No | Integer | Year. |
activeTab |
string | No | String | asset | liabilities | equity; unknown values fall back to asset. Not enum-validated. |
qboId |
string | No | String | Narrow the result to a single account (used by work/save to swap one row in place). |
Example request
curl -X POST https://api.example.com/api/mc/glvalidation/work/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026,"activeTab":"asset"}'
Success response — 201 Created. The body is a bare array of account rows ([] when the company has no accounts of the tab's types).
[
{
"id": 3312,
"company_id": 401,
"acc_id": "77",
"account_num": "1400",
"account_name": "Prepaid Insurance",
"account_type": "Other Current Asset",
"active": 1,
"source_id": 3,
"source_label": "Google Sheets",
"source_path": "https://docs.google.com/spreadsheets/d/…",
"g_tag_type": 1,
"g_tag_value": "12500.00",
"g_tag_date": "2026-04-30",
"error_log": "",
"org_source_bal": 12500,
"source_bal": "12,500.00",
"org_gl_bal": 12500,
"gl_bal": "12,500.00",
"org_variance": 0,
"variance": "0.00",
"commentCount": 2,
"unResolvedCount": 1,
"sectionThreadCount": { "unresolvedCount": 3, "totalCount": 9 }
}
]
| Field | Type | Description |
|---|---|---|
id |
number | null | accounting_gl_validation.id, null when the account has never been configured. |
acc_id |
string | accounts.qbo_id. |
source_id / source_label |
number / string | See GL_SOURCE_TYPE / GL_SOURCE_LABEL; label falls back to -. |
g_tag_type |
number | 0 unset, 1 above, 2 Left. |
g_tag_value / g_tag_date |
string | The value scraped off the sheet's #g: tag and the date it applies to. |
error_log |
string | Last scrape error for this account (see SHEET_ERROR_MESSAGE). |
org_* / formatted twins |
number / string | Raw numeric and display-formatted source balance, GL balance and variance (variance = source − GL). |
commentCount / unResolvedCount |
number | This account's own thread counts. |
sectionThreadCount |
object | The same unresolved/total pair for the whole type-50 set for the month — identical on every row, and drives the section-level badge. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Deleted accounts are excluded; inactive ones are not — the screen's "Show deleted accounts" checkbox filters on
activeclient-side.
Returns what the "Source Selection" modal should pre-fill for one account.
| Module | Monthly Close > GL Validation |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Both companyId and the company on the resolved row are firm-checked, so an id from another firm cannot be probed. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
id |
number | No | Integer | accounting_gl_validation.id, or 0/absent when the account has never been configured. |
monthVal |
string | No | String | Month token. |
yearVal |
number | No | Integer | Year. |
Example request
curl -X POST https://api.example.com/api/mc/glvalidation/work/modal \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"id":3312,"monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
Configured account:
{ "source_id": 3, "tag_type": "above", "url": "https://docs.google.com/spreadsheets/d/…" }
Never-configured account:
{ "tag_type": "Left", "source_id": 3, "url": "" }
| Field | Type | Description |
|---|---|---|
source_id |
number | The saved source. A stored genie (1) source opens as Google Sheets (3), because Genie is not offered in the modal. |
tag_type |
string | '', above or Left. For a never-configured account it comes from the company's accounting_gl_validation_config.location_of_gl_balance, falling back to the firm-wide accounting_default row. |
url |
string | The sheet URL, preferring the one on this month's source-balance row over the one on the validation row (a bulk update rewrites the balance rows first). |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Saves one account's source configuration, immediately re-reads the sheet, and returns the freshly recomputed row.
| Module | Monthly Close > GL Validation |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company is firm-checked. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
source |
string | Yes | String | One of genie | excel | google | exclude. Not enum-validated. |
accId |
string | Yes | String | accounts.qbo_id of the row being configured. |
id |
number | No | Integer | Existing accounting_gl_validation.id. |
compId |
number | No | Integer | Company id. |
companyId |
number | No | Integer | Alias of compId; compId wins when both are present. |
g_tag |
string | No | String | above | Left — where the balance sits relative to the #g: tag. |
gdrive_g_tag |
string | No | String | Google Drive tag field. |
url |
string | No | String | The sheet URL. |
account_name |
string | No | String | Account name, used when resolving the sheet tab. |
monthVal |
string | No | String | Month token. |
yearVal |
number | No | Integer | Year. |
activeTab |
string | No | String | Tab, so the re-read row matches the visible list. |
Example request
curl -X POST https://api.example.com/api/mc/glvalidation/work/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"source":"google","accId":"77","compId":401,"g_tag":"above","url":"https://docs.google.com/spreadsheets/d/…","monthVal":"Apr","yearVal":2026,"activeTab":"asset"}'
Success response — 201 Created
{
"error": false,
"accountData": { "acc_id": "77", "source_id": 3, "g_tag_value": "12500.00", "variance": "0.00" },
"source_type": "Google Sheets",
"data_id": 3312,
"val": "12500.00"
}
| Field | Type | Description |
|---|---|---|
accountData |
object | null | The re-read account row (same shape as one work/filter element), null if nothing came back. |
source_type |
string | The GL_SOURCE_LABEL for the saved source ('' when unknown). |
data_id |
number | The accounting_gl_validation row id. |
val |
string | The scraped g_tag_value, defaulting to "0.00". |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Re-scrapes every Google-sourced account listed in the payload.
| Module | Monthly Close > GL Validation |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; each item's company is firm-checked. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
object[] | Yes | Array; each item validated against the item schema below | One entry per account to refresh. |
Each data[] item:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
data |
object | Yes | Nested object; validated | { company_id: number (integer, required), qbo_id: string (string, required) }. |
monthVal |
string | No | String | Month token. |
yearVal |
number | No | Integer | Year. |
position |
number | No | Integer | accounting_gl_validation.g_tag_type — 1 above, 2 Left. |
url |
string | No | String | Sheet URL override. |
Example request
curl -X POST https://api.example.com/api/mc/glvalidation/work/refresh \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"data":[{"monthVal":"Apr","yearVal":2026,"position":1,"url":"https://docs.google.com/spreadsheets/d/…","data":{"company_id":401,"qbo_id":"77"}}]}'
Success response — 201 Created
{ "error": false, "refreshed": 1, "failed": 0 }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false — per-sheet problems are recorded in each row's error_log instead. |
refreshed |
number | How many items were attempted. |
failed |
number | How many failed. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
The GL Validation comment sidebar's read: every accounting_comments thread for one GL account in one close month.
| Module | Monthly Close > GL Validation |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company is firm-checked. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
accId |
string | Yes | String | accounts.qbo_id — what accounting_comments.common_list_id holds for this type. |
monthVal |
string | No | String | Month token. |
yearVal |
number | No | Integer | Year. |
Example request
curl -X POST https://api.example.com/api/mc/glvalidation/comments/view \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"accId":"77","monthVal":"Apr","yearVal":2026}'
Success response — 201 Created
{
"error": false,
"loginUser": 88,
"threads": [
{
"threadId": 1201,
"resolve": 0,
"comments": [
{
"id": 7701,
"comment": "Schedule updated.",
"resolve": 0,
"userId": 88,
"userName": "Dana Reed",
"createdAt": "2026-04-29T09:41:02.000Z",
"assignTo": [91],
"assignNames": ["Sam Ortiz"]
}
]
}
],
"unResolveCount": 1,
"totalCount": 1
}
| Field | Type | Description |
|---|---|---|
loginUser |
number | Caller's user id. |
threads[] |
object[] | Grouped by thread_id. A thread's resolve is 0 if any of its comments is unresolved (resolve is stored per row but written per thread). |
unResolveCount / totalCount |
number | Counted over threads, not individual comments — the same definition the table's per-row pill uses. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Read-only. Every mutation the sidebar performs uses the existing Open Comments endpoints, which write
accounting_commentsformodule: 102/source: 'accounting'.
"Bulk Update Google Sheet": points every Google-sourced account on the visible tab at one workbook, then re-scrapes.
| Module | Monthly Close > GL Validation |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company is firm-checked. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
url |
string | Yes | String | The workbook every listed account should point at. |
qbo_id |
(string|number)[] | Yes | Array | The accounts.qbo_id list of the Google-sourced rows on the visible tab. |
company_id |
number | No | Numeric (numeric strings are coerced) Integer | Company id. |
companyId |
number | No | Integer | Alias of company_id; company_id wins when both are present. |
monthVal |
string | No | String | Month token. |
yearVal |
number | No | Integer | Year. |
Example request
curl -X POST https://api.example.com/api/mc/glvalidation/work/bulkupdate \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"company_id":401,"monthVal":"Apr","yearVal":2026,"url":"https://docs.google.com/spreadsheets/d/…","qbo_id":["77","78","91"]}'
Success response — 201 Created
{ "error": false, "updated": 3, "refreshed": 11, "failed": 1 }
| Field | Type | Description |
|---|---|---|
updated |
number | How many accounts had the URL written (the caller's list, i.e. the visible tab). |
refreshed |
number | How many accounts were re-scraped. |
failed |
number | How many scrapes failed. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- The URL is written only to the accounts the caller listed, but the subsequent refresh runs over every Google-sourced account of the company, each using its own stored tag position — hence
refreshedcan exceedupdated.
12.5 Firm Configuration
Two kinds of screen live here: the firm-level configuration (calendar / relative period definitions and the weekend close-deadline rule) and the per-section Configure screens (Bank & Card, AR, AP, Balance Sheet, Income Statement, Payroll, Uncharacterized, Cash Flow, Other) that set the thresholds each review section is measured against.
Every Configure screen is scoped either to one company or to one company group. The shared scope resolution reads type ('group' selects group scope; anything else, including absent, means company scope) and then:
- company scope →
companyId|company_id|id(some routes also acceptcompId/onlyCompanyId), and refuses a zero/absent id; - group scope →
companyGroupId, writing against the table'scompany_group_idcolumn.
Configure filter responses carry a canEdit / accessLevel pair: each Configure table is gated on Admin access, so a user without Admin access to this company's Monthly Close menu — or looking at an inactive company — sees the whole table disabled.
Loads the firm-level Monthly Close configuration: the calendar and relative period definitions effective for the current month, plus the firm's weekend close-deadline rule.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Scoped to the caller's firm. |
| Content type | application/json |
Query parameters — none; any query string sent is ignored.
Example request
curl -b 'genie.sid=<session-cookie>' https://api.example.com/api/mc/firm/config/filter
Success response — 200 OK
{
"routeArr": { "saveFirmConfig": "mc_save_firm_config" },
"calFirmPeriods": [
{ "id": 71, "user_id": 9, "type": 1, "periods": "1-10", "period_type": 1, "past_or_due": 0, "saved_date": "2026-04-01", "deleted": 0, "created_at": null, "updated_at": null }
],
"relFirmPeriods": [
{ "id": 84, "user_id": 9, "type": 2, "periods": "1-5", "period_type": 1, "past_or_due": 1, "saved_date": "2026-04-01", "deleted": 0, "created_at": null, "updated_at": null }
],
"calenderDate": "2026-04-01",
"relativeDate": "2026-04-01",
"closeDeadlineConfig": 2
}
| Field | Type | Description |
|---|---|---|
routeArr |
object | Retained for compatibility with earlier clients; it may be ignored. |
calFirmPeriods[] |
object[] | Calendar-period rows (type = 1) for the snapshot effective this month, ordered by period_type. |
relFirmPeriods[] |
object[] | Relative-period rows (type = 2) for their snapshot, ordered by past_or_due. |
calenderDate / relativeDate |
string | The saved_date snapshot each list was resolved to. |
closeDeadlineConfig |
number | null | The firm's weekend-adjustment rule (null when never configured). |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- The effective
saved_dateper type is resolved against the current month, and only non-deleted rows take part in resolving it. The rows actually returned are not filtered on deletion, so a soft-deleted row carrying the effective date is still returned.
Replaces the firm's calendar and/or relative period definitions.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Global Master System Administrator role or firm admin required. Otherwise 403. |
| Content type | application/json |
Request body (all fields optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
calenderPeriods |
string[] | No | Array | Calendar period range labels. |
calenderPeriodsType |
number[] | No | Array Numeric (numeric strings are coerced) | Their period types, positionally aligned. |
relativePeriods |
string[] | No | Array | Relative period range labels. |
relativePeriodsType |
number[] | No | Array Numeric (numeric strings are coerced) | Their period types. |
relativePastOrDue |
number[] | No | Array Numeric (numeric strings are coerced) | Past/due discriminator per relative period. |
calenderDate |
string | No | String | The snapshot saved_date for the calendar set. |
relativeDate |
string | No | String | The snapshot saved_date for the relative set. |
Example request
curl -X POST https://api.example.com/api/mc/firm/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"calenderPeriods":["1-10","11-20","21-31"],"calenderPeriodsType":[1,2,3],"calenderDate":"2026-05-01"}'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller is neither a Global Master System Administrator nor a firm admin | Unauthorized. |
Sets the firm's weekend close-deadline adjustment rule.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Global Master System Administrator role or firm admin required, and firmId must be the session user's own firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
firmId |
number | Yes | Numeric (numeric strings are coerced) Integer | Must match the caller's own firm. |
value |
number | Yes | Numeric (numeric strings are coerced) Integer | The rule; the backend whitelists 1, 2, 3. |
Example request
curl -X POST https://api.example.com/api/mc/firm/config/close-deadline/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"firmId":9,"value":2}'
Success response — 201 Created
{ "error": false, "message": "Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | firms table lacks the expected columns |
{ "error": true, "message": "Firm table has unsupported schema." } |
| 200/201 | No firms row for that id |
{ "error": true, "message": "Firm row not found." } |
Notes
- Three controls apply to this route: the caller must be a global or firm administrator,
firmIdmust be the caller's own firm, andvaluemust be1,2or3.
Kicks off a background refresh of the stored QuickBooks book-close date for the selected companies.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The requested ids are intersected with the companies in the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companies |
number[] | Yes | Array Numeric (numeric strings are coerced) | Company ids to refresh. De-duplicated; non-positive ids dropped. |
type |
string | No | String | Accepted; not read by this endpoint. |
Example request
curl -X POST https://api.example.com/api/mc/configure/update \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companies":[401,402,403]}'
Success response — 201 Created
{ "error": false, "message": "Refresh started successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | No usable company ids, or none survive the firm intersection | { "error": true, "message": "No companies selected." } |
| 200/201 | company table missing |
{ "error": true, "message": "Company table not found." } |
Notes
- Fire-and-forget: the POST returns immediately and the QuickBooks calls continue server-side, one company at a time. A failure on one company does not stop the run.
- A
nullresult from QuickBooks ("not connected / call failed") leaves the stored value untouched; an empty result clearscompany.qbo_close_date.
Loads the Bank & Card Configure screen: the per-transaction-type day thresholds plus the undeposited-funds GL account picker.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; canEdit / accessLevel are returned for UI gating. |
| Content type | application/json |
Request body (all fields optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | No | Numeric (numeric strings are coerced) Integer | Company scope. |
id |
number | No | Integer | Alternative id. |
type |
string | No | String | 'group' for group scope; otherwise company scope. |
companyGroupId |
number | No | Numeric (numeric strings are coerced) Integer | Group scope id. |
Example request
curl -X POST https://api.example.com/api/mc/ar/config/filter/bank \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company"}'
Success response — 201 Created
{
"error": false,
"data": {
"companyId": 401,
"companies": [{ "id": 401, "name": "Acme Holdings" }],
"companyDrop": [],
"rows": [
{ "transactionType": "Expense", "threshold": 5, "globalThreshold": 5 },
{ "transactionType": "Check", "threshold": 10, "globalThreshold": 10 }
],
"glAccounts": { "account": [], "filter": [] }
},
"canEdit": true,
"accessLevel": 4
}
| Field | Type | Description |
|---|---|---|
data.companies[] |
object[] | Companies in scope. |
data.companyDrop[] |
object[] | In-table company picker options; populated only for group scope, empty for company scope. |
data.rows[] |
object[] | One row per transaction type with the effective threshold and the firm-wide globalThreshold. Defaults when unconfigured: Expense 5, Journal 5, Transfer 5, Check 10, Deposit 3, Bill payment 10, Payment 10 (global 5), Refund Receipt 10 (global 5), Sales Receipt 5, Purchase 5. |
data.glAccounts |
object | null | { account, filter } — the company's chart of accounts plus the saved undeposited-funds selection. null for group scope or before a company is chosen. |
canEdit / accessLevel |
boolean / number | Admin gate and the raw access level (1 NoAccess … 4 Admin). |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Autosave for the Bank & Card Configure screen — thresholds and/or the undeposited-funds GL account.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; company scope is firm-checked. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
rows |
object[] | Yes | Array; each item validated | Each row: transactionType (string, required) and threshold (int, required). |
companyId |
number | No | Numeric (numeric strings are coerced) Integer | Company scope. Optional because a group-scope save carries companyGroupId instead. |
type |
string | No | String | 'group' for group scope. |
companyGroupId |
number | No | Integer | Group scope id. |
glAccount |
string[] | No | Array Each item must be a string | The undeposited-funds GL account picker (0 or 1 element — it is single-select). Omitting the key means "this save didn't touch the GL account". |
Example request
curl -X POST https://api.example.com/api/mc/ar/config/save/bank \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company","rows":[{"transactionType":"Check","threshold":12}],"glAccount":["1200"]}'
Success response — 201 Created
{ "error": false }
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Writes a firm-wide default value (mc_default.global_default).
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Global Master System Administrator role required. A non-holder gets a body-level error, not an HTTP error. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
defaultId |
number | Yes | Numeric (numeric strings are coerced) Integer | The mc_default.id to update. |
value |
number | Yes | Numeric (numeric strings are coerced) Integer | The new global default. |
Example request
curl -X POST https://api.example.com/api/mc/default/save-global \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"defaultId":16,"value":7}'
Success response — 201 Created
{ "error": false, "message": "Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | Caller does not hold the Global Master System Administrator role | { "error": true, "message": "Unauthorized." } |
| 200/201 | mc_default table missing |
{ "error": true, "message": "mc_default table not found." } |
| 200/201 | mc_default lacks a global_default column |
{ "error": true, "message": "mc_default has unsupported schema." } |
Notes
- The Configure page's "Global Defaults" column is display-only, so no client currently calls this route.
Loads the AR or AP Configure screen — one shared endpoint, discriminated by configTypeId.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; canEdit / accessLevel / checkUserDisable / canResetToGlobal are returned for UI gating. |
| Content type | application/json |
Request body (all fields optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | No | Numeric (numeric strings are coerced) Integer | Company scope. |
typeId |
number[] | No | Array | Explicit override of the three mc_setting type ids to load. |
configTypeId |
number | No | Integer | Screen discriminator: 3 = AR Configure, 4 = AP Configure. 4 selects types [3, 12, 9]; anything else selects AR's [2, 10, 11]. |
type |
string | No | String | 'group' for group scope. |
companyGroupId |
number | No | Integer | Group scope id (falls back to companyId when group scope is selected). |
Example request
curl -X POST https://api.example.com/api/mc/ar/config/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company","configTypeId":3}'
Success response — 201 Created
{
"error": false,
"type": { "type_id": 2, "defaultId": 16, "label": "Days Past Due to Flag Open Invoices", "customValue": 90, "globalValue": 60 },
"type_custom_payment": { "type_id": 10, "defaultId": 21, "label": "Days Past to Flag Unapplied Customer Payments", "customValue": 30, "globalValue": 30 },
"type_vendor_credit": { "type_id": 11, "defaultId": 22, "label": "Days Past to Flag Unapplied Customer Credit Memos", "customValue": 30, "globalValue": 30 },
"checkUserDisable": true,
"canEdit": true,
"accessLevel": 4,
"canResetToGlobal": false
}
| Field | Type | Description |
|---|---|---|
type / type_custom_payment / type_vendor_credit |
object | The three configured slots, positionally from the resolved type triple. label comes from mc_setting, falling back to a built-in map (2 Days Past Due to Flag Open Invoices, 10 Days Past to Flag Unapplied Customer Payments, 11 Days Past to Flag Unapplied Customer Credit Memos, 3 Days Past Due to Flag Open Bills, 12 Days Past to Flag Unapplied Vendor Payments, 9 Days Past to Flag Unapplied Vendor Credits). |
checkUserDisable |
boolean | true disables the Global Default input — it is true for anyone who is not a Global Master System Administrator. |
canEdit / accessLevel |
boolean / number | The per-company Admin gate. |
canResetToGlobal |
boolean | Global admin or firm admin only — a distinct rule from checkUserDisable. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- Without
configTypeId(and without an explicittypeId) the backend defaults to the AR triple, so an AP request that omits it returns AR's settings.
Saves one AR/AP threshold value.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; company scope is firm-checked. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
typeId |
number | Yes | Numeric (numeric strings are coerced) Integer | Which mc_setting slot to write (2/10/11 for AR, 3/12/9 for AP). |
customValue |
number | Yes | Numeric (numeric strings are coerced) Integer | The company/group-specific value. |
companyId |
number | No | Integer | Company scope (optional — a group-scope save carries companyGroupId). |
type |
string | No | String | 'group' for group scope. |
companyGroupId |
number | No | Integer | Group scope id. |
Example request
curl -X POST https://api.example.com/api/mc/ar/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company","typeId":2,"customValue":120}'
Success response — 201 Created
{ "error": false }
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Loads the Balance Sheet General Configure screen.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; canEdit / accessLevel are returned for UI gating. |
| Content type | application/json |
Request body (the shared Configure-filter contract; companyId / type / companyGroupId are the fields this screen uses).
Example request
curl -X POST https://api.example.com/api/mc/balancesheet/config/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company"}'
Success response — 201 Created
{
"error": false,
"row_id": 512,
"filterType": "company",
"defaultSetting": { "type": {} },
"glVariance": 10,
"ignoreVariance": 500,
"variationPeriod": 3,
"glVarianceDefault": 10,
"ignoreVarianceDefault": 500,
"prepaidThreshold": 1000,
"prepaidThresholdDefault": 1000,
"fixedThreshold": 2500,
"fixedThresholdDefault": 2500,
"deferredThreshold": 1000,
"deferredThresholdDefault": 1000,
"needPrepaid": 1,
"needFixed": 1,
"needDeferred": 0,
"canEdit": true,
"accessLevel": 4
}
| Field | Type | Description |
|---|---|---|
row_id |
number | null | The mc_balance_sheet row id, null when none exists yet. |
filterType |
string | company or group. |
defaultSetting.type |
object | The raw firm-wide defaults. |
glVariance / ignoreVariance / variationPeriod |
number | Effective values (company/group value, else the default). |
*Default fields |
number | The firm-wide defaults, shown beside each input. |
prepaidThreshold / fixedThreshold / deferredThreshold |
number | Section thresholds. |
needPrepaid / needFixed / needDeferred |
number | Section toggles. Forced to 0 for group scope. |
canEdit / accessLevel |
boolean / number | Admin gate. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Autosave for Balance Sheet General Configure: variance trio, thresholds, section toggles, and the GL-account checkboxes.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; company scope is firm-checked. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied and unknown properties are passed through. The keys it reads are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
onlyCompanyId | companyId | company_id | compId | id |
number | Yes (company scope) | none | Company scope, resolved in that precedence order. A zero/absent scope is refused. |
type |
string | No | none | 'group' selects group scope. |
companyGroupId |
number | Conditional | none | Required for group scope. |
onlyCompany |
any | No | none | Truthy (with account_type) selects the GL-account checkbox branch. |
account_type | accountType |
string | Conditional | none | The account bucket: prepaid, fixed, deferred, contra, amortization. |
account_id | accountId |
string | Conditional | none | The account being ticked. |
checked |
boolean | 1 | '1' |
No | none | Tick state; anything else is treated as unticked. |
row_id | rowId |
number | No | none | Existing mc_balance_sheet row; created on demand when absent. |
glVariance, ignoreVariance, variationPeriod |
number | No | none | The variance trio. Present-ness selects that branch; variationPeriod defaults to 0. |
prepaidThreshold, fixedThreshold, deferredThreshold |
number | No | none | The thresholds branch. |
needPrepaid, needFixed, needDeferred |
boolean-ish | No | none | Section toggles; written as 1/0 only when present. |
isReset |
boolean | 1 | '1' |
No | none | Writes the variance trio and the three thresholds in one statement. |
Example request
curl -X POST https://api.example.com/api/mc/balancesheet/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"onlyCompanyId":401,"glVariance":12,"ignoreVariance":500,"variationPeriod":3,"needPrepaid":1}'
Success response — 201 Created
{ "error": false, "row_id": 512 }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | No company selected (company scope with a zero/absent id) | { "error": true, "message": "No company selected." } |
| 200/201 | mc_balance_sheet table missing |
{ "error": true, "message": "mc_balance_sheet missing." } |
Notes
- Ticking an
amortizationaccount also writes acontrarow; that one bucket expands to both, and the client mirrors it optimistically. - Unknown keys are not stripped on this route; they are simply not read.
Loads the Income Statement General Configure screen.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; canEdit / accessLevel are returned. |
| Content type | application/json |
Request body (companyId / type / companyGroupId).
Example request
curl -X POST https://api.example.com/api/mc/incomestatement/config/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company"}'
Success response — 201 Created
{
"error": false,
"row_id": 318,
"filterType": "company",
"defaultSetting": { "type": {} },
"gl_variance": 10,
"period": 3,
"ignore_variance": 500,
"ignore_ck_box": 2,
"margin_ck_box": 2,
"gross_margin_variance": 5,
"gross_margin_period": 3,
"glVarianceDefault": 10,
"ignoreVariance": 500,
"grossMarginVariance": 5,
"count_contra": 4,
"canEdit": true,
"accessLevel": 4
}
| Field | Type | Description |
|---|---|---|
row_id |
number | null | The mc_income_statement row id. |
gl_variance / period / ignore_variance |
number | Effective GL-variance settings. |
ignore_ck_box / margin_ck_box |
number | The two checkbox columns; both default to 2. |
gross_margin_variance / gross_margin_period |
number | Gross-margin comparison settings. |
glVarianceDefault / ignoreVariance / grossMarginVariance |
number | Raw firm-wide defaults; the remaining defaults are read from defaultSetting.type. |
count_contra |
number | How many contra accounts are ticked. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Autosave for Income Statement General Configure.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; company scope is firm-checked. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied and unknown properties are passed through. The keys it reads are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId | company_id | cgId | compId | id |
number | Yes (company scope) | none | Scope, in that precedence order. |
type |
string | No | none | 'group' selects group scope. |
companyGroupId |
number | Conditional | none | Group scope id. |
saveModalData |
any | No | none | Truthy (with an account id) selects the account-checkbox branch and skips the settings write. |
accountId | account_id |
string | Conditional | none | Account being ticked. |
accountType | account_type |
string | No | none | Bucket; defaults to contra. |
checked |
boolean | 1 | '1' |
No | none | Tick state. |
glVariance |
number | No | none | → i_gl_variance. |
period |
number | No | none | → i_period_variance. |
chkIgnoreC |
number | No | none | → i_ignore_ch_box; defaults to 2 when absent. |
ignoreVariance |
number | No | none | → i_ignore_variance. |
chkValidateC |
number | No | none | → gross_margin_chbox; defaults to 2 when absent. |
grossMargin |
number | No | none | → gross_margin_variance. |
gmperiod |
number | No | none | → gross_margin_period. |
Example request
curl -X POST https://api.example.com/api/mc/incomestatement/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"glVariance":10,"period":3,"ignoreVariance":500,"grossMargin":5,"gmperiod":3}'
Success response — 201 Created
{ "error": false, "id": 318 }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | No company selected | { "error": true, "message": "No company selected." } |
| 200/201 | mc_income_statement table missing |
{ "error": true, "message": "mc_income_statement missing." } |
Notes
- The settings branch always writes all seven columns together, so a partial payload blanks the omitted ones to
null.
Loads the Payroll Configure screen.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; canEdit / accessLevel are returned. |
| Content type | application/json |
Request body (companyId / type / companyGroupId).
Example request
curl -X POST https://api.example.com/api/mc/payroll/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company"}'
Success response — 201 Created
{
"error": false,
"row_id": 204,
"filterType": "company",
"w2": 24,
"payrollDept": 1,
"taxTarget": 12,
"taxTolerance": 2,
"taxTargetDefault": 10,
"taxToleranceDefault": 2,
"pr_w2": { "primary": 61, "value": 20 },
"pr_department": { "primary": 62, "value": 1 },
"pr_target": { "primary": 63, "value": 10 },
"pr_tolerance": { "primary": 64, "value": 2 },
"countWages": 3,
"countPayroll": 5,
"canEdit": true,
"accessLevel": 4
}
| Field | Type | Description |
|---|---|---|
w2 / payrollDept / taxTarget / taxTolerance |
number | Effective values (company/group value, else the firm default). |
pr_w2 / pr_department / pr_target / pr_tolerance |
object | { primary, value } pairs — primary is the mc_default.id the Global Defaults column posts to. |
countWages / countPayroll |
number | How many wages / payroll-tax accounts are ticked. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Autosave for Payroll Configure: the four scalars and the account checkboxes.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; company scope is firm-checked. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied and unknown properties are passed through. The keys it reads are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId | company_id | compId | id |
number | Yes (company scope) | none | Scope, in that precedence order. |
type |
string | No | none | 'group' selects group scope. |
companyGroupId |
number | Conditional | none | Group scope id. |
w3Emp | w2Employees | w2_employees |
number | No | none | → w2_employees. |
payrollDept | payrollDepartment | payroll_department |
number | No | none | → payroll_department. |
taxTarget | tax_target |
number | No | none | → tax_target. |
taxTolerance | tax_tolerance |
number | No | none | → tax_tolerance. |
saveModalData |
any | No | none | Truthy (with an account id) also writes an account checkbox. |
accountId | account_id |
string | Conditional | none | Account being ticked. |
accountType | account_type |
string | No | none | Bucket (wages / payroll tax). |
checked |
boolean | 1 | '1' |
No | none | Tick state. |
Example request
curl -X POST https://api.example.com/api/mc/payroll/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"w3Emp":24,"taxTarget":12,"taxTolerance":2}'
Success response — 201 Created
{ "error": false, "row_id": 204 }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | No company selected | { "error": true, "message": "No company selected." } |
| 200/201 | mc_payroll_config table missing |
{ "error": true, "message": "mc_payroll_config missing." } |
Notes
- Unlike Income Statement, only the scalars actually present are written — a
nullvalue is skipped rather than blanking the column. - The account sets written here (
mc_payroll_config_accounts) are whatGET /api/mc/pr/review/list/:idreads back.
Loads the Uncharacterized Revenue and Expense Configure screen.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; canEdit / accessLevel / checkUserDisable are returned. |
| Content type | application/json |
Request body (companyId / type / companyGroupId).
Example request
curl -X POST https://api.example.com/api/mc/revenue/config/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company"}'
Success response — 201 Created
{
"error": false,
"row_id": 145,
"revenue": { "revenue_expense": [], "filter": [], "class": "revenue" },
"expense": { "revenue_expense": [], "filter": [], "class": "expense" },
"classRevenue": 1,
"classExpense": 0,
"revenueCount": 0,
"expenseCount": 0,
"checkUserDisable": true,
"canEdit": true,
"accessLevel": 4
}
| Field | Type | Description |
|---|---|---|
row_id |
number | null | The mc_revenue_expense row id. |
revenue / expense |
object | Each carries revenue_expense (the selectable GL accounts) and filter (the saved selection) plus a literal class tag. Account-type whitelists: revenue = Income, Other Income; expense = Cost of Goods Sold, Other Expense, Expense. |
classRevenue / classExpense |
number | The two class-check toggles (0/1). |
revenueCount / expenseCount |
number | Size of each saved selection. |
checkUserDisable |
boolean | true for anyone who is not a Global Master System Administrator. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Autosave for Uncharacterized Configure: the two class flags, or one account checkbox.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company is firm-checked. This route is company-scoped only — it has no group branch. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied and unknown properties are passed through. The keys it reads are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId | company_id | compId |
number | Yes | none | Company; firm-checked. |
accountType | account_type |
string | Conditional | none | revenue or expense — any other value falls through to the class-flag branch. |
accountId | account_id |
string | Conditional | none | Account being ticked; selects the account branch when present alongside a valid accountType. |
checked |
boolean | 1 | '1' |
No | none | Tick state. |
classRevenue | class_revenue |
number | No | none | Written as 1/0 only when the key is present. |
classExpense | class_expense |
number | No | none | Written as 1/0 only when the key is present. |
Example request
curl -X POST https://api.example.com/api/mc/revenue/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"classRevenue":1,"classExpense":0}'
Success response — 201 Created
{ "error": false, "row_id": 145 }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | mc_revenue_expense table missing |
{ "error": true, "message": "mc_revenue_expense missing." } |
Notes
- The class flags are written only when present, so an account-only payload cannot blank them.
Saves the Cash Flow Statement review's five checkboxes for one company and month.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close (menu 3) Admin or Standard access on the company, or global/firm admin. Otherwise 403. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
monthVal |
string | Yes | String | Month token. |
yearVal |
number | Yes | Numeric (numeric strings are coerced) Integer | Year. |
option_1 … option_5 |
number | No | Integer | The five checkboxes as 0/1. Missing options default to 0. |
option1 … option5 |
number | No | Integer | Accepted as aliases of option_1 … option_5; the backend coalesces each pair. |
Example request
curl -X POST https://api.example.com/api/mc/cash/flow/statement/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"monthVal":"Apr","yearVal":2026,"option1":1,"option2":0,"option3":1,"option4":0,"option5":0}'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller's Monthly Close access on the company is ViewOnly or NoAccess | Standard 403 error response |
| 200/201 | mc_cash_flow table missing |
{ "error": true, "message": "mc_cash_flow missing." } |
Notes
- Both key spellings are accepted, so a client posting
option1…option5has its values persisted correctly. - Despite appearing under Firm Configuration, this route writes review state (per month), not configuration.
Loads the "Other" checklist items configured for a company or group.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; canEdit / accessLevel are returned. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied and unknown properties are passed through. The keys it reads, resolved through the shared Configure scope rules, are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId | company_id | id |
number | Yes (company scope) | none | Company. |
type |
string | No | none | 'group' selects group scope. |
companyGroupId |
number | Conditional | none | Group scope id. |
Example request
curl -X POST https://api.example.com/api/mc/other/config/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"type":"company"}'
Success response — 201 Created
{
"error": false,
"content": [
{ "id": 77, "item_name": "Bank fees reviewed", "type_of_field": 2, "mc_option": null },
{ "id": 78, "item_name": "Owner draws confirmed", "type_of_field": 3, "mc_option": "Yes|No|N/A" }
],
"canEdit": true,
"accessLevel": 4
}
| Field | Type | Description |
|---|---|---|
content[] |
object[] | The configured items, ordered by id, excluding soft-deleted rows. type_of_field is the field kind (1 Checkbox, 2 the default text/other, 3 PickList); mc_option holds the pipe-delimited option set for Checkbox/PickList items. |
canEdit / accessLevel |
boolean / number | Admin gate. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | No company selected | { "error": true, "message": "No company selected." } |
Creates, renames, retypes, sets the options of, or soft-deletes one "Other" checklist item.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | Requires Monthly Close Admin or Standard access on the company/group, or global/firm admin. Otherwise 403. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied and unknown properties are passed through. The keys it reads are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId | company_id | id |
number | Yes (company scope) | none | Scope (note id is also the item id below — the scope resolver reads it only when the company keys are absent). |
type |
string | No | none | 'group' selects group scope. |
companyGroupId |
number | Conditional | none | Group scope id. |
id |
number | No | none | The item id. 0/absent means create. |
field |
string | No | none | 'delete' soft-deletes; 'mc_option' writes the option set; 'type_of_field' writes the field kind; anything else (default 'item_name') writes the name. |
datavalue |
string | Conditional | none | The value for the chosen field. Coerced to a number for type_of_field. |
mc_option |
string | No | none | Alias of datavalue on the mc_option branch. |
Example request
curl -X POST https://api.example.com/api/mc/other/config/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"id":0,"field":"item_name","datavalue":"Bank fees reviewed"}'
Success response — 201 Created
Create (or recycle of a soft-deleted row):
{ "error": false, "id": 79, "createOrUpdate": "create" }
Update:
{ "error": false, "id": 77, "createOrUpdate": "update" }
Delete / option write:
{ "error": false }
| Field | Type | Description |
|---|---|---|
id |
number | The row id — the client stores it so the next edit is an update, not another insert. |
createOrUpdate |
string | create or update. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 403 | Caller's Monthly Close access is ViewOnly or NoAccess | { "message": "You do not have permission to edit this section." } |
| 200/201 | No company selected | { "error": true, "message": "No company selected." } |
| 200/201 | mc_other_config table missing |
{ "error": true, "message": "mc_other_config missing." } |
Notes
- Delete blanks the row as it soft-deletes it (
item_name = NULL,type_of_field = 2,mc_option = NULL), because create recycles this company's oldest soft-deleted row before inserting — leaving stale values would resurrect them under the next item added. - Create writes only the one column it was given, so adding a row and then setting its type does not blank the name (and vice versa).
Persists the two "Other Monthly Close Items" panel checkboxes for a company and period.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company is firm-checked. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied and unknown properties are passed through. The keys it reads are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId | company_id |
number | Yes | none | Company; firm-checked. |
asOnDate |
string | No | none | Sent as "MMM-YYYY" (e.g. Apr-2022); it is split on - and resolved to the month start. |
monthVal |
string | No | none | Takes precedence over the month half of asOnDate. |
yearVal |
number | No | none | Takes precedence over the year half of asOnDate. |
otherSectionComplete |
number | No | none | Written as 1/0 to col_name = 'other_section_complete'. |
otherSectionIncomplete |
number | No | none | Written as 1/0 to col_name = 'other_section_incomplete'. |
Example request
curl -X POST https://api.example.com/api/other/section/check \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"asOnDate":"Apr-2026","otherSectionComplete":1,"otherSectionIncomplete":0}'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | mc_checkbox_data table missing |
{ "error": true, "message": "mc_checkbox_data table not found." } |
| 200/201 | mc_checkbox_data lacks the expected columns |
{ "error": true, "message": "mc_checkbox_data has unsupported schema." } |
Notes
- Both
col_namerows are written on every call, regardless of which checkbox changed. - The saved values are read back by
POST /api/other/section/detailsin itscheckobject.
Saves one answer on the "Other" review checklist.
| Module | Monthly Close > Firm Configuration |
| Authentication | Required — authenticated session cookie |
| Authorization | No module-permission gate. The company is firm-checked and nothing more; unlike the neighbouring routes, this one does not require Monthly Close Admin or Standard access. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied and unknown properties are passed through. The keys it reads are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId | company_id |
number | Yes | none | Company; firm-checked. |
answer_id | id |
number | Yes | none | The mc_other_config item being answered. |
mc_answer | value | answer |
string | string[] | No | none | The answer. An array (checkbox items, type_of_field = 1) is joined with a \| separator before storage. |
monthVal | month |
string | No | none | Month token. |
yearVal | year |
number | No | none | Year. |
Example request
curl -X POST https://api.example.com/api/mc/filter/other/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"id":78,"answer":["Yes","N/A"],"month":"Apr","year":2026}'
Success response — 201 Created
{ "error": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | mc_other_answer table missing |
{ "error": true, "message": "mc_other_answer missing." } |
Notes
- Two payload shapes are accepted:
{id, answer, type, month, year, companyId}and{answer_id, mc_answer, monthVal, yearVal}. - Upserts on
company_id+answer_id+ the resolved month start.
12.6 Review Assignments
The Review Assignments grid lists companies down the side and 24 months across the top, with the assigned reviewer in each cell plus per-company Primary Accountant, Monthly Close Manager and Close Deadline columns. The month header band is computed independently of the current filter: 24 columns spanning today − 11 months through today + 12 months, with a six-month visible band (today − 1 month … today + 4 months) whose first column is tagged startPos: 'start-here'.
The request contract used on these routes is not the Close Status one: here every field, including monthVal/yearVal, is optional, and an extra isReviewAssignment flag is accepted.
Returns the distinct primary accountants recorded across the companies in scope, for the grid's Primary Accountant filter.
| Module | Monthly Close > Review Assignments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; scope is resolved within the caller's firm. |
| Content type | application/json |
Request body (Review Assignments variant; all fields optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
cg_id |
string | No | String | Scope token (g_<id> / c_<id>). |
id |
number | No | Integer | Selected company/group id. |
type |
string | No | String | 'group' / 'company' (not enum-validated here). |
primary / reviewer / status / closeDead / page |
number | No | Integer | Accepted; not used by this endpoint. |
monthVal |
string | No | String | Accepted; not used by this endpoint. |
yearVal |
number | No | Integer | Accepted; not used by this endpoint. |
sortColumn / sortBy |
string | No | String | Accepted; not used by this endpoint. |
isReviewAssignment |
number | No | Integer | Accepted; only meaningful on /get/user_reviewers. |
Example request
curl -X POST https://api.example.com/api/get/primary_accountant \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"cg_id":"g_12","id":12,"type":"group"}'
Success response — 201 Created. The body is a bare array.
[
{ "user_id": 88, "user_name": "Dana Reed", "email": "dana@firm.com", "profile_image": null }
]
| Field | Type | Description |
|---|---|---|
user_id |
number | The user id. |
user_name |
string | Last-first display name (falls back to first+last). |
email |
string | Email address. |
profile_image |
null | Always null on this route. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- The primary-accountant source table has no month column, so this list is month-agnostic by nature.
Returns the reviewer option list — either the eligibility roster or the recorded reviewers, depending on isReviewAssignment.
| Module | Monthly Close > Review Assignments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; scope is resolved within the caller's firm. |
| Content type | application/json |
Request body (Review Assignments variant; all fields optional). The fields this endpoint reads:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
cg_id |
string | No | String | Scope token. |
id |
number | No | Integer | Company/group id. |
type |
string | No | String | Scope kind. |
isReviewAssignment |
number | No | Numeric (numeric strings are coerced) Integer | Truthy → users mapped to the selected company/group (the Review Assignments page always sends 1). Falsy/absent → users already recorded as a reviewer, restricted to the requested month (the Close Status page's behaviour). |
monthVal |
string | No | String | Month token; used only on the falsy-isReviewAssignment branch, and only when paired with yearVal. |
yearVal |
number | No | Integer | Year; same. |
Example request
curl -X POST https://api.example.com/api/get/user_reviewers \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"cg_id":"g_12","id":12,"type":"group","isReviewAssignment":1}'
Success response — 201 Created. A bare array.
Eligibility-roster branch (isReviewAssignment truthy) — includes name parts:
[
{ "user_id": 91, "first_name": "Sam", "last_name": "Ortiz", "user_name": "Ortiz, Sam", "email": "sam@firm.com", "profile_image": null }
]
Recorded-reviewer branch (isReviewAssignment falsy):
[
{ "user_id": 91, "user_name": "Ortiz, Sam", "email": "sam@firm.com", "profile_image": null }
]
| Field | Type | Description |
|---|---|---|
user_id / user_name / email |
number / string / string | The reviewer. |
first_name / last_name |
string | Present only on the eligibility-roster branch. |
profile_image |
null | Always null. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- When
monthVal/yearValare absent on the recorded-reviewer branch, the month restriction is dropped entirely (monthStartisnull).
Loads the Review Assignments grid — one group block per call, plus the month headers, reviewer values, completion flags and manager list.
| Module | Monthly Close > Review Assignments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Cell editability is returned per row: haveAccess is global admin / firm admin / holder of the Monthly Close Manager role (role id 11); checkCloseDeadLineAccess is firm-or-global admin, or this company's own Monthly Close Manager. |
| Content type | application/json |
Request body (all fields optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
cgId |
string | No | String | Scope token. |
compId |
number | No | Numeric (numeric strings are coerced) Integer | Selected company id. |
type |
string | No | String | 'group' / 'company'. |
primary |
number | No | Integer | Filter by primary accountant. |
reviewer |
number | No | Integer | Filter by reviewer. |
compName |
string | No | String | Substring filter on company name (case-insensitive). |
filterManager |
string | No | String | Manager filter — a numeric id matches exactly, any other string is a case-insensitive substring match on the manager name. |
startMonth |
string | No | String | Start month for the grid. |
month |
string | No | String | Month token. |
year |
number | No | Integer | Year. |
includeInactive |
number | No | Integer | Include inactive companies. |
reBalance |
number | No | Integer | Truthy = lazy-load one subgroup's direct companies only (identified by groupId), skipping the full filter pipeline. |
groupId |
number | No | Integer | The subgroup to load when reBalance is set. |
Example request
curl -X POST https://api.example.com/api/mc/peer/review/assignment/filter \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"cgId":"g_12","compId":0,"type":"group","month":"Apr","year":2026}'
Success response — 201 Created
{
"error": false,
"corg": "group",
"corgId": 12,
"headerArr": [
{ "monthKey": "may_2025", "month": "May 25", "startPos": "", "cellDisplay": "none" },
{ "monthKey": "apr_2026", "month": "Apr 26", "startPos": "start-here", "cellDisplay": "" }
],
"peerReviewerData": [
{
"group": "West Region",
"group_id": 12,
"company": [
{
"companyId": 401,
"name": "Acme Holdings",
"accountantName": "Dana Reed",
"primaryAccountantId": 88,
"deleteOrInactive": 0,
"deadline": 15,
"manager": { "user_id": 88, "user": "Dana Reed" },
"user": [],
"haveAccess": true,
"checkCloseDeadLineAccess": true
}
]
}
],
"value": [{ "401": { "apr_2026": { "id": 91, "name": "Sam Ortiz", "email": "sam@firm.com" } } }],
"completed": [{ "401": { "apr_2026": 1 } }],
"managers": [{ "user_id": 88, "user": "Dana Reed" }],
"companyLength": 1,
"bal_peerData": [18, 19]
}
| Field | Type | Description |
|---|---|---|
corg / corgId |
string / number | Resolved scope kind and id. |
headerArr[] |
object[] | The 24 month columns. cellDisplay is 'none' outside the visible six-month band; startPos is 'start-here' on the first visible month. |
peerReviewerData[] |
object[] | Always an array of group blocks; this response carries just the first one, and reBalance calls append more. Each block is { group, group_id, company[] }. |
peerReviewerData[].company[].manager |
object | string | { user_id, user }, or the literal string "Unassigned". |
peerReviewerData[].company[].haveAccess |
boolean | Whether reviewer cells are editable (forced false for a deleted company). |
peerReviewerData[].company[].checkCloseDeadLineAccess |
boolean | Whether the Close Deadline input is editable. |
value[] |
object[] | [ { companyId: { monthKey: reviewer } } ] — the assigned reviewer per cell. |
completed[] |
object[] | [ { companyId: { monthKey: 0\|1 } } ] — completion flag per cell. |
managers[] |
object[] | Manager filter options. |
companyLength |
number | Rows in this block after filtering. |
bal_peerData |
number[] | Group scope only — the remaining subgroup ids for the client to lazy-load. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Saves one grid cell (a month's reviewer, the primary accountant, or the close deadline), or copies a month's reviewers forward.
| Module | Monthly Close > Review Assignments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. On the ordinary save path the company is firm-checked; the copy path is checked before that (see notes). |
| Content type | application/json |
Request body (all fields optional by design)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
fromCopy |
number | No | Numeric (numeric strings are coerced) | Truthy = run the copy-reviewer-for-month operation and ignore every field below except cgId / copyMonth / copyYear / month / year / primary / reviewer. Checked first. |
companyId |
number | No | Numeric (numeric strings are coerced) Integer | Company (required in practice on the ordinary path — a copy request carries none). |
head |
string | No | String | What is being saved: close_deadline, primary_accountant, or a <month>_<year> cell key (e.g. apr_2026). Lower-cased and trimmed. |
value |
unknown | No | none | The new value. ''/falsy clears the field. |
month |
string | No | String | Overrides the month parsed out of head. |
year |
string | No | String | Overrides the year parsed out of head. |
cgId |
string | No | String | Scope token for the copy operation. |
copyMonth |
string | No | String | Source month for the copy operation. |
copyYear |
number | No | Integer | Source year for the copy operation. |
primary |
number | No | Integer | Filter carried into the copy operation. |
reviewer |
number | No | Integer | Filter carried into the copy operation. |
Example request
curl -X POST https://api.example.com/api/mc/peer/review/save \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401,"head":"apr_2026","value":91}'
Success response — 201 Created
Ordinary save:
{ "error": false }
Copy operation (fromCopy truthy):
{ "error": false, "message": "Reviewer Updated Successfully." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200/201 | head is empty on the ordinary path |
{ "error": true, "message": "Invalid assignment head." } |
| 200/201 | A month cell save with no resolvable month or year | { "error": true, "message": "Invalid month assignment." } |
Notes
companyIdandheadare optional by design:fromCopyis evaluated beforecompanyIdis validated, and a copy request carries neither. Requiring them would cause request validation to reject copy requests.- The close-deadline range is not validated server-side; whatever arrives is stored, including a blank value, which clears the field.
- Saving a truthy
close_deadlinetriggers a reviewer to-do refresh against last month's data.
Returns the users assignable as a reviewer for one company.
| Module | Monthly Close > Review Assignments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; the company is firm-checked and the roster is additionally scoped to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced) Integer | Company. |
Example request
curl -X POST https://api.example.com/api/mc/peer/review/user-list \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyId":401}'
Success response — 201 Created
{
"error": false,
"data": [
{ "id": 91, "name": "Ortiz, Sam", "email": "sam@firm.com", "profile": "" }
]
}
| Field | Type | Description |
|---|---|---|
data[] |
object[] | Users with a direct user_company_access grant on this company, or a user_group_access grant anywhere in the company's full group tree, scoped to Monthly Close (menu 3) with access_type != 1. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Generates the blank Review Assignments import template and returns a one-shot download link.
| Module | Monthly Close > Review Assignments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body (all fields optional)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
compId |
number | No | Numeric (numeric strings are coerced) Integer | The selected company/group id; falls back to companyId. |
companyId |
number | No | Integer | Alias of compId. |
companyType |
string | No | String | 'group' selects group resolution; anything else means company. Falls back to type. |
type |
string | No | String | Alias of companyType. |
cgId |
string | No | String | Accepted; not read on this path. |
month |
string | No | String | Accepted; not read on this path. |
year |
number | No | Integer | Accepted; not read on this path. |
Example request
curl -X POST https://api.example.com/api/mc/peer/review/assignment/download/file \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"compId":12,"companyType":"group"}'
Success response — 201 Created
{ "error": false, "redirect": "/api/mc/peer/review/assignment/download/generated/6d0f9a11-38ff-4b62-9df3-c2b7a4d10e55" }
| Field | Type | Description |
|---|---|---|
redirect |
string | Path to the token download route. Single-use. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- This is a blank fill-in template (header rows only) plus a reference "Available Data" sheet; it is not pre-populated with current assignments.
- Three sheets:
Close Deadline(Company, Close Deadline),Review Assignments(Company,Month Year(MM/YYYY), Reviewer) and the reference sheet. The same builder produces the error-annotated re-download after a failed import, adding aReasoncolumn.
Imports a filled-in Review Assignments template.
| Module | Monthly Close > Review Assignments |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Session validity only — this endpoint derives nothing else from the authenticated user. |
| Content type | multipart/form-data |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
file |
file part | Yes | extension must be xlsx or xlx |
The workbook. Both a file name and file content are required. |
compId | companyId |
number | No | Integer | The selected id. |
companyType / type / cgId / month / year |
— | No | As on the template download route | Accepted; only compId/companyId is read on this path. |
Example request
curl -X POST https://api.example.com/api/mc/peer/review/assignment/import/file \
-b 'genie.sid=<session-cookie>' \
-F 'file=@Review Assignments Template.xlsx' \
-F 'compId=401'
Success response — 201 Created
{ "error": false, "message": "Data are Imported Successfully." }
Row-level failures (still 201, error in the body):
{
"error": true,
"errorLog": {
"head": ["Company", "Close Deadline", "Month Year(MM/YYYY)", "Reviewer", "Reason"],
"body": [{ "company": "Acme Holdings", "monthYear": "13/2026", "reviewer": "Sam Ortiz", "error": "Invalid Month Year." }]
},
"errorExcelLink": "/api/mc/peer/review/assignment/download/generated/2f7c9d40-1a6b-4f8e-9c21-77b0e3a5d612"
}
| Field | Type | Description |
|---|---|---|
errorLog.head |
string[] | Column headings for the error table. |
errorLog.body[] |
object[] | The rejected rows with their reason. |
errorExcelLink |
string | Download path for an error-annotated copy of the workbook (same 3-sheet shape with Reason populated). |
Errors (all returned in the body, HTTP 201)
| Status | Condition | Message/shape |
|---|---|---|
| 201 | No file, or no buffer | { "error": true, "message": "Please upload an Excel file." } |
| 201 | Extension is not xlsx/xlx |
{ "error": true, "message": "Please provide Excel sheet format supported file." } |
| 201 | Workbook unreadable, or a header row contains an unexpected column | { "error": true, "message": "Invalid Excel File. Columns are not matched with the template." } |
| 201 | Both sheets entirely empty | { "error": true, "message": "Empty Excel Sheet.Please put on some data to import." } |
| 201 | Review Assignments sheet has data rows but the wrong header triple | { "error": true, "message": "Review Assignments Excel Formation is Wrong." } |
| 201 | Headers present but no data rows survived | { "error": true, "message": "No Data to Import." } |
Notes
- The accepted extensions are exactly
xlsxandxlx;.xlsfiles are rejected. Header validation rejects unexpected columns only — it does not require every expected column to be present. - On this route
compIdis always resolved as a company id, never as a group id. A group-scoped import therefore resolves no firm, and every row fails with "Company Not Found in Genie." Import with a company-scoped id. - Close-deadline updates and reviewer inserts are applied best-effort: valid rows are written even when other rows in the workbook error.
Streams a generated Review Assignments template (blank or error-annotated) and deletes it.
| Module | Monthly Close > Review Assignments |
| Authentication | Not required — possession of the single-use token is the only credential |
| Authorization | No role requirement |
| Content type | Binary .xlsx download |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The UUID from a redirect / errorExcelLink. |
Example request
curl -OJ https://api.example.com/api/mc/peer/review/assignment/download/generated/6d0f9a11-38ff-4b62-9df3-c2b7a4d10e55
Success response — 200 OK, Content-Disposition: attachment; filename="Review Assignments Template.xlsx" (or "Error of Review Assignments Template.xlsx"), body is the XLSX binary.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 404 | Unknown or already-consumed token | { "error": true, "message": "File not found." } |
Notes
- The generated file is held as temporary server-side storage and referenced by a single-use token. The download must be served by the same application instance that produced it, so multi-instance deployments must preserve instance affinity for these routes. See Deployment & Operational Considerations.
12.7 QuickBooks Close Dates
A read-only report of the QuickBooks book close date stored against each company in a group, with its primary accountant and reviewer, plus an Excel export. The stored dates themselves are refreshed by POST /api/mc/configure/update (Firm Configuration).
Returns the close-date report rows for a company group (or a single company) plus the manager filter options.
| Module | Monthly Close > QuickBooks Close Dates |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session; group membership is intersected with the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyGroup |
number | Yes | Numeric (numeric strings are coerced) Integer | The selected group (or company) id. A value ≤ 0 returns an empty report. |
type |
string | Yes | String One of group, company |
Which kind of id companyGroup is. |
filterManager |
number[] | No | Array Numeric (numeric strings are coerced) | Manager user ids to narrow the client list to. |
Example request
curl -X POST https://api.example.com/api/mc/report/closedate \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyGroup":12,"type":"group","filterManager":[88]}'
Success response — 200 OK (this read-only report returns 200, not 201)
{
"error": false,
"clients": [
{
"id": 401,
"name": "Acme Holdings",
"qbo_close_date": "04/30/2026",
"primary_user": "Dana Reed",
"reviewer_user": "Sam Ortiz",
"user_id": 88,
"assign_to": 91
}
],
"managers": [{ "user_id": 88, "user": "Dana Reed", "company_id": 401 }],
"connectionAvailable": true
}
| Field | Type | Description |
|---|---|---|
clients[] |
object[] | One row per company, ordered by company name. qbo_close_date is formatted MM/DD/YYYY, or - when unset. user_id / assign_to are the primary and reviewer user ids (null when unset). |
managers[] |
object[] | Manager filter options, sorted by name. |
connectionAvailable |
boolean | True when at least one company in scope has qbo_connection = 1. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- With no companies in scope the response is
{ "error": false, "clients": [], "managers": [], "connectionAvailable": false }. - Where a company yields multiple primary/reviewer rows, the last row wins outright, including when its primary/reviewer values are
NULL. WhenfilterManageris set, each selected manager contributes exactly one company (the first it was found on) and the list is ordered by manager rather than by company name.
Generates the close-date report workbook and returns a one-shot download link.
| Module | Monthly Close > QuickBooks Close Dates |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyGroup |
number | Yes | Numeric (numeric strings are coerced) Integer | The group id. This route accepts no type field — the export always resolves the scope as 'group'. |
filterManager |
number[] | No | Array Numeric (numeric strings are coerced) | Manager ids to filter by. |
exportName |
string | No | String | User-chosen file name; \ / # ? " % are stripped and an empty result falls back to QuickBooks Close Date Report. |
Example request
curl -X POST https://api.example.com/api/mc/report/closedate/export \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"companyGroup":12,"filterManager":[88],"exportName":"April close dates"}'
Success response — 200 OK (this route returns 200, not 201)
{ "error": false, "redirect": "/api/mc/report/closedate/export/generated/44c1f0a9-6b6e-4d3f-8b23-5c9b1f0f9a2e" }
| Field | Type | Description |
|---|---|---|
redirect |
string | Path to the token download route. Single-use, and bound to the requesting user. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | The report has no rows | { "error": true, "msg": "There is no data to Export" } |
Notes
- Workbook styling: every column width 20, the spacer and header rows bold, size 12 and centred, and all data cells except the Client column right-aligned.
Streams a generated close-date report workbook to the user who requested it, and deletes it.
| Module | Monthly Close > QuickBooks Close Dates |
| Authentication | Required — authenticated session cookie; unlike the other download routes in this section, this one also requires the caller to be the user who generated the export |
| Authorization | Owner-only. |
| Content type | Binary .xlsx download |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The UUID from the export's redirect. |
Example request
curl -OJ -b 'genie.sid=<session-cookie>' \
https://api.example.com/api/mc/report/closedate/export/generated/44c1f0a9-6b6e-4d3f-8b23-5c9b1f0f9a2e
Success response — 200 OK, Content-Disposition: attachment; filename="<exportName>.xlsx", body is the XLSX binary.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 404 | Unknown or already-consumed token | { "error": true, "message": "File not found." } |
| 404 | The token belongs to another user | { "error": true, "message": "File not found." } — the same 404 as an unknown token, so someone else's export is never confirmed to exist. The entry is left in place so its real owner can still fetch it. |
Notes
- Unlike the other download routes in this module, this one requires an authenticated session. The
redirectis followed by a same-origin top-level navigation, so the session cookie is sent automatically and the check is transparent to the user. - The workbook is an
.xlsxfile held as temporary server-side storage and referenced by a single-use token. The download must be served by the same application instance that produced it, so multi-instance deployments must preserve instance affinity for these routes. See Deployment & Operational Considerations.
12.8 Company Group Tree
Returns the company-group tree and flat group/company option lists the Monthly Close scope picker renders.
| Module | Monthly Close > Company Group Tree |
| Authentication | Required — authenticated session cookie |
| Authorization | Three tiers resolved server-side: (1) a Global Company Administrator sees every group and company in every firm, with no firm/company filter at all; (2) a firm admin sees the whole firm unconditionally; (3) any other user sees only the companies and groups they hold access to for the requested menu (default MONTHLY_CLOSE_MENU_ID = 3). |
| Content type | application/json |
Request body — this endpoint accepts a loosely typed request body; no schema validation is applied.
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
module |
number | No | none | The permission menu to scope tier-3 visibility to. Coerced to a number; a value ≤ 0 (or absent) falls back to the Monthly Close menu id (3). |
Example request
curl -X POST https://api.example.com/api/get/treeview \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{"module":3}'
Success response — 201 Created
{
"tree": [
{
"id": 12,
"name": "West Region",
"clickable": true,
"child": {
"group": [
{ "id": 18, "name": "California", "clickable": true, "child": { "group": [], "company": [{ "id": 401, "name": "Acme Holdings" }] } }
],
"company": []
}
}
],
"group": [{ "id": 12, "name": "West Region" }, { "id": 18, "name": "California" }],
"company": [{ "id": 401, "name": "Acme Holdings", "deleted": 0 }]
}
| Field | Type | Description |
|---|---|---|
tree[] |
object[] | The nested group tree. Each node is { id, name, clickable, child: { group[], company[] } }; clickable marks whether the group itself may be selected. |
group[] |
object[] | Flat { id, name } list of every visible group. |
company[] |
object[] | Flat { id, name, deleted } list of every visible company; deleted lets the picker mark inactive companies. |
Errors
No endpoint-specific error conditions are documented for this endpoint; the standard responses in §5.3 Status codes apply.
Notes
- This route is not under the
mc/prefix even though it belongs to this module; request it at the path shown. - Tier-3 visibility uses the shared,
plan_type-matched menu-access rules, resolved for menu 3 (Monthly Close) rather than menu 81 (Tasks & Time). - Tier 1 is resolved from the caller's assigned roles, not from a
users.global_admincolumn, which does not exist in this database.
13. Comments Center
Comments Center holds the threaded comment panels attached to Tasks and to Invoicing batches. Both are built on the shared comment_thread table with per-domain comment and assignee tables.
Conventions used throughout this section:
- Paths in this section are served under
/api. - Global request validation runs on every request. Any body property not declared in the endpoint's request body schema is silently deleted before the endpoint sees it.
- Authentication is enforced per endpoint rather than by a route-level gate: every request resolves the caller from the session, which must carry a user id and be in the
authenticatedstate; otherwise the request fails with401 { "message": "These credentials do not match our records." }. The session cookie isgenie.sid(httpOnly,SameSite=Lax). - No CSRF token is required on any endpoint in this section; CSRF protection applies only to the authentication endpoints.
- Several
:idpath parameters and body ids accept either a plain numeric id or an encrypted (base64) id. List responses hand back the encrypted form asencryptId.
13.1 Task Comments
Threaded comments on a Task Detail record.
Loads every comment thread on a task, with each comment's author, assignees and per-thread resolve state, plus the list of users assignable on the task's company.
| Module | Comments Center > Task Comments |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. The task's company must satisfy company.firm_id = <caller's firm> and company.deleted = 0; a task outside the caller's firm returns the empty payload rather than an error. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced), integer | Company context. Used only as a fallback — the company actually used is the task's own company_id. |
taskId |
string | Yes | String | Task id, plain numeric or encrypted. |
Example request
curl -X POST 'https://api.example.com/api/task/view/comment' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "companyId": 412, "taskId": "9081" }'
Success response — 200 OK
{
"getComment": [
{
"id": 5521,
"thread_id": 1183,
"comment": "Bank rec is missing the November statement.",
"resolve": 0,
"user_id": 77,
"createdUser": { "id": 77, "name": "Nguyen, Alex" },
"createdDateTime": "12/03/2025 09:14 AM",
"assignee": [
{
"assign_to": 91,
"user": { "id": 91, "lf_name": "Patel, Riya", "email": "riya@example.com", "name": "Patel, Riya" }
}
]
}
],
"getCommentThread": [{ "id": 1183, "task_id": 9081, "resolve": 0 }],
"assignUsers": [
{ "id": 91, "name": "Patel, Riya", "company_id": 412, "user_id": 91, "user_name": "Patel, Riya", "email": "riya@example.com", "profile_image": "" }
],
"viewComments": [
{ "caption": "Resolve Thread", "none": "", "resolveLabelShow": "d-none", "allCommentValue": 0 }
],
"threadCount": 1,
"loginUser": 77
}
| Field | Type | Description |
|---|---|---|
getComment[] |
array | One entry per non-deleted task_comments row, ascending by id. |
getComment[].createdUser |
object | { id, name } — users.lf_name ("Last, First"), falling back to first+last. |
getComment[].createdDateTime |
string | Formatted timestamp. |
getComment[].assignee[] |
array | { assign_to, user: { id, lf_name, email, name } } per task_comments_assignee row. |
getCommentThread[] |
array | comment_thread rows for the task (id, task_id, resolve). When no thread rows exist, threads are synthesised from the comments' distinct thread_ids. |
assignUsers[] |
array | Users mapped to the company who hold non-revoked Task & Time (menu 81) access on the company or its company group. |
viewComments[] |
array | Per-thread UI labels, index-aligned with getCommentThread. |
threadCount |
number | Length of getCommentThread. |
loginUser |
number | Caller's user id. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId missing/non-integer, or taskId missing/non-string |
Standard request-validation error array |
| 401 | No authenticated session, or user row missing/deleted | { "message": "These credentials do not match our records." } |
Notes
- An unresolvable
taskId, or a task outside the caller's firm, returns the empty payload (getComment: [],threadCount: 0,loginUser) — not a 404 — but still withassignUsers: []. - Assignable-user resolution first requires a
user_company_mappingrow; if that yields nothing it falls back to a company/group-access-only query so the picker is never empty for group-granted companies.
Adds a comment to a task, creating a new comment_thread when no threadId is supplied, and optionally assigning users to that comment.
| Module | Comments Center > Task Comments |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session; the task must belong to the caller's firm and to a non-deleted company. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | Yes | Numeric (numeric strings are coerced), integer | Fallback company; the task's own company_id wins. |
taskId |
string | Yes | String | Plain or encrypted task id. |
comment |
string | Yes | String | Comment body. Trimmed; an empty result is rejected. |
assignUser |
array | No | Optional, array — elements typed number \| string |
User ids to assign. Coerced to a number; values <= 0 are dropped. |
threadId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Reply into an existing thread. Omit to start a new thread. |
Example request
curl -X POST 'https://api.example.com/api/task/view/comment/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "companyId": 412, "taskId": "9081", "comment": "Statement uploaded.", "assignUser": [91], "threadId": 1183 }'
Success response — 200 OK
{ "error": false, "id": 5522, "thread_id": 1183 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
id |
number | New task_comments.id. |
thread_id |
number | Thread the comment landed in (new or supplied). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | taskId unresolvable, or task not in the caller's firm |
{ "error": true, "message": "Task not found." } |
| 200 | comment blank after trim |
{ "error": true, "message": "Comment Field is required." } |
| 400 | Missing/invalid companyId, taskId or comment |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Failure cases return HTTP 200 with
error: true; only validation and auth produce non-2xx. - New threads are written with
in_company = 0andresolve = 0explicitly (the column isNOT NULLwith no DB default under strictsql_mode). task_comments.comment_dateis stamped with the first day of the current month.
Edits one task comment: either replaces its text, or replaces its full assignee set.
| Module | Comments Center > Task Comments |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session; the owning task must belong to the caller's firm. No author-only restriction — any user with access to the task may edit any comment on it. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
commentId |
number | Yes | Numeric (numeric strings are coerced), integer | task_comments.id. |
colName |
string | Yes | String | 'assign_to' switches to assignee-replacement mode; any other value is treated as a comment-text update. |
colValue |
unknown | No | Optional only — no type validation | For assign_to: an array of user ids. Otherwise: the new comment text (coerced with String(colValue ?? '')). |
Example request
curl -X POST 'https://api.example.com/api/task/view/comment/edit' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "commentId": 5521, "colName": "assign_to", "colValue": [91, 104] }'
Success response — 200 OK
{
"error": false,
"message": "Comment Updated Successfully",
"assignee": [
{ "task_comments_id": 5521, "assign_to": 91, "id": 91, "lf_name": "Patel, Riya", "first_name": "Riya", "last_name": "Patel", "email": "riya@example.com" }
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Always "Comment Updated Successfully". |
assignee[] |
array | Refreshed assignee rows joined to users. Empty array on a text update. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | commentId not found / soft-deleted, or its task is outside the caller's firm |
{ "error": true, "message": "Comment not found." } |
| 400 | Missing/invalid commentId or colName |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Assignee mode is destructive: all existing
task_comments_assigneerows for the comment are deleted, then re-inserted fromcolValue. SendingcolValue: []clears every assignee. - On the text path a missing
colValuewrites an empty string — unlike the Invoicing Comments equivalent, this endpoint has no blank-value guard.
Toggles a comment thread between resolved and re-opened, cascading the new state to every comment in the thread.
| Module | Comments Center > Task Comments |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session; the thread's task must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
commentId |
number | Yes | Numeric (numeric strings are coerced), integer | Despite the name, this is a comment_thread.id, not a comment id. |
resolveVal |
number | Yes | Numeric (numeric strings are coerced), integer | The current state. 0 resolves the thread (new value 1); any other value re-opens it (new value 0). |
Example request
curl -X POST 'https://api.example.com/api/task/comment/resolve' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "commentId": 1183, "resolveVal": 0 }'
Success response — 200 OK
{ "error": false, "resolveComment": 1 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
resolveComment |
number | The state just written: 1 = resolved, 0 = re-opened. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Thread id not found, or its task is outside the caller's firm | { "error": true, "message": "Thread not found." } |
| 400 | Missing/invalid commentId or resolveVal |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Two writes occur:
comment_thread.resolvefor the thread, andtask_comments.resolvefor every non-deleted comment with thatthread_id.
13.2 Invoicing Comments
Threaded comments on an invoicing batch (batch_invoice). Save and resolve share the same open-comment-thread mechanics that back the Monthly Close / Accounting comment sources.
Loads the comment threads on an invoice batch plus the assignable-user list for the comment composer.
| Module | Comments Center > Invoicing Comments |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
batchInvoiceId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Invoice batch id. Omitted or 0 returns the empty payload. |
companyId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Fallback company for the assignable-user lookup; the batch's own company takes precedence when it can be resolved. |
threadId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Accepted by the request body schema; it does not affect the result. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/view/comment' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "batchInvoiceId": 3320, "companyId": 412 }'
Success response — 200 OK
{
"getComment": [
{
"id": 812,
"thread_id": 2044,
"comment": "Hold this batch until the rate change is approved.",
"resolve": 0,
"user_id": 77,
"createdUser": { "id": 77, "name": "Nguyen, Alex" },
"createdDateTime": "12/03/2025 09:14 AM",
"assignee": []
}
],
"getCommentThread": [{ "id": 2044, "resolve": 0 }],
"assignUsers": [{ "id": 91, "name": "Patel, Riya" }],
"viewComments": [
{ "caption": "Resolve Thread", "none": "", "resolveLabelShow": "d-none", "allCommentValue": 0 }
],
"threadCount": 1,
"loginUser": 77
}
| Field | Type | Description |
|---|---|---|
getComment[] |
array | Non-deleted invoice_comments rows for the batch, ascending by id, shaped exactly as the Task Comments equivalent. |
getCommentThread[] |
array | comment_thread rows (id, resolve) referenced by those comments. |
assignUsers[] |
array | Assignable users for the batch's company. |
viewComments[] |
array | Per-thread UI labels, index-aligned with getCommentThread. |
threadCount |
number | Length of getCommentThread. |
loginUser |
number | Caller's user id. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Non-integer values supplied for the optional fields | Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- When
batchInvoiceIdis absent/0, or theinvoice_commentstable is not present, the empty payload is returned butassignUsersis still populated, so the composer opens with a working picker on a batch that has no comments yet.
Posts a comment on an invoice batch — a reply when threadId is supplied, otherwise a new thread.
| Module | Comments Center > Invoicing Comments |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement, but the resolved company must belong to the caller's firm; a mismatch raises 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
batchInvoiceId |
number | Yes | Numeric (numeric strings are coerced), integer | Invoice batch id. |
companyId |
number | Yes | Numeric (numeric strings are coerced), integer | Fallback company; batch_invoice.company_id wins when the batch resolves. |
comment |
string | Yes | String | Comment body; trimmed before insert. |
assignTo |
number[] | No | Optional, array, Numeric (numeric strings are coerced) | User ids to assign; values <= 0 are dropped. |
threadId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Reply into an existing thread. Omit to create one. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/view/comment/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "batchInvoiceId": 3320, "companyId": 412, "comment": "Approved.", "assignTo": [91] }'
Success response — 200 OK
New thread:
{ "error": false, "thread_id": 2045, "source": "invoice" }
Reply into an existing thread:
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
thread_id |
number | Present on the new-thread path only. |
source |
string | Present on the new-thread path only; always "invoice" for this route. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | New-thread path where the resolved batchInvoiceId is <= 0 |
{ "error": true, "message": "Threads can only be started for Monthly Close or Accounting from Open Comments." } |
| 400 | Missing/invalid batchInvoiceId, companyId or comment |
Standard request-validation error array |
| 401 | No authenticated session, or the resolved company is not in the caller's firm | { "message": "These credentials do not match our records." } |
Notes
- Threads and replies created here are recorded under module
106(Invoice). - New invoice threads write
comment_thread.task_id = NULL(it is an FK totasks),in_company = 0,resolve = 0. - The reply path carries the batch id forward from the thread's first comment; it does not re-check the supplied
batchInvoiceIdagainst the thread.
Edits one invoice comment: replaces its text, or replaces its full assignee set.
| Module | Comments Center > Invoicing Comments |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session, and no firm-scope assertion on this path — the update is keyed on commentId alone. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
commentId |
number | Yes | Numeric (numeric strings are coerced), integer | invoice_comments.id. |
colName |
string | Yes | String | 'assign_to' switches to assignee-replacement mode; any other value updates the comment text. |
colValue |
unknown | Yes | Accepted as-is — no type constraint, so both shapes are preserved | Array of user ids for assign_to; the new comment text otherwise. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/view/comment/edit' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "commentId": 812, "colName": "comment", "colValue": "Approved after rate review." }'
Success response — 200 OK
{ "error": false, "message": "Comment Updated Successfully" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
message |
string | Always "Comment Updated Successfully". |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | invoice_comments table not present |
{ "error": true, "message": "Comments table unavailable." } |
| 200 | Text path with a blank/missing colValue after trim |
{ "error": true, "message": "Comment Field is required." } |
| 400 | Missing/invalid commentId or colName, or colValue absent entirely |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
colValuecarries no declared type because it is polymorphic; it is nevertheless part of the request body schema, so the global unknown-property stripping does not remove it.- Unlike Task Comments, a blank text value is rejected instead of being written.
- Assignee mode deletes all
invoice_comments_assigneerows for the comment before re-inserting; it does not return the refreshed assignee list.
Toggles an invoice comment thread between resolved and re-opened.
| Module | Comments Center > Invoicing Comments |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement. Firm scoping is applied inside the SQL: the comment_thread update only matches when the thread's task or batch invoice belongs to a company in the caller's firm. A global admin with no firm_id bypasses that predicate. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
commentId |
number | Yes | Numeric (numeric strings are coerced), integer | Despite the name, a comment_thread.id. |
resolveVal |
number | Yes | Numeric (numeric strings are coerced), integer | Current state. 0 resolves (new value 1); anything else re-opens (new value 0). |
companyId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Passed through; used only on the Monthly Close / Accounting sources, not on the invoice source. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/comment/resolve' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "commentId": 2044, "resolveVal": 0, "companyId": 412 }'
Success response — 200 OK
{ "error": false, "resolveComment": 1 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
resolveComment |
number | The state just written: 1 = resolved, 0 = re-opened. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing/invalid commentId or resolveVal |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Two writes:
comment_thread.resolve(firm-scoped) andinvoice_comments.resolvefor every non-deleted comment on the thread. - The
invoice_commentsupdate is not firm-scoped; only thecomment_threadupdate is. A thread outside the caller's firm therefore leaves the thread row untouched while still returningerror: false.
14. Report Center
The Report Center is the reporting half of Genie: it lets a firm build, preview, package and export financial reports over its QuickBooks-synced ledger. It is organised into five areas — Report List (browse/add/clone/delete report definitions), Report Preview (run a report definition and export it), Report Writer (author a report's rows, columns, period settings and axis), Report Packages (group reports into a package and run/export them together) and Configure (per-company accounting method, fiscal month, QuickBooks refresh). All report generation runs through a single shared engine, so the preview screen, a package card and an Excel export can never disagree about a report's numbers.
Every route in this section is published under the /api prefix, so the public path is /api/<method path>. Authentication is enforced per endpoint and requires a session whose login.userId is set and whose login.state is authenticated. Request validation is applied globally: it strips unknown body and query keys and coerces numeric string values, so any field not listed in the request schema is silently discarded before the endpoint runs.
Two conventions recur throughout:
- Encrypted ids.
report_id,package_id,company_idandidpath/body values accept either a plain numeric id or the application's encrypted (base64) id. Both forms are resolved server-side; when neither decodes, the id resolves to0and is treated as "not found". erroris not an HTTP status. Several endpoints return200with{ error: true, msg: ... }for business rejections (duplicate name, package already exists) because the front end shows a toast rather than treating them as request failures.
14.1 Report List
Loads the Report List screen: the company scope picker, the Global and Custom report columns for one report group, the GL-validation badge and the Chart-of-Accounts change counter.
| Module | Report Center > Report List |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Capability flags are computed rather than blocking the request: canManage = global admin, firm admin, or role 1 (GlobalMasterSystemAdministrator) / role 5 (GlobalReportandDashboardAdministrator); is_global_user = global admin or role 1/5; show_custom_reports = session company ≠ 0 or role 9 (CompanyGroupReportandDashboardAdministrator) |
| Content type | application/json |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
group_id |
integer | No | 1 |
Report group tab (global_report_list.group_type). Falsy values fall back to 1. |
searchKey |
string | No | '' |
Case-insensitive substring match on report name. |
orderBy |
string | No | ASC |
Only the literal desc (case-insensitive) selects DESC; anything else is ASC. |
searchLetter |
string | No | '' |
A–Z jump filter. Only a single [a-z] character is honoured; anything else is ignored. |
companyId |
integer | No | session company | Company scope override. 0/absent falls back to the session company, then the firm-internal company, then the first visible company. |
isValidation |
integer | No | 0 |
1 switches to the deferred, expensive GL-validation response (see Notes). |
Example request
curl -sS 'https://api.example.com/api/reports/list?group_id=1&orderBy=asc' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"group_id": 1,
"selectedCompany": { "id": 12, "encId": "aVs2...", "name": "Acme Holdings", "qbo_connection": 1, "active": 1, "deleted": 0 },
"companies": [
{ "id": 12, "encId": "aVs2...", "name": "Acme Holdings", "qbo_connection": 1, "active": 1, "deleted": 0 }
],
"includeInactiveDefault": false,
"account_changes_unseen": 3,
"changes_redirect": "/company/balanceSheet?changes=1",
"glValidation": { "status": "Pass", "color": "green", "canRefresh": false, "qboError": false },
"data": {
"global": [
{
"id": 20, "encryptId": "Qk9...", "report_name": "Balance Sheet (QuickBooks Mirror)",
"type": 1, "is_removable": 1, "mirror_report": 2, "group_type": 1, "company_id": 0,
"desc": "", "preview_url": "/reports/preview/Qk9..."
}
],
"custom": []
},
"canManage": true,
"is_global_user": true,
"global_can_edit": true,
"show_custom_reports": true
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success. |
group_id |
integer | Echo of the resolved group tab. |
selectedCompany |
object | null | { id, encId, name, qbo_connection, active, deleted }. |
companies |
array | Companies visible to the user (role-scoped to the companies visible to the caller), including deleted = 1 rows so the "Include Inactive Companies" toggle has something to reveal. |
includeInactiveDefault |
boolean | Always false. |
account_changes_unseen |
integer | Unseen Chart-of-Accounts changes for the selected company. |
changes_redirect |
string | Target of the changes strip's "Click here" link. |
glValidation |
object | Cheap probe: { status: 'Pass'\|'Fail'\|'N/A', color, canRefresh, qboError, reason? }. |
data.global / data.custom |
array | Report rows: id, encryptId, report_name, type, is_removable, mirror_report, group_type, company_id, desc (tooltip, joined from report_writer.desc), preview_url. |
canManage, is_global_user, global_can_edit, show_custom_reports |
boolean | UI capability gates (see Roles above). |
With isValidation=1 the shape is instead:
{
"error": false,
"glValidation": { "status": "Fail", "color": "red", "canRefresh": true, "qboError": false, "reason": "View variances" },
"glVariance": [ { "label": "Total Income", "genie": 120500.0, "quickbooks": 120400.0, "variance": 100.0 } ]
}
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | group_id, companyId or isValidation is not an integer |
Request validation error array |
| 401 | No authenticated session, or the user row is missing/deleted | { "message": "These credentials do not match our records." } |
Notes
type = 1rows are the Global column (company-less),type = 3/4the Custom column (scoped to the selected company). Rows withdeleted = 1are excluded.descdoes not live onglobal_report_list; it is LEFT JOINed fromreport_writer(company0for the Global list, the selected company for the Custom list).isValidation=1is the expensive path: two live Intuit Reports API calls (P&L + Balance Sheet over the running fiscal year through end of last month) reconciled against Genie's own seeded Balance Sheet mirror (report id 20). It is intended to be fired once, after the page paints — never on the initial list load.- The badge returned on the plain list load is only the cheap connection/unseen-changes probe.
Search alias for the Report List. Identical behaviour to GET /api/reports/list.
| Module | Report Center > Report List |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session (same capability flags as /api/reports/list) |
| Content type | application/json |
Query parameters — identical to GET /api/reports/list.
Example request
curl -sS 'https://api.example.com/api/reports/searchReport?searchKey=income&group_id=1' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK. Identical shape to GET /api/reports/list.
Errors — identical to GET /api/reports/list.
Notes
- Compatibility route: retained for compatibility with earlier clients. It accepts the same parameters and produces the same result as
GET /api/reports/list, which is the canonical route.
Creates a new, empty report definition in global_report_list and returns the URLs for previewing and editing it.
| Module | Report Center > Report List |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin, firm admin, or any of role 1 (GlobalMasterSystemAdministrator), 5 (GlobalReportandDashboardAdministrator), 9 (CompanyGroupReportandDashboardAdministrator). Otherwise 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportName |
string | Yes | String; additionally rejected by this endpoint when blank after trim | Report display name. |
type |
integer | Yes | Numeric (numeric strings are coerced), integer | global_report_list.type: 1 GlobalReport, 2 GlobalCalulated, 3 CustomReport, 4 CustomCalulated. |
groupType |
integer | Yes | Numeric (numeric strings are coerced), integer | Report group tab the report belongs to. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/addReport' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"reportName":"Monthly Income Statement","type":3,"groupType":1}'
Success response — 200 OK
{
"error": false,
"report_id": 481,
"report_name": "Monthly Income Statement",
"previewURL": "/reports/preview/S0xt...",
"editURL": "/reports/writer/S0xt..."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success; true with msg on a duplicate name. |
report_id |
integer | New global_report_list.id. For the special-user path, the id of the last row created. |
report_name |
string | Trimmed name as stored. |
specialUser |
boolean | Present and true only when the role-9 multi-company path fired. |
previewURL / editURL |
string | Front-end routes carrying the encrypted report id. |
Duplicate name (still 200):
{ "error": true, "msg": "Report name already exist." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | reportName blank after trim |
Report name field is required |
| 400 | global_report_list table missing |
Report list table is unavailable. |
| 400 | type / groupType not integers, or reportName not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401 | Caller holds none of the allowed roles | Not allowed to add report. |
Notes
- Company scoping:
type3 or 4 stores the session company (falling back to the firm's internal company); type 1/2 storesNULL/0. - Duplicate check is name + type, additionally company-scoped for type 3/4, and ignores soft-deleted rows.
- Special-user fan-out: a role-9 user working at company scope
0who creates a Custom report gets oneglobal_report_listrow per mapped company (from their user-company map), each stampedadded_by. The response then carriesspecialUser: trueand the id of the last row inserted. - Inserted columns are chosen by schema introspection, so a deployment missing
group_type,mirror_report,added_byetc. simply omits them rather than failing.
Removes a report definition (soft delete when the deleted column exists).
| Module | Report Center > Report List |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin only. Otherwise 401. |
| Content type | application/json |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
report_id |
string | Yes | — | String. Plain numeric or encrypted report id. |
Example request
curl -sS 'https://api.example.com/api/reports/deleteReport?report_id=481' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_id absent or not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401 | Caller is neither global nor firm admin | Not allowed to delete report. |
Notes
- This route uses
GETwith a query parameter although it performs a mutation. - When
global_report_list.deletedexists the row is soft-deleted (deleted = 1,updated_attouched); otherwise it is hard-deleted. - A missing
global_report_listtable returns{ error: false }rather than an error. - Soft-deleted reports still generate: preview and package runs do not filter on
deleted.
Clones a report definition under a new name, including a deep copy of its writer (header, columns and detail rows).
| Module | Report Center > Report List |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin only. Otherwise 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
report_id |
string | Yes | String | Source report id (plain or encrypted). |
report_name |
string | Yes | String; rejected by this endpoint when blank after trim | Name for the clone. |
type |
integer | Yes | Numeric (numeric strings are coerced), integer | Target type: 1 Global, 2 GlobalCalulated, 3 Custom, 4 CustomCalulated. Falls back to the source report's type when 0. |
groupType |
integer | Yes | Numeric (numeric strings are coerced), integer | Accepted but not used for the insert — the clone inherits group_type from the source row. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/clone' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"report_id":"481","report_name":"Monthly Income Statement (Copy)","type":3,"groupType":1}'
Success response — 200 OK
{
"error": false,
"report_id": 482,
"url": "/reports/writer/T0Ru..."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success; true with msg on a duplicate name. |
report_id |
integer | New report id. |
url |
string | Report Writer route for the clone (encrypted id). |
Duplicate name (still 200): { "error": true, "msg": "Report name already exist." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_name blank after trim |
Report name field is required |
| 400 | global_report_list table missing |
Report list table is unavailable. |
| 400 | Source report id does not resolve to a row | Report not found |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401 | Caller is neither global nor firm admin | Not allowed to clone report. |
Notes
- Target company: a clone saved as Global (
type = 1) is stored withcompany_id = 0; any other type lands on the session company. group_type,based_onandreporting_groupare copied from the source report, not from the request — so a clone of a Customer/Project/Class/Location/Reporting-Group writer keeps its axis.- Deep copy: the source's
report_writerrow is duplicated (a Global target reads thecompany_id = 0writer; any other target reads the report's first writer), then everyreport_writer_columnandreport_writer_detailsrow is copied onto the new writer. - Only live (
deleted = 0) column and detail rows are copied; soft-deleted columns and rows are not carried into the clone.
14.2 Report Preview
Preview generation is delegated to the shared report engine, which is also what Report Packages uses for each card.
Bootstraps the Report Preview screen for one report: the report row, the preview form's controls and state, any saved preview parameters, and — as a first-paint convenience — an already-generated report payload.
| Module | Report Center > Report Preview |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The response returns meta.global_can_edit (global admin, firm admin, or role 1/5, and the selected company's name must not match /inactive/i) and meta.isGlobalAdmin (global admin or role 1) as UI gates. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Report id — plain numeric or encrypted. Not schema-validated. |
Example request
curl -sS 'https://api.example.com/api/reports/preview/Qk9tRTA4' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"report": {
"id": 20, "encryptId": "Qk9tRTA4", "report_name": "Balance Sheet (QuickBooks Mirror)",
"type": 1, "group_type": 1, "mirror_report": 2, "based_on": 1, "reporting_group": 0
},
"meta": {
"is_fixed": 0, "as_month": "", "as_year": "", "mirror_id": 2, "based_on": 1,
"checkAccExist": false, "company_id": 12, "connection_qbo": 1,
"sync_date": "08/21/25 04:15PM", "global_can_edit": true, "isGlobalAdmin": true,
"report_for": "Acme Holdings",
"reportingGroups": [ { "id": 4, "name": "West Region", "type": 1 } ],
"classAndLocation": [ { "qbo_id": "3", "cls_name": "Retail", "cls_or_dept": 1 } ],
"previewData": { "monthVal": "Aug", "yearVal": "2025", "main_period": 1 }
},
"generated": {
"error": false, "report_id": 20, "report_name": "Balance Sheet (QuickBooks Mirror)",
"mirror_report": 2, "based_on": 1, "reporting_group": 0, "is_fixed": 0, "is_mirror": 1, "mirror_id": 2,
"headers": ["Aug 25"],
"headerGroups": { "first": [], "second": [] },
"columns": [ { "key": "c1", "order": "A", "label": "Aug 25", "format": 1, "rw_precision": 0, "is_override": 0 } ],
"rows": [ { "account": "TOTAL ASSETS", "account_num": "-", "row_type": 2, "row_class": "rw-format-bold", "rw_precision": 0, "format": 1, "class_indent": 0, "empty_formula": false, "values": { "c1": 1284300.55 } } ],
"allowReportWithRG": true, "isSameFiscal": true, "isSameReportMethod": true, "accountingMethods": []
}
}
| Field | Type | Description |
|---|---|---|
report |
object | id, encryptId, report_name, type, group_type, mirror_report, based_on, reporting_group. |
meta.is_fixed |
0 | 1 | 1 hides Period / Period Sequence / No. of Periods / Compare-another-period. Forced to 0 for mirror reports 1, 2 and 5. |
meta.as_month / meta.as_year |
string | Writer's saved as-on date, only populated when is_fixed. |
meta.mirror_id |
integer | 0 none, 1 Profit & Loss, 2 Balance Sheet, 3 A/R Aging Summary, 4 A/P Aging Summary, 5 Cash Flow. |
meta.based_on |
integer | 1 GLAccount, 2 Customer, 3 Project, 4 Class, 5 Location, 6 ReportingGroup. |
meta.checkAccExist |
boolean | Whether a custom (mirror_report = 0) report has direct account rows. |
meta.connection_qbo |
0 | 1 | Selected company's QuickBooks connection state. |
meta.sync_date |
string | Formatted last QBO sync timestamp. |
meta.report_for |
string | Company name, or the reporting group's name for a based_on = 6 report. |
meta.reportingGroups |
array | { id, name, type } — type 1 Firm, 2 Cross Firm, 3 ME Group. |
meta.classAndLocation |
array | { qbo_id, cls_name, cls_or_dept } — cls_or_dept 1 Class, 2 Location. Sourced from the report's reporting group when based_on = 6, otherwise from the session company. |
meta.previewData |
object | null | Previously saved preview parameters for (report, company, user), or null. |
generated |
object | A pre-generated run; same shape as POST /api/reports/view/generate_income_statement. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | global_report_list missing, or the id does not resolve to a row |
Report not found |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Mirror reports 3 and 4 (A/R and A/P Aging Summary) are not pre-generated —
generatedcomes back as{ error: false, report_id, headers: [], columns: [], rows: [] }, because those two are a live Intuit Reports API call the client immediately re-runs with the form's real aging period. - A report with no
report_writerrow is treated as fixed — except for mirror 1/2/5, which are always non-fixed. - The pre-generated
generatedblock is a first-paint convenience; clients may ignore it and call the generate endpoint themselves.
Runs a report definition against the session company's ledger and returns the full grid (headers, column metadata and valued rows). This is the core report-generation endpoint.
| Module | Report Center > Report Preview |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. Reporting-group access is checked inside the engine for a based_on = 6 report. |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied, so unknown properties are neither rejected nor stripped. The keys read are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
report_id (alias reportId) |
string | number | Yes | Plain or encrypted id; unresolvable ids yield Report not found |
The report to run. |
monthVal (alias month) |
string | No | Short month name (Jan…Dec); the literal Month is treated as unset |
As-on month. |
yearVal (alias year) |
number | string | No | > 0 | As-on year. Falls back to the writer's as_on_date, then to today. |
main_period (alias mainPeriod) |
number | No | 3 = Months YTD |
Period mode. |
period |
number | No | — | Period sequence. |
trailing_period (aliases trailingPeriod, num_of_period) |
number | No | 12 |
Number of trailing periods. |
include_total (alias includeTotal) |
boolean-ish | No | true when absent |
Whether the Total column is built. |
is_period (alias isPeriod) |
number | No | 1 enables |
Compare-another-period mode. |
periodColArr |
string[] | No | — | The compare-period column keys. |
selectedClass |
string[] | No | — | Class filter. |
selectedLoc |
string[] | No | — | Location filter. |
expandone / expandtwo |
number | No | 0 None, 1 Class, 2 Location |
Expand 1st By / Expand 2nd By. |
nonZero / nonZero2 |
number | No | 1 enables |
Non-zero-only variants of the two expand axes. |
acc_rep_group |
number | No | > 0 | Run against this reporting group. |
expandCompany |
number | No | 1 enables |
Expand a reporting-group run by company. |
agingPeriod (alias aging_period) |
number | No | — | A/R & A/P Aging Summary only: days per aging bucket. |
numPeriod (alias num_period) |
number | No | — | A/R & A/P Aging Summary only: number of buckets. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/view/generate_income_statement' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{
"report_id": "481",
"monthVal": "Aug", "yearVal": 2025,
"main_period": 1, "period": 2, "trailing_period": 12,
"include_total": 1, "expandone": 1, "nonZero": 1,
"selectedClass": ["3"], "selectedLoc": []
}'
Success response — 200 OK
{
"error": false,
"report_id": 481,
"report_name": "Monthly Income Statement",
"mirror_report": 0,
"based_on": 1,
"reporting_group": 0,
"is_fixed": 0,
"is_mirror": 0,
"mirror_id": 0,
"headers": ["Jul 25", "Aug 25", "Total"],
"headerGroups": { "first": [], "second": [] },
"columns": [
{ "key": "c1", "order": "A", "label": "Jul 25", "type": 1, "format": 1, "style": "rw-format-bold",
"rw_precision": 0, "is_override": 0, "group_key": null, "group_label": null,
"second_key": null, "second_label": null, "leaf_label": "Jul 25",
"axis_cls": null, "axis_loc": null, "axis_com": null, "detail_order": 1 }
],
"rows": [
{ "account": "Total Income", "account_num": "-", "row_type": 2, "row_class": "rw-format-bold",
"rw_precision": 0, "format": 1, "class_indent": 0, "empty_formula": false,
"values": { "c1": 82500.0, "c2": 91300.25, "total": 173800.25 } }
],
"allowReportWithRG": true,
"isSameFiscal": true,
"isSameReportMethod": true,
"accountingMethods": []
}
| Field | Type | Description |
|---|---|---|
headers |
string[] | Column labels in order. |
headerGroups |
object | { first: [], second: [] } — grouped header rows for an expanded or multi-axis run; both empty when nothing is expanded. |
columns |
array | Per-column metadata. key is what each row's values map is keyed by; format, rw_precision and is_override decide the cell's suffix and decimals. |
rows |
array | account, account_num ('-' when absent), row_type, row_class (rw-format-* style classes), rw_precision, format, class_indent, empty_formula, values. |
multiaxis |
1 | Present only on a multi-axis (based_on 2–6, non-mirror) run. |
allowReportWithRG |
boolean | false when the report cannot be run against the requested reporting group; the grid comes back empty. |
isSameFiscal, isSameReportMethod, accountingMethods |
boolean / array | Reporting-group consistency strip. accountingMethods is { company, method }[]. |
A/R and A/P Aging Summary (mirror_report 3 or 4) return a different, flat table alongside empty headers/columns/rows:
{
"error": false, "report_id": 22, "report_name": "A/R Aging Summary",
"mirror_report": 3, "is_mirror": 1, "mirror_id": 3, "is_fixed": 0,
"type": "aging", "header": ["Customer", "Current", "1 - 30", "31 - 60", "Total"],
"body": [["Acme Corp", "1200.00", "0.00", "340.00", "1540.00"]],
"total_rows": [1],
"headers": [], "columns": [], "rows": []
}
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_id does not resolve to a global_report_list row |
Report not found |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 500 | An engine failure (invalid formula, QBO error, unserialisable value) | Logged server-side; the response is JSON |
Notes
include_totaldefaults to true when the field is absent — a caller that wants the Total column dropped must sendinclude_total: 0explicitly.- Soft-deleted reports still generate: this endpoint applies no
deletedfilter, so a report removed from the list still runs inside a package. - Mirror reports have no
report_writerrows; their rows are synthesised per run from the company's chart of accounts (mirror 1 P&L, 2 Balance Sheet, 5 Cash Flow). On a reporting-group run the chart of accounts is the name-clubbed union of the group's companies. - Reporting-group refusals (
allowReportWithRG: false) happen when: the report'sbased_onis not GLAccount; the report is mirror 3, 4 or 5; a custom report contains a direct Income-Statement/Balance-Sheet account row; or the user cannot access the group of abased_on = 6report. An empty group likewise returns an empty grid. - All
/reports/requests are traced in the application log (arrival, completion with duration, and a "connection closed with NO response" entry when a proxy gives up). Large integer values are converted before serialisation, so a large ledger total cannot break the JSON response.
Returns the merged class/location list for a reporting group's companies, plus the fiscal-month and accounting-method consistency flags the preview strip shows.
| Module | Report Center > Report Preview |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body — this endpoint accepts a free-form JSON object; no schema validation is applied, so unknown properties are neither rejected nor stripped. The keys read are:
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
reportingGroup (alias group_id) |
number | No | Defaults to 0 |
Reporting group id, or (when modeRg = 'com') a company id. 0 returns the empty payload. |
modeRg |
string | No | 'rg' (default) or 'com' |
'rg' expands the id into the group's companies; 'com' treats it as a single company id and forces both consistency flags to true. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/rgCompany/classLoc' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"reportingGroup":4,"modeRg":"rg"}'
Success response — 200 OK
{
"error": false,
"classAndLocation": [
{ "qbo_id": "3", "cls_name": "Retail", "cls_or_dept": 1 },
{ "qbo_id": "9", "cls_name": "Portland", "cls_or_dept": 2 }
],
"isSameFiscal": true,
"isSameReportMethod": false,
"accountingMethods": [
{ "company": "Acme Holdings", "method": "Accrual" },
{ "company": "Acme West", "method": "Cash" }
]
}
| Field | Type | Description |
|---|---|---|
classAndLocation |
array | { qbo_id, cls_name, cls_or_dept } — cls_or_dept 1 Class, 2 Location. Merged across the group's companies and de-duplicated by name when more than one company is in play. |
isSameFiscal |
boolean | Whether every company in the group shares a fiscal month. Always true when modeRg = 'com'. |
isSameReportMethod |
boolean | Whether every company shares an accounting method. Always true when modeRg = 'com'. |
accountingMethods |
array | { company, method } per company. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- A missing or
0group id short-circuits to{ error: false, classAndLocation: [], isSameFiscal: true, isSameReportMethod: true, accountingMethods: [] }— it is not an error. - The same consistency computation is embedded in every reporting-group report run, so this endpoint exists purely to refresh the strip without re-running the report.
Persists the preview form's parameters for the current (report, company, user) triple so the screen reopens with the same selections.
| Module | Report Center > Report Preview |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
report_id |
string | Yes | String | Plain or encrypted report id. |
parameters |
object | No | Optional, object; stored verbatim as JSON | The preview form state. Defaults to {} when omitted. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/view/save_preview' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{
"report_id": "481",
"parameters": { "monthVal": "Aug", "yearVal": "2025", "main_period": 1,
"trailing_period": 12, "include_total": 1,
"selectedClass": ["3"], "showType": "Non Zero" }
}'
Success response — 200 OK
{ "error": false, "message": "Preview saved successfully." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
message |
string | Preview saved successfully., or Preview saved (storage table not available). when report_writer_preview does not exist. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_id missing/not a string, or parameters not an object |
Request validation error array |
| 400 | report_id does not resolve, or the session has no company |
Report not found |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Upsert keyed on (
report_id,company_id,user_id) inreport_writer_preview, with each key included only when the column exists. - Saved parameters are read back by
GET /api/reports/preview/:idasmeta.previewData. parametersis a free-form object; its contents are not validated beyond being an object. Report saves accept a request body of up to 20 MB, which accommodates a large payload (many per-value conditional-formatting rules, hundreds of class/location selections). A body above that limit returns413 request entity too large.
Regenerates the report from the supplied preview parameters and returns a styled .xlsx workbook, base64-encoded in the JSON envelope.
| Module | Report Center > Report Preview |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json envelope carrying a base64 XLSX payload (contentType: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) — not a binary HTTP download |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
report_id |
string | Yes | String | Plain or encrypted report id. |
parameters |
object | No | Optional, object | The same parameter bag generate_income_statement accepts (spread into the generator call), plus two export-only keys read here: include_total (default 1), showType ('Non Zero' drops all-zero rows), acc_rep_group, monthVal/yearVal (used for the as-on banner) and export_name (the file name). |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/view/download' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{
"report_id": "481",
"parameters": { "monthVal": "Aug", "yearVal": "2025", "main_period": 1,
"trailing_period": 12, "include_total": 1,
"showType": "Non Zero", "export_name": "Aug 2025 IS" }
}'
Success response — 200 OK
{
"error": false,
"fileName": "Aug 2025 IS.xlsx",
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"contentBase64": "UEsDBBQABgAIAAAAIQ..."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success. |
fileName |
string | <export_name or report name>.xlsx, with \ / # % stripped; falls back to report.xlsx. |
contentType |
string | Always the XLSX MIME type. |
contentBase64 |
string | The workbook. The client is responsible for decoding and saving it. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_id missing/not a string, or parameters not an object |
Request validation error array |
| 400 | report_id does not resolve |
Report not found |
| 400 | Underlying generation cannot find the report | Report not found |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- The workbook is rebuilt from the generator payload that backs the on-screen table, so the export and the screen cannot drift apart. This means a download re-runs the report.
- Layout: two merged bold grey size-14 info rows (report name + generated-on; company or reporting group + as-on date + generated-by), then bold size-12 centred header rows honouring colspan merges, then the body — column A left-aligned and 45 characters wide, all other columns right-aligned and 16 wide, negatives in red, and
rw-format-bold/-underline/-underline-double/-up-underline/-boxrow classes rendered as bold and thin/double/top borders. - The on-screen visibility rules are honoured:
include_totaloff drops the Total column(s), andshowType = 'Non Zero'drops all-zero rows. - The worksheet is named after the report with
\ / : * ? " < > | [ ]stripped, truncated to 30 characters. - A/R and A/P Aging Summary export their own flat QuickBooks table instead of the writer grid.
- Because
parameterscan be large, this route also accepts a request body of up to 20 MB.
14.3 Report Writer
Loads everything the Report Writer screen needs on open: the report row, its writer header, the company scope, class/location and reporting-group pick lists, edit locks and dashboard-card warnings.
| Module | Report Center > Report Writer |
| Authentication | Required — authenticated session cookie |
| Authorization | No hard role check. global_can_edit is false for a Global report (type 1/2) unless the caller is a global admin or holds role 1 (GlobalMasterSystemAdministrator) or role 5 (GlobalReportandDashboardAdministrator); it is also forced false when the selected company's name matches /inactive/i, and forced true for a global or firm admin. |
| Content type | application/json |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
report_id |
string | Yes | — | String. Plain or encrypted report id. |
company_id |
integer | No | session company | Optional, numeric (numeric strings are coerced), integer. Report List's company picker; 0/absent uses the session company. Ignored for a Global report, which always reads at company 0. |
Example request
curl -sS 'https://api.example.com/api/reports/writer?report_id=481&company_id=12' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"report": {
"id": 481, "encryptId": "S0xt...", "report_name": "Monthly Income Statement",
"type": 3, "group_type": 1, "mirror_report": 0, "based_on": 1, "reporting_group": 0
},
"writer": {
"id": 903, "company_id": 12, "type": 3, "desc": "Board pack version",
"as_on_date": "2025-08-01", "as_on_month": 8, "as_on_year": 2025, "is_fixed": 0
},
"fisical_month": "Jan",
"prior_year": 2021,
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
"global_can_edit": true,
"globalUser": false,
"locked": false,
"add_locked": false,
"company": { "id": 12, "encId": "aVs2...", "name": "Acme Holdings", "qbo_connection": 1, "active": 1, "deleted": 0 },
"companies": [],
"clsAndLoc": [ { "qbo_id": "3", "cls_name": "Retail", "cls_or_dept": 1 } ],
"reportingGroups": [ { "id": 4, "name": "West Region", "type": 1 } ],
"isGlobalAdmin": true,
"desiger": false,
"designer_msg": "",
"designer_msg_html": ""
}
| Field | Type | Description |
|---|---|---|
report |
object | id, encryptId, report_name, type (1 GlobalReport / 2 GlobalCalulated / 3 CustomReport / 4 CustomCalulated), group_type, mirror_report, based_on, reporting_group. |
writer |
object | null | report_writer header: id, company_id (nullable), type, desc, as_on_date (YYYY-MM-DD or ''), as_on_month, as_on_year, is_fixed. |
fisical_month |
string | Short fiscal-month name from report_configure.fiscal_month, default Jan. |
prior_year |
integer | Current year − 4; the lower bound of the Sample As-on-date year list. |
months |
string[] | The 12 short month names. |
global_can_edit |
boolean | Master edit gate for the screen. |
globalUser |
boolean | true for a Global report — disables the Add-row sidebar's GL Accounts tab and hides the Custom groups block, because a global report has no company and therefore no chart of accounts. |
locked |
boolean | Report id 2 or 20: no sort handle, no delete, no reference, read-only names, dead row-style dropdown. |
add_locked |
boolean | Report id 2 only: hides Add row, the Enable-Fixed-Period button and the fixed-period switch. |
company / companies |
object / array | Company scope, role-filtered. |
clsAndLoc |
array | { qbo_id, cls_name, cls_or_dept } — 1 Class, 2 Location. |
reportingGroups |
array | { id, name, type } — a global admin or role 1 sees every firm's groups; everyone else only their own firm's. |
isGlobalAdmin |
boolean | Global admin or role 1. |
desiger, designer_msg, designer_msg_html |
boolean / string | Set when the report feeds a dashboard card; the writer then refuses row deletion and locks the fixed-period switch. |
A mirror report returns a redirect instruction instead (still 200, still error: false):
{ "error": false, "msg": "This report cannot be edited in the Report Writer.", "redirect": "/reports/list" }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_id absent/unresolvable, or no matching global_report_list row |
Report not found |
| 400 | company_id not an integer, report_id not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- A Global report's writer is the row with
company_id IS NULL/0; the session company's own writer row for the same report is not used. - The mirror-report refusal is returned with
error: falsebecause it is a redirect instruction rather than a request failure.
Returns the rows and columns for the selected "Based on" axis. GL-Account reports get the editable row/column grid; the multi-axis modes get a read-only preview plus the GL Account Editor rows.
| Module | Report Center > Report Writer |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
report_id |
string | Yes | String | Plain or encrypted report id. |
based_on |
integer | No | Optional, numeric (numeric strings are coerced), integer. 1 GLAccount, 2 Customer, 3 Project, 4 Class, 5 Location, 6 ReportingGroup. Defaults to 1. |
The axis being previewed. |
company_id |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the session company for the writer and account lookups. Ignored (forced to 0) for a Global report. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/writer/based-on' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"report_id":"481","based_on":1,"company_id":12}'
Success response — 200 OK. Two shapes, discriminated by mode.
GL-Account mode (based_on = 1):
{
"error": false,
"mode": "gl",
"based_on": 1,
"writer": { "id": 903, "company_id": 12, "type": 3, "desc": "", "as_on_date": "", "as_on_month": 0, "as_on_year": 0, "is_fixed": 0 },
"rows": [
{ "id": 5501, "unique_id": 1, "row_type": 1, "account_id": 84, "group_type": 3, "operator": 0,
"style": "bold", "format": 1, "rw_precision": 0, "display": 1, "order": 1,
"result_name": "4000 Sales", "formula": "", "reference_id": 0, "class": "", "location": "" }
],
"columns": [
{ "id": 771, "column_type": 1, "period_type": 0, "period": "0", "column_name": "Aug 25",
"style": "bold", "format": 1, "rw_precision": 0, "is_override": 0, "order": "A",
"operation": "0", "formula": "" }
],
"totalRowCount": 1,
"desiger": false, "designer_msg": "", "designer_msg_html": ""
}
Multi-axis mode (based_on 2–6):
{
"error": false,
"mode": "multi",
"based_on": 4,
"writer": { "id": 903, "company_id": 12, "type": 3, "desc": "", "as_on_date": "", "as_on_month": 0, "as_on_year": 0, "is_fixed": 0 },
"isGlobal": false,
"reportingGroup": 0,
"reportingGroups": [ { "id": 4, "name": "West Region", "type": 1 } ],
"isGlobalAdmin": true,
"previewRows": [ { "name": "Retail" }, { "name": "Wholesale" } ],
"glEditorRows": [],
"columns": [],
"desiger": false, "designer_msg": "", "designer_msg_html": "",
"totalRowCount": 0
}
| Field | Type | Description |
|---|---|---|
mode |
'gl' | 'multi' |
Which pane set the client should render. |
writer |
object | null | The report_writer header (same shape as GET /api/reports/writer). |
rows |
array | GL mode only. report_writer_details joined to accounts for display names. |
columns |
array | report_writer_column rows. |
previewRows |
array | Multi-axis only: { name }[], a read-only preview — top 10 customers/projects (leaf of a Parent:Child QBO name) or top 10 classes. Empty for based_on = 6. For a Global report it is the literal placeholder list Customer 1 … Customer 10 (or Project/Class/Location). |
glEditorRows |
array | Multi-axis only: the GL Account Editor's carried-over rows. Only row_type = 1 (account) rows carry over when the report's previous axis was GLAccount. |
isGlobal |
boolean | Whether this is a Global report. |
reportingGroup |
integer | The group id stored on the report row. |
totalRowCount |
integer | Row count in GL mode; always 0 in multi-axis mode. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_id absent/unresolvable, or no matching report row |
Report not found |
| 400 | based_on / company_id not integers |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- In multi-axis mode the Rows pane is read-only; the editable panes are the GL Account Editor and the Fixed Period Column Editor.
- The row-name join applies
(row_type = 1 AND group_type = 3) OR group_type = 4when taking the account name — so agroup_type = 4row takes it regardless ofrow_type.
Backs the Add-row sidebar and the GL Account Editor pick lists: account groups (global and custom) on one tab, GL accounts on the other.
| Module | Report Center > Report Writer |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The response's globalUser flag is 1 for a global admin or role 1/5. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
based_on |
integer | No | Optional, numeric (numeric strings are coerced), integer. Default 0 |
Current axis. Class (4) and Location (5) suppress balNeeded. |
checkGroups |
integer | No | Optional, numeric (numeric strings are coerced), integer. 1 reporting groups tab (default), 2 GL accounts tab |
Which tab is being populated. |
searchKey |
string | No | Optional, string | Substring match on group name, or on account name/number. |
report_type |
integer | No | Optional, numeric (numeric strings are coerced), integer. Default 3 |
global_report_list.type. 1 (Global) suppresses the Custom groups block. |
checkActive |
boolean | string | number | No | Optional, no type constraint. Accepted as the string "true", boolean true, or numeric 1 |
"Show deleted accounts". When falsy the account query filters accounts.active = 1. |
isGlEditor |
integer | No | Optional, numeric (numeric strings are coerced), integer | 1 when the list feeds the GL Account Editor (checkbox rows instead of add-row buttons). Echoed back untouched. |
company_id |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the session company for the account/custom-group lookups. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/writer/search-rows' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"checkGroups":2,"searchKey":"4000","company_id":12,"checkActive":"false","based_on":1}'
Success response — 200 OK. Two shapes, discriminated by checkGroups.
Groups tab (checkGroups = 1):
{
"error": false, "checkGroups": 1, "globalUser": 1, "reportType": 3, "isGlEditor": 0,
"globalGroups": [ { "id": 7, "name": "Total Income", "type": 1 } ],
"customGroups": [ { "id": 31, "name": "Marketing Spend" } ]
}
Accounts tab (checkGroups = 2):
{
"error": false, "checkGroups": 2, "globalUser": 1, "reportType": 3, "isGlEditor": 0,
"incomeAccounts": [
{ "qbo_id": "84", "account_num": "4000", "account_name": "Sales", "label": "4000 Sales", "active": 1 }
],
"balanceAccounts": [],
"balNeeded": true
}
| Field | Type | Description |
|---|---|---|
globalUser |
0 | 1 | Whether the caller is a global report administrator. |
globalGroups |
array | global_account_groups rows of type 1 or 2: { id, name, type }. |
customGroups |
array | custom_account_group rows for the company: { id, name }. Always [] when report_type = 1 (Global). |
incomeAccounts |
array | Accounts of type Income, Other Income, Cost of Goods Sold, Expense, Other Expense. |
balanceAccounts |
array | Accounts of type Accounts Payable, Accounts Receivable, Bank, Credit Card, Equity, Fixed Asset, Long Term Liability, Other Current Liability, Other Current Asset, Other Asset. |
| account row | object | { qbo_id, account_num, account_name, label, active }; label is "<account_num> <account_name>" trimmed. |
balNeeded |
boolean | false when based_on is Class (4) or Location (5) — Balance Sheet accounts cannot be picked for those axes. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Any numeric field is not an integer, or searchKey is not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
checkActiveis intentionally not type-constrained: the checkbox state may be sent as the string"true"/"false", as a boolean, or as1, and all three are accepted.activeis returned on every account row because "Show deleted accounts" only toggles a CSS class on already-rendered rows client-side.- With no company id resolvable (company
0), the account and custom-group lists come back empty.
Returns the companies inside a reporting group plus the merged class/location list the Apply Filter sidebar shows.
| Module | Report Center > Report Writer |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
group_id |
integer | No | Optional, numeric (numeric strings are coerced), integer | Reporting group id. 0/absent returns the empty payload. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/writer/reporting-group/companies' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"group_id":4}'
Success response — 200 OK
{
"error": false,
"companies": [ { "id": 12, "name": "Acme Holdings" }, { "id": 18, "name": "Acme West" } ],
"clsAndLoc": [
{ "qbo_id": "3", "cls_name": "Retail", "cls_or_dept": 1 },
{ "qbo_id": "9", "cls_name": "Portland", "cls_or_dept": 2 }
]
}
| Field | Type | Description |
|---|---|---|
companies |
array | { id, name } for every live company mapped to the group via reporting_group_company. |
clsAndLoc |
array | { qbo_id, cls_name, cls_or_dept } across all the group's companies, de-duplicated case-insensitively on (cls_or_dept, cls_name). cls_or_dept 1 Class, 2 Location. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | group_id not an integer |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- A missing or
0group_idreturns{ error: false, companies: [], clsAndLoc: [] }— not an error. - The class/location list is de-duplicated by name, so two companies with a "Retail" class contribute one entry (the first company's
qbo_idwins).
Persists the whole writer: the report row, the writer header, every row, every fixed-period column and (for multi-axis reports) the GL Account Editor columns.
| Module | Report Center > Report Writer |
| Authentication | Required — authenticated session cookie |
| Authorization | Any authenticated user may save a Custom report. Saving a Global report (type 1 or 2) requires global admin, role 1 (GlobalMasterSystemAdministrator) or role 5 (GlobalReportandDashboardAdministrator); otherwise 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
report_id |
string | Yes | String | Plain or encrypted report id. |
report_name |
string | No | Optional, string; a blank/trimmed-empty value is rejected in the body (200 with msg) |
New report name. |
desc |
string | No | Optional, string | Report description / tooltip, stored on report_writer.desc. |
based_on |
integer | No | Optional, numeric (numeric strings are coerced), integer. 1 GLAccount, 2 Customer, 3 Project, 4 Class, 5 Location, 6 ReportingGroup. Defaults to 1. |
The report's axis. |
type |
integer | No | Optional, numeric (numeric strings are coerced), integer. 1 Global, 2 GlobalCalulated, 3 Custom, 4 CustomCalulated | Decides the writer's company_id and the global-report role gate. Defaults to the report's stored type. |
reporting_group |
number | string | null | No | Optional, no type constraint | Stored only when based_on = 6; otherwise written as NULL. |
monthVal |
string | number | No | Optional, no type constraint. 0 means "not selected" |
Sample As-on-date month (1–12). |
yearVal |
string | number | No | Optional, no type constraint. 0 means "not selected" |
Sample As-on-date year. |
is_fixed |
integer | No | Optional, numeric (numeric strings are coerced), integer. 1 = fixed period |
Fixed-period switch. |
company_id |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the session company for scoping and the duplicate-name check. |
details |
object[] | No | Optional, array; elements are free-form objects | The report's rows — see the row-shape table below. |
columnData |
object[] | No | Optional, array; elements are free-form objects | Fixed-period columns — see the column-shape table below. |
editorColumnData |
object[] | No | Optional, array; elements are free-form objects | GL Account Editor columns (multi-axis reports only). |
details[] keys read by this endpoint (each element is a loosely typed object; unknown keys are ignored):
| Key | Type | Description |
|---|---|---|
order |
number | 1-based row position; the upsert key together with the writer id. |
row_type |
number | Row kind. 3 marks a reference row. |
qbo_id |
number | Stored as account_id. |
operator |
number | Arithmetic operator for the row. |
style |
string | string[] | Row style classes; an array is joined. |
number |
string | Stored as format. |
display |
number | Show/hide flag; default 1. |
precision |
number | Stored as rw_precision. |
res_name |
string | Stored as result_name (the row label). |
refrence |
number | For row_type = 3, the order of the referenced row; resolved server-side to that detail's primary key. The field name is spelled exactly as shown and must be sent that way. |
group_id |
number | Stored as group_type. |
unique_id |
number | Stable row identity; falls back to order when 0. |
formula |
string | Calculated-row formula. |
class, location |
string | null | Per-row class/location filter. |
columnData[] keys read by this endpoint:
| Key | Type | Description |
|---|---|---|
order |
string | Column letter (A, B, …); the upsert key together with the writer id. |
column_type |
number | Column kind; default 1. |
period_type |
number | Period type; default 0. |
period |
string | Period value; default '0'. |
column_name |
string | Header label. |
columnStyle / style |
string | string[] | Column style classes. |
columnFormat / format |
string | Number format. |
precision / rw_precision |
number | Decimal places. |
is_override |
number | Whether the column overrides row formatting. |
operation |
string | Column operation; default '0'. |
formula |
string | Calculated-column formula. |
editorColumnData[] keys read by this endpoint: order (a column letter, converted to A→1, B→2, …), column_type, account_group (stored as account_id), editor_title (used as the result name when column_type = 1), column_name (used otherwise), columnStyle/style, columnFormat/format, display, precision/rw_precision, editor_type (stored as group_type), formula, class, location.
Example request
curl -sS -X POST 'https://api.example.com/api/reports/writer/save' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{
"report_id": "481",
"report_name": "Monthly Income Statement",
"desc": "Board pack version",
"based_on": 1, "type": 3, "is_fixed": 0,
"monthVal": 8, "yearVal": 2025, "company_id": 12,
"details": [
{ "order": 1, "row_type": 1, "qbo_id": 84, "group_id": 3, "operator": 0,
"style": ["bold"], "number": "1", "display": 1, "precision": 0,
"res_name": "4000 Sales", "unique_id": 1, "formula": "", "class": null, "location": null },
{ "order": 2, "row_type": 3, "refrence": 1, "res_name": "Ref: Sales",
"style": [], "number": "1", "display": 1, "precision": 0, "group_id": 0, "unique_id": 2 }
],
"columnData": [
{ "order": "A", "column_type": 1, "period_type": 0, "period": "0",
"column_name": "Aug 25", "columnStyle": ["bold"], "columnFormat": "1",
"precision": 0, "is_override": 0, "operation": "0", "formula": "" }
],
"editorColumnData": []
}'
Success response — 200 OK
{ "error": false, "report_id": 481, "writer_id": 903 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. Also false — with msg and no ids — on the two business rejections below. |
report_id |
integer | The saved report id. |
writer_id |
integer | The report_writer row id (created on first save). |
Business rejections (both 200, both error: false):
{ "error": false, "msg": "Report name field is required" }
{ "error": false, "msg": "Report name already exist." }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_id absent/unresolvable, or no matching report row |
Report not found |
| 400 | The writer header could not be created or updated | Unable to save the report writer. |
| 400 | A request body field fails validation (e.g. details not an array) |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401 | Saving a Global report without global admin / role 1 / role 5 | Not allowed to update report. |
| 413 | Payload exceeds the 20 MB JSON body limit | request entity too large |
Notes
- This is the large-payload route. JSON and URL-encoded request bodies are accepted up to 20 MB, because a report save can carry a per-value conditional-formatting rule for every distinct value in a Company/Project column — hundreds of entries. A body above that limit fails with
413. - Persistence contract.
report_writerholds one row per (report_id,type,company_id);company_idisNULLwhen a global admin saves a Global report.report_writer_detailsis soft-deleted wholesale, then every posted row is re-upserted on (report_writer_id,order) — sounique_idand each row's primary key survive a reorder.report_writer_columnfollows the same delete-then-re-upsert pattern keyed on (report_writer_id,order). - The name uniqueness check is scoped to the same
type(and, for a Custom report, the same company) and excludes the report being saved. monthVal/yearValof0(the "Month"/"Year" placeholder options) storeNULLas the writer'sas_on_date; otherwise it is stored asYYYY-MM-01.- GL Account Editor columns are not stored as
report_writer_columnrows — they becomereport_writer_detailsrows whoseorderis the column letter's index + 1. - After the save, dashboard card-designer references are remapped so cards keep pointing at the same logical rows.
14.4 Report Packages
Lists report packages split into Global and Custom tabs, with the company scope, GL-validation badge and Chart-of-Accounts change counter the screen shows.
| Module | Report Center > Report Packages |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. canManageGlobal (the "+ New package" button on the Global tab) requires global admin or role 1 (GlobalMasterSystemAdministrator) / role 5 (GlobalReportandDashboardAdministrator); canManageCustom also allows firm admins. |
| Content type | application/json |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
searchKey |
string | No | '' |
Case-insensitive substring match on package name. |
type |
integer | No | 0 (both) |
1 Global packages only, 2 Custom packages only. |
group_id |
integer | No | — | Accepted but not read by this endpoint. |
companyId |
integer | No | session company | Company scope override, resolved the same way as the Report List. |
Example request
curl -sS 'https://api.example.com/api/reports/viewPackage?type=2&searchKey=close' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"canManage": true,
"canManageGlobal": true,
"canManageCustom": true,
"isGlobalAdmin": true,
"selectedCompany": { "id": 12, "encId": "aVs2...", "name": "Acme Holdings", "qbo_connection": 1, "active": 1, "deleted": 0 },
"companies": [],
"glValidation": { "status": "Pass", "color": "green", "canRefresh": false, "qboError": false },
"connection_qbo": 1,
"account_changes_unseen": 0,
"changes_redirect": "/company/balanceSheet?changes=1",
"packages": [
{ "id": 66, "encryptId": "UGtn...", "package_name": "Monthly Close Pack", "package_desc": "",
"type": 2, "reporting_group": 0, "expand_by": 0, "company_id": 12,
"report_ids": [20, 481], "report_count": 2 }
],
"data": { "global": [], "custom": [] },
"reportingGroups": [ { "id": 4, "name": "West Region", "type": 1 } ]
}
| Field | Type | Description |
|---|---|---|
packages |
array | All packages passing the filters. type 1 Global, 2 Custom; expand_by is the Expand-By-Company flag; report_ids and report_count come from live report_package_details rows. |
data.global / data.custom |
array | The same rows partitioned by type. |
connection_qbo |
0 | 1 | Selected company's QuickBooks connection state. |
glValidation |
object | Same cheap probe shape as the Report List. |
account_changes_unseen / changes_redirect |
integer / string | Chart-of-Accounts change strip. |
canManage, canManageGlobal, canManageCustom, isGlobalAdmin |
boolean | UI capability gates. |
reportingGroups |
array | { id, name, type } groups available to the user. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | type, group_id or companyId not an integer |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Visibility filtering happens after the query: a Custom package (
type = 2) is dropped unless itscompany_idmatches the selected company; a Global package (type = 1) is dropped if it carries anycompany_id. - If the
report_packagetable is absent the endpoint still returns a well-formed payload with emptypackagesanddata.
Creates, updates or clones a report package, and (conditionally) rewrites the list of reports inside it.
| Module | Report Center > Report Packages |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin, firm admin, role 1 (GlobalMasterSystemAdministrator) or role 5 (GlobalReportandDashboardAdministrator). Otherwise 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
package_id |
string | No | Optional, string | Plain or encrypted id. Absent/0 creates a new package. |
package_name |
string | Yes | String; rejected by this endpoint when blank after trim | Package name. |
package_desc |
string | No | Optional, string | Description. |
type |
integer | No | Optional, numeric (numeric strings are coerced), integer. Coerced to 1 unless it is 1 or 2 |
1 Global, 2 Custom. On an update, the stored type wins; on a clone, the parent's type wins. |
reporting_group |
integer | No | Optional, numeric (numeric strings are coerced), integer | Reporting group the package runs against. On a clone, inherited from the parent when omitted. |
expanyByCompany |
integer | No | Optional, numeric (numeric strings are coerced), integer | Expand-By-Company flag, stored as expand_by. On a clone, inherited from the parent when omitted. The field name is spelled exactly as shown and must be sent that way. |
parent_package_id |
string | No | Optional, string | Source package when cloning. |
report_ids |
(string | number)[] | No | Optional, array | Simple ordered list of report ids (plain or encrypted); order is the array index + 1. Used only when report_rows is empty. |
report_rows |
object[] | No | Optional, array; elements are free-form objects | Full per-report settings — see below. |
report_rows[] keys read by this endpoint (each maps to a report_package_details column, written only when that column exists):
| Key | Type | Default | Description |
|---|---|---|---|
report_id (or id) |
string | number | — | Plain or encrypted report id. Rows that do not resolve are skipped. |
order |
number | index + 1 | Position in the package; also disambiguates the same report added twice. |
period_id |
number | 1 |
Period mode picker (1-based). |
period_sequence |
number | 1 |
Period sequence, or the aging-bucket size for A/R & A/P reports. |
trailing_periods |
number | 12 |
Number of trailing periods. |
show_id |
number | 2 |
1 = show zero rows, 2 = hide all-zero rows. |
include_total |
number | 0 |
Whether the Total column is generated. |
another_period |
string | '' |
Comma-separated compare-period keys. The literal '0' means "none". |
expand_one / expand_two |
number | 0 |
Expand axes: 0 None, 1 Class, 2 Location, 3 Class (non-zero only), 4 Location (non-zero only). |
class_filter / location_filter |
string | '' |
Comma-separated qbo ids; '0' or empty means no filter. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/viewPackage/save' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{
"package_id": "66",
"package_name": "Monthly Close Pack",
"package_desc": "Board pack",
"type": 2, "reporting_group": 0, "expanyByCompany": 0,
"report_rows": [
{ "report_id": "20", "order": 1, "period_id": 1, "period_sequence": 1,
"trailing_periods": 12, "show_id": 2, "include_total": 1,
"another_period": "0", "expand_one": 0, "expand_two": 0,
"class_filter": "", "location_filter": "" }
]
}'
Success response — 200 OK
{ "error": false, "package_id": 66, "encryptId": "UGtn..." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success; true with msg on a duplicate name. |
package_id |
integer | Created or updated package id. |
encryptId |
string | Encrypted form of the same id. |
Duplicate name (still 200): { "error": true, "msg": "Package already exist" }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | package_name blank after trim |
Package name field is required |
| 400 | report_package table missing |
Report package table is unavailable. |
| 400 | A request body field fails validation | Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401 | Caller lacks the manage roles | Not allowed to update report package. |
| 413 | Payload exceeds the 20 MB JSON body limit | request entity too large |
Notes
- Detail rows are only touched when the caller sends them. The destructive wipe-and-reinsert of
report_package_detailsruns only ifreport_rowsorreport_idsis present (even as an empty array), or if this is a clone. A save that carries neither leaves the package's report list completely alone. - An explicitly empty
report_rows: []does clear the list, because removing the last report from a package is submitted through this endpoint. - Cloning (
parent_package_id): the clone inherits the parent'stype, and — when the request omits them — itsreporting_groupandexpand_by. If neitherreport_rowsnorreport_idsis supplied, the parent's detail rows are copied. - Company scoping on insert:
type = 2stores the session company;type = 1stores0. - Duplicate check is name + type, company-scoped for Custom packages, excluding the package being updated and any soft-deleted rows.
- This route accepts a request body of up to 20 MB, which accommodates a large
report_rowsarray.
Soft-deletes a package and all of its detail rows.
| Module | Report Center > Report Packages |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin, firm admin, role 1 (GlobalMasterSystemAdministrator) or role 5 (GlobalReportandDashboardAdministrator). Otherwise 401. |
| Content type | application/json |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
package_id |
string | Yes | — | String. Plain or encrypted package id. |
Example request
curl -sS 'https://api.example.com/api/reports/viewPackage/delete?package_id=66' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | package_id absent or not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401 | Caller lacks the manage roles | Not allowed to delete report package. |
Notes
- This route uses
GETwith a query parameter although it performs a mutation. - When a
deletedcolumn exists bothreport_packageand itsreport_package_detailsrows are soft-deleted; otherwise both are hard-deleted. - An unresolvable id or a missing
report_packagetable returns{ error: false }rather than an error.
Loads one package's edit screen: the package row, its ordered report list with each report's period settings, and the class/location options and QuickBooks state for the session company.
| Module | Report Center > Report Packages |
| Authentication | Required — authenticated session cookie |
| Authorization | No hard role check. canEdit (which gates every Edit / Clone / Delete / Add Report control) requires canManage — global admin, firm admin, role 1 or role 5 — and, for a Global package, global admin or role 1/5 specifically. A Custom package only needs canManage. |
| Content type | application/json |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
package_id |
string | Yes | — | String. Plain or encrypted package id. |
Example request
curl -sS 'https://api.example.com/api/reports/viewPackage/detail?package_id=UGtn...' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"canManage": true,
"canEdit": true,
"isGlobalAdmin": true,
"connection_qbo": 1,
"update_date": "08/21/25 04:15PM",
"package": {
"id": 66, "encryptId": "UGtn...", "package_name": "Monthly Close Pack",
"package_desc": "Board pack", "type": 2, "reporting_group": 0, "expand_by": 0, "company_id": 12
},
"reports": [
{
"rpd_id": 2201, "package_id": 66, "report_id": 20, "report_encrypt_id": "Qk9t...",
"report_name": "Balance Sheet (QuickBooks Mirror)", "order": 1,
"period_id": 1, "period_sequence": 1, "trailing_periods": 12,
"show_id": 2, "include_total": 1, "another_period": "0",
"expand_one": 0, "expand_two": 0, "class_filter": "", "location_filter": "",
"group_type": 1, "type": 1, "mirror_report": 2, "based_on": 1
}
],
"qboClass": [],
"qboLocation": [],
"reportingGroups": [ { "id": 4, "name": "West Region", "type": 1 } ]
}
| Field | Type | Description |
|---|---|---|
package |
object | id, encryptId, package_name, package_desc, type, reporting_group, expand_by, company_id (falls back to the session company for a Global package). |
reports |
array | Ordered report_package_details rows joined to global_report_list, carrying every per-report period/filter setting plus the report's report_name, type, group_type, mirror_report and based_on. |
qboClass / qboLocation |
array | Class and Location pick lists for the session company. |
update_date |
string | Last QuickBooks sync timestamp for the scope company. |
connection_qbo |
0 | 1 | QuickBooks connection state. |
canManage, canEdit, isGlobalAdmin |
boolean | UI capability gates. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | package_id unresolvable, report_package table missing, or no matching row |
Package not found |
| 400 | package_id absent or not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Class/Location options and the QuickBooks connection state come from the session company, not
report_package.company_id— which is0for every Global package and would otherwise leave the dropdowns empty and "Refresh report" permanently disabled across the whole Global tab. - Detail rows are not filtered on the report's
deletedorcompany_id, so a package keeps showing a report that was later removed from the Report List.
Builds the Run Package shell: header facts (as-on date, generated-by/on, scope name), the effective reporting group and expand-by-company flags, and one card descriptor per report in the package. It generates no report data.
| Module | Report Center > Report Packages |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
package_id |
string | Yes | String | Plain or encrypted package id. |
monthVal |
string | No | Optional, string. Defaults to the current short month name | As-on month. |
yearVal |
string | No | Optional, string. Defaults to the current year | As-on year. |
reportingGroup |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the package's stored reporting_group when > 0. |
expanyByCompany |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the package's stored expand_by when present (including 0). |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/viewPackage/run' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"package_id":"66","monthVal":"Aug","yearVal":"2025","reportingGroup":4,"expanyByCompany":1}'
Success response — 200 OK
{
"error": false,
"package": {
"id": 66,
"package_name": "Monthly Close Pack",
"as_on_month": "Aug",
"as_on_year": "2025",
"as_on_date": "Aug, 2025",
"reporting_group": 4,
"reporting_group_name": "West Region",
"expand_by": 1,
"company_name": "Acme Holdings",
"generated_by": "Dana Reyes",
"generated_on": "08/21/25 04:15PM",
"is_same_report_method": true
},
"reports": [
{ "report_id": 20, "report_encrypt_id": "Qk9t...", "report_name": "Balance Sheet (QuickBooks Mirror)",
"order": 1, "group_type": 1, "show_id": 2, "supported": true }
]
}
| Field | Type | Description |
|---|---|---|
package.as_on_date |
string | "<monthVal>, <yearVal>". |
package.reporting_group / reporting_group_name |
integer / string | The effective group (request beats the package's stored value). Name is '' when no group. |
package.expand_by |
integer | Effective Expand-By-Company flag. |
package.generated_on |
string | MM/DD/YY hh:mmAM/PM. |
package.is_same_report_method |
boolean | Whether every company in the reporting group shares an accounting method. true when no group. |
reports[] |
array | One card descriptor per report: report_id, report_encrypt_id, report_name, order, group_type, show_id, supported. |
reports[].supported |
boolean | false renders the "Reporting Group doesn't support for this report" card instead of a table. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | package_id unresolvable, report_package table missing, or no matching row |
Package not found |
| 400 | A request body field fails validation | Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Each card then fetches itself: the client fires one
POST /api/reports/viewPackage/run/reportper entry inreports. This endpoint is intentionally inexpensive. supportedisfalse(only when a reporting group is in play) when the report'sbased_onis not GLAccount, when it is mirror 3 / 4 / 5, or when a custom report contains a direct Income-Statement/Balance-Sheet account row.
Generates one report card on the Run Package screen. The generation parameters are rebuilt server-side from the package's detail row — the client only names the package, the report and the as-on scope.
| Module | Report Center > Report Packages |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
package_id |
string | Yes | String | Plain or encrypted package id. |
report_id |
string | Yes | String | Plain or encrypted report id; must be a member of this package. |
monthVal |
string | No | Optional, string. Defaults to the current short month name | As-on month. |
yearVal |
string | No | Optional, string. Defaults to the current year | As-on year. |
reportingGroup |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the package's stored group when > 0. |
expanyByCompany |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the package's stored expand_by when present. |
order |
integer | No | Optional, numeric (numeric strings are coerced), integer | report_package_details.order — disambiguates the same report added to the package twice. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/viewPackage/run/report' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"package_id":"66","report_id":"20","order":1,"monthVal":"Aug","yearVal":"2025","reportingGroup":4,"expanyByCompany":1}'
Success response — 200 OK
{
"error": false,
"generated": {
"error": false, "report_id": 20, "report_name": "Balance Sheet (QuickBooks Mirror)",
"mirror_report": 2, "based_on": 1, "reporting_group": 0,
"is_fixed": 0, "is_mirror": 1, "mirror_id": 2,
"headers": ["Aug 25"],
"headerGroups": { "first": [], "second": [] },
"columns": [ { "key": "c1", "order": "A", "label": "Aug 25", "format": 1, "rw_precision": 0, "is_override": 0 } ],
"rows": [ { "account": "TOTAL ASSETS", "account_num": "-", "row_type": 2, "row_class": "rw-format-bold",
"rw_precision": 0, "format": 1, "class_indent": 0, "empty_formula": false,
"values": { "c1": 1284300.55 } } ],
"allowReportWithRG": true, "isSameFiscal": true, "isSameReportMethod": true, "accountingMethods": [],
"showZero": 2
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success; true when this one report failed to generate. |
generated |
object | null | The full generator payload (same shape as POST /api/reports/view/generate_income_statement), plus showZero. null on a per-report failure. |
generated.showZero |
1 | 2 | Derived from the detail row's show_id: 1 shows zero rows, 2 hides all-zero rows client-side. |
generate_error |
string | Present only on a per-report failure — the error message the card renders in place of its table. |
Per-report failure (still 200):
{ "error": true, "generated": null, "generate_error": "Division by zero in formula for row 4" }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | package_id or report_id unresolvable, or report_package table missing |
Report package not found. |
| 400 | No matching package row | Report package not found. |
| 400 | The report (with the given order) is not in this package |
Report not found in this package. |
| 400 | The underlying report row cannot be found by the generator | Report not found |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Non-client-supplied parameters. Period mode, period sequence, trailing periods, include-total, expand axes, non-zero flags and class/location filters all come from the package's
report_package_detailsrow, not from the request. - Derivations worth knowing:
main_period = period_id - 1(the picker is 1-based);main_period = 3(Months YTD) forces a 12-column trailing run withperiod = 2; expand axes3/4are the non-zero variants of1/2and are rewritten accordingly;another_periodof'0'yields an empty compare-period list andis_period = 0; for A/R and A/P Aging Summary (mirror_report3/4)period_sequenceis reinterpreted as the aging-bucket size (minimum 30) andtrailing_periodsas the bucket count (default 4). - Failures are contained: one broken report yields one broken card (
error: true+generate_error) and is logged server-side, while the rest of the package still renders. Genuine client errors still surface as400.
Exports the whole package (or one report's sheet) as a styled .xlsx workbook, base64-encoded in the JSON envelope. One worksheet per report.
| Module | Report Center > Report Packages |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json envelope carrying a base64 XLSX payload (contentType: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) — not a binary HTTP download |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
package_id |
string | Yes | String | Plain or encrypted package id. |
report_id |
string | No | Optional, string | When present, cuts the workbook to that one report's sheet. |
order |
integer | No | Optional, numeric (numeric strings are coerced), integer | Together with report_id, picks a specific occurrence of a repeated report. |
monthVal |
string | No | Optional, string. Defaults to the current short month name | As-on month. |
yearVal |
string | No | Optional, string. Defaults to the current year | As-on year. |
reportingGroup |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the package's stored group when > 0. |
expanyByCompany |
integer | No | Optional, numeric (numeric strings are coerced), integer | Overrides the package's stored expand_by when present. |
export_name |
string | No | Optional, string; \ / # % are stripped |
File name. Falls back to <scope name><unix timestamp>. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/viewPackage/run/download' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"package_id":"66","monthVal":"Aug","yearVal":"2025","reportingGroup":4,"export_name":"Monthly Close Aug 2025"}'
Success response — 200 OK
{
"error": false,
"fileName": "Monthly Close Aug 2025.xlsx",
"contentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"contentBase64": "UEsDBBQABgAIAAAAIQ..."
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false on success. |
fileName |
string | <export_name or scope name + timestamp>.xlsx. |
contentType |
string | Always the XLSX MIME type. |
contentBase64 |
string | The workbook; the client decodes and saves it. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | package_id unresolvable, report_package table missing, or no matching row |
Report package not found. |
| 400 | report_id given but not present in the package (with the given order) |
Report not found in this package. |
| 400 | A request body field fails validation | Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Each sheet is generated through the same parameter builder and generator the on-screen cards use, so the workbook cannot disagree with the page. This means a download re-runs every report in the package.
- Layout per sheet: two merged bold grey size-14 info rows (package name + generated-on;
Company: <name>orReporting Group: <name>+ as-on date + generated-by), bold size-12 centred header rows with colspan merges, then the body right-aligned with column A left; negatives red in parentheses,rw-format-*/cw-format-*classes rendered as bold and thin/double/boxed borders,%andXsuffixes on percentage and multiple rows. - Failure containment: a report that cannot generate, or one the reporting group does not support, becomes a message-only sheet ("Reporting Group doesn't support for this report", or the error text) so the rest of the workbook survives.
- Sheet names come from the report name with
\ / : * ? " < > | [ ]stripped, truncated to 28 characters, and de-duplicated with a numeric suffix. A package with no exportable reports yields a singleReport Packagesheet reading--No Reports found--.
14.5 Configure
Multiplexed refresh endpoint: re-reads the report configuration, pulls the QuickBooks book-close date live, or pulls class/location tracking preferences live — selected by type.
| Module | Report Center > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | No role requirement, but the report-config and accounting branches reject a target company outside the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
integer | No | Optional, numeric (numeric strings are coerced), integer | Target company. 0/absent falls back to the firm's internal company. |
type |
string | No | Optional, string. Compared case-insensitively against report-config and accounting; anything else (including absent) takes the class-tracking branch |
Which refresh to perform. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/configure/update' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"companyId":12,"type":"report-config"}'
Success response — 200 OK. Three shapes, selected by type.
type: "report-config" — re-reads report_configure:
{ "error": false, "config_id": 55, "report_method": 2, "fiscal_month": 1, "company_id": 12 }
type: "accounting" — fetches and persists the QuickBooks book-close date:
{ "error": false, "close_book_date": "12/31/2024", "close_book_date_value": "2024-12-31", "company": 12 }
Any other type — fetches and persists class/location tracking preferences:
{ "error": false, "track_class": true, "assign_class": 1, "track_location": false }
| Field | Type | Description |
|---|---|---|
config_id |
integer | report_configure row id, 0 when none exists. |
report_method |
1 | 2 | Accounting method; defaults to 2. |
fiscal_month |
1–12 | Fiscal year start month; defaults to 1. |
close_book_date |
string | MM/DD/YYYY, or '-' when QuickBooks reports no close date. |
close_book_date_value |
string | Raw YYYY-MM-DD (or ''). |
track_class / track_location |
boolean | QuickBooks class/location tracking preferences. |
assign_class |
integer | The QuickBooks class-assignment preference. |
Company-resolution failure (still 200):
{ "error": true, "message": "Company not found." }
{ "error": true, "msg": "Please configure firm internal company.", "track_class": false, "assign_class": 0, "track_location": false }
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | companyId not an integer / type not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401/403 | Target company does not belong to the caller's firm | Rejected by the firm-ownership check |
Notes
- The
accountingbranch is the "Refresh Data" button on Monthly Close → QuickBooks Close Dates. It fetchesPreferences.AccountingInfoPrefs.BookCloseDatelive from QuickBooks and writes it tocompany.qbo_close_date. - That fetch never throws: a disconnected company or a failed call degrades to the currently stored value rather than erroring the row, so a client refreshing many companies in a loop is never broken by one bad company.
- The class-tracking branch also writes: a successful live fetch is upserted into
qbo_categories_configurationbefore being returned; only if the live fetch fails does it fall back to the stored config. - The field name is
companyIdhere, while/reports/configure/saveand/switch-companyusecompany_id.
Loads the Report Configure screen: the company list and current scope, the company's accounting method and fiscal month, and the Chart-of-Accounts change counter.
| Module | Report Center > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | No additional role requirement beyond an authenticated session. The visible company list is role-scoped (a global admin or role 1/3 sees every company; a firm admin sees their firm's; company-group roles 4/7/8 see their group's). |
| Content type | application/json |
Query parameters — none.
Example request
curl -sS 'https://api.example.com/api/reports/configure' \
-H 'Cookie: genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"companies": [
{ "id": 12, "encId": "aVs2...", "name": "Acme Holdings", "qbo_connection": 1, "active": 1, "deleted": 0 }
],
"selectedCompany": { "id": 12, "encId": "aVs2...", "name": "Acme Holdings", "qbo_connection": 1 },
"config_id": 55,
"report_method": 2,
"fiscal_month": 1,
"account_changes_unseen": 0,
"changes_redirect": "/company/balanceSheet?changes=1"
}
| Field | Type | Description |
|---|---|---|
companies |
array | { id, encId, name, qbo_connection, active, deleted }, ordered firm-internal company first then by name, including deleted = 1 rows for the "Include Inactive Companies" toggle. |
selectedCompany |
object | null | { id, encId, name, qbo_connection }. null when the session is scoped to Global (company_id = 0). |
config_id |
integer | Latest report_configure row id for the company; 0 when none. |
report_method |
1 | 2 | Accounting method; defaults to 2. |
fiscal_month |
1–12 | Fiscal year start month; defaults to 1. |
account_changes_unseen |
integer | Unseen Chart-of-Accounts changes. |
changes_redirect |
string | Target of the changes strip's link. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
Notes
- Side effect: when the session has no company yet, this endpoint defaults it — writing both the
companyandcarryOversession keys, while preserving the period, so an as-on month/year already chosen elsewhere is not cleared. It never overrides an existing selection. - A session company id of
0is Genie's sticky "Global" scope, not "unset": it returnsselectedCompany: nulland leaves the config values at their defaults rather than silently substituting the firm's internal company.
Saves a company's accounting method and fiscal-year start month (report_configure).
| Module | Report Center > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | Global admin or firm admin only. Otherwise 401. Additionally, the target company must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
config_id |
integer | No | Optional, numeric (numeric strings are coerced), integer | Accepted but not read by this endpoint — the existing row is located by company id. |
report_method |
integer | Yes | Numeric (numeric strings are coerced), integer; additionally must be 1 or 2 |
Accounting method (1 and 2 are the only accepted values). |
fiscal_month |
integer | Yes | Numeric (numeric strings are coerced), integer; additionally must be 1–12 | Fiscal year start month. |
company_id |
string | No | Optional, string. Plain or encrypted id | Target company. Absent falls back to the session company, then the firm's internal company. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/configure/save' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"report_method":2,"fiscal_month":1,"company_id":"12"}'
Success response — 200 OK
{ "error": false, "config_id": 55, "report_method": 2, "fiscal_month": 1, "company_id": 12 }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
config_id |
integer | Updated or newly inserted report_configure id. 0 when the table does not exist. |
report_method |
1 | 2 | Echo of the saved value. |
fiscal_month |
1–12 | Echo of the saved value. |
company_id |
integer | Resolved target company. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | report_method is not 1 or 2 |
Invalid report method. |
| 400 | fiscal_month outside 1–12 |
Invalid fiscal month. |
| 400 | No company could be resolved | Company not found. |
| 400 | report_method / fiscal_month missing or not integers |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401 | Caller is neither global nor firm admin | Not allowed to update reports configuration. |
| 401/403 | Target company does not belong to the caller's firm | Rejected by the firm-ownership check |
Notes
- Upsert on the company: the most recent non-deleted
report_configurerow for the company is updated in place; if none exists, one is inserted. fiscal_monthfeeds every report run — the preview engine reads it (clamped to 1–12, defaulting to 1) to build fiscal-year-to-date columns, and the Report Writer returns its short name asfisical_month.- Column names are resolved by schema introspection (
id/config_id,company_id,report_method,fiscal_month), so a deployment with variant column naming still works.
Switches the session's active company, resetting the as-on period to today.
| Module | Report Center > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | No role requirement, but the target company must belong to the caller's firm. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String. Plain or encrypted id | The company to switch to. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/configure/switch-company' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"company_id":"aVs2..."}'
Success response — 200 OK
{
"error": false,
"company": {
"company_id": 12,
"qbo_connection": 1,
"global_company_name": "Acme Holdings",
"global_company_logo": "acme.png"
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
company |
object | The newly stored session company: company_id, qbo_connection, global_company_name, global_company_logo. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | company_id does not resolve to an id |
Company not found. |
| 400 | The company row could not be loaded | Company not found. |
| 400 | company_id absent or not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401/403 | Target company does not belong to the caller's firm | Rejected by the firm-ownership check |
Notes
- Side effect, session-wide. This writes both session keys —
login.companyandlogin.carryOver— and the as-on month/year resets to today. Picking a company here therefore changes the default scope on every other Report Center screen. - Sessions are persisted in the shared MySQL
sessionstable, so the switch is visible to every backend instance immediately.
Marks a company's pending Chart-of-Accounts changes as seen, clearing the "Changes have been made to the Chart of Accounts" strip.
| Module | Report Center > Configure |
| Authentication | Required — authenticated session cookie |
| Authorization | No role requirement, but the target company must belong to the caller's firm. |
| Content type | application/json |
Request body (the same body schema as switch-company)
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String. Plain or encrypted id | Target company. This endpoint falls back to the session company for a falsy value, but the field must still be present and a string. |
Example request
curl -sS -X POST 'https://api.example.com/api/reports/configure/changes-seen' \
-H 'Content-Type: application/json' \
-H 'Cookie: genie.sid=<session-cookie>' \
-d '{"company_id":"12"}'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | company_id absent or not a string |
Request validation error array |
| 401 | Not authenticated | { "message": "These credentials do not match our records." } |
| 401/403 | Target company does not belong to the caller's firm | Rejected by the firm-ownership check |
Notes
- Side effect: marks the company's account-change records as seen. After this call,
account_changes_unseendrops to0on/api/reports/list,/api/reports/viewPackageand/api/reports/configure. - Because unseen changes also drive the GL-validation badge's
Fail/ "View variances" state, clearing them flips that badge back toPassfor a QuickBooks-connected company. - When neither
company_idnor a session company resolves, this endpoint returns{ error: false }without doing anything.
15. Feedback Center
Feedback Center holds the two in-product feedback surfaces: Suggestions (product suggestions and bug reports, with voting, subscriptions and labels) and Support & Issues (a ticket and chat surface staffed by users holding the Feedback Center Administrator role). Both are backed by the issue_list, issue_reply and issue_files tables, with attachments in Google Cloud Storage and notifications sent over SMTP.
Conventions used throughout this section:
- Paths in this section are served under
/api. - Global request validation runs on every request. Any body property not declared in the endpoint's request body schema is silently deleted before the endpoint sees it.
- Authentication is enforced per endpoint rather than by a route-level gate: every request resolves the caller from the session, which must carry a user id and be in the
authenticatedstate; otherwise the request fails with401 { "message": "These credentials do not match our records." }. The session cookie isgenie.sid(httpOnly,SameSite=Lax). - No CSRF token is required on any endpoint in this section; CSRF protection applies only to the authentication endpoints.
- Several
:idpath parameters and body ids accept either a plain numeric id or an encrypted (base64) id. List responses hand back the encrypted form asencryptId.
15.1 Suggestions
Product suggestions and bug reports (issue_list.type 1 = Suggestion, 2 = Issue/Bug; type 3 is a Support ticket, documented in the next subsection). Supports voting, subscription, module filtering, labels, soft delete and file attachments.
The only role that unlocks administrative behaviour here is Feedback Center Administrator (id 10), resolved from global, non-revoked assign_user_roles grants (company_id IS NULL, role_from IS NULL, not soft-deleted), matched by id or by the role name "Feedback Center Administrator". Delete/"show deleted" additionally accepts Global Master System Administrator (id 1).
Returns the Suggestions (or Issues) landing payload: the module filter list, per-status counts and the full item list.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No gate on the route. canDelete on each item is true for a Global Master System Administrator (1) or a Feedback Center Administrator (10); everyone else gets false. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | 'suggestion' selects issue_list.type = 1; any other value selects type = 2 (Issues/Bugs). |
Example request
curl 'https://api.example.com/api/feedbackcenter/suggestion' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"type": 1,
"menuList": [{ "id": 81, "menu_name": "Task & Time" }],
"status": [{ "status": 1, "total": 12 }, { "status": 2, "total": 3 }],
"getFeedback": [
{
"id": 4412,
"encryptId": "b3RoZXI9",
"title": "Add a bulk assign action to Task List",
"status": 1,
"post_anonymously": 0,
"user_id": 77,
"user_name": "Alex Nguyen",
"created_at": "2025-11-28T14:02:11.000Z",
"voteCount": 9,
"replyCount": 4,
"fileCount": 1,
"deleted": 0,
"canDelete": false
}
],
"issueListSugg": 15
}
| Field | Type | Description |
|---|---|---|
type |
number | 1 for suggestions, 2 otherwise. |
menuList[] |
array | { id, menu_name } from top-level active menus rows, excluding ids 1 and 11, ordered by name. |
status[] |
array | { status, total } counts across non-deleted rows of this type. |
getFeedback[] |
array | Item list, newest first. |
getFeedback[].encryptId |
string | Encrypted id for detail/download links. |
getFeedback[].canDelete |
boolean | Whether the caller may delete/see deleted items. |
issueListSugg |
number | Sum of all status[].total. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Deleted items are excluded here; only
POST /api/issue/filter/modulecan include them (delete_show = 1). user_nameon this list isfirst_name + last_name(notlf_name), and is not anonymised even whenpost_anonymously = 1— the flag is carried alongside for the client to apply.
Returns the "new suggestion / new issue" form bootstrap — the module dropdown and the resolved type.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | 'suggestion' → 1; any other value → 2. |
Example request
curl 'https://api.example.com/api/new/suggestion' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "type": 1, "menuList": [{ "id": 81, "menu_name": "Task & Time" }] }
| Field | Type | Description |
|---|---|---|
type |
number | 1 for suggestions, 2 otherwise. |
menuList[] |
array | { id, menu_name } — the module options for the form. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Creates a suggestion, issue or support ticket, links any previously staged attachments to it, and e-mails the Genie team.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session — any authenticated user may open a ticket. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
mouduleId |
number | Yes | Numeric (numeric strings are coerced), integer | Menu/module id. The field name is spelled exactly as shown and must be sent that way. Written to issue_list.module. |
title |
string | Yes | String | Ticket title; trimmed, must be non-empty. |
message |
string | No | Optional, string | HTML body. Sanitised before insert. |
customSwitch |
number | No | Optional, Numeric (numeric strings are coerced), integer | 1 sets post_anonymously = 1; anything else sets 0. |
type |
number | Yes | Numeric (numeric strings are coerced), integer | 1 = Suggestion, 2 = Issue/Bug, 3 = Support ticket. Drives the redirect and the e-mail template. |
insertedIds |
number[] | No | Optional, array | issue_files.id values returned by POST /api/ajax_upload/issue_files, to be attached to this ticket. |
Example request
curl -X POST 'https://api.example.com/api/issue/feedback/save' \
-H 'Content-Type: application/json' \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/142.0.0.0' \
-b 'genie.sid=<session-cookie>' \
-d '{ "mouduleId": 81, "title": "Add bulk assign", "message": "<p>Would save a lot of clicks.</p>", "type": 1, "customSwitch": 0, "insertedIds": [9001] }'
Success response — 200 OK
{ "error": false, "id": 4413, "redirect": "/feedbackcenter/suggestion" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
id |
number | New issue_list.id (plain numeric). |
redirect |
string | /support/feedback/chat/<encrypted id> when type = 3; /feedbackcenter/suggestion otherwise. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | title blank after trim |
{ "error": true, "message": "Title field is required." } |
| 400 | issue_list table not present |
{ "message": "Suggestions table is unavailable." } (BadRequest) |
| 400 | Missing/invalid mouduleId, title or type |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The inbound
User-Agentheader is parsed into a"<Browser> <version>"label and stored onissue_list.browser; the ticket sidebar renders it as "Browser:". - Staged attachments are re-keyed on save:
issue_files.issue_list_idis set to the ticket id andissue_or_cmdto1. - A new-ticket e-mail is dispatched fire-and-forget to every active user holding role 10. A mail failure never fails the save.
issue_list.classificationis written only if the column exists, and always as the literal'low'.classificationis not part of this endpoint's request body schema, so any value supplied here is stripped before the endpoint runs. Classification is set afterwards viaPOST /api/issue/save/stautswithbtnType: "classification".issue_list.statusis initialised to1.
Full detail view of one suggestion/issue: the ticket, its attachments, its replies, participants, and the caller's own vote/subscription state.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Readable by any authenticated user. can_manage is true only for a Feedback Center Administrator (10) — not for a Global Master System Administrator. When can_manage is false, user_email is stripped from the ticket and from every reply server-side. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | issue_list.id, plain numeric or encrypted. |
Example request
curl 'https://api.example.com/api/issue/list/detail/4413' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"getIssue": {
"id": 4413,
"title": "Add bulk assign",
"message": "<p>Would save a lot of clicks.</p>",
"type": 1,
"status": 1,
"module": 81,
"user_id": 77,
"post_anonymously": 0,
"browser": "Chrome 142.0.0.0",
"user_name": "Nguyen, Alex",
"profile_image": "",
"labels": ["ui"],
"voteCount": 9
},
"getIssueFiles": [
{
"id": 9001,
"issue_list_id": 4413,
"reply_id": 0,
"file_path": "1764341531_ab12.png",
"original_name": "mockup.png",
"issue_or_cmd": 1,
"download_url": "/api/issue/files/download/<encrypted id>/mockup.png"
}
],
"getReply": [
{
"id": 771,
"issue_list_id": 4413,
"rply_message": "<p>Agreed.</p>",
"user_id": 91,
"user_name": "Patel, Riya",
"profile_image": "",
"role_id": "6,10",
"files": []
}
],
"partUser": [{ "id": 77, "name": "Nguyen, Alex" }, { "id": 91, "name": "Patel, Riya" }],
"is_vote": 1,
"subscribe": 1,
"role_id": 10,
"can_manage": true,
"type": 1
}
| Field | Type | Description |
|---|---|---|
getIssue |
object | The issue_list row plus user_name (lf_name, falling back to first+last), profile_image, parsed labels array and voteCount. user_email present only when can_manage. |
getIssueFiles[] |
array | Ticket-body attachments (issue_or_cmd = 1), each with a ready-made download_url. |
getReply[] |
array | Non-deleted issue_reply rows, ascending by created_at, each with user_name, profile_image, the raw comma-joined role_id string from users.role, and its own files[] (issue_or_cmd = 2). |
partUser[] |
array | Distinct participants ({ id, name }), ticket author first. Never anonymised. |
is_vote |
number | 1 if the caller has voted, else 0. |
subscribe |
number | 1 if the caller is subscribed, else 0. |
role_id |
number | The caller's first global role id, or 0. Informational only. |
can_manage |
boolean | Whether the caller holds Feedback Center Administrator. |
type |
number | issue_list.type. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Ticket id not found | { "message": "Suggestion not found." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Reply attachments are keyed on the reply id, not the ticket id —
issue_files.issue_list_idis overloaded:0= staged, ticket id whenissue_or_cmd = 1, reply id whenissue_or_cmd = 2. download_urlembeds an encryptedissue_files.id, so it can be handed straight to the browser.
Posts a reply on a suggestion/issue, links any staged attachments to that reply, and notifies subscribers.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session — any authenticated user may reply to any suggestion/issue. (The Support chat route applies its own ownership check; this one does not.) |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
issueId |
string | Yes | String | issue_list.id, plain numeric or encrypted. |
message |
string | No | Optional, string | HTML reply body; sanitised before insert. |
customSwitch |
number | No | Optional, Numeric (numeric strings are coerced), integer | 1 sets issue_reply.post_anonymously = 1 (when the column exists) and makes the notification e-mail attribute the reply to "Anonymous". |
insertedIds |
number[] | No | Optional, array | Staged issue_files.id values to attach to this reply. |
Example request
curl -X POST 'https://api.example.com/api/issue/feedback/replay/send' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "issueId": "4413", "message": "<p>Agreed.</p>", "customSwitch": 0, "insertedIds": [9002] }'
Success response — 200 OK
{ "error": false, "id": 772 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
id |
number | New issue_reply.id. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | issue_reply table not present |
{ "message": "Reply table is unavailable." } |
| 400 | issueId missing or not a string |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The insert is schema-adaptive:
post_anonymously,status,created_atandupdated_atare written only when those columns exist.statusis set to1. - Attachments are linked with
issue_or_cmd = 2andissue_list_id = <reply id>. - An e-mail is sent only when the ticket has at least one subscriber — To: the ticket creator, Bcc: the other subscribers. Fire-and-forget.
- The route path is spelled
replay(notreply) and must be requested exactly as shown.
Toggles the caller's vote on a suggestion and returns the new vote state and total.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String | issue_list.id, plain numeric or encrypted. |
Example request
curl -X POST 'https://api.example.com/api/issue/vote/count' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "id": "4413" }'
Success response — 200 OK
{ "error": false, "vote": 1, "voteCount": 10 }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
vote |
number | The caller's own state after the toggle: 1 voted, 0 withdrawn. |
voteCount |
number | Total issue_vote rows with is_vote = 1 for the ticket. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | id missing or not a string |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- First vote inserts with
is_vote = 1; subsequent calls flip the existing row between1and0. - If the
issue_votetable is absent the endpoint returns{ error: false, vote: 0, voteCount: 0 }without writing.
Re-queries the suggestion/issue list with module, status, search, sort and show-deleted filters applied.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No gate on the route. canDelete on each returned item is true for a Global Master System Administrator (1) or Feedback Center Administrator (10). delete_show = 1 is honoured for any caller — the role check only affects the canDelete flag, not the filter. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
type |
number | Yes | Numeric (numeric strings are coerced), integer | issue_list.type to list (1 = Suggestion, 2 = Issue). |
id |
number | No | Optional, Numeric (numeric strings are coerced), integer | Module (menus.id) filter. 0/omitted = all modules. |
filter_content |
number | No | Optional, Numeric (numeric strings are coerced), integer | Status preset: 1 → status 1; 2 → status 2; 3 → only the caller's own tickets; 4 → status 3; 5 → status in (1,3); 6 → status 6. Any other value applies no status filter. |
search_content |
string | No | Optional, string | Case-insensitive LIKE '%…%' on title. |
sort_type |
number | No | Optional, Numeric (numeric strings are coerced), integer | 2 → oldest first; 3 → most votes first; 4 → fewest votes first; anything else (default) → newest first. |
delete_show |
number | No | Optional, Numeric (numeric strings are coerced), integer | 1 includes soft-deleted rows; anything else excludes them. |
Example request
curl -X POST 'https://api.example.com/api/issue/filter/module' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "type": 1, "id": 81, "filter_content": 1, "search_content": "assign", "sort_type": 3, "delete_show": 0 }'
Success response — 200 OK
{
"error": false,
"data": [
{
"id": 4412,
"encryptId": "b3RoZXI9",
"title": "Add a bulk assign action to Task List",
"status": 1,
"post_anonymously": 0,
"user_id": 77,
"user_name": "Alex Nguyen",
"created_at": "2025-11-28T14:02:11.000Z",
"voteCount": 9,
"replyCount": 4,
"fileCount": 1,
"deleted": 0,
"canDelete": true
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
data[] |
array | Same item shape as getFeedback[] on GET /api/feedbackcenter/:type. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing/invalid type |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Updates a ticket's status and/or classification, optionally appends a label, and notifies the ticket creator on a status change.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Feedback Center Administrator (10) or Global Master System Administrator required; anyone else gets 401. This is a superset of the UI control, which shows the editor only to role 10. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
issueId |
string | Yes | String | issue_list.id, plain numeric or encrypted. |
status |
number | Yes | Numeric (numeric strings are coerced), integer | New issue_list.status. |
labelName |
string | No | Optional, string | Appended to the JSON labels array (de-duplicated) when the labels column exists. |
classification |
string | No | Optional, string | Lower-cased and written to issue_list.classification when that column exists. |
btnType |
string | No | Optional, string | 'status' triggers the status-change e-mail. 'classification' suppresses the status write unless status > 0. |
Example request
curl -X POST 'https://api.example.com/api/issue/save/stauts' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "issueId": "4413", "status": 3, "btnType": "status", "labelName": "ui" }'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 401 | Caller is neither Feedback Center Administrator nor global admin | { "message": "Not allowed to update suggestion status." } |
| 400 | Missing/invalid issueId or status |
Standard request-validation error array |
Notes
- The route path is spelled
stautsand must be requested exactly as shown. - When
btnType = 'classification'andstatusis0, the status column is left untouched. - If neither a status nor a classification update applies, this endpoint returns
{ error: false }without writing. - The status-change e-mail fires only when
btnTypeis exactly'status'(case-insensitive), and goes to the ticket creator with the previous and new status labels.
Soft-deletes a suggestion/issue (issue_list.deleted = 1).
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Global Master System Administrator (1) or Feedback Center Administrator (10), resolved from global non-revoked grants; a resolved global-admin identity also passes. Anyone else gets 401. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String | issue_list.id, plain numeric or encrypted. |
Example request
curl -X POST 'https://api.example.com/api/feedbackcenter/DelList' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "id": "4413" }'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 401 | Caller holds neither role 1 nor role 10 and is not a global admin | { "message": "Not allowed to delete suggestion." } |
| 400 | id missing or not a string |
Standard request-validation error array |
Notes
- Soft delete only — the row and its replies/attachments remain. Deleted rows reappear through
POST /api/issue/filter/modulewithdelete_show: 1. - The route is case-sensitive:
DelListmust be sent exactly as shown.
Subscribes or unsubscribes the caller from a ticket's reply notifications.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
issue_list_id |
string | Yes | String | issue_list.id, plain numeric or encrypted. |
is_subscribe |
number | Yes | Numeric (numeric strings are coerced), integer | 1 subscribes; any other value unsubscribes (stored as 0). |
Example request
curl -X POST 'https://api.example.com/api/subscribe/status' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "issue_list_id": "4413", "is_subscribe": 1 }'
Success response — 200 OK
{ "error": false, "subscribed": true }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
subscribed |
boolean | true when the stored value is 1. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Missing/invalid issue_list_id or is_subscribe |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- If the
issue_subscribetable is absent the endpoint returns{ error: false }(nosubscribedkey) without writing. - Subscription state is what gates the reply e-mail on
POST /api/issue/feedback/replay/send— no subscribers means no mail is sent at all.
Stages one or more attachments in Google Cloud Storage and returns their issue_files row ids, to be passed later as insertedIds when saving a ticket or reply.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | multipart/form-data |
Request body
The request is a multipart upload — any field name is accepted and every uploaded part is processed; there is no declared body schema. Files are buffered in memory and no transport-level upload size limit is applied.
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
| (any field name) | file (repeatable) | No | Extension must be one of jpeg, png, jpg, gif, svg, doc, docx, txt, pdf, xlsx, xls, mp4, webm, mkv, avi, mpg; size must be <= 10 MB (10 × 1024 × 1024 bytes) |
Any part failing either check is silently skipped — no error is raised and no id is returned for it. Sending no files yields insertedIds: []. |
Example request
curl -X POST 'https://api.example.com/api/ajax_upload/issue_files' \
-b 'genie.sid=<session-cookie>' \
-F 'files[]=@mockup.png;type=image/png' \
-F 'files[]=@spec.pdf;type=application/pdf'
Success response — 200 OK
{ "error": false, "insertedIds": [9001, 9002] }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
insertedIds[] |
number[] | New issue_files.id values, in upload order, for the files that passed validation. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Each accepted file is renamed to
<epoch-ms>_<random>.<ext>and written to the GCS object key<GCS_ISSUE_FILES_PREFIX>/<filename>(default prefixissue_files/) in bucketGCS_BUCKET. The database stores only the bare filename inissue_files.file_path; the object key is derived from the prefix and that filename on both write and read. - Rows are inserted with
issue_list_id = 0andissue_or_cmd = 0(staged/unlinked). They become owned only when the returned ids are passed asinsertedIdstoPOST /api/issue/feedback/save(→issue_or_cmd = 1),POST /api/issue/feedback/replay/sendorPOST /api/support/chat/reply/save(→issue_or_cmd = 2). Ids never passed on stay orphaned. - Uploaded files are retrieved through
GET /api/issue/files/download/:id[/:name]; the bucket itself stays private. - If the
issue_filestable is absent the endpoint returns{ error: false, insertedIds: [] }and uploads nothing. - The global body limit (
20mbfor JSON and urlencoded payloads) does not apply to multipart bodies; the effective per-file cap is the 10 MB check above.
Streams one attachment back to the browser as a download.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session — any authenticated user may download any attachment whose id they hold. There is no ownership or ticket-visibility check. |
| Content type | The object's stored GCS contentType, falling back to application/octet-stream |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | issue_files.id, plain numeric or encrypted (download_url supplies the encrypted form). If it resolves to 0, this endpoint falls back to a lookup by original_name — which, with no :name segment present, matches on an empty string and therefore normally 404s. |
Example request
curl -OJ 'https://api.example.com/api/issue/files/download/9001' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
Binary file body. Headers set by this endpoint:
Content-Type: <object content type>Content-Disposition: attachment; filename="<name>"; filename*=UTF-8''<url-encoded name>
The download name is the :name segment when present, else issue_files.original_name, else the stored filename; double quotes are stripped.
Response body: binary stream — no JSON shape.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 404 | No matching issue_files row, or the row has an empty file_path |
{ "error": true, "message": "File not found." } |
| 404 | The derived GCS object does not exist | { "error": true, "message": "File not found." } |
| 404 | GCS read stream errors before headers are sent | { "error": true, "message": "File not found." } (if headers were already sent, the response is simply ended) |
Notes
- The object key is
<GCS_ISSUE_FILES_PREFIX>/<issue_files.file_path>; bytes are streamed rather than buffered, so memory stays bounded. - The bucket is never made public and no signed URL is issued on this path — all reads go through this authenticated endpoint.
Same as the previous route with an explicit download filename appended — this is the form emitted in download_url.
| Module | Feedback Center > Suggestions |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session — any authenticated user may download any attachment whose id they hold. There is no ownership or ticket-visibility check. |
| Content type | The object's stored GCS contentType, falling back to application/octet-stream |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | issue_files.id, plain numeric or encrypted. |
name |
string | Yes | Filename to present in Content-Disposition. Also acts as the lookup key when id resolves to 0: the newest issue_files row with original_name = <name> is served. |
Example request
curl -OJ 'https://api.example.com/api/issue/files/download/9001/mockup.png' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
Binary file body with Content-Disposition: attachment; filename="mockup.png"; filename*=UTF-8''mockup.png.
Response body: binary stream — no JSON shape.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 404 | No row matched by id or by original_name, or file_path is empty |
{ "error": true, "message": "File not found." } |
| 404 | Object missing in GCS, or stream error before headers | { "error": true, "message": "File not found." } |
Notes
- The name-based fallback is global: an unresolvable id plus a known
original_namewill serve the most recent file with that name from any ticket.
15.2 Support & Issues
Support tickets are issue_list rows with type = 3, rendered as a chat thread. The single role that matters is Feedback Center Administrator (10) — the "Genie support agent". An agent sees every ticket in the system; everyone else sees only tickets they opened. Global and firm administrators get no special treatment here unless they also hold role 10.
Support status ladder: 1 New · 2 Waiting for user feedback · 3 In progress · 4 Resolved · 7 "Unable to Recreate" for agents, "Resolved" for everyone else · any other value renders as "In progress".
Returns the support ticket list for the caller, plus the module dropdown and the caller's agent flag.
| Module | Feedback Center > Support & Issues |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Feedback Center Administrator (10) widens the result set to every type = 3 ticket; all other callers get il.user_id = <caller> only. |
| Content type | application/json |
Example request
curl 'https://api.example.com/api/support/list' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"menuList": [{ "id": 81, "menu_name": "Task & Time" }],
"renderFilterSupp": [
{
"id": 5120,
"encryptId": "cXdlcnR5",
"title": "Timesheet will not submit",
"status": 3,
"status_text": "In progress",
"classification": "high",
"module": 81,
"module_name": "Task & Time",
"opened_by": "alex@example.com",
"opened_on": "2025-11-30T10:41:00.000Z",
"last_updated": "2025-12-02T16:08:00.000Z",
"closed_on": null
}
],
"role_id": 10,
"isSupportAgent": true
}
| Field | Type | Description |
|---|---|---|
menuList[] |
array | { id, menu_name } module options. |
renderFilterSupp[] |
array | Ticket rows, default order updated_at DESC, created_at DESC. |
renderFilterSupp[].status_text |
string | Rendered status label; 7 reads "Unable to Recreate" only for agents. |
renderFilterSupp[].opened_by |
string | Author's e-mail, falling back to lf_name. |
renderFilterSupp[].closed_on |
string | null | updated_at when status = 4, else null. |
role_id |
number | The caller's first global role id, or 0. |
isSupportAgent |
boolean | Whether the caller holds Feedback Center Administrator. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Equivalent to calling
POST /api/support/filter/feedbackwith{ status: 0, title: "", created_filter: 3 }. - Soft-deleted tickets are always excluded from this surface.
Bootstrap for the "raise a support ticket" form.
| Module | Feedback Center > Support & Issues |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No gate on the route. isSupportAgent (Feedback Center Administrator, 10) tells the client whether to render the Classification control. |
| Content type | application/json |
Example request
curl 'https://api.example.com/api/support/new/form' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"menuList": [{ "id": 81, "menu_name": "Task & Time" }],
"type": 3,
"defaultClassification": "low",
"isSupportAgent": false
}
| Field | Type | Description |
|---|---|---|
menuList[] |
array | { id, menu_name } module options. |
type |
number | Always 3 — the value to post as type when submitting. |
defaultClassification |
string | Always "low". |
isSupportAgent |
boolean | Whether the Classification control should be shown. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- There is no dedicated support-create endpoint: the form submits to
POST /api/issue/feedback/savewithtype: 3, which returns aredirectinto the chat view.
Loads one support ticket as a chat thread: the ticket, its attachments, every reply, and whether replying is still allowed.
| Module | Feedback Center > Support & Issues |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Feedback Center Administrator (10) may open any ticket. Everyone else may open only tickets where issue_list.user_id = <caller>; otherwise 401. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | issue_list.id, plain numeric or encrypted. Must resolve to a row with type = 3. |
Example request
curl 'https://api.example.com/api/support/feedback/chat/5120' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"getIssue": {
"id": 5120,
"encryptId": "cXdlcnR5",
"title": "Timesheet will not submit",
"message": "<p>The submit button does nothing.</p>",
"type": 3,
"status": 3,
"module": 81,
"classification": "high",
"user_id": 77,
"user_name": "Nguyen, Alex",
"profile_image": "",
"labels": [],
"voteCount": 0
},
"getIssueFiles": [],
"getReply": [
{
"id": 880,
"issue_list_id": 5120,
"rply_message": "<p>Can you confirm the browser?</p>",
"user_id": 12,
"user_name": "Ortiz, Sam",
"role_id": "6,10",
"files": []
}
],
"menuList": [{ "id": 81, "menu_name": "Task & Time" }],
"role_id": 10,
"isSupportAgent": true,
"canSupportReply": true
}
| Field | Type | Description |
|---|---|---|
getIssue |
object | The ticket row plus encryptId, user_name, profile_image, parsed labels and voteCount. |
getIssueFiles[] |
array | Ticket-body attachments (issue_or_cmd = 1) with download_url. |
getReply[] |
array | Chat messages ascending by created_at, each with its own files[] and the raw comma-joined role_id (used client-side to draw the "Genie Admin" pill). |
menuList[] |
array | Module options. |
role_id |
number | The caller's first global role id, or 0. |
isSupportAgent |
boolean | Whether the caller holds Feedback Center Administrator. |
canSupportReply |
boolean | true when status is 1, 2 or 3 — i.e. the ticket is still open for replies. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Ticket not found, or its type is not 3 |
{ "message": "Support ticket not found." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 401 | Non-agent caller who did not open the ticket | { "message": "Not allowed to access this ticket." } |
Notes
- Unlike
GET /api/issue/list/detail/:id, this route does not stripuser_emailfrom the ticket or replies for non-agents — though non-agents can only ever reach their own tickets here.
Posts a message into a support ticket's chat, attaches staged files, touches the ticket's updated_at, and notifies the other party.
| Module | Feedback Center > Support & Issues |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Feedback Center Administrator (10) may reply to any ticket; everyone else only to tickets they opened. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
issueId |
string | Yes | String | issue_list.id, plain numeric or encrypted. Must be a type = 3 ticket. |
msg |
string | No | Optional, string | Message body. Trimmed; an empty result is rejected with 400, so in practice it is required. Sanitised before insert. |
moduleId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Accepted by the request body schema; it does not affect the result. |
insertedIds |
number[] | No | Optional, array | Staged issue_files.id values to attach to this reply. |
Example request
curl -X POST 'https://api.example.com/api/support/chat/reply/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "issueId": "5120", "msg": "<p>Chrome 142 on Windows.</p>", "insertedIds": [9003] }'
Success response — 200 OK
{ "error": false, "id": 881 }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
id |
number | New issue_reply.id. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Ticket not found or type != 3 |
{ "message": "Support ticket not found." } |
| 400 | Ticket status is not 1, 2 or 3 |
{ "message": "Ticket is closed for replies." } |
| 400 | msg blank after trim |
{ "message": "Please enter the message" } |
| 400 | issue_reply table not present |
{ "message": "Reply table is unavailable." } |
| 400 | issueId missing or not a string |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 401 | Non-agent caller who did not open the ticket | { "message": "Not allowed to reply to this ticket." } |
Notes
- The reply is written with
post_anonymously = 0— support chat has no anonymous mode. - Attachments link with
issue_or_cmd = 2against the new reply id; usePOST /api/ajax_upload/support_files(or theissue_filesroute — the two behave identically) to stage them first. issue_list.updated_atis bumped so the ticket rises in the default list ordering.- The chat-reply e-mail goes to the Genie team (role 10 users) when the replier is not role 10, and to the ticket creator when they are. Fire-and-forget.
Re-queries the support ticket list with title, status and ordering filters.
| Module | Feedback Center > Support & Issues |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Feedback Center Administrator (10) sees every ticket; everyone else only their own. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
title |
string | No | Optional, string | Case-insensitive LIKE '%…%' on issue_list.title. |
status |
number | No | Optional, Numeric (numeric strings are coerced), integer | 0/omitted = no status filter; 5 = status in (1,2,3) ("open"); any other positive value = exact status match. |
created_filter |
number | No | Optional, Numeric (numeric strings are coerced), integer | Ordering: 1 → created_at DESC; 2 → created_at ASC; 4 → updated_at ASC, created_at ASC; anything else (default 3) → updated_at DESC, created_at DESC. |
Example request
curl -X POST 'https://api.example.com/api/support/filter/feedback' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "title": "timesheet", "status": 5, "created_filter": 1 }'
Success response — 200 OK
{
"error": false,
"renderFilterSupp": [
{
"id": 5120,
"encryptId": "cXdlcnR5",
"title": "Timesheet will not submit",
"status": 3,
"status_text": "In progress",
"classification": "high",
"module": 81,
"module_name": "Task & Time",
"opened_by": "alex@example.com",
"opened_on": "2025-11-30T10:41:00.000Z",
"last_updated": "2025-12-02T16:08:00.000Z",
"closed_on": null
}
]
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
renderFilterSupp[] |
array | Same row shape as GET /api/support/list. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Non-integer status or created_filter |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
created_atis applied as a same-direction tiebreaker on theupdated_atorderings, so tickets sharing an identicalupdated_atstamp keep a stable order.
Resolves a ticket id into the front-end chat URL. A navigation aid; it performs no write.
| Module | Feedback Center > Support & Issues |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session — the returned URL is not access-checked here; GET /api/support/feedback/chat/:id applies the real gate. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
id |
string | Yes | String | issue_list.id, plain numeric or encrypted. |
Example request
curl -X POST 'https://api.example.com/api/support/chat/reply' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "id": "5120" }'
Success response — 200 OK
{ "error": false, "redirect": "/support/feedback/chat/cXdlcnR5" }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
redirect |
string | Front-end path /support/feedback/chat/<url-encoded encrypted id>. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | id missing or not a string |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- This returns a relative path in a JSON body — it is not an HTTP redirect.
- An unresolvable id still returns a URL (built from id
0); nothing is validated against the database.
Stages support-ticket attachments. Functionally identical to POST /api/ajax_upload/issue_files — both write the same issue_files rows.
| Module | Feedback Center > Support & Issues |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | multipart/form-data |
Request body
The request is a multipart upload; any field name is accepted, with no declared body schema and no transport-level size limit.
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
| (any field name) | file (repeatable) | No | Extension in jpeg, png, jpg, gif, svg, doc, docx, txt, pdf, xlsx, xls, mp4, webm, mkv, avi, mpg; size <= 10 MB |
Non-conforming parts are silently skipped. |
Example request
curl -X POST 'https://api.example.com/api/ajax_upload/support_files' \
-b 'genie.sid=<session-cookie>' \
-F 'files[]=@console-log.txt;type=text/plain'
Success response — 200 OK
{ "error": false, "insertedIds": [9003] }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
insertedIds[] |
number[] | New issue_files.id values for the accepted files. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Storage layout, prefix and retrieval are identical to the Suggestions upload route — see its Notes. Files are downloaded through
GET /api/issue/files/download/:id[/:name]regardless of which upload route staged them. - Pass the returned ids as
insertedIdsonPOST /api/support/chat/reply/save(links withissue_or_cmd = 2) or onPOST /api/issue/feedback/savewithtype: 3(links withissue_or_cmd = 1).
16. Integrations
This section covers the third-party services Genie connects to on a firm's behalf: the Google Drive and Google Sheets connection used by "Save to Google Sheet" exports and by GL Account Validation, and the QuickBooks Online connection, sync dispatch and tax-lookup surface. The conventions in the previous section apply here too, with the exception of the browser-visited OAuth routes, which are served at the root and issue real HTTP redirects.
16.1 Google Integration (Drive / Sheets OAuth)
Per-user Google Drive / Google Sheets connection, used by the "Save to Google Sheet" export flow and by GL Account Validation. The paths are exactly as documented — five under /api/..., plus the browser-visited OAuth callback at /google/drive/callback.
Tokens are stored per user in the drive_config table; the browser only ever receives a short-lived Picker access token, never the refresh token.
Entry point for "Send to Google Drive". Returns a Picker access token when the user is connected, or a consent URL when they are not.
| Module | Integrations > Google |
| Authentication | Required — fully authenticated session (two-factor completed). Every route in this section is user-scoped and takes the user id from the session, never from the request body. |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
sendType |
string | No | Optional, string, one of excel, pdf, "" |
Echoed back so the caller can resume the right export branch after the Picker closes. Defaults to 'excel' when absent or empty. |
Example request
curl -X POST 'https://api.example.com/api/send/drive' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "sendType": "excel" }'
Success response — 200 OK
Connected:
{
"error": false,
"sendType": "excel",
"accessToken": "<short-lived Google access token>",
"picker": {
"clientId": "<oauth client id>",
"appId": "<gcp project number>",
"developerKey": "<browser api key>"
}
}
Not yet connected (or token unrecoverable):
{
"error": true,
"message": "You are not configured with Google Drive. Please configure your Google account.",
"url": "https://accounts.google.com/o/oauth2/v2/auth?..."
}
Server misconfigured:
{ "error": true, "message": "Google Drive is misconfigured on this server: GOOGLE_CLIENT_SECRET is empty." }
| Field | Type | Description |
|---|---|---|
error |
boolean | false only on the connected path. |
sendType |
string | Echo of the requested export type. |
accessToken |
string | Short-lived Google access token for PickerBuilder.setOAuthToken(). Scoped to drive.file + spreadsheets. |
picker |
object | Public Picker config: clientId, appId (GCP project number, derived from the client id when GOOGLE_PICKER_APP_ID is unset), developerKey (from GOOGLE_PICKER_DEVELOPER_KEY). None of these are secrets. |
message |
string | Present on the error paths. |
url |
string | Consent URL, present when the user must (re)authorise. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Server credentials missing/malformed | { "error": true, "message": "Google Drive is misconfigured on this server: …" } |
| 200 | User not connected, or refresh failed | { "error": true, "message": "…", "url": "<consent url>" } |
| 400 | sendType not one of excel, pdf, '' |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
| 500 | Any token-layer failure other than a missing or malformed server credential configuration | Default framework error shape |
Notes
- Requesting the consent URL mints a single-use random CSRF state and stores it against the caller's session; it is consumed by
GET /google/drive/callback, which must therefore be reached with the same session cookie.
Reports whether the caller has a usable Google connection, so the UI can skip the Configure modal.
| Module | Integrations > Google |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Example request
curl 'https://api.example.com/api/google/drive/status' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"configured": true,
"credentialsConfigured": true,
"picker": { "clientId": "<oauth client id>", "appId": "<gcp project number>", "developerKey": "<browser api key>" }
}
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
configured |
boolean | Whether this user has usable stored Drive tokens. |
credentialsConfigured |
boolean | Whether the server has both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET set. |
picker |
object | Public Picker config (clientId, appId, developerKey). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- This route does not mint a consent state and does not attempt a token refresh.
Returns a fresh Google consent URL for the "Configure Google Account" popup.
| Module | Integrations > Google |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json |
Example request
curl 'https://api.example.com/api/google/drive/auth-url' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=…&state=…" }
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success, true when the server's credentials are unusable. |
url |
string | Consent URL (present on success). |
message |
string | Diagnostic describing the credential problem (present on the error path). |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET missing or malformed |
{ "error": true, "message": "Google Drive is misconfigured on this server: …" } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Each call overwrites the OAuth state stored against the caller's session with a new value, invalidating any consent window opened by a previous call.
- The URL carries
access_type=offline,prompt=select_account consentandinclude_granted_scopes=true;prompt=consentis what guarantees a refresh token is issued on every authorisation, not just the first.
Clears the caller's stored Google Drive tokens.
| Module | Integrations > Google |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session; only the caller's own drive_config row is touched. |
| Content type | application/json (no request body) |
Example request
curl -X POST 'https://api.example.com/api/google/drive/disconnect' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "error": false, "message": "Google account disconnected." }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
message |
string | Always "Google account disconnected.". |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- This clears Genie's stored tokens only; it does not revoke the grant with Google. The user remains able to reconnect without re-approving unless they revoke access in their Google account.
- No-ops silently when the
drive_configtable is absent.
Uploads a base64 workbook into a Drive folder, converting it to a native Google Sheet by default. Reusable by any screen that can produce an .xlsx in memory.
| Module | Integrations > Google |
| Authentication | Required — fully authenticated session (two-factor completed) — the Google identity used is the caller's own stored token. |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | application/json (the file travels as base64 in the JSON body, not as multipart) |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
fileBase64 |
string | Yes | String | Base64 workbook. A leading data:<type>;base64, prefix is tolerated and stripped. |
fileName |
string | Yes | String, maximum length 255 | Display name. When converting to a Sheet, a trailing .xlsx / .xls / .csv is stripped; an empty result becomes "Export". |
folderId |
string | No | Optional, string, maximum length 255 | Drive folder id from the Picker. Effectively required — this endpoint rejects a blank value before reaching the backend (see Errors). |
folderName |
string | No | Optional, string, maximum length 255 | Display name of the chosen folder; echoed back for the success message only. |
mimeType |
string | No | Optional, string, maximum length 255 | Source MIME type of the payload. Defaults to the xlsx type. |
convertToSheet |
boolean | No | Optional, boolean | Defaults to true (only an explicit false disables conversion). |
Example request
curl -X POST 'https://api.example.com/api/google/drive/upload' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{
"fileBase64": "UEsDBBQABgAIAAAAIQ...",
"fileName": "Task List.xlsx",
"folderId": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
"folderName": "Client Exports",
"convertToSheet": true
}'
Success response — 200 OK
{
"error": false,
"file": "1XyZaBcDeFgHiJkLmNoPqRsTuV",
"name": "Task List",
"view_link": "https://docs.google.com/spreadsheets/d/1XyZaBcDeFgHiJkLmNoPqRsTuV/edit",
"folderName": "Client Exports"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
file |
string | New Drive file id. |
name |
string | Name Drive assigned (falls back to the computed name). |
view_link |
string | webViewLink, or a constructed Sheets URL when Drive omits it. |
folderName |
string | null | Echo of the supplied folderName, or null. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | folderId blank/absent |
{ "error": true, "message": "Please select a Google Drive folder before uploading." } |
| 200 | fileBase64 empty after stripping a data: prefix |
{ "error": true, "message": "Nothing to upload." } |
| 200 | User not connected, refresh failed, or Google returned 401 mid-flight | { "error": true, "needsAuth": true, "message": "…", "url": "<consent url, when server credentials exist>" } |
| 200 | Drive rejected the call (bad folder, permissions, API failure) | { "error": true, "message": "…", "status": <http status from Google> } |
| 200 | Any other error | { "error": true, "message": "<error message or 'Failed to upload the file to Google Drive. Please try again.'>" } |
| 400 | Missing/invalid fileBase64 or fileName, or a maximum-length breach |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Every failure on this route is reported as HTTP 200 with
error: true;needsAuth: trueis the client's signal to re-open the Configure Google Account modal. - When
folderIdis supplied the backend first verifies the folder exists, is a folder, and is writable — turning an opaque Drive 404 into "pick a different folder". - Conversion to a native Sheet is triggered by an asymmetry Drive requires: the multipart metadata part declares
application/vnd.google-apps.spreadsheetwhile the media part carries the workbook MIME type. - Nothing is written to disk; the workbook is streamed from memory. Note the global JSON body limit of
20mbapplies to the base64 payload. - Under the
drive.filescope the app can only write to folders the user handed it through the Picker — which is whypicker.appIdmust be correct or every upload fails as "folder not found".
Google's OAuth redirect target. A browser-visited route (not an API call): it exchanges the authorisation code, stores the tokens against the session's user, and renders a small self-closing popup page.
| Module | Integrations > Google |
| Authentication | Required — the route is session-bound: the user is taken from the session cookie sent with the top-level browser request (SameSite=Lax). Without a recognised session a failure page is rendered |
| Authorization | No additional role requirement beyond an authenticated session |
| Content type | text/html (a self-closing popup page) — not an HTTP redirect |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
code |
string | Yes (success path) | — | Google authorisation code. |
state |
string | Yes (success path) | — | Must equal the OAuth state stored against the caller's session, character for character, or the exchange is refused as a CSRF mismatch. |
error |
string | No | — | Set by Google when the user cancels or denies consent. |
Example request
GET /google/drive/callback?code=4%2F0AeanS0b...&state=6f1c2e6a-9a1d-4f1e-9d63-1e0c9c2c1d77&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive.file HTTP/1.1
Host: api.example.com
Cookie: genie.sid=<session-cookie>
Success response — 200 OK
A minimal HTML document that postMessages the outcome to window.opener and closes itself after ~400 ms:
<!doctype html>
<html><head><meta charset="utf-8"><title>Google Drive</title>…</head><body>
<p>Google Drive connected.</p>
<p>You can close this window.</p>
<script>/* posts { source: 'genie-google-drive', success: true, message: 'Google Drive connected.' } */</script>
</body></html>
| Posted message field | Type | Description |
|---|---|---|
source |
string | Always "genie-google-drive" — the discriminator the opener listens for. |
success |
boolean | Whether the connection was established. |
message |
string | Human-readable outcome. |
Errors
Every outcome — including failure — is delivered as HTTP 200 with an HTML page carrying success: false; there are no non-2xx responses on this route.
| Status | Condition | Message/shape |
|---|---|---|
| 200 | ?error= present, or no code |
Page with "Google authorisation was cancelled." |
| 200 | state absent from session or does not match |
Page with "Google authorisation failed. Please try again. (the sign-in session was not recognised — please close this window and try again)" |
| 200 | Session expired / user not resolvable | Page with "Your session has expired. Please sign in again." |
| 200 | Token exchange rejected (invalid_client) |
Page naming the credential problem, e.g. that GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must belong to the same OAuth client |
| 200 | Token exchange rejected (redirect_uri_mismatch) |
Page naming the configured redirect URI and instructing that it be registered on the OAuth client |
| 200 | Any other exchange failure | Page with "Google authorisation failed. Please try again. (<detail>)" |
Notes
- The CSRF state is consumed only on the success path, immediately before the code exchange, so a cancelled or failed consent leaves the stored state intact for a further attempt. Once consumed it cannot be replayed.
postMessage'stargetOriginis pinned toFRONTEND_ORIGIN(falling back toAPP_URL, then the request host) rather than'*', so an unrelated window cannot read the result. The request's ownHostheader is not used first, because this route is proxied server-to-server and the request host is the internal upstream.- Interpolated values are HTML-escaped, and inside the inline script every
<is emitted as the<escape, so nothing in the message can break out of the script element. - This is the only route in this section registered outside the
/apiprefix; the redirect URI registered with Google is the front-end path${APP_URL}/google/drive/callback, which the front-end rewrites through to this endpoint.
16.2 QuickBooks Online Integration
Company-level QuickBooks Online connection, sync dispatch and lookups. Genie itself does not perform the QBO sync outside local development — it builds a payload and fires it at an AWS Lambda, which owns every fetch, every write and the token rotation; Genie then polls sync status.
These endpoints are served under /api. Authentication and firm scoping are applied per endpoint, as documented below.
Dispatches a QuickBooks sync for one company across one or more modules, marking the requested modules as running immediately.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement on this route (unlike POST /api/company/qbo/syncall, which additionally enforces the QBO edit rule). When companyId/company_id is omitted, the target is the caller's firm-internal company, so the caller's firm bounds the request. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
lambda |
string | Yes | String | Modules to sync. 'all' (or empty) = every module. Otherwise a comma-separated list of module names (account, reports, customer, vendor, invoice, bill, sr, po, cm, pl, bs, gl, ar, ap, class, location, vc, cf, products, terms) or their numeric ids. reports (id 2) is always filtered out — it is a UI grouping placeholder; the five report modules are ids 10–14. |
companyId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Target company. |
company_id |
number | No | Optional, Numeric (numeric strings are coerced), integer | Snake-case alias; companyId wins. When both are absent the caller's firm-internal company is used. |
count |
number | No | Optional, Numeric (numeric strings are coerced), integer | Accepted by the request body schema; it does not affect the result. |
start |
string | No | Optional, string | Explicit window start. When both start and end are present they are used exactly as supplied instead of deriving the window from company.qb_sync_type. |
end |
string | No | Optional, string | Explicit window end. |
Example request
curl -X POST 'https://api.example.com/api/qbo/import/all' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "lambda": "account,customer,gl", "companyId": 412, "start": "2025-11-01", "end": "2025-11-30" }'
Success response — 200 OK
{
"error": false,
"lambda_req": {
"companies": [{ "id": 412, "modules": ["account", "customer", "gl"] }],
"start_date": "2025-11-01",
"end_date": "2025-11-30",
"method": "Accrual"
}
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false when the sync was dispatched. |
lambda_req |
object | The exact payload sent to the sync Lambda. |
lambda_req.companies[] |
array | { id, modules: [<module name>, …] }. |
lambda_req.start_date / end_date |
string | Resolved sync window. |
lambda_req.method |
string | "Cash" when report_configure.report_method = 1, else "Accrual". |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No target company resolvable | { "error": true, "msg": "connection error" } |
| 200 | lambda resolves to no valid modules |
{ "error": true, "msg": "Invalid QuickBooks sync module." } |
| 200 | Company has no qbo_realmid, or the token check rejected it |
{ "error": true, "msg": "connection error", "company_id": <id>, "client_id": 0 } |
| 200 | LAMBDA_SYNC is not configured |
{ "error": true, "msg": "QuickBooks sync service is not configured (LAMBDA_SYNC)." } |
| 400 | lambda missing or not a string |
Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Before dispatching, this endpoint asks
LAMBDA_TOKEN_URLto verify/refresh the company's QuickBooks token. A non-200 from that endpoint is treated as "assume the connection is fine"; only an expliciterror: truein a 200 body counts as a failure. - The requested modules are optimistically marked "running" so the UI's spinners appear before the Lambda writes its first status row. On a
LAMBDA_SYNCmisconfiguration they are immediately marked finished with a generic error. - When
APP_ENV=local, no Lambda is called — the application performs the sync itself, covering the core account and report modules, fire-and-forget. - The response returns as soon as the request is dispatched; progress is read back through
POST /api/qbo/syncstatus.
Polling endpoint for QuickBooks sync progress. Called roughly every 5 seconds by the QBO page while a sync runs.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement, and no firm-scope check — status for any company id is returned to any authenticated caller. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
companyId |
number | string | No | Optional; no type constraint | Numeric or encrypted company id. No integer constraint is applied, so the encrypted form is accepted rather than rejected with a 400. |
company_id |
number | string | No | Optional; no type constraint | Snake-case alias; companyId wins. |
Example request
curl -X POST 'https://api.example.com/api/qbo/syncstatus' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "companyId": 412 }'
Success response — 200 OK
{
"syncStatus": "{\"m_1\":true,\"m_3\":false}",
"syncError": "{\"m_3\":\"There is an error with QuickBooks connection\"}",
"syncTime": "{\"m_1\":\"12/03/2025 09:14 AM\"}",
"ajaxRun": true
}
| Field | Type | Description |
|---|---|---|
syncStatus |
string | JSON-encoded string (not an object) mapping m_<moduleId> → boolean "currently running". |
syncError |
string | JSON-encoded string mapping m_<moduleId> → a sanitised error message. Raw Intuit fault payloads are never surfaced; they go to the server log and the client sees "There is an error with QuickBooks connection". |
syncTime |
string | JSON-encoded string mapping m_<moduleId> → last-synced timestamp. |
ajaxRun |
boolean | Whether the client should keep polling. Forced false when company.qbo_connection = 0. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | Company id absent or unresolvable | { "syncStatus": "{}", "syncError": "{}", "syncTime": "{}", "ajaxRun": false } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The three map fields are strings containing JSON — clients must parse them before use.
- The Lambda's reported state is authoritative once it has written anything; otherwise the locally-tracked optimistic state is returned.
Returns a QuickBooks consent URL for the caller's firm-internal company.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement on this route. The target company is always the caller's own firm-internal company, so the caller's firm bounds it. (The admin equivalent, GET /api/company/qbo/connect/:id, does enforce the QBO edit rule.) |
| Content type | application/json |
Example request
curl 'https://api.example.com/api/qbo/connect' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"url": "https://appcenter.intuit.com/connect/oauth2?client_id=…&state=1764341531123-a1b2c3d4",
"companyId": 412,
"state": "1764341531123-a1b2c3d4"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
url |
string | Intuit consent URL. |
companyId |
number | Company the connection will be bound to. |
state |
string | Opaque CSRF/correlation state, <epoch-ms>-<random>. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No firm-internal company configured for the caller's firm | { "error": true, "message": "Firm internal company is not configured." } |
| 200 | QBO_CLIENT_ID / QBO_CLIENT_SECRET not set |
{ "error": true, "message": "QuickBooks OAuth credentials are not configured (QBO_CLIENT_ID / QBO_CLIENT_SECRET)." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The consent URL requests scopes
com.intuit.quickbooks.accounting,openid,profile,email,phone,address. - The
stateis single-use and is held as temporary server-side storage for 15 minutes (QUICKBOOKS_OAUTH_STATE_TTL_MS); after that window it is no longer accepted. The OAuth callback must be served by the same application instance that issued the consent URL, so multi-instance deployments must preserve instance affinity for these routes. See Deployment & Operational Considerations.
Reports whether the caller's firm-internal company is currently connected to QuickBooks.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session; scoped to the caller's firm-internal company. |
| Content type | application/json |
Example request
curl 'https://api.example.com/api/qbo/connection/status' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{ "connected": true, "companyId": 412, "companyName": "Northwind Advisory LLP" }
| Field | Type | Description |
|---|---|---|
connected |
boolean | true when company.qbo_connection = 1. |
companyId |
number | The firm-internal company id, or 0 when none is configured. |
companyName |
string | Company name, or "". |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No firm-internal company configured | { "connected": false, "companyId": 0, "companyName": "" } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Intuit's OAuth redirect target. Exchanges the authorisation code, binds the realm to the pending company, captures the QuickBooks company name, and redirects the popup to a front-end hand-off page.
| Module | Integrations > QuickBooks Online |
| Authentication | Not required (OAuth callback) — the caller is identified solely by the single-use state issued with the consent URL, which is held in temporary server-side storage on the issuing instance |
| Authorization | No role requirement. Authorisation is carried by the unguessable, single-use, 15-minute state. The request must reach the same application instance that issued the consent URL. |
| Content type | 302 redirect (Location header); no response body |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
code |
string | Yes (success path) | — | Intuit authorisation code. |
realmId |
string | Yes (success path) | — | QuickBooks company (realm) id. |
state |
string | Yes (success path) | — | The state minted by GET /api/qbo/connect or GET /api/company/qbo/connect/:id. Consumed on first use. |
error |
string | No | — | Set by Intuit when consent is denied. |
Every query parameter received is forwarded unchanged into the callback URI used for the token exchange.
Example request
GET /api/qbo/callback?code=AB11...&state=1764341531123-a1b2c3d4&realmId=9130350000000000 HTTP/1.1
Host: api.example.com
Success response — 302 Found
Location is always on FRONTEND_ORIGIN, at the hand-off page /task/qbo/callback:
- With a
returnTorecorded at connect time:${FRONTEND_ORIGIN}/task/qbo/callback?redirect=<url-encoded returnTo>— e.g.…?redirect=%2Fadmin%2Fcompany%2F<encrypted id>%2Fquickbooks%3Fs%3D1. - Without one:
${FRONTEND_ORIGIN}/task/qbo/callback?success=1.
Response body: none (redirect).
Errors
Failures are also delivered as 302 redirects, never as error status codes.
| Status | Condition | Message/shape |
|---|---|---|
| 302 | Intuit returned ?error= |
Location: ${FRONTEND_ORIGIN}/task/qbo/callback?error=<intuit error> |
| 302 | Missing code, realmId or state |
…?error=Missing%20QuickBooks%20OAuth%20callback%20parameters. |
| 302 | Unknown state, or state older than 15 minutes | …?error=QuickBooks%20OAuth%20state%20expired.%20Please%20try%20again. |
| 302 | QBO_CLIENT_ID / QBO_CLIENT_SECRET unset |
…?error=QuickBooks%20OAuth%20credentials%20are%20not%20configured. |
| 302 | Token exchange failed, or the response lacked realm / access / refresh token | …?error=<Intuit error description or 'Failed to exchange QuickBooks authorization code.'> |
| 302 | The company table has none of the QuickBooks columns |
…?error=Company%20table%20is%20missing%20QuickBooks%20columns. |
| 302 | Realm conflict — this QuickBooks file is already bound to another company | …?redirect=<page>?error=true&error_type=1&error_code=<Genie company name>&qbo_error_code=<QuickBooks file name> |
| 302 | Company conflict — this company already carries a different realm id | …?redirect=<page>?error=true&error_type=2&error_code=<Genie company name>&qbo_error_code=<QuickBooks file name> |
Notes
- The two conflict cases redirect back to the integration page with structured query parameters rather than failing outright, so the page can render its own alert. The company is not repointed to a different QuickBooks file.
- On success,
companyis updated withqbo_realmid,qbo_access_token,qbo_refresh_token,qbo_state,qbo_connection = 1,last_syncandupdated_at— each only if the column exists. - A best-effort
CompanyInfocall then stores the QuickBooks company name incompany.qbo_file; this is what the integration page renders as "QuickBooks: …". A failure here is logged and ignored. - Sync status for modules 1, 3 and 21 is seeded with
ajaxRun: falseafter a successful connection; this too is best-effort. - The redirect targets the front-end hand-off page rather than the application directly, because this response lands in the OAuth popup — the hand-off page moves the opener to
returnTo(with?s=1, which auto-triggers "Sync all") and then closes itself.
Resolves the sales-tax rate for an invoicing line — a live QuickBooks probe when possible, otherwise the stored customer rate.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | No additional role requirement beyond an authenticated session. The live probe runs against the caller's firm-internal company, so the QuickBooks identity used is always the caller's firm's. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
customerQbo |
number | No | Optional, Numeric (numeric strings are coerced), integer | qbo_customer.id. One of customerQbo / customerId is required in practice — with neither, the response is a flat taxRate: 0. |
customerId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Fallback for customerQbo. |
companyId |
number | No | Optional, Numeric (numeric strings are coerced), integer | Accepted by the request body schema; it does not affect the result — the company comes from the session's firm. |
productQbo |
number | No | Optional, Numeric (numeric strings are coerced), integer | When > 0 and QuickBooks is connected, triggers the live tax probe (a dummy-invoice recompute) instead of the stored rate. |
location |
number | No | Optional, Numeric (numeric strings are coerced), integer | Location/department id passed to the live probe. |
Example request
curl -X POST 'https://api.example.com/api/invoicing/tax/qbo' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "customerQbo": 5501, "productQbo": 812, "location": 3 }'
Success response — 200 OK
{ "error": false, "taxRate": 8.25 }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
taxRate |
number | Effective tax rate. 0 when unresolvable. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | No customer id supplied, qbo_customer table absent, or no tax-rate column present |
{ "error": false, "taxRate": 0 } |
| 400 | Non-integer values supplied | Standard request-validation error array |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- The live probe is attempted only when
productQbo > 0and a firm-internal company resolves. Any probe failure, or a probe result of0, falls back to the stored rate. - The stored rate is read from
qbo_customer.tax_rate, falling back toqbo_customer.taxable_rate, whichever column exists. - This route never reports an error condition — an unresolvable rate is indistinguishable from a genuine zero rate.
Full payload for the Admin → Company → QuickBooks Online Integration page: connection state, module catalogues, per-module last-synced times, live sync status and the caller's edit permission.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Readable without a role check and without a firm-scope assertion. The caller's edit rights are reported as global_can_edit: true for users.firm_admin = 1 or a Global Master System Administrator (role id 1 or name match); otherwise false when the company name matches /inactive/i, or when the caller's Company-menu (id 48) access for this company is View Only (access_type = 2); true otherwise. Any lookup failure defaults to true. |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | company.id, plain numeric or encrypted. |
Example request
curl 'https://api.example.com/api/company/qbo/412' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"company": {
"id": 412,
"encryptId": "cXdlcnR5",
"name": "Northwind Advisory LLP",
"qbo_connection": 1,
"qbo_realmid": "9130350000000000",
"qbo_file": "Northwind Advisory (Sandbox)",
"qb_sync_type": 2
},
"yearType": 2,
"moduleArr": { "1": "Account", "2": "Reports", "3": "Customer", "4": "Vendor", "5": "Invoice", "6": "Bill", "7": "Sales Receipt", "8": "Purchase Order", "9": "Credit Memo", "19": "Cash Flow Statement", "20": "Products", "21": "Terms" },
"reportModuleArr": { "10": "Profit & Loss report", "11": "Balance Sheet report", "12": "General Ledger report", "13": "AR Aging", "14": "AP Aging" },
"syncArr": { "m_1": "12/03/2025 09:14 AM" },
"syncStatus": "{\"m_1\":false}",
"syncError": "{}",
"ajaxRun": false,
"global_can_edit": true,
"syncDetails": [{ "module_id": "1", "last_synced": "12/03/2025 09:14 AM" }]
}
| Field | Type | Description |
|---|---|---|
company |
object | { id, encryptId, name, qbo_connection, qbo_realmid, qbo_file, qb_sync_type }. qbo_file is the QuickBooks company name captured at OAuth time. |
yearType |
number | company.qb_sync_type, surfaced at the top level too. Defaults to 1. |
moduleArr |
object | { "<moduleId>": "<label>" } for the primary modules. Key "2" ("Reports") is a UI placeholder the page replaces in place with reportModuleArr. |
reportModuleArr |
object | { "<moduleId>": "<label>" } for the five report modules (ids 10–14). |
syncArr |
object | { "m_<moduleId>": "<last synced>" }. |
syncStatus / syncError |
string | JSON-encoded strings, same format as POST /api/qbo/syncstatus. |
ajaxRun |
boolean | Whether to poll. Forced false when qbo_connection != 1. |
global_can_edit |
boolean | Whether the caller may connect/disconnect/sync/change the sync year. |
syncDetails[] |
array | Flat form of syncArr, retained for compatibility with earlier clients: { module_id, last_synced }. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | Company id not found | { "message": "Company not found." } |
| 401 | No authenticated session | { "message": "These credentials do not match our records." } |
Notes
- Every column read is schema-guarded; a deployment missing
qbo_connection/qbo_realmid/qbo_file/qb_sync_typegets a default rather than a query failure. global_can_editis enforced server-side on the four mutating routes below, not only in the user interface.
Returns a QuickBooks consent URL for a specific company from the Admin integration page, with a returnTo that auto-triggers "Sync all" after connecting.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | Two checks: the company must belong to the caller's firm (company.firm_id = <caller's firm>, deleted = 0), and the caller must hold QuickBooks edit rights on it (a firm admin or Global Master System Administrator always does; otherwise the caller is blocked for an "inactive"-named company or a View-Only Company-menu grant). |
| Content type | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | company.id, plain numeric or encrypted. |
Example request
curl 'https://api.example.com/api/company/qbo/connect/412' \
-b 'genie.sid=<session-cookie>'
Success response — 200 OK
{
"error": false,
"url": "https://appcenter.intuit.com/connect/oauth2?client_id=…&state=1764341531123-a1b2c3d4",
"companyId": 412,
"state": "1764341531123-a1b2c3d4"
}
| Field | Type | Description |
|---|---|---|
error |
boolean | false on success. |
url |
string | Intuit consent URL. |
companyId |
number | Company the connection binds to. |
state |
string | Correlation state, valid for 15 minutes, single-use. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | QBO_CLIENT_ID / QBO_CLIENT_SECRET unset |
{ "error": true, "message": "QuickBooks OAuth credentials are not configured (QBO_CLIENT_ID / QBO_CLIENT_SECRET)." } |
| 401 | No authenticated session, or the company is not in the caller's firm | { "message": "These credentials do not match our records." } |
| 401 | Caller lacks QuickBooks edit rights on this company | { "message": "You do not have access to change the QuickBooks connection." } |
Notes
- The recorded
returnTois/admin/company/<encrypted id>/quickbooks?s=1;GET /api/qbo/callbackhands the popup back to it, and the?s=1marker is what makes the page auto-click "Sync all". - This differs from
GET /api/qbo/connectonly in the target company, the permission checks and thereturnTo.
Disconnects a company from QuickBooks: clears the local connection flag and tokens, asks the Lambda to revoke the grant with Intuit, and drops in-flight sync state.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | The company must belong to the caller's firm and the caller must hold QuickBooks edit rights on it. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String | company.id, plain numeric or encrypted. Declared as a string so the encrypted form is accepted. |
lambda |
string | No | Optional, string | Accepted by the request body schema; it does not affect this operation. |
Example request
curl -X POST 'https://api.example.com/api/company/qbo/disconnect' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "company_id": "412" }'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | company_id missing or not a string |
Standard request-validation error array |
| 401 | No authenticated session, or the company is not in the caller's firm | { "message": "These credentials do not match our records." } |
| 401 | Caller lacks QuickBooks edit rights on this company | { "message": "You do not have access to change the QuickBooks connection." } |
Notes
- Writes
qbo_connection = 0and blanksqbo_access_token/qbo_refresh_token(each only if the column exists).qbo_realmidandqbo_fileare not cleared, so a later reconnect to a different QuickBooks file still trips the company-conflict branch of the callback. - Calls
LAMBDA_TOKEN_URLwithoperation: 'revoke'so the Lambda revokes the grant with Intuit and clears its own copy of the tokens. Skipping this would leave the connection live on Intuit's side. - Server-side tracked sync state for the company is dropped so the page stops polling immediately.
Triggers a full (or module-scoped) sync for one company from the Admin integration page.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | The company must belong to the caller's firm and the caller must hold QuickBooks edit rights on it. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String | company.id, plain numeric or encrypted. |
lambda |
string | No | Optional, string | Modules to sync; same vocabulary as POST /api/qbo/import/all. Defaults to 'all'. |
Example request
curl -X POST 'https://api.example.com/api/company/qbo/syncall' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "company_id": "412", "lambda": "all" }'
Success response — 200 OK
{
"error": false,
"lambda_req": {
"companies": [{ "id": 412, "modules": ["account", "customer", "vendor", "invoice", "bill", "sr", "po", "cm", "pl", "bs", "gl", "ar", "ap", "class", "location", "vc", "cf", "products", "terms"] }],
"start_date": "2025-01-01",
"end_date": "2025-12-31",
"method": "Accrual"
}
}
Response shape is that of POST /api/qbo/import/all, which this route delegates to.
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 200 | company.qbo_connection = 0 |
{ "error": true, "msg": "connection error", "company_id": <id> } |
| 200 | Any failure of the underlying sync dispatch (no realm id, token check rejected, LAMBDA_SYNC unset, invalid modules) |
See the error table for POST /api/qbo/import/all |
| 400 | company_id missing or not a string |
Standard request-validation error array |
| 401 | No authenticated session, or the company is not in the caller's firm | { "message": "These credentials do not match our records." } |
| 401 | Caller lacks QuickBooks edit rights on this company | { "message": "You do not have access to change the QuickBooks connection." } |
Notes
- This is the permission-enforcing entry point for the same sync dispatch: it adds the firm-scope and QuickBooks edit checks that
POST /api/qbo/import/alldoes not apply, plus an up-frontqbo_connectioncheck. - The client treats
{ error: true, msg: 'connection error' }as "QuickBooks dropped us" and re-renders the page as disconnected. - Progress is polled through
POST /api/qbo/syncstatus.
Two operations behind one route: change a company's QuickBooks sync-year setting, or clear the recorded last-sync times for some/all modules so they re-sync from scratch.
| Module | Integrations > QuickBooks Online |
| Authentication | Required — fully authenticated session (two-factor completed) |
| Authorization | The company must belong to the caller's firm and the caller must hold QuickBooks edit rights on it. |
| Content type | application/json |
Request body
| Field | Type | Required | Validation / allowed values | Description |
|---|---|---|---|---|
company_id |
string | Yes | String | company.id, plain numeric or encrypted. |
sync_type |
number | No | Optional, Numeric (numeric strings are coerced), integer | 1 selects the sync-year branch: company.qb_sync_type is set to type_id. Any other value (or omission) selects the reset-sync-times branch. |
type_id |
number | No | Optional, Numeric (numeric strings are coerced), integer | New company.qb_sync_type value on the sync-year branch. Defaults to 1. |
module_id |
string | No | Optional, string | Comma-separated module ids whose sync times should be cleared. Ignored when type_all is "1". |
type_all |
number | No | Optional, Numeric (numeric strings are coerced), integer | When it stringifies to "1", every module for the company is reset regardless of module_id. |
Example request
Change the sync year:
curl -X POST 'https://api.example.com/api/company/qbo/settings/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "company_id": "412", "sync_type": 1, "type_id": 2 }'
Reset the recorded sync times for two modules:
curl -X POST 'https://api.example.com/api/company/qbo/settings/save' \
-H 'Content-Type: application/json' \
-b 'genie.sid=<session-cookie>' \
-d '{ "company_id": "412", "module_id": "1,12" }'
Success response — 200 OK
{ "error": false }
| Field | Type | Description |
|---|---|---|
error |
boolean | Always false. |
Errors
| Status | Condition | Message/shape |
|---|---|---|
| 400 | company_id missing or not a string |
Standard request-validation error array |
| 401 | No authenticated session, or the company is not in the caller's firm | { "message": "These credentials do not match our records." } |
| 401 | Caller lacks QuickBooks edit rights on this company | { "message": "You do not have access to change the QuickBooks connection." } |
Notes
- The reset branch nulls
qb_sync_details.sync_time(andqbo_timewhere that column exists), which is what makes the next sync re-pull the full window. - Both branches return
{ error: false }unconditionally — including when theqb_sync_detailstable or theqb_sync_typecolumn is absent and nothing was written. - Only the value
1widens the reset to all modules;type_allis compared as a string, so any other value leavesmodule_idin control.
16.3 Integration Notes
16.3.1 Google OAuth (Drive / Sheets)
Scopes — the default scope set requests exactly three:
https://www.googleapis.com/auth/spreadsheetshttps://www.googleapis.com/auth/drive.filehttps://www.googleapis.com/auth/spreadsheets.readonly
drive.file is the per-file scope: it grants access only to files the app creates and to files/folders the user explicitly hands over through the Google Drive Picker. That is exactly how this flow works — the Picker's browse UI runs under the user's own Google session, not Genie's token — so the narrow scope is intentional least-privilege. The scope list can be overridden with GOOGLE_DRIVE_SCOPES (space- or comma-separated); it falls back to the three above when unset or empty.
Credentials and redirect URI — the module reuses the same OAuth client as "Sign in with Google":
| Variable | Purpose |
|---|---|
GOOGLE_CLIENT_ID |
OAuth client id. Validated to end in .apps.googleusercontent.com. |
GOOGLE_CLIENT_SECRET |
OAuth client secret. Rejected with a specific diagnostic if it starts with AIza (that is an API key, not a client secret). |
GOOGLE_DRIVE_REDIRECT_URI |
Redirect URI registered with Google. Defaults to ${APP_URL}/google/drive/callback. |
APP_URL |
Base used to derive the default redirect URI (defaults to http://localhost:3000). |
FRONTEND_ORIGIN |
postMessage target origin for the callback popup; falls back to APP_URL, then the request host. |
GOOGLE_PICKER_APP_ID |
Optional. GCP project number for the Picker's setAppId. Derived from the leading numeric segment of GOOGLE_CLIENT_ID when unset. |
GOOGLE_PICKER_DEVELOPER_KEY |
Browser API key handed to the Picker. Public by design. |
GOOGLE_DRIVE_SCOPES |
Optional scope override. |
The registered redirect URI is the front-end path (${APP_URL}/google/drive/callback), which the front-end rewrites server-to-server through to the backend endpoint at GET /google/drive/callback.
Flow — the client calls GET /api/google/drive/auth-url (or POST /api/send/drive, which returns a consent URL when unconnected). The server mints a single-use random state, stores it against the caller's session, and builds an authorisation URL with access_type=offline, prompt=select_account consent and include_granted_scopes=true. prompt=consent is required: without it, re-authorising an already-linked account yields no refresh token and the connection fails at the next expiry. Google redirects the popup to the callback, whose session cookie rides along on the top-level GET (SameSite=Lax) and identifies the user — so the callback must be reached with the same session that requested the consent URL. The state must come back character for character; it is consumed only on the success path (so a cancelled consent does not prevent subsequent attempts), and once consumed a replayed code cannot be re-submitted.
Token storage and refresh — tokens live in the drive_config table (DRIVE_CONFIG_TABLE), one row per user, plus an optional shared row with user_id IS NULL used as a "default account" identity for server-side Sheets reads (GL Account Validation). Access tokens are treated as valid for GOOGLE_TOKEN_DEFAULT_TTL_SECONDS (3600) and refreshed GOOGLE_TOKEN_REFRESH_SKEW_SECONDS (60) before expiry, using the stored refresh token; the refreshed pair is written straight back to the same table. Token errors are classified: invalid_grant / invalid_client / unauthorized_client require re-consent, while network / server_error are retryable, so a transient 5xx from Google never pushes a healthy connection back through the consent screen. POST /api/google/drive/disconnect clears the caller's row locally; it does not revoke with Google. The refresh token never leaves the server — only the short-lived Picker access token is returned to the browser, and only because the Google Drive Picker has no server-side variant.
16.3.2 QuickBooks Online OAuth and connection
Scopes — the consent URL is built against Intuit's OAuth 2.0 authorisation endpoint with scopes com.intuit.quickbooks.accounting, openid, profile, email, phone, address.
| Variable | Purpose |
|---|---|
QBO_CLIENT_ID |
Intuit app client id. Absent → the connect routes return "QuickBooks OAuth credentials are not configured". |
QBO_CLIENT_SECRET |
Intuit app client secret. |
QBO_ENV |
production selects the production Intuit environment and API base; any other value (and the default) selects sandbox. Compared lower-case after trimming. |
QBO_REDIRECT_URI |
Redirect URI registered on the Intuit app. Defaults to ${BACKEND_ORIGIN}/api/qbo/callback. |
BACKEND_ORIGIN |
Base for the default redirect URI. Defaults to http://localhost:${PORT}. |
PORT |
Used only in that fallback (default 4000). |
FRONTEND_ORIGIN |
Base for every post-callback redirect, including the /task/qbo/callback hand-off page. Defaults to http://localhost:3000. |
APP_ENV |
local disables the Lambda entirely: the token check is skipped and the application performs the sync itself. |
LAMBDA_SYNC |
AWS Lambda / API Gateway endpoint that performs the actual QuickBooks sync. Unset → sync cannot be dispatched. |
LAMBDA_TOKEN_URL |
Endpoint that verifies/refreshes and revokes QuickBooks tokens on Genie's behalf. Unset → the token check is skipped and the connection is assumed healthy. |
LAMBDA_CONTENT_TYPE |
Content-Type sent on both Lambda calls. Defaults to application/x-www-form-urlencoded: the body is a JSON string posted under a form content type, because announcing application/json can cause API Gateway to route the body where it is not read. |
Flow — GET /api/qbo/connect (firm-internal company) or GET /api/company/qbo/connect/:id (a specific company) mints a <epoch-ms>-<random> state, records { userId, companyId, createdAt, returnTo } against it in temporary server-side storage with a 15-minute validity window (QUICKBOOKS_OAUTH_STATE_TTL_MS), and returns the Intuit consent URL. Intuit redirects the popup to GET /api/qbo/callback, which consumes the state (single-use, and rejected once the 15 minutes have elapsed), exchanges the code, and before writing anything checks two conflicts: the realm already bound to another company (error_type=1) and this company already carrying a different realm (error_type=2). Both redirect back to the integration page with structured query parameters instead of silently repointing the company. On success it writes qbo_realmid, qbo_access_token, qbo_refresh_token, qbo_state, qbo_connection = 1, last_sync and updated_at onto the company row, then best-effort fetches CompanyInfo.CompanyName into company.qbo_file.
Because the OAuth state is held as temporary server-side storage on the instance that issued the consent URL, GET /api/qbo/callback must be served by that same application instance. Multi-instance deployments must therefore preserve instance affinity for the connect and callback routes; without it, a callback reaching a different instance fails with QuickBooks OAuth state expired. Please try again. See Deployment & Operational Considerations.
Token ownership — outside APP_ENV=local, the Lambda owns the QuickBooks tokens. Genie never rotates them itself: before dispatching a sync it posts to LAMBDA_TOKEN_URL with operation: 'update' and waits (that answer decides whether the sync is dispatched at all); on disconnect it posts operation: 'revoke'. A non-200 from that endpoint is treated as "connection is fine"; only an explicit error: true in a 200 body is a failure. Errors returned from Intuit are never surfaced to the client as received — the full fault is written to the server log and the client sees the generic "There is an error with QuickBooks connection".
16.3.3 File attachment storage
Feedback Center attachments (both Suggestions and Support) are stored in Google Cloud Storage through a generic, feature-agnostic storage layer.
| Variable | Purpose |
|---|---|
GCS_BUCKET |
Target bucket name. Required before any read or write; unset logs a warning at boot and every storage call then fails. |
GCP_PROJECT_ID |
Optional. Normally inferred from Application Default Credentials. |
GCS_ISSUE_FILES_PREFIX |
Object-key prefix for issue_files objects. Defaults to issue_files/; a missing trailing slash is added. |
GOOGLE_APPLICATION_CREDENTIALS |
Local development only — path to a service-account key file. On Cloud Run the attached service account is used via ADC and nothing needs configuring. |
Authentication is Application Default Credentials; no credentials appear in code or in the database. The database column issue_files.file_path stores only the bare filename (e.g. 1764341531_ab12.png); the full object key is derived from the configured prefix and that filename on both write and read, so the prefix can change without a schema change. Uploads are non-resumable single-shot writes tagged with the browser-supplied content type. Downloads are streamed through the authenticated GET /api/issue/files/download/:id[/:name] endpoint rather than via a public object or a signed URL, so the bucket stays private and server memory stays bounded. Signed-URL direct download is not used by this flow. Local disk is not used: the runtime filesystem is ephemeral, so attachments written there would not survive a restart or a scale event.
16.3.4 Outbound e-mail
All Feedback Center notifications go through the application's generic SMTP mail transport.
| Variable | Purpose |
|---|---|
MAIL_HOST |
SMTP host. Unset → every outgoing e-mail is silently skipped (a warning is logged once). |
MAIL_PORT |
SMTP port. Defaults to 587. Port 465 uses implicit TLS (secure: true); everything else uses STARTTLS (secure: false). |
MAIL_USERNAME |
SMTP username. When absent, the transport is created without auth. |
MAIL_PASSWORD |
SMTP password. |
MAIL_ENCRYPTION |
Accepted as a configuration key, but TLS mode is derived from the port alone and this value is not read. |
MAIL_SENDER_ADDRESS |
From address on every message. |
MAIL_SENDER_NAME |
Default From display name (defaults to Genie). Feedback Center overrides it per-message with genie administrator. |
FRONTEND_ORIGIN / APP_URL |
Base for the deep links in every template (FRONTEND_ORIGIN first, then APP_URL). |
Mail is fire-and-forget and never throws to the caller — a failure is logged and the user action that triggered it still succeeds.
Four events send mail:
| Event | Triggering endpoint | Recipients |
|---|---|---|
| New suggestion / issue / support ticket | POST /api/issue/feedback/save |
All active users holding assign_user_roles.role_id = 10 (the Genie team). No mail if that set is empty. |
| Suggestion / issue reply | POST /api/issue/feedback/replay/send |
To: the ticket creator; Bcc: the other subscribers. Sent only when the ticket has at least one subscriber. Attributed to "Anonymous" when customSwitch = 1. |
| Support chat reply | POST /api/support/chat/reply/save |
The Genie team when the replier is not role 10; the ticket creator when they are. No mail if there is no recipient. |
| Status change | POST /api/issue/save/stauts with btnType = "status" |
The ticket creator, with the previous and new status labels. Not sent on a classification-only or label-only update. |
Deep links point at /issue/list/detail/<encrypted id> for suggestions and issues, and /support/feedback/chat/<encrypted id> for type = 3 tickets. All HTML bodies are sanitised: paste artefacts (background, font-family, font-size, -webkit-text-fill-color, <font> tags) are stripped and near-white text colours are rewritten to a readable dark value — otherwise content pasted from a dark editor is invisible in both the ticket bubble and the e-mail.
17. Deployment & Operational Considerations
This section collects the behaviours that affect how the Genie API is deployed, scaled and operated. Each item is stated in terms of its impact on a deployment rather than as an internal implementation note.
| Area | Consideration | Impact on the deployment |
|---|---|---|
| Sessions | Sessions are stored in the database, not in application memory, and every instance reads the same store. | No session affinity is required for normal API traffic. The sessions table must exist in the schema before the service starts; it is not created automatically. |
| Two-step export downloads | POST .../export builds a file, holds it in temporary server-side storage on the instance that produced it, and returns a single-use token; GET .../export/generated/:token then streams it. Tokens are single use and do not survive a restart of that instance. |
The download request must reach the same application instance that created the export. Multi-instance deployments must preserve instance affinity for these routes, or the download returns "not found" even immediately after a successful export. Affects the export/download pairs in Admin, Client Success, Monthly Close, Projects, Reports Builder and Invoicing. |
| Token-only download routes | On several of the two-step download routes the single-use token is the only credential presented; no session is read. | Treat these URLs as bearer credentials: they should not be logged, forwarded or shared, and they stop working after first use. |
| QuickBooks Online OAuth | The state value issued by GET /api/qbo/connect (or GET /api/company/qbo/connect/:id) is single use and valid for 15 minutes, and is held in temporary server-side storage on the issuing instance. |
The Intuit callback (GET /api/qbo/callback) must be served by the instance that issued the consent URL. Load balancing must preserve that continuity for the OAuth routes. |
| Google Drive / Sheets OAuth | The Drive connection flow depends on continuity between the authorization request and the callback, which carries the caller's session. | Preserve request continuity between GET /api/google/drive/auth-url and the GET /google/drive/callback completion. |
| Temporary processing files | Company logos, generated import templates and generated export workbooks are written to temporary server-side storage during the request workflow. | Deployment architecture should allow for temporary local storage per instance, and should not assume such files are visible to other instances or survive a restart. |
| Sign-in throttling | Failed sign-in attempts are counted per instance (6 attempts, 60-second decay, keyed on email and client IP). | With several instances behind a load balancer the effective allowance scales with the number of instances. Deployments that require a strict global limit should enforce it at the edge. |
| Scheduled work | Task recurrence and invoice recurrence generation run on a schedule inside the application process rather than as a separate worker, and are not exposed as HTTP endpoints. | Deployment planning should account for scheduled work running inside each application instance. |
| QuickBooks sync dispatch | Outside local environments, company sync is dispatched to an external sync function rather than executed in the request. | The sync function endpoint and token must be reachable from the deployment (LAMBDA_SYNC, LAMBDA_TOKEN_URL). Sync status is reported through the Admin QBO Sync Status endpoints. |
| Request size | Bodies up to 20 MB are accepted for both JSON and URL-encoded content; larger bodies return 413. |
Any reverse proxy, gateway or CDN in front of the service must permit request bodies of the same size, otherwise large Report Writer saves fail before reaching the API. |
| TLS termination and cookies | The service trusts proxy headers so that session cookies are marked Secure when the deployment terminates TLS upstream. |
Run the service behind an HTTPS-terminating proxy and forward the standard X-Forwarded-* headers, or session cookies will not be issued as secure. |
| Browser origin | Exactly one browser origin is permitted for direct cross-origin calls, configured as FRONTEND_ORIGIN, with credentials enabled. |
Browser clients should call through the web application's own paths (§3.1). A second origin requires a configuration change. |
| Health checks | GET / and GET /health both verify database connectivity and return 503 when it fails, while the process itself stays up. |
Use either path for liveness and readiness probes. A 503 from these endpoints indicates a database-connectivity problem rather than a failed process. |
18. Appendix A — Complete Endpoint Index
Every endpoint exposed by the Genie API — 476 documented entries covering 475 distinct method-and-path combinations — grouped by section and subsection. Each path links to its full entry. Auth indicates whether an authenticated session cookie is required: Yes, No, or Token where a single-use token in the URL is the only credential.
One path, POST /api/task/firm, is documented in two places because it is shared by two screens; the canonical entry is identified in both.
A.1 Authentication, Session & Core
25 endpoints — full entries in 8. Authentication, Session & Core.
8.3 Health & CSRF
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/ |
Backend liveness ping used by hosting/uptime checks. Verifies the database connection as well as the process. | No |
GET |
/health |
Explicit health endpoint. Returns the same payload and behaviour as GET /, for platforms that require a named health path. | No |
GET |
/csrf |
Issues (or returns the existing) synchronizer CSRF token for this session and echoes it in a JS-readable cookie. Must be called before any… | No |
8.4 Credential Login & Password Recovery
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/login |
Verifies email and password and establishes the pre-2FA session. | No |
GET |
/login/check |
The post-credential branch point: tells the client whether the user must select a 2FA method, enrol in Google Authenticator, enter an emailed OTP, or… | Yes |
POST |
/password/email |
Requests a password-reset link for an email address. | No |
POST |
/password/reset |
Consumes a reset token, sets the new password, and signs the user straight in. | No |
8.5 Two-Factor Verification
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/login/select-2fa |
For users whose auth_type is still 0, records their chosen second factor and returns everything the verify screen needs to continue. | Yes |
POST |
/login/verify/googleotp |
Verifies a Google Authenticator TOTP code and completes login. | Yes |
POST |
/login/verify/otp |
Verifies the emailed OTP (for auth_type 2 and 3) and completes login. | Yes |
POST |
/login/verify/resend |
Issues a fresh emailed OTP and restarts the expiry clock. | Yes |
8.6 Sign in with Google
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/google/redirect |
Starts the Google OAuth flow by redirecting the browser to Google's consent screen. | No |
GET |
/google/callback |
Google's OAuth redirect target. Exchanges the authorization code, then logs the user in, auto-links the Google account to a matching Genie account,… | No |
GET |
/google/signup |
Validates the linking code from GET /google/callback against the 1-minute google cookie and returns the Google email for the "One Last Time" linking… | No |
POST |
/google/signup |
Completes account linking: verifies the user's existing Genie email + password, attaches the Google account, and signs them in. | No |
8.7 Logout
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/logout |
Tears down the authenticated session for browser clients. | No |
8.8 Session State & Company Selection
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/check/session |
The bootstrap call every screen makes at mount. Validates the session and returns the signed-in user, their roles, their per-company menu access map,… | Yes |
GET |
/api/company/session |
Reads the shared company selection and as-on period out of the session, so a fresh browser or a second tab starts on the same company. | Yes |
POST |
/api/company/session |
Changes the company every screen is scoped to. This is the single shared switch point — picking a company on Reports also changes it on Client… | Yes |
POST |
/api/company/session/period |
Changes only the as-on month/year, keeping the selected company. Backs Monthly Close's month/year selects. | Yes |
8.9 Notifications
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/user-based/notification |
Returns the 10 most recent notifications for the signed-in user plus a total count — the header bell feed. | Yes |
GET |
/api/user-based/notification/all |
Returns the complete notification list for the signed-in user — the "See All" screen. | Yes |
8.10 Company & Group Tree
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/get/web-treeview |
Returns the company/group hierarchy behind the shared "Select company/group" picker, plus flat lists of every visible group and company. | Yes |
8.11 Role Map Diagnostics
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/utilities/configure/debug/role-map-compare |
Read-only dry run that compares what the role-map engine would derive for a set of companies against what the database currently holds. Used to… | Yes |
POST |
/api/utilities/configure/debug/role-map-inputs |
Read-only diagnostic dump of the raw inputs the role-map engine reads for a single company — useful when role-map-compare reports a mismatch and you… | Yes |
A.2 Admin
71 endpoints — full entries in 9. Admin.
9.1 Company
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/company/list |
Returns the company dropdown for the signed-in user, scoped by their role and by their access to the requested module. | Yes |
GET |
/api/firm/list |
Returns the "Account" (firm) dropdown shown above the Company dropdown for a Global Master System Administrator, who has no firm of their own to… | Yes |
POST |
/api/firm/company/list |
Returns the Company dropdown for one specific firm — the second step after a Global Master System Administrator picks an Account from GET… | Yes |
POST |
/api/company/view_all |
The Admin > Company list: a paged, searchable company table plus every page-level flag and dropdown source the screen needs. | Yes |
POST |
/api/company/search |
Search variant of the company list. Identical contract and behaviour to POST /api/company/view_all. | Yes |
POST |
/api/company/newcompany |
Creates a company, seeds its accounting defaults and service roles, attaches it to a company group, and makes it the session's selected company. | Yes |
GET |
/api/company/check_company_exists |
Live duplicate check for the company-name field in the add form. | Yes |
POST |
/api/company/import/template/download |
Builds the bulk-import Excel template for a firm and returns a one-shot download link. | Yes |
POST |
/api/company/import/template |
Uploads a filled-in import workbook, validates every row, and queues the valid ones for background company creation. | Yes |
GET |
/api/company/import/queue/status |
Reports whether a bulk company-import job is currently running for the caller. | Yes |
GET |
/api/company/import/template/generated/:token |
Streams a previously generated import template workbook and then deletes it. | Token |
POST |
/api/company/redirect |
Called on every company-row click: pins the company into the session, touches its "Last used" timestamp, and returns the edit URL plus registration… | Yes |
POST |
/api/company/delete |
Reactivates, deactivates, or soft-deletes a company. | Yes |
GET |
/api/company/details/:id |
Loads the company edit screen: core details, billing information, the edit permission flag and the QuickBooks reconnection warning. | Yes |
POST |
/api/company/save |
Saves the company edit form (name, domain, budget flag). | Yes |
POST |
/api/company/logo/upload |
Uploads a company logo image. | Yes |
POST |
/api/company/logo/remove |
Removes a company's logo file and clears company.logo. | Yes |
GET |
/api/company/logo/:id |
Streams a company's logo image. | Yes |
POST |
/api/company/billInfo/save |
Upserts the company's billing contact block. | Yes |
GET |
/api/company/defineGroups/:id |
Loads the "Define Reporting Groups" screen for a company: the global groups, the company's custom groups and the Name/Number mode. | Yes |
POST |
/api/company/defineGroups |
Creates a new reporting group — either a global one or a company-specific custom one. | Yes |
POST |
/api/company/defineGroups/edit |
Renames an existing reporting group. | Yes |
DELETE |
/api/company/defineGroups |
Soft-deletes a reporting group, unless it is still referenced by Report Writer. | Yes |
POST |
/api/company/defineGroups/type/edit |
Switches a company between mapping its chart of accounts by account Name and by account Number. | Yes |
POST |
/api/company/mapping/get |
Loads the chart-of-accounts to reporting-group mapping grid for one statement, in either by-name or by-number mode. | Yes |
POST |
/api/company/mapping/save |
Saves the chart-of-accounts mapping for one statement and one mapping mode. | Yes |
POST |
/api/company/industry/get |
Loads the Industry Verticals and Business Model Verticals pickers for a company. | Yes |
POST |
/api/company/industry/save |
Replaces a company's selected verticals for one vertical type. | Yes |
9.2 Company Groups
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/company-group/list |
Returns the company-group tree, optionally filtered by a search term, plus the tree's action flags. | Yes |
POST |
/api/company-group |
Creates a company group. | Yes |
GET |
/api/company-group/:id |
Loads the company-group detail screen: the group, its parent options, child-group and company checklists, the users with access, and the placement… | Yes |
POST |
/api/company-group/details/save |
Saves the company-group detail form: name, parent, child groups, member companies and the Manager select. | Yes |
POST |
/api/company-group/placement |
Renders a preview of where a group and its selected children/companies would sit in the tree, using unsaved checkbox state. | Yes |
POST |
/api/company-group/user-access/load |
Loads the per-group user-access editor: the assignable users, the module matrix, and the selected user's current grants. | Yes |
POST |
/api/company-group/user-access/save |
Grants or updates one user's access within a company group, in either group-wide or per-company mode. | Yes |
DELETE |
/api/company-group/user-access |
Revokes a user's access to a company group. | Yes |
DELETE |
/api/company-group |
Deletes a company group and its whole descendant subtree. | Yes |
POST |
/api/company-group/firm-accounting-managers |
Loads the "Manage Access to Internal Firm Accounting" screen for the caller's firm. | Yes |
POST |
/api/company-group/save-firm-accounting-managers |
Assigns the Firm Internal Accounting Manager role on the firm's internal company and rebuilds the corresponding internal group and access fan-out. | Yes |
DELETE |
/api/company-group/company |
Detaches one company from one company group, without touching the group. | Yes |
9.3 Users
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/user/list |
Returns the Admin > Users list (active users) together with every page-level flag and dropdown the screen needs. | Yes |
GET |
/api/user/module/access |
Returns the Permission Exclusion configuration — the module_permission rows grouped by module. | Yes |
POST |
/api/user/global |
Switches the session out of any selected company and into "Global" scope. | Yes |
POST |
/api/user/search |
Filtered/sorted user list. Returns the same envelope as GET /api/user/list minus allRoles. | Yes |
POST |
/api/user/add_new |
Creates a user, assigns roles, and emails them a create-password link. | Yes |
POST |
/api/user/save/active |
Applies a status change to a user: activate, deactivate, or delete. | Yes |
POST |
/api/user/delete |
Finalizes the User Check workflow — the "In Active" / "Delete" button that appears once every dependency count reaches zero. | Yes |
POST |
/api/user/check |
Loads the "User Check" screen: every open dependency the user still holds, grouped into sections, with the alternatives available for reassignment. | Yes |
POST |
/api/user/check/update |
Applies one section's reassignments from the User Check screen and returns the refreshed counts. | Yes |
POST |
/api/subscription/user/switch |
Impersonates another user ("Login as this user"): rewrites the session to that user and pins their first accessible company. | Yes |
GET |
/api/user/details/:id |
Loads the user detail screen: profile, roles, module/company access, multi-inbox emails, and the flags that lock parts of the form. | Yes |
GET |
/api/user/details/:id/:type |
Same as GET /api/user/details/:id, but flags the request as the "profile" variant. | Yes |
POST |
/api/user/details/save |
Saves the user detail form: names, emails, phone, profile path and global role assignments. | Yes |
POST |
/api/user/details/auth/save |
Sets the user's two-factor authentication method and, for Google Authenticator, returns the enrolment secret and QR code. | Yes |
POST |
/api/user/save_new_access |
Grants a user access to a company or a company group from the "New Company/Group Access" modal. | Yes |
POST |
/api/user/new_access |
Loads the data behind the "New Company/Group Access" modal: the selectable tree, the module list and the plan-derived menu sets. | Yes |
POST |
/api/user/details/access/save |
Saves edits to a user's existing company and group access grids from the detail screen. | Yes |
POST |
/api/user/batch/access/save |
Applies one access matrix to many groups and companies at once ("batch access"). | Yes |
POST |
/api/user/details/profile/save |
Uploads a profile picture and returns it as an inline data URL. | Yes |
9.4 Reporting Groups
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/reporting-group/list |
Returns the three reporting-group tabs (firm, cross-firm, multi-entity) for the reporting-groups landing screen. | Yes |
POST |
/api/reporting-group/add |
Creates a reporting group. | Yes |
POST |
/api/reporting-group/search |
Type-scoped search across reporting groups. | Yes |
POST |
/api/reporting-group/delete |
Soft-deletes a reporting group. | Yes |
POST |
/api/reporting-group/clone |
Duplicates an existing reporting group under a new name. | Yes |
GET |
/api/reporting-group/:id |
Loads the reporting-group editor: the group, the company-group dropdown, the candidate companies and the currently selected ones. | Yes |
POST |
/api/reporting-group/company/group |
Returns the companies available under a company group; the reporting-group editor calls it whenever the company-group dropdown changes. | Yes |
POST |
/api/reporting-group/company/save |
Saves the reporting-group editor: renames the group, re-points its company group, and syncs its member companies. | Yes |
9.5 Logs (Task Audit / Activity Log)
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/logs/filters |
Returns everything the log page's filter bar needs: the user dropdown and the company/company-group lists. | Yes |
POST |
/api/logs/filter |
Returns the filtered activity-log rows. | Yes |
POST |
/api/logs/details |
Returns the module-access before/after detail for one log row. | Yes |
9.6 QBO Sync Status
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/qbo/sync/history |
Returns the sync-history board: every company, every client, and the Lambda sync runs their histories reference. | Yes |
A.3 Client Success
41 endpoints — full entries in 10. Client Success.
10.1 Client Info — 10.1.1 Company Info Configuration
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/cs/company-info/:type |
Lists the Company Info field definitions for either the global or the firm scope, together with whether the current user may edit them. | Yes |
PUT |
/api/cs/company-info |
Creates a new Company Info field definition in the global or firm scope. | Yes |
PATCH |
/api/cs/company-info/:id |
Updates an existing Company Info field definition. | Yes |
DELETE |
/api/cs/company-info/:id |
Soft-deletes a Company Info field definition. | Yes |
POST |
/api/cs/company-info/change/order |
Persists a drag-and-drop reordering of the Company Info field definitions. | Yes |
10.1 Client Info — 10.1.2 Client Contacts Configuration
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/cs/company-contacts/:type |
Lists the Client Contacts column definitions for the global or firm scope. | Yes |
PUT |
/api/cs/company-contacts |
Creates a new Client Contacts column definition. | Yes |
PATCH |
/api/cs/company-contacts/:id |
Updates a Client Contacts column definition, unless it is a static system column. | Yes |
DELETE |
/api/cs/company-contacts/:id |
Soft-deletes a Client Contacts column definition, unless it is static. | Yes |
POST |
/api/cs/company-contacts/change/order |
Persists a reordering of the Client Contacts column definitions. | Yes |
10.1 Client Info — 10.1.3 Company Data
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/cs/company-info/details |
Returns one company's Company Info tab: every field definition in the company's scope, joined to that company's stored value. | Yes |
POST |
/api/cs/company-info/details-save |
Saves one Company Info value for one company — an upsert into company_info_details. | Yes |
POST |
/api/cs/company-contacts/details |
Returns one company's Client Contacts grid: the column definitions plus the row/value matrix, along with the company's email-capture flag. | Yes |
POST |
/api/cs/company-contacts/details-save |
Saves one cell of the Client Contacts grid — an upsert keyed on (field, company, row order). | Yes |
POST |
/api/cs/company-contacts/deleteDetail |
Deletes an entire contact row (all columns at one row-order position) for a company. | Yes |
POST |
/api/cs/company-contacts |
Toggles the company-level email-capture flag (company.email_capture). | Yes |
10.1 Client Info — 10.1.4 Template Download and Import
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/cs/company-info/download/file |
Generates an Excel import template for one of four Client Info models and returns a single-use download link. | Yes |
POST |
/api/cs/company-info/import/file |
Uploads a filled-in Excel template and imports its rows. | Yes |
GET |
/api/cs/company-info/download/generated/:token |
Streams a previously generated template or error workbook, then deletes it. | Token |
10.2 Configure — 10.2.1 Service Roles
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/utilities/configure/get |
Loads the Client Success Configure screen for either the Global or the Firm tab: the service role list with pricing and flags, the service lines, and… | Yes |
POST |
/api/utilities/configure/save |
Creates a new client service role. | Yes |
POST |
/api/utilities/configure/update |
Multi-purpose role/setting update. The colName field selects one of three distinct behaviours: the firm-level Service Line Approver master switch,… | Yes |
POST |
/api/utilities/configure/delete |
Changes a service role's activation state — restore, inactivate, or delete. Inactivating a role that is still assigned returns the list of companies… | Yes |
POST |
/api/utilities/service/role/update |
Persists a drag-and-drop reordering of the service role list. | Yes |
POST |
/api/utilities/configure/firm/update |
Toggles "Use Firm Settings" (firms.use_service_role). Turning it on clones the global configuration into the firm and queues a role-map rebuild. | Yes |
POST |
/api/utilities/configure/getRolesAndCompany |
For a role being retired: lists the alternate roles available and the companies currently using it. Step one of the reassignment flow. | Yes |
POST |
/api/utilities/configure/updateAlterRoles |
Reassigns every company using a role to a chosen alternate, repoints tasks to a budget role, and inactivates the original. Step two of the… | Yes |
10.2 Configure — 10.2.2 Client Info Start Date
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/utilities/firm-configure |
Reads the effective Start Date values for one company: its custom value and the global default. | Yes |
POST |
/api/utilities/firm-configure/update |
Saves the Start Date custom value and/or the global default. | Yes |
10.2 Configure — 10.2.3 Service Team
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/utilities/configure/get-roles |
Loads the whole Service Team panel for one company: the role catalogue, the assigned team, selectable users, service lines, override state and the… | Yes |
POST |
/api/utilities/configure/save-roles |
Assigns a user to a service role for a company, or updates the company's role overrides. Which of the two it does is selected by override. | Yes |
POST |
/api/utilities/configure/save-roles-only |
Adds a service role to a company's Service Team without assigning anybody — the row is created (or reset) with user_id = NULL. | Yes |
POST |
/api/utilities/configure/remove-roles |
Removes the user from a Service Team row, leaving the role in place. | Yes |
POST |
/api/utilities/configure/save-role-price |
Sets the hourly rate on one Service Team row. | Yes |
POST |
/api/utilities/configure/override-default-price |
Toggles a company's "override default pricing" flag, and optionally restores every one of its Service Team rows back to the role default rates. | Yes |
POST |
/api/utilities/configure/save-sl-invoice-approver |
Saves one of the two per-company Service Line Invoice Approver checkboxes — the master switch, or one specific service line. | Yes |
10.2 Configure — 10.2.4 Client Tags
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/utilities/configure/tags/get |
Lists the firm's active client tags. | Yes |
POST |
/api/utilities/configure/tags/save |
Saves the firm's tag list: creates new names, revives previously deleted ones, and soft-deletes any active tag not present in the payload. | Yes |
POST |
/api/utilities/configure/save/tags |
Compatibility route: retained for compatibility with earlier clients. POST /api/utilities/configure/tags/save is the canonical route; behaviour is… | Yes |
POST |
/api/utilities/configure/tags/update |
Renames a single tag by id, preserving its task associations. | Yes |
POST |
/api/utilities/configure/tags/delete |
Soft-deletes a single tag by id. | Yes |
A.4 Tasks & Time
184 endpoints — full entries in 11. Tasks & Time.
11.1 Tasks
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/task/filter-options |
Returns every dropdown option set the Task List filter panel and the Add/Edit Task form need, scoped to the caller's firm and company access. | Yes |
GET |
/api/task/multi-task/list |
Returns a flat { roleOptions, userOptions } pair for the Reports Builder preview's Role/User global-filter dropdowns. | Yes |
POST |
/api/task/company/projects |
Returns the assignable roles/users and the project list for one company — the payload the Add/Edit Task form loads after a company is picked. | Yes |
POST |
/api/task/get-assigned-name |
Resolves the single user who should be auto-assigned for a given service role at a given company. | Yes |
POST |
/api/task/company/authUserRoles |
Given a company and a set of candidate service roles, returns the role/user pair to pre-select when exactly one role matches. | Yes |
POST |
/api/task/bulk/fetch/clients |
Lists the company/project pairs eligible for a bulk task creation run, optionally narrowed by project, service line, invoice detail or invoicing type. | Yes |
POST |
/api/task/bulk/create/tasks |
Creates one task per selected client/project from a task template, in a single call. | Yes |
PUT |
/api/task |
Creates a single task. | Yes |
PATCH |
/api/task/:id |
Updates an existing task using the same field set as create. | Yes |
POST |
/api/task/get-task |
Loads the full Task Details payload for one task: field values, resolved dropdown labels, steps, resources, recurrence, latest time history, the… | Yes |
POST |
/api/task/clone |
Creates a copy of an existing task, including its steps and resources, under a new name. | Yes |
POST |
/api/task/makeToTemplate |
Converts an existing task into a reusable task template, carrying over its settings, steps and recurrence. | Yes |
GET |
/api/task/:id/time-history |
Returns the caller's most recent time-history row for a task — the timer state the Task Details view resumes from. | Yes |
PUT |
/api/task/time-history |
Creates (or updates, when id is supplied) a time-history row for a task — used when the timer is stopped. | Yes |
POST |
/api/task/time-history/update |
Updates only the end time of an existing time-history row. | Yes |
POST |
/api/task/task-history |
Returns the per-entry time log for a task: who logged time, when, how much was captured and how much was reported. | Yes |
POST |
/api/task/time/disposition |
Records the outcome of working a task — Complete / Skip / Defer / Reassign, or Reopen — together with the time captured and its… | Yes |
POST |
/api/task/recurrence/all |
Creates or updates the recurrence rule attached to a task. | Yes |
PATCH |
/api/task/update/status/:id |
Changes a task's service-line status (the workflow status, not the disposition). | Yes |
PATCH |
/api/task/time-notes/:id |
Replaces a task's time notes. | Yes |
GET |
/api/task/:id/step/list |
Lists the checklist steps attached to a task, in order. | Yes |
POST |
/api/task/step/all |
Saves the full set of checklist steps for a task in one call — updating existing rows and inserting new ones. | Yes |
DELETE |
/api/task/step/:id |
Deletes one checklist step. | Yes |
GET |
/api/task/:id/resource/list |
Lists the reference links (resources) attached to a task. | Yes |
POST |
/api/task/resource/all |
Saves the full set of resources for a task. | Yes |
DELETE |
/api/task/resource/:id |
Deletes one task resource. | Yes |
POST |
/api/task/list |
The main Task List query: filtered, sorted and paginated tasks for the caller's company scope. | Yes |
POST |
/api/task/list/export-excel |
Exports the full filtered Task List as an Excel workbook, returned as a base64 data: URI. | Yes |
POST |
/api/task/list/export-google-sheet |
Builds the same Task List workbook and uploads it straight into a chosen Google Drive folder, where Drive converts it to a native Sheet. | Yes |
POST |
/api/task/multi-task/update |
Applies one or more field changes to a set of tasks at once (the Multi-Edit grid action). | Yes |
POST |
/api/task/multi-task/delete |
Soft-deletes a set of tasks. | Yes |
DELETE |
/api/task/:id |
Deletes a single task. | Yes |
POST |
/api/task/multi-task/skip |
Marks a set of tasks as skipped. | Yes |
POST |
/api/task/home/list |
Returns the Home dashboard's saved view definition together with the task list rendered through it. | Yes |
GET |
/api/task/getOpenTask/list |
Returns the caller's currently open tasks with their captured time, for the timer/open-task widget. | Yes |
POST |
/api/task/audit/list |
The Task Audit Log: paginated, filtered audit rows, optionally accompanied by the dropdown option sets the filter bar needs. | Yes |
GET |
/api/task/company/resource/:companyId |
Lists the Client Info company resources (shared reference links) for a company. | Yes |
POST |
/api/task/company/resource/all |
Saves the full set of Client Info company resources for a company. | Yes |
DELETE |
/api/task/company/resource/:id |
Deletes one Client Info company resource. | Yes |
11.2 Task Board
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/board/view |
Returns the boards visible to the caller, optionally together with the task list rendered through the default board. | Yes |
POST |
/api/task/board/addEditBoard |
Creates a new board or replaces an existing one, including its full column list. | Yes |
POST |
/api/task/board/removeBoard |
Soft-deletes a board and its columns. | Yes |
POST |
/api/task/board/default |
Sets — or clears — the caller's personal default board. | Yes |
POST |
/api/task/board/updatePin |
Pins or unpins a board for the calling user. | Yes |
POST |
/api/task/board/reorderBoards |
Re-orders boards to match the submitted sequence. | Yes |
11.3 Calendar
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/calendar |
Returns the calendar grid for a date range: tasks grouped by user and day, plus the user picker and the caller's permission to view other users. | Yes |
POST |
/api/task/calendar/position |
Applies a drag: reschedules a task to a new day, optionally reassigns it to another user, and persists the per-day ordering of the affected columns. | Yes |
11.4 Projects
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/project/based/data |
Returns the service line, billing, invoicing and fixed-fee facts for one project — used to auto-fill the Add/Edit Task form once a project is chosen. | Yes |
POST |
/api/task/project/getProjectData |
The Projects grid query: a paginated, filtered project list plus the dropdown option sets the page needs. | Yes |
PUT |
/api/task/project |
Creates a project. | Yes |
PATCH |
/api/task/project/:id |
Updates a project. | Yes |
DELETE |
/api/task/project/:id |
Soft-deletes a project, appending (deleted) to its name. | Yes |
POST |
/api/task/project/active/status |
Activates or inactivates a project. | Yes |
POST |
/api/task/project/comment |
Returns a project's per-period comments, guaranteeing an entry exists for the current month. | Yes |
POST |
/api/task/project/comment/save |
Saves a project's per-period comments. | Yes |
POST |
/api/task/project/download/file |
Generates the Project Import Excel template and returns a one-time download link. | Yes |
POST |
/api/task/project/import/file |
Uploads a filled-in Project Import template, validates every row, imports the valid ones and reports the rest. | Yes |
POST |
/api/task/project/import/save |
Saves a set of reviewed/corrected import rows without re-uploading a file. | Yes |
GET |
/api/task/project/download/generated/:token |
Streams a previously generated project template or import-error workbook. | Token |
POST |
/api/task/project/budget/default |
Returns the firm's default budget configuration data: service roles with their default percentages, service lines, and the projects the defaults can… | Yes |
POST |
/api/task/project/budget/servicerole |
Returns the Primary Budget grid for one project: its monthly budget/minimum plus the per-service-role price, default percentage, custom percentage… | Yes |
POST |
/api/task/project/budget/override |
Returns the per-role override grid (custom percentage and monthly hours) for one project. | Yes |
POST |
/api/task/project/save/budget |
Saves a project's primary monthly budget and its per-service-role allocation, transactionally. | Yes |
POST |
/api/task/project/budget/save/servicerole |
Saves the firm's default budget allocation, or one of the two per-project override variants — the branch is chosen by type. | Yes |
POST |
/api/task/project/budget/validate |
Checks that every service role referenced by a budget allocation has a usable Price Per Hour before the allocation is applied. | Yes |
POST |
/api/task/project/budget/error/export |
Turns a list of budget-apply error messages into a downloadable CSV. | Yes |
11.5 Task & Time Templates
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/template/groupList |
Lists template groups for the Global or Firm Templates tab, with optional service-line / task-type | Yes |
GET |
/api/task/template/group/included/:id |
Returns one template group together with the templates included in it — the "included templates" panel. | Yes |
GET |
/api/task/template/edit/group/:id |
Backs the Edit Template Group page: the group plus both sides of the template picker (already selected | Yes |
GET |
/api/task/template/group/based/template/:id |
Returns a template group and the templates it contains — the read-only "apply this group" view used when | Yes |
PUT |
/api/task/template/groupList |
Creates a template group (or updates one when id is supplied in the body), including its template | Yes |
PATCH |
/api/task/template/groupList/:id |
Updates an existing template group. Identical to the PUT above except the id comes from the path. | Yes |
DELETE |
/api/task/template/groupList/:id |
Soft-deletes a template group. | Yes |
POST |
/api/task/template/cloneTemplateGroup |
Clones an existing template group under a new name, optionally re-selecting which templates it contains. | Yes |
POST |
/api/task/template/templateList |
Lists individual templates for the Global or Firm Templates tab, with the same scoping and filtering | Yes |
PUT |
/api/task/template/templateList |
Creates a template (or updates one when id is supplied). | Yes |
PATCH |
/api/task/template/templateList/:id |
Updates an existing template; the id comes from the path. | Yes |
DELETE |
/api/task/template/templateList/:id |
Soft-deletes a template, its settings and its group mappings — with a two-step confirmation when the | Yes |
POST |
/api/task/template/cloneTemplate |
Clones a single template (and its settings) under a new name. | Yes |
POST |
/api/task/template/template-setting |
Loads the Template Settings editor: the saved settings for a template plus every dropdown's option set. | Yes |
PUT |
/api/task/template/template-setting |
Saves the Template Settings editor: the settings row, its steps and its recurrence. | Yes |
POST |
/api/task/template/template-setting/service-line/options |
Returns the task-type and task-status option lists for one service line — the dependent-dropdown refresh | Yes |
POST |
/api/task/template/removeStep |
Soft-deletes one step from a template, addressed by its ordinal position. | Yes |
POST |
/api/task/template/clone |
The "Use Firm Task/Project Templates" first-time switch: copies the whole global template catalogue into | Yes |
POST |
/api/task/template/firm/checkBoxData |
Returns the firm's template usage flags and whether the firm already has task/project data — the state the | Yes |
POST |
/api/task/firm |
Flips the firm's template usage flags. This path is also documented in the Configure subsection, and that entry is the canonical one — see the note… (duplicate path) | Yes |
POST |
/api/task/template/from/group |
Creates real tasks from a template group's templates — the "apply template group" action. Takes a raw | Yes |
11.6 Configure
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/configs/serviceline/group |
Loads the whole Tasks & Time Configure page in one call: priorities, billing types, service lines with | Yes |
POST |
/api/task/configs/clone/status |
Reports whether the firm is set to use its own configuration and whether the global-to-firm clone has | Yes |
POST |
/api/task/configs/serviceline/clone |
Copies the global priorities, service lines, task types and statuses into the firm — the "Use Firm | Yes |
POST |
/api/task/configs/set/priority/high |
Marks one priority as the firm's (or the global) "high priority", clearing the flag on all others in the | Yes |
POST |
/api/task/configs/priority/inactive |
Inactivates a priority after repointing everything that used it to a replacement priority. | Yes |
POST |
/api/task/config/service-line/inactive |
Inactivates a service line, repointing tasks to a replacement service line, task type and task status. | Yes |
POST |
/api/task/type/inactive |
Inactivates a task type after repointing everything that used it. | Yes |
POST |
/api/task/change/priority/order |
Persists the drag-and-drop ordering of priorities. | Yes |
POST |
/api/task/config/change/order |
Persists the drag-and-drop ordering of service lines, task types or statuses, selected by from. | Yes |
POST |
/api/task/home/order |
Replaces the firm's Home View column list and ordering, creating the Home View board if it does not exist. | Yes |
POST |
/api/task/config/save |
Adds a configuration value. Only colName = "priority" is implemented. | Yes |
PUT |
/api/task/configs/serviceline |
Creates or updates a service line. | Yes |
PUT |
/api/task/type |
Creates or updates a task type. | Yes |
PATCH |
/api/task/type/:id |
Updates a task type; the id comes from the path. | Yes |
DELETE |
/api/task/type/:id/:status |
Deletes or (in)activates a task type, depending on the status segment. | Yes |
PUT |
/api/task/status |
Creates or updates a task status under a service line. | Yes |
PATCH |
/api/task/status/:id |
Updates a task status; the id comes from the path. | Yes |
DELETE |
/api/task/status/:id |
Soft-deletes a task status. | Yes |
POST |
/api/task/manage/status |
Renames a task status. | Yes |
POST |
/api/task/status/inactive |
Inactivates a task status after repointing tasks to a replacement status. | Yes |
DELETE |
/api/task/configs/serviceline/:id/:deleted |
Deletes or (in)activates a service line, depending on the deleted segment. | Yes |
POST |
/api/task/config/update |
Renames a configuration value. Only colName = "priority" is implemented. | Yes |
POST |
/api/task/config/delete |
Deletes or (in)activates a configuration value. Only colName = "priority" is implemented. | Yes |
POST |
/api/task/update-alternate-type |
Repoints one task's lookup columns after a configuration value it referenced was inactivated — the | Yes |
POST |
/api/task/firm |
Flips a firm-level usage flag (use_task / use_project / use_service_line), optionally recording that the configuration clone has been done. This is… | Yes |
POST |
/api/task/utilization/config |
Loads the Utilization configuration tab: the firm's service-role targets, its users' overrides, and the | Yes |
POST |
/api/task/utilization/config/save |
Saves one utilization row — either a role-level target or a user-level override. | Yes |
11.7 Reports (Reports Builder)
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/tat/reports/list |
Returns every report the caller can see for one module, bucketed by placement, plus the user list used by | Yes |
GET |
/api/tat/reports/profitability/access |
Tells the client whether the current user may see profitability reports. | Yes |
POST |
/api/tat/reports/update |
Favourites, un-favourites or deletes a report from the list screen. | Yes |
POST |
/api/tat/reports/clone |
Copies a report — its list row and all of its column details — under a new name and placement. | Yes |
POST |
/api/tat/reports/share |
Shares a report with a set of users in the caller's firm. | Yes |
POST |
/api/tat/reports/update/order |
Persists drag-and-drop ordering within one bucket of the report list. | Yes |
POST |
/api/tat/reports/details |
Loads the report editor: the report's saved columns and row groups, plus the full catalogue of columns | Yes |
POST |
/api/tat/reports/getcolumn |
Returns the default value sets used to populate filter dropdowns in the report editor. | Yes |
POST |
/api/tat/reports/getdate |
Returns the report's reference date column and the date window the preview should open with. | Yes |
POST |
/api/tat/reports/save |
Creates or updates a report definition — the list row, its row groups and its full column-detail set. | Yes |
POST |
/api/tat/reports/preview |
Runs a saved report and returns rows, row-group summaries and totals — the main reporting call. | Yes |
POST |
/api/tat/reports/make/export |
Generates an XLSX workbook for a report and returns a one-time download link. | Yes |
GET |
/api/tat/reports/export/generated/:token |
Streams a previously generated export workbook. One-time, session-bound. | Yes |
POST |
/api/tat/reports/standard/report/dropdown |
Returns the option lists for the Custom tab's pre-built ("standard") report filters. | Yes |
POST |
/api/tat/reports/standard/report/preview |
Runs one of the four pre-built standard reports. | Yes |
POST |
/api/tat/reports/standard/report/export |
Generates an XLSX workbook for one of the standard reports and returns a one-time download link. | Yes |
11.8 Invoicing — 11.8.1 Create Invoice
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/invoicing/fetch/dropdown |
Returns every dropdown/reference list the Create Invoice and Ad Hoc Invoice screens need in one payload — companies, company groups, projects,… | Yes |
POST |
/api/invoicing/batch/list |
The single list endpoint behind the Create Invoice screen. Depending on status it returns the un-invoiced queue, creates preview invoices from the… | Yes |
POST |
/api/invoicing/todo/pending/approval-list |
Lists the batch invoices awaiting approval that are attached to a given to-do, for the Home/To-do invoice modal. | Yes |
POST |
/api/invoicing/preview/un-invoice-to-preview |
Converts explicitly selected un-invoiced queue rows into Preview batch invoices. | Yes |
POST |
/api/invoicing/queue/delete |
Removes lines from the un-invoiced queue without invoicing them: hourly lines have their billable time moved to non-billable, fixed-price lines are… | Yes |
POST |
/api/invoicing/queue/not-approved/timesheet/list |
Returns the users/weeks whose timesheets are not yet approved for the companies in scope — the blocking warning shown before previewing invoices. | Yes |
POST |
/api/invoicing/product/config |
Resolves the description, QBO class and rate to apply to an invoice line when a product/item is selected. | Yes |
POST |
/api/invoicing/preview/delete |
Deletes preview (or any) invoices with a full cascade: restores hours and fixed-price occurrences to the queue, removes approver to-dos, and voids… | Yes |
POST |
/api/invoicing/preview/update |
Saves an edited invoice: header fields, line items, dates and amounts. Editing an already-approved or posted invoice unposts it and returns it to the… | Yes |
POST |
/api/invoicing/preview/route/approval |
Routes one or more preview invoices for approval: seeds approvers, generates approver to-dos and sets the batch status to Invoice. | Yes |
POST |
/api/invoicing/preview/disposition |
Records an approver's decision on an invoice (approve / reject / unapprove / re-submit) and recomputes the batch status. | Yes |
POST |
/api/invoicing/preview/post/comment |
Adds a quick comment to an invoice and records a Commented audit entry. | Yes |
POST |
/api/invoicing/fetch/custorcomp |
Two-way lookup between a Genie company and its mapped QuickBooks customer — used by the Ad Hoc Invoice screen to fill the counterpart field when… | Yes |
POST |
/api/invoicing/adhocinvoice/save |
Creates an ad-hoc invoice or credit memo directly, bypassing the un-invoiced queue. The new document is created in Invoice status with approvers and… | Yes |
11.8 Invoicing — 11.8.2 Configure
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/invoicing/config |
Loads an Invoicing Configure screen's data set. The from discriminator selects which screen: the default (managers, users, messages, dates), the… | Yes |
POST |
/api/task/invoicing/config/save-invoice-manager |
Sets the firm's Invoice Managers (role 14) by assigning and de-assigning the role. | Yes |
POST |
/api/task/invoicing/config/save-invoice-approvers |
Sets the firm's default invoice approvers per service role, then re-resolves approvers on every un-approved invoice in the firm. | Yes |
POST |
/api/task/invoicing/config/save-sl-approvers |
Sets the firm's default approvers per service line + service role, then re-resolves approvers on existing un-approved invoices. | Yes |
POST |
/api/task/invoicing/config/save-date |
One endpoint serving three mutually exclusive date settings: the invoice start date, the approver to-do due-date offset, and the… | Yes |
POST |
/api/task/invoicing/config/save-message |
Saves the firm's default "Message on Invoice" and "Message on Statement" text. | Yes |
POST |
/api/task/invoicing/config/gl-product |
Loads the mapping data for one of three Configure screens, selected by from: GL Product/Service mapping, Labor Items, or Service-Line Class Mapping. | Yes |
POST |
/api/task/invoicing/config/gl-customer |
Loads the GL Customer mapping grid — each of the firm's companies with its mapped QBO customer, payment term, class and location, plus the dropdown… | Yes |
POST |
/api/task/invoicing/config/save-gl-product |
Saves the firm's GL Product/Service mapping — which QBO item, description, class and location each service role bills to. | Yes |
POST |
/api/task/invoicing/config/save-labor-items |
Flags which QBO items count as labor for the firm's internal company. | Yes |
POST |
/api/task/invoicing/config/save-gl-customer |
Saves the GL Customer mapping — which QBO customer, payment term, class and location each company invoices to. | Yes |
POST |
/api/task/invoicing/config/save-sl-class |
Saves the Service-Line → QBO Class mapping for the firm. | Yes |
POST |
/api/task/invoicing/config/export/file |
Generates the GL Customer Mapping Excel template (a blank mapping sheet plus an "Available Data" reference sheet) and returns a one-shot download… | Yes |
POST |
/api/task/invoicing/config/import/file |
Imports a completed GL Customer Mapping workbook. Valid rows are saved even when other rows fail. | Yes |
GET |
/api/task/invoicing/config/export/file/generated/:token |
Streams a previously generated Excel template (GL Customer Mapping or Fixed Price Invoicing) and deletes it. | Token |
POST |
/api/task/invoicing/config/fixed-price |
Loads the fixed-price item list for a project, together with the QBO product list needed to edit it. | Yes |
POST |
/api/task/invoicing/config/fixed-price/save |
Inserts or updates the fixed-price item lines for a project. | Yes |
DELETE |
/api/task/invoicing/config/fixed-price/:id |
Soft-deletes a single fixed-price item line. | Yes |
POST |
/api/task/project/fixed/download/file |
Generates the Fixed Price Invoicing Excel template for the firm — a blank entry sheet plus "Existing Data" and "Available Data" reference sheets —… | Yes |
POST |
/api/task/project/fixed/import/file |
Imports a completed Fixed Price Invoicing workbook, inserting new price-list rows and updating existing ones by their internal id. | Yes |
11.8 Invoicing — 11.8.3 Post to GL
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/invoicing/post-to-gl/get |
Returns the Post to GL grid — one row per posting batch, with its derived status, invoice counts and any QuickBooks error messages. | Yes |
POST |
/api/invoicing/post-to-gl/post |
Posts an entire batch to QuickBooks Online, or unposts it. This is the primary QuickBooks write path. | Yes |
POST |
/api/invoicing/bulk/action |
Applies Post, UnPost or Delete to an explicit set of invoice ids from the invoice list. | Yes |
11.8 Invoicing — 11.8.4 Timesheet Details
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/invoicing/timesheet/details |
Returns the timesheet entries behind a set of invoice line items, for the Timesheet Details modal on an invoice. | Yes |
POST |
/api/invoicing/timesheet/details/edit |
Saves edits made in the Timesheet Details modal: writes the grid back to time_history, optionally returns entries to the un-invoiced queue, rebuilds… | Yes |
POST |
/api/invoicing/timesheet/details/export |
Turns the rows currently shown in the Timesheet Details grid into a CSV payload for client-side download. | Yes |
11.8 Invoicing — 11.8.5 Invoicing Audit Log
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/invoicing/audit/list |
Returns the Invoicing Audit Log — every recorded invoice action with the user, role, invoice number and before/after values — with filtering, sorting… | Yes |
POST |
/api/invoicing/audit/dropdownData |
Returns just the filter dropdown data for the Invoicing Audit Log — the firm's users and the roles actually assigned within it. | Yes |
11.9 Timesheet — 11.9.2 Timesheet Entry
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/timesheet/weekly |
Loads the dropdown catalogues for the Daily/Weekly timesheet grid — selectable users, companies, projects (grouped by company), billing types,… | Yes |
POST |
/api/task/timesheet/filter |
Returns one user's timesheet rows for a week (or a single day), together with the edit-permission flags, the week's disposition status, and the… | Yes |
POST |
/api/task/timesheet/save |
Persists a batch of timesheet rows for one user — creating manual entries, updating existing rows, and soft-deleting removed ones — and writes an… | Yes |
POST |
/api/task/timesheet/weekly/status |
Records a disposition transition (submit / unsubmit / approve / unapprove / reject / void / unvoid) for one user's week, applies the approval flags… | Yes |
11.9 Timesheet — 11.9.3 Timesheet Reports
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/timesheet/user/list |
Returns the user dropdown for the Timesheet Reports screen for a given week. | Yes |
POST |
/api/task/timesheet/weekly/list |
Returns the Timesheet Reports roster: one row per candidate user for the week, with their disposition status and whether the caller may open their… | Yes |
POST |
/api/task/timesheet/weekly/export |
Builds a styled .xlsx workbook of timesheet data and returns it as a base64 data: URI in the JSON body. Four different payload shapes select four… | Yes |
11.9 Timesheet — 11.9.4 Timesheet Configure
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/timesheet/config |
Loads the Timesheet Configuration grid for one firm: every visible firm user with their approver, permission flags, cost rate and full/part-time… | Yes |
GET |
/api/task/timesheet/firm/dropdown |
Returns every non-deleted firm as { id, name }, for the Global Timesheet Configuration screen's firm selector. | Yes |
POST |
/api/task/timesheet/config/save |
Saves the Timesheet Configuration grid. Two mutually exclusive modes: toggling the firm's use_timesheet master switch, or rewriting the per-user… | Yes |
11.9 Timesheet — 11.9.5 Timesheet Audit Log
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/timesheet/audit/list |
Returns a paged, filtered, sorted list of timesheet audit records — the Created / Updated / Deleted trail written by POST /api/task/timesheet/save.… | Yes |
POST |
/api/task/timesheet/audit/dropdownData |
Returns the user list that populates the Audit Log's "User" filter dropdown. | Yes |
A.5 Monthly Close
83 endpoints — full entries in 12. Monthly Close.
12.1 Close Status
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/mc/peer/review/status/filter |
Returns one company-group node's Close Status rows — that group's own direct companies (paged) plus its direct child-group ids, which the client… | Yes |
POST |
/api/mc/status/manager/closeDeadline |
The Calendar tab: per-manager completed/total counts bucketed by the firm's configured calendar periods (day-of-month ranges), plus the drill-down… | Yes |
POST |
/api/mc/status/manager/unClosedDeadline |
The Relative tab: per-manager counts of unclosed companies bucketed by how many days past/due the deadline is. | Yes |
POST |
/api/mc/status/manager/completionResults |
The Completion tab: per-manager closed-on-time / closed-late / unclosed / no-deadline counts with percentages. | Yes |
POST |
/api/mc/status/manager/makeExport |
Generates the drill-down modal export workbook from rows the client posts back, and returns a one-shot download link. | Yes |
POST |
/api/mc/status/manager/makeIndivudualExport |
Generates the grid export workbook by re-running the requested tab's own query server-side, and returns a one-shot download link. (The route path is… | Yes |
GET |
/api/mc/status/manager/download/generated/:token |
Streams a previously generated Close Status export workbook and deletes it. | Token |
12.2 Close Details
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/mc/monthly/close/filter |
The Close Details landing grid: the nine sections with their status, comment counts and the whole-close QA action flags for the selected company and… | Yes |
POST |
/api/mc/status/update |
Skip / Submit / Approve a single section, or run the corresponding "… All" action across the whole close; also supports reverting each of those. | Yes |
POST |
/api/other/section/details |
The "Other Monthly Close Items" panel header: task counts, combined comment counts and the two saved checkbox flags for the company and date. | Yes |
POST |
/api/mc/bankcard/review/filter |
Loads the Bank & Card Reconciliations review screen: bank accounts, credit cards, undeposited-funds data, balances and the section's QA status. | Yes |
POST |
/api/mc/bankcard/flag/allcount |
Phase-2 call for the Bank & Card screen: given every account's qbo_id, returns the flagged-transaction count per account. | Yes |
GET |
/api/mc/bank/view/reconciliations/:id |
The "View flagged List" drill-down for one bank or credit-card account. | Yes |
POST |
/api/mc/save/bank/reconciliations |
Per-row autosave for the Bank & Card screen: the reconciliation checkbox, the "all transactions posted" checkbox, statement dates, and the… | Yes |
POST |
/api/mc/ar/review/filter |
Loads the Accounts Receivable review screen. | Yes |
POST |
/api/mc/ap/review/filter |
Loads the Accounts Payable review screen. | Yes |
POST |
/api/mc/checkbox/save |
Generic save for the per-section reconciliation attestation checkboxes (e.g. "does AR Aging tie to the Balance Sheet AR balance?"). | Yes |
GET |
/api/mc/ar/review/list/:id |
Paginated transaction list behind one Accounts Receivable review bucket. | Yes |
GET |
/api/mc/ap/review/list/:id |
Paginated transaction list behind one Accounts Payable review bucket. | Yes |
POST |
/api/mc/common/accept |
Accept or revoke a single flagged transaction on any section's list screen. | Yes |
POST |
/api/mc/common/accept/all |
Batch accept/revoke — the header "select all" action on a list screen. | Yes |
POST |
/api/mc/balancesheet/review/filter |
Loads the Balance Sheet General review screen. | Yes |
GET |
/api/mc/balancesheet/review/list/:id |
One of the six Balance Sheet General "View List" drill-downs. | Yes |
POST |
/api/mc/incomestatement/review/filter |
Loads the Income Statement General review screen. | Yes |
GET |
/api/mc/incomestatement/review/list/:id |
One of the two Income Statement General "View List" drill-downs. | Yes |
POST |
/api/mc/pr/review/filter |
Loads the Payroll review screen. | Yes |
GET |
/api/mc/pr/review/list/:id |
The Payroll review's single "View List" drill-down (wages plus payroll-tax GL accounts). | Yes |
POST |
/api/mc/revenue/review/filter |
Loads the Uncharacterized Revenue and Expense review screen. | Yes |
GET |
/api/mc/revenue/review/list/:id |
One of the four Uncharacterized "View List" drill-downs, paginated. | Yes |
POST |
/api/mc/cash/flow/statement/filter |
Loads the Cash Flow Statement review screen — five checkbox options plus the section QA status. | Yes |
POST |
/api/mc/filter/other/reivew |
Loads the "Other" section's review checklist for the company and month. (The path segment is spelled reivew, exactly as shown, and must be requested… | Yes |
12.3 Open Comments
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/mc/comment/view |
Reads the Monthly Close comment threads for one company, month and comment type (optionally narrowed to a single transaction). | Yes |
POST |
/api/comment/module |
Builds the Open Comments filter tree — the Module › Submodule › Section hierarchy from comment_module, plus the assignable-user list for the selected… | Yes |
POST |
/api/mc/thread/sort |
The Open Comments grid: every comment thread visible to the caller across Monthly Close, Accounting, Task and Invoice, filtered and sorted. | Yes |
POST |
/api/mc/thread/sort/fetch-users |
Returns the users who can be assigned on a new or existing thread for one company and module. | Yes |
POST |
/api/mc/comment/create |
Posts a Monthly Close comment (starting a thread, or replying into an existing one). | Yes |
POST |
/api/mc/comment/resolve |
Resolves or un-resolves one or more Monthly Close comments. | Yes |
POST |
/api/comment/edit |
Edits the text of one Monthly Close comment. | Yes |
POST |
/api/comment/editAssignUser |
Replaces the assignee set on one Monthly Close comment. | Yes |
POST |
/api/thread/create |
Starts a new comment thread from the Open Comments screen (or from the Invoicing/Task comment panels). | Yes |
POST |
/api/thread/comment/create |
Replies into an existing thread on any of the four sources. | Yes |
POST |
/api/comment/resolve |
Resolves or un-resolves an Open Comments thread (or a single comment within one). | Yes |
POST |
/api/open-comment/edit |
Edits one comment (and optionally its assignees) from the Open Comments grid, on any source. | Yes |
12.4 GL Validation
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/mc/glvalidation/work/filter |
Lists the GL accounts for one tab with their source configuration, scraped source balance, GL balance, variance and comment counts. | Yes |
POST |
/api/mc/glvalidation/work/modal |
Returns what the "Source Selection" modal should pre-fill for one account. | Yes |
POST |
/api/mc/glvalidation/work/save |
Saves one account's source configuration, immediately re-reads the sheet, and returns the freshly recomputed row. | Yes |
POST |
/api/mc/glvalidation/work/refresh |
Re-scrapes every Google-sourced account listed in the payload. | Yes |
POST |
/api/mc/glvalidation/comments/view |
The GL Validation comment sidebar's read: every accounting_comments thread for one GL account in one close month. | Yes |
POST |
/api/mc/glvalidation/work/bulkupdate |
"Bulk Update Google Sheet": points every Google-sourced account on the visible tab at one workbook, then re-scrapes. | Yes |
12.5 Firm Configuration
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/mc/firm/config/filter |
Loads the firm-level Monthly Close configuration: the calendar and relative period definitions effective for the current month, plus the firm's… | Yes |
POST |
/api/mc/firm/config/save |
Replaces the firm's calendar and/or relative period definitions. | Yes |
POST |
/api/mc/firm/config/close-deadline/save |
Sets the firm's weekend close-deadline adjustment rule. | Yes |
POST |
/api/mc/configure/update |
Kicks off a background refresh of the stored QuickBooks book-close date for the selected companies. | Yes |
POST |
/api/mc/ar/config/filter/bank |
Loads the Bank & Card Configure screen: the per-transaction-type day thresholds plus the undeposited-funds GL account picker. | Yes |
POST |
/api/mc/ar/config/save/bank |
Autosave for the Bank & Card Configure screen — thresholds and/or the undeposited-funds GL account. | Yes |
POST |
/api/mc/default/save-global |
Writes a firm-wide default value (mc_default.global_default). | Yes |
POST |
/api/mc/ar/config/filter |
Loads the AR or AP Configure screen — one shared endpoint, discriminated by configTypeId. | Yes |
POST |
/api/mc/ar/config/save |
Saves one AR/AP threshold value. | Yes |
POST |
/api/mc/balancesheet/config/filter |
Loads the Balance Sheet General Configure screen. | Yes |
POST |
/api/mc/balancesheet/config/save |
Autosave for Balance Sheet General Configure: variance trio, thresholds, section toggles, and the GL-account checkboxes. | Yes |
POST |
/api/mc/incomestatement/config/filter |
Loads the Income Statement General Configure screen. | Yes |
POST |
/api/mc/incomestatement/config/save |
Autosave for Income Statement General Configure. | Yes |
POST |
/api/mc/payroll/filter |
Loads the Payroll Configure screen. | Yes |
POST |
/api/mc/payroll/config/save |
Autosave for Payroll Configure: the four scalars and the account checkboxes. | Yes |
POST |
/api/mc/revenue/config/filter |
Loads the Uncharacterized Revenue and Expense Configure screen. | Yes |
POST |
/api/mc/revenue/config/save |
Autosave for Uncharacterized Configure: the two class flags, or one account checkbox. | Yes |
POST |
/api/mc/cash/flow/statement/save |
Saves the Cash Flow Statement review's five checkboxes for one company and month. | Yes |
POST |
/api/mc/other/config/filter |
Loads the "Other" checklist items configured for a company or group. | Yes |
POST |
/api/mc/other/config/save |
Creates, renames, retypes, sets the options of, or soft-deletes one "Other" checklist item. | Yes |
POST |
/api/other/section/check |
Persists the two "Other Monthly Close Items" panel checkboxes for a company and period. | Yes |
POST |
/api/mc/filter/other/save |
Saves one answer on the "Other" review checklist. | Yes |
12.6 Review Assignments
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/get/primary_accountant |
Returns the distinct primary accountants recorded across the companies in scope, for the grid's Primary Accountant filter. | Yes |
POST |
/api/get/user_reviewers |
Returns the reviewer option list — either the eligibility roster or the recorded reviewers, depending on isReviewAssignment. | Yes |
POST |
/api/mc/peer/review/assignment/filter |
Loads the Review Assignments grid — one group block per call, plus the month headers, reviewer values, completion flags and manager list. | Yes |
POST |
/api/mc/peer/review/save |
Saves one grid cell (a month's reviewer, the primary accountant, or the close deadline), or copies a month's reviewers forward. | Yes |
POST |
/api/mc/peer/review/user-list |
Returns the users assignable as a reviewer for one company. | Yes |
POST |
/api/mc/peer/review/assignment/download/file |
Generates the blank Review Assignments import template and returns a one-shot download link. | Yes |
POST |
/api/mc/peer/review/assignment/import/file |
Imports a filled-in Review Assignments template. | Yes |
GET |
/api/mc/peer/review/assignment/download/generated/:token |
Streams a generated Review Assignments template (blank or error-annotated) and deletes it. | Token |
12.7 QuickBooks Close Dates
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/mc/report/closedate |
Returns the close-date report rows for a company group (or a single company) plus the manager filter options. | Yes |
POST |
/api/mc/report/closedate/export |
Generates the close-date report workbook and returns a one-shot download link. | Yes |
GET |
/api/mc/report/closedate/export/generated/:token |
Streams a generated close-date report workbook to the user who requested it, and deletes it. | Yes |
12.8 Company Group Tree
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/get/treeview |
Returns the company-group tree and flat group/company option lists the Monthly Close scope picker renders. | Yes |
A.6 Comments Center
8 endpoints — full entries in 13. Comments Center.
13.1 Task Comments
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/task/view/comment |
Loads every comment thread on a task, with each comment's author, assignees and per-thread resolve state, plus the list of users assignable on the… | Yes |
POST |
/api/task/view/comment/save |
Adds a comment to a task, creating a new comment_thread when no threadId is supplied, and optionally assigning users to that comment. | Yes |
POST |
/api/task/view/comment/edit |
Edits one task comment: either replaces its text, or replaces its full assignee set. | Yes |
POST |
/api/task/comment/resolve |
Toggles a comment thread between resolved and re-opened, cascading the new state to every comment in the thread. | Yes |
13.2 Invoicing Comments
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/invoicing/view/comment |
Loads the comment threads on an invoice batch plus the assignable-user list for the comment composer. | Yes |
POST |
/api/invoicing/view/comment/save |
Posts a comment on an invoice batch — a reply when threadId is supplied, otherwise a new thread. | Yes |
POST |
/api/invoicing/view/comment/edit |
Edits one invoice comment: replaces its text, or replaces its full assignee set. | Yes |
POST |
/api/invoicing/comment/resolve |
Toggles an invoice comment thread between resolved and re-opened. | Yes |
A.7 Report Center
27 endpoints — full entries in 14. Report Center.
14.1 Report List
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/reports/list |
Loads the Report List screen: the company scope picker, the Global and Custom report columns for one report group, the GL-validation badge and the… | Yes |
GET |
/api/reports/searchReport |
Search alias for the Report List. Identical behaviour to GET /api/reports/list. | Yes |
POST |
/api/reports/addReport |
Creates a new, empty report definition in global_report_list and returns the URLs for previewing and editing it. | Yes |
GET |
/api/reports/deleteReport |
Removes a report definition (soft delete when the deleted column exists). | Yes |
POST |
/api/reports/clone |
Clones a report definition under a new name, including a deep copy of its writer (header, columns and detail rows). | Yes |
14.2 Report Preview
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/reports/preview/:id |
Bootstraps the Report Preview screen for one report: the report row, the preview form's controls and state, any saved preview parameters, and — as a… | Yes |
POST |
/api/reports/view/generate_income_statement |
Runs a report definition against the session company's ledger and returns the full grid (headers, column metadata and valued rows). This is the core… | Yes |
POST |
/api/reports/rgCompany/classLoc |
Returns the merged class/location list for a reporting group's companies, plus the fiscal-month and accounting-method consistency flags the preview… | Yes |
POST |
/api/reports/view/save_preview |
Persists the preview form's parameters for the current (report, company, user) triple so the screen reopens with the same selections. | Yes |
POST |
/api/reports/view/download |
Regenerates the report from the supplied preview parameters and returns a styled .xlsx workbook, base64-encoded in the JSON envelope. | Yes |
14.3 Report Writer
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/reports/writer |
Loads everything the Report Writer screen needs on open: the report row, its writer header, the company scope, class/location and reporting-group… | Yes |
POST |
/api/reports/writer/based-on |
Returns the rows and columns for the selected "Based on" axis. GL-Account reports get the editable row/column grid; the multi-axis modes get a… | Yes |
POST |
/api/reports/writer/search-rows |
Backs the Add-row sidebar and the GL Account Editor pick lists: account groups (global and custom) on one tab, GL accounts on the other. | Yes |
POST |
/api/reports/writer/reporting-group/companies |
Returns the companies inside a reporting group plus the merged class/location list the Apply Filter sidebar shows. | Yes |
POST |
/api/reports/writer/save |
Persists the whole writer: the report row, the writer header, every row, every fixed-period column and (for multi-axis reports) the GL Account Editor… | Yes |
14.4 Report Packages
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/reports/viewPackage |
Lists report packages split into Global and Custom tabs, with the company scope, GL-validation badge and Chart-of-Accounts change counter the screen… | Yes |
POST |
/api/reports/viewPackage/save |
Creates, updates or clones a report package, and (conditionally) rewrites the list of reports inside it. | Yes |
GET |
/api/reports/viewPackage/delete |
Soft-deletes a package and all of its detail rows. | Yes |
GET |
/api/reports/viewPackage/detail |
Loads one package's edit screen: the package row, its ordered report list with each report's period settings, and the class/location options and… | Yes |
POST |
/api/reports/viewPackage/run |
Builds the Run Package shell: header facts (as-on date, generated-by/on, scope name), the effective reporting group and expand-by-company flags, and… | Yes |
POST |
/api/reports/viewPackage/run/report |
Generates one report card on the Run Package screen. The generation parameters are rebuilt server-side from the package's detail row — the client… | Yes |
POST |
/api/reports/viewPackage/run/download |
Exports the whole package (or one report's sheet) as a styled .xlsx workbook, base64-encoded in the JSON envelope. One worksheet per report. | Yes |
14.5 Configure
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/reports/configure/update |
Multiplexed refresh endpoint: re-reads the report configuration, pulls the QuickBooks book-close date live, or pulls class/location tracking… | Yes |
GET |
/api/reports/configure |
Loads the Report Configure screen: the company list and current scope, the company's accounting method and fiscal month, and the Chart-of-Accounts… | Yes |
POST |
/api/reports/configure/save |
Saves a company's accounting method and fiscal-year start month (report_configure). | Yes |
POST |
/api/reports/configure/switch-company |
Switches the session's active company, resetting the as-on period to today. | Yes |
POST |
/api/reports/configure/changes-seen |
Marks a company's pending Chart-of-Accounts changes as seen, clearing the "Changes have been made to the Chart of Accounts" strip. | Yes |
A.8 Feedback Center
20 endpoints — full entries in 15. Feedback Center.
15.1 Suggestions
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/feedbackcenter/:type |
Returns the Suggestions (or Issues) landing payload: the module filter list, per-status counts and the full item list. | Yes |
GET |
/api/new/:type |
Returns the "new suggestion / new issue" form bootstrap — the module dropdown and the resolved type. | Yes |
POST |
/api/issue/feedback/save |
Creates a suggestion, issue or support ticket, links any previously staged attachments to it, and e-mails the Genie team. | Yes |
GET |
/api/issue/list/detail/:id |
Full detail view of one suggestion/issue: the ticket, its attachments, its replies, participants, and the caller's own vote/subscription state. | Yes |
POST |
/api/issue/feedback/replay/send |
Posts a reply on a suggestion/issue, links any staged attachments to that reply, and notifies subscribers. | Yes |
POST |
/api/issue/vote/count |
Toggles the caller's vote on a suggestion and returns the new vote state and total. | Yes |
POST |
/api/issue/filter/module |
Re-queries the suggestion/issue list with module, status, search, sort and show-deleted filters applied. | Yes |
POST |
/api/issue/save/stauts |
Updates a ticket's status and/or classification, optionally appends a label, and notifies the ticket creator on a status change. | Yes |
POST |
/api/feedbackcenter/DelList |
Soft-deletes a suggestion/issue (issue_list.deleted = 1). | Yes |
POST |
/api/subscribe/status |
Subscribes or unsubscribes the caller from a ticket's reply notifications. | Yes |
POST |
/api/ajax_upload/issue_files |
Stages one or more attachments in Google Cloud Storage and returns their issue_files row ids, to be passed later as insertedIds when saving a ticket… | Yes |
GET |
/api/issue/files/download/:id |
Streams one attachment back to the browser as a download. | Yes |
GET |
/api/issue/files/download/:id/:name |
Same as the previous route with an explicit download filename appended — this is the form emitted in download_url. | Yes |
15.2 Support & Issues
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
GET |
/api/support/list |
Returns the support ticket list for the caller, plus the module dropdown and the caller's agent flag. | Yes |
GET |
/api/support/new/form |
Bootstrap for the "raise a support ticket" form. | Yes |
GET |
/api/support/feedback/chat/:id |
Loads one support ticket as a chat thread: the ticket, its attachments, every reply, and whether replying is still allowed. | Yes |
POST |
/api/support/chat/reply/save |
Posts a message into a support ticket's chat, attaches staged files, touches the ticket's updated_at, and notifies the other party. | Yes |
POST |
/api/support/filter/feedback |
Re-queries the support ticket list with title, status and ordering filters. | Yes |
POST |
/api/support/chat/reply |
Resolves a ticket id into the front-end chat URL. A navigation aid; it performs no write. | Yes |
POST |
/api/ajax_upload/support_files |
Stages support-ticket attachments. Functionally identical to POST /api/ajax_upload/issue_files — both write the same issue_files rows. | Yes |
A.9 Integrations
17 endpoints — full entries in 16. Integrations.
16.1 Google Integration (Drive / Sheets OAuth)
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/send/drive |
Entry point for "Send to Google Drive". Returns a Picker access token when the user is connected, or a consent URL when they are not. | Yes |
GET |
/api/google/drive/status |
Reports whether the caller has a usable Google connection, so the UI can skip the Configure modal. | Yes |
GET |
/api/google/drive/auth-url |
Returns a fresh Google consent URL for the "Configure Google Account" popup. | Yes |
POST |
/api/google/drive/disconnect |
Clears the caller's stored Google Drive tokens. | Yes |
POST |
/api/google/drive/upload |
Uploads a base64 workbook into a Drive folder, converting it to a native Google Sheet by default. Reusable by any screen that can produce an .xlsx in… | Yes |
GET |
/google/drive/callback |
Google's OAuth redirect target. A browser-visited route (not an API call): it exchanges the authorisation code, stores the tokens against the… | Yes |
16.2 QuickBooks Online Integration
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
POST |
/api/qbo/import/all |
Dispatches a QuickBooks sync for one company across one or more modules, marking the requested modules as running immediately. | Yes |
POST |
/api/qbo/syncstatus |
Polling endpoint for QuickBooks sync progress. Called roughly every 5 seconds by the QBO page while a sync runs. | Yes |
GET |
/api/qbo/connect |
Returns a QuickBooks consent URL for the caller's firm-internal company. | Yes |
GET |
/api/qbo/connection/status |
Reports whether the caller's firm-internal company is currently connected to QuickBooks. | Yes |
GET |
/api/qbo/callback |
Intuit's OAuth redirect target. Exchanges the authorisation code, binds the realm to the pending company, captures the QuickBooks company name, and… | No |
POST |
/api/invoicing/tax/qbo |
Resolves the sales-tax rate for an invoicing line — a live QuickBooks probe when possible, otherwise the stored customer rate. | Yes |
GET |
/api/company/qbo/:id |
Full payload for the Admin → Company → QuickBooks Online Integration page: connection state, module catalogues, per-module last-synced times, live… | Yes |
GET |
/api/company/qbo/connect/:id |
Returns a QuickBooks consent URL for a specific company from the Admin integration page, with a returnTo that auto-triggers "Sync all" after… | Yes |
POST |
/api/company/qbo/disconnect |
Disconnects a company from QuickBooks: clears the local connection flag and tokens, asks the Lambda to revoke the grant with Intuit, and drops… | Yes |
POST |
/api/company/qbo/syncall |
Triggers a full (or module-scoped) sync for one company from the Admin integration page. | Yes |
POST |
/api/company/qbo/settings/save |
Two operations behind one route: change a company's QuickBooks sync-year setting, or clear the recorded last-sync times for some/all modules so they… | Yes |
19. Appendix B — Environment Variables
Configuration is supplied through the deployment environment. No values are reproduced in this document — only the variable names the service reads and what each one controls.
B.1 Application
| Variable | Used for | Default |
|---|---|---|
PORT |
HTTP listen port | 4000 |
NODE_ENV |
Marks the genie.sid, XSRF-TOKEN, enc_log and google cookies Secure when set to production |
unset |
APP_ENV |
When set to local or development, enables a development-only two-factor bypass for a restricted set of accounts, and runs QuickBooks sync inside the application rather than dispatching it to the external sync function |
unset — no bypass |
APP_URL |
Application base URL. Used to derive OAuth redirect URIs and as a fallback base for links in outgoing e-mail | http://localhost:3000 |
SESSION_SECRET |
Session signing secret | A development fallback is used when unset; a value must be configured in any real deployment |
FRONTEND_ORIGIN |
The single browser origin allowed for direct cross-origin calls; credentials are enabled | http://localhost:3000 |
BACKEND_ORIGIN |
Backend base URL used when building callback URLs | — |
APP_NAME |
Product name used in outgoing e-mail subjects and templates | Genie |
FRONTEND_URL |
Alternative web application base URL used when building password-reset links, after FRONTEND_ORIGIN |
— |
SHOW_DEV_RESET_LINK |
When true and NODE_ENV is not production, the password-reset response also returns the reset link for local testing. It is never returned in production |
unset — the link is e-mailed only |
B.2 Database
| Variable | Used for |
|---|---|
DB_HOST, DB_PORT |
Database host and port (ignored when INSTANCE_CONNECTION_NAME is set) |
DB_USERNAME, DB_PASSWORD, DB_DATABASE |
Credentials and schema name |
DB_SYNCHRONIZE |
'true' enables automatic schema synchronisation — should remain off in production |
DB_SSL |
'true' connects over SSL |
INSTANCE_CONNECTION_NAME |
Cloud SQL instance. When set, both the application and the session store connect over the instance's Unix socket instead of host and port |
The sessions table is expected to exist in the schema already; the service does not create it.
B.3 Mail (SMTP)
| Variable | Used for |
|---|---|
MAIL_DRIVER |
Mail transport selector |
MAIL_HOST, MAIL_PORT |
SMTP endpoint. Port 465 uses implicit TLS; any other port uses STARTTLS |
MAIL_USERNAME, MAIL_PASSWORD |
SMTP credentials |
MAIL_SENDER_ADDRESS, MAIL_SENDER_NAME |
From address and display name |
MAIL_ENCRYPTION |
Present in configuration but not read by the service — the encryption mode is determined by MAIL_PORT |
Mail is sent for password-reset links, emailed sign-in codes, and Feedback Center notifications (new tickets, replies and status changes).
B.4 QuickBooks Online
| Variable | Used for |
|---|---|
QBO_CLIENT_ID, QBO_CLIENT_SECRET |
Intuit OAuth 2.0 application credentials |
QBO_REDIRECT_URI |
OAuth callback registered with Intuit |
QBO_ENV |
sandbox or production — selects the Intuit API base URL |
LAMBDA_SYNC, LAMBDA_TOKEN_URL, LAMBDA_CONTENT_TYPE |
External QuickBooks sync function endpoint, token endpoint and content type |
QUICKBOOKS_OAUTH_STATE_TTL_MS |
Validity window of the OAuth state value (15 minutes by default) |
B.5 Google (Drive / Sheets)
| Variable | Used for |
|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Google OAuth 2.0 client credentials |
GOOGLE_DRIVE_REDIRECT_URI |
OAuth callback for the Drive and Sheets connection. Defaults to ${APP_URL}/google/drive/callback |
GOOGLE_PICKER_APP_ID |
Google Picker application id used by the web application |
GOOGLE_PICKER_DEVELOPER_KEY |
Browser API key handed to the Google Picker (public by design) |
GOOGLE_DRIVE_SCOPES |
Overrides the OAuth scopes requested for the Drive and Sheets connection |
GOOGLE_APPLICATION_CREDENTIALS |
Path to a service-account key file, for environments where application default credentials are not available |
GOOGLE_TOKEN_DEFAULT_TTL_SECONDS, GOOGLE_TOKEN_REFRESH_SKEW_SECONDS |
Assumed access-token lifetime and the margin before expiry at which a token is refreshed |
B.6 Object storage
| Variable | Used for |
|---|---|
GCS_BUCKET |
Google Cloud Storage bucket holding Feedback Center attachments |
GCS_ISSUE_FILES_PREFIX |
Key prefix within that bucket for issue attachments |
B.7 Web application
| Variable | Used for |
|---|---|
BACKEND_URL |
The backend origin that the web application proxies API requests to |
Credential handling: every variable above that holds a secret — database password, session secret, SMTP password, OAuth client secrets — must be supplied through the deployment's secret management. No secret values appear in this document.
20. Appendix C — Health Checks & Operations
C.1 Health endpoints
| Endpoint | Purpose |
|---|---|
GET / |
Hosting and liveness ping |
GET /health |
Explicit health check |
Both verify database connectivity and return:
{
"status": "ok",
"service": "smartbook-genie-backend",
"message": "Backend and database are healthy.",
"checks": { "database": { "status": "up", "latencyMs": 3 } },
"timestamp": "2026-09-01T09:15:22.104Z"
}
If the database check fails, the response is HTTP 503 with status: "error", checks.database.status: "down" and an error message. The process itself stays up, which distinguishes "the service is down" from "the service is up but cannot reach its database".
C.2 Logging and request tracing
Three diagnostic layers are active in the running service:
- Arrival and completion tracing for report requests — the method and URL on arrival, and the status, method, URL and elapsed time on completion.
- An error log for every
5xxresponse, whether or not the request was traced. - A warning when a connection is closed before a response was sent — the signature of an upstream proxy timing out, which is otherwise indistinguishable from a request that never arrived.
Failures that occur while a response is being written are returned as JSON rather than as plain text, so a client always receives a parseable body.
C.3 Scheduled work
The service runs scheduled background work, including task recurrence generation and invoice recurrence generation. These are not HTTP endpoints and are not callable by API clients. See Deployment & Operational Considerations for the deployment impact.
C.4 Support information to capture
When raising an API issue, the following makes diagnosis fastest:
| Item | Why it helps |
|---|---|
| Full request path and HTTP method | Several paths differ only by method |
| Request body (with secrets removed) | Most endpoints are POST with the payload in the body |
| Complete response body, not only the status code | Business-rule failures are reported inside a 200/201 body (§5.2) |
| Timestamp with timezone | Matches server-side request traces |
| Whether the call was made through the web application or directly to the backend | Determines whether proxy rules are involved (§3.1) |
End of document — Genie API Documentation, 476 documented endpoints, 1 September 2026.