Synergist API Authentication

Synergist API Authentication

This document covers authentication for Synergist's HTTP API surface (/jsonapi, /xmlapi,
/csvapi), all of which is gated by a single entry point, API_Connection.

Three authentication methods are currently supported. New integrations should use OAuth 2.0
(Method 1)
— the other two are legacy and are expected to be phased out as part of a broader
API overhaul; they remain documented here so existing integrations keep working until that
migration completes.

MethodStatusUse for
OAuth 2.0 (Bearer access token)RecommendedAll new integrations
Session tokenLegacyExisting integrations only
HTTP Basic Auth / username-passwordLegacy — being deprecatedExisting integrations only; avoid for new development

An administrator can force OAuth-2-only access by setting [System Parameters]Settings.APIAccessTokensOnly
to true. When enabled, API_Connection rejects Basic Auth and username/password credentials
outright (error 404001: "the only API authentication method allowed on this system is by
access token"
) — this is the switch that completes the migration off the legacy methods once all
consumers have moved to OAuth 2.0.


Method 1: OAuth 2.0 (recommended)

Synergist runs its own OAuth 2.0 Authorization Server, implementing the Authorization Code
grant with PKCE
and the Refresh Token grant. Access and refresh tokens are RS256-signed JWTs.

Discovery

Server metadata is published at:

GET /.well-known/openid-configuration/openid_config.json

Returns the standard OIDC-discovery-style document (issuer, endpoint URLs, supported grant types,
supported PKCE methods, supported scopes). Consumers should read endpoint URLs from here rather
than hardcoding them.

Endpoints

EndpointMethodPurposeHandler
/oauth/authorizeGETAuthorization Code step (user login/consent)OAUTH_PROCESS_AUTHORIZE
/oauth/tokenPOSTExchange code (or refresh token) for tokensOAUTH_PROCESS_TOKEN
/oauth/revokePOSTRevoke an access or refresh tokenOAUTH_PROCESS_REVOKE

1. Registering a client application

Before a consumer can authenticate, an administrator must register an OAuth client in Synergist
(Utilities → OAuth Clients), which produces:

  • a client_id
  • one or more client_secret values (secrets support rotation — multiple hashed secrets can be
    active at once via the client's SecretList)
  • one or more registered redirect_uri values (the authorization step rejects any redirect_uri
    not on this list)

2. Authorization Code step

Direct the user's browser to:

GET /oauth/authorize
  ?client_id=<client_id>
  &response_type=code
  &redirect_uri=<one of the client's registered redirect URIs>
  &scope=<space-delimited scopes, e.g. "openid email profile">
  &state=<opaque value round-tripped back to you>
  &code_challenge=<PKCE code challenge>            (optional, but recommended)
  &code_challenge_method=plain|S256                (optional; defaults to "plain")

client_id, response_type, redirect_uri, scope, and state are mandatory. On success the
user is taken through login and the flow redirects back to redirect_uri with an authorization
code. The code is single-use and expires after 10 minutes. On failure, an OAuth-standard
error page is shown (invalid_request, unsupported_response_type, or unauthorized_client —
see § Error Responses below).

3. Token exchange

POST /oauth/token
Content-Type: application/json

{
  "grant_type": "authorization_code",
  "client_id": "<client_id>",
  "client_secret": "<client_secret>",
  "redirect_uri": "<same redirect_uri used in step 2>",
  "code": "<code from step 2>",
  "code_verifier": "<required if code_challenge was sent in step 2>"
}

Response (200 OK):

{
  "access_token": "<JWT>",
  "refresh_token": "<JWT>",
  "scope": "openid email profile",
  "expires_in": 1800,
  "token_type": "Bearer"
}

4. Refreshing an access token

POST /oauth/token
Content-Type: application/json

{
  "grant_type": "refresh_token",
  "client_id": "<client_id>",
  "client_secret": "<client_secret>",
  "refresh_token": "<refresh_token>"
}

Returns a new access/refresh token pair in the same shape as step 3. The old refresh token is
consumed and replaced (rotation).

5. Calling the API with an access token

Send the access token as a Bearer credential on any /jsonapi, /xmlapi, or /csvapi request:

GET /jsonapi/<area>?action=...
Authorization: Bearer <access_token>

6. Revoking a token

POST /oauth/revoke?client_id=<client_id>&client_secret=<client_secret>&token=<access_or_refresh_token>

Revoking an access token retains the refresh token; revoking a refresh token also revokes the
paired access token.

Token lifetimes

Token typeLifetime
Authorization code10 minutes
Access token30 minutes
Refresh token90 days

Error responses

/oauth/authorize and /oauth/token return standard OAuth 2.0 error codes
(invalid_request, invalid_client, invalid_grant, unauthorized_client, access_denied,
unsupported_grant_type, unsupported_response_type) with an error / error_description body
(or, for /oauth/authorize, an error page). See § Legacy error codes below for the distinct error
scheme used once a request reaches the general API layer (e.g. an expired or unrecognised access
token on a /jsonapi call).


Method 2: Session token (legacy)

A session is established by API_Login after an initial successful Basic Auth or username/password
credential check, producing a [Web Sessions] record (Browser:="API"). The resulting session ID
is passed back to the caller and then supplied on subsequent requests as a session query
parameter:

GET /jsonapi/<area>?action=...&session=<session_id>

Sessions are IP-pinned by default (rejected with 404006 if the caller's IP changes, unless
[System Parameters]Web_Disable_IP_Check is set or the server is running standalone).

This method predates OAuth 2.0 support and does not benefit from token expiry, rotation, or
per-client secrets — new integrations should authenticate directly via OAuth 2.0 instead.


Method 3: HTTP Basic Auth / username-password (legacy — being deprecated)

user/username plus either password or a pre-hashed sha512password are validated against
[Users]Password_SHA512 and, on success, mint a session token as described in Method 2. All four
credential-transmission mechanisms below resolve through the same parameter lookup
(API_Parameters_Extract, which reads from a single array that Web_Connection_Variables
populates from the query string, the form body, and the HTTP headers indiscriminately) — so
which one you pick is a security/tooling choice, not a capability difference, except where noted.

If you can't yet move to OAuth 2.0, prefer options further down this list — each keeps credentials
out of one more place they'd otherwise leak into (URLs end up in server/proxy access logs, browser
history, and Referer headers; request bodies and custom headers don't):

#MechanismVerbsNotes
1Query string — ?user=...&password=... (or &sha512password=...)AnyAvoid. Credentials are exposed in logs/history/referrers. This is what most of the existing example calls in the docs still show.
2Form body fields — user / sha512password as multipart or urlencoded fieldsPOST/PUT onlyKeeps credentials out of the URL. Only available where the request has a body — a GET request has none, so this can't apply there.
3Custom headers — user / sha512password sent as raw HTTP headersAnyKeeps credentials out of both the URL and the body, and works on GET too since headers aren't verb-dependent. Still non-standard — general-purpose HTTP tooling (Postman, API gateways, log scrubbers) has no built-in concept of a sha512password header, so it isn't handled any better than a bespoke query param by anything except your own client code.
4Authorization: Basic <base64(username:password)>AnyRecommended fallback if OAuth 2.0 isn't an option yet. Standard RFC 7617 mechanism, so Postman, curl, and every HTTP library support it natively (a built-in "Basic Auth" tab, not a hand-rolled header) — see Testing the Synergist API with Postman.

Option 4's password half must be the plaintext password, not the pre-hashed sha512password
value.
Confirmed both by reading API_Connection.4dm (only the sha512password-named field
sets cAPI_PasswordType:="SHA512", skipping re-hashing; the Authorization: Basic branch never
sets it, so its password half always goes through Hash_SHA512(...) before comparison) and by a
live test (2026-08-19, curl -u "<user>:<plaintext password>" against /jsonapi/jobs.json —
200 OK with real data). A sample Basic Auth call seen alongside this doc's drafting reused the
sha512password hash as the Basic Auth password instead — per both the source and this test,
that would not authenticate.

This is the "old authentication way" referenced by the API overhaul — even at its best
(option 4), it's a long-lived credential sent on every request with no expiry or per-client
scoping, and it cannot be disabled per-client independently. Administrators can block it
system-wide by setting [System Parameters]Settings.APIAccessTokensOnly = true, which forces all
callers onto OAuth 2.0 access tokens.

Code examples

Worked examples for options 2–4 above, each shown as curl, raw HTTP, and JavaScript (XMLHttpRequest).
All three create a project via POST /jsonapi/projects.json; swap in your own server address,
credentials, and payload. The credentials shown are dummy values, not a real account.

Option 2: form body fields

curl --location 'https://<SERVER>/jsonapi/projects.json' \
--form 'user="Aladdin"' \
--form 'sha512password="da0f46990f3d9a7222911d8f674b19792623347d3e4d4a516127d610dfde0dd40001a228ffc64252ea67f70a9e7d81610f3c4f2123ba54f7c7f93a539577db93"' \
--form 'version="4.2"' \
--form 'company="1"' \
--form 'action="create"' \
--form 'input="{\"data\":[{\"projectClientCode\":\"1/WORK\",\"projectName\":\"Test Created 21-07-25\"}]}"'
POST /jsonapi/projects.json HTTP/1.1
Host: <SERVER>
Content-Length: 802
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="user"

Aladdin
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="sha512password"

da0f46990f3d9a7222911d8f674b19792623347d3e4d4a516127d610dfde0dd40001a228ffc64252ea67f70a9e7d81610f3c4f2123ba54f7c7f93a539577db93
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="version"

4.2
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="company"

1
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="action"

create
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="input"

{"data":[{"projectClientCode":"1/WORK","projectName":"Test Created 21-07-25"}]}
------WebKitFormBoundary7MA4YWxkTrZu0gW--
// NB: for POST requests, body is set to null by browsers unless sent as FormData like this.
var data = new FormData()
data.append('user', 'Aladdin')
data.append(
  'sha512password',
  'da0f46990f3d9a7222911d8f674b19792623347d3e4d4a516127d610dfde0dd40001a228ffc64252ea67f70a9e7d81610f3c4f2123ba54f7c7f93a539577db93'
)
data.append('version', '4.2')
data.append('company', '1')
data.append('action', 'create')
data.append(
  'input',
  '{"data":[{"projectClientCode":"1/WORK","projectName":"Test Created 21-07-25"}]}'
)

var xhr = new XMLHttpRequest()
xhr.withCredentials = true

xhr.addEventListener('readystatechange', function () {
  if (this.readyState === 4) {
    console.log(this.responseText)
  }
})

xhr.open('POST', 'https://<SERVER>/jsonapi/projects.json')
xhr.send(data)

Option 3: custom HTTP header fields

curl --location 'https://<SERVER>/jsonapi/projects.json' \
--header 'user: Aladdin' \
--header 'sha512password: da0f46990f3d9a7222911d8f674b19792623347d3e4d4a516127d610dfde0dd40001a228ffc64252ea67f70a9e7d81610f3c4f2123ba54f7c7f93a539577db93' \
--form 'version="4.2"' \
--form 'company="1"' \
--form 'action="create"' \
--form 'input="{\"data\":[{\"projectClientCode\":\"1/WORK\",\"projectName\":\"Test Created 21-07-25\"}]}"'
POST /jsonapi/projects.json HTTP/1.1
Host: <SERVER>
user: Aladdin
sha512password: da0f46990f3d9a7222911d8f674b19792623347d3e4d4a516127d610dfde0dd40001a228ffc64252ea67f70a9e7d81610f3c4f2123ba54f7c7f93a539577db93
Content-Length: 484
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="version"

4.2
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="company"

1
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="action"

create
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="input"

{"data":[{"projectClientCode":"1/WORK","projectName":"Test Created 21-07-25"}]}
------WebKitFormBoundary7MA4YWxkTrZu0gW--
var data = new FormData()
data.append('version', '4.2')
data.append('company', '1')
data.append('action', 'create')
data.append(
  'input',
  '{"data":[{"projectClientCode":"1/WORK","projectName":"Test Created 21-07-25"}]}'
)

var xhr = new XMLHttpRequest()
xhr.withCredentials = true

xhr.addEventListener('readystatechange', function () {
  if (this.readyState === 4) {
    console.log(this.responseText)
  }
})

xhr.open('POST', 'https://<SERVER>/jsonapi/projects.json')
xhr.setRequestHeader('user', 'Aladdin')
xhr.setRequestHeader(
  'sha512password',
  'da0f46990f3d9a7222911d8f674b19792623347d3e4d4a516127d610dfde0dd40001a228ffc64252ea67f70a9e7d81610f3c4f2123ba54f7c7f93a539577db93'
)
xhr.send(data)

Option 4: Authorization: Basic header

Per the note above, the password half must be plaintext, not sha512password — so the
base64 payload here is username:plaintext_password, not username:sha512_hash. The example
below reuses the same dummy hash purely as a stand-in string; treat it as illustrative of the
encoding mechanics, not as a literal password to base64-encode in a real call.

curl --location 'https://<SERVER>/jsonapi/projects.json' \
--header 'Authorization: Basic QWxhZGRpbjpkYTBmNDY5OTBmM2Q5YTcyMjI5MTFkOGY2NzRiMTk3OTI2MjMzNDdkM2U0ZDRhNTE2MTI3ZDYxMGRmZGUwZGQ0MDAwMWEyMjhmZmM2NDI1MmVhNjdmNzBhOWU3ZDgxNjEwZjNjNGYyMTIzYmE1NGY3YzdmOTNhNTM5NTc3ZGI5Mw==' \
--form 'version="4.2"' \
--form 'company="1"' \
--form 'action="create"' \
--form 'input="{\"data\":[{\"projectClientCode\":\"1/WORK\",\"projectName\":\"Test Created 21-07-25\"}]}"'
POST /jsonapi/projects.json HTTP/1.1
Host: <SERVER>
Authorization: Basic QWxhZGRpbjpkYTBmNDY5OTBmM2Q5YTcyMjI5MTFkOGY2NzRiMTk3OTI2MjMzNDdkM2U0ZDRhNTE2MTI3ZDYxMGRmZGUwZGQ0MDAwMWEyMjhmZmM2NDI1MmVhNjdmNzBhOWU3ZDgxNjEwZjNjNGYyMTIzYmE1NGY3YzdmOTNhNTM5NTc3ZGI5Mw==
Content-Length: 484
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="version"

4.2
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="company"

1
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="action"

create
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="input"

{"data":[{"projectClientCode":"1/WORK","projectName":"Test Created 21-07-25"}]}
------WebKitFormBoundary7MA4YWxkTrZu0gW--
var stringToEncode = 'Aladdin:<plaintext password here>'
var encodedAuthentication = 'Basic ' + btoa(stringToEncode)

var data = new FormData()
data.append('version', '4.2')
data.append('company', '1')
data.append('action', 'create')
data.append(
  'input',
  '{"data":[{"projectClientCode":"1/WORK","projectName":"Test Created 21-07-25"}]}'
)

var xhr = new XMLHttpRequest()
xhr.withCredentials = true

xhr.addEventListener('readystatechange', function () {
  if (this.readyState === 4) {
    console.log(this.responseText)
  }
})

xhr.open('POST', 'https://<SERVER>/jsonapi/projects.json')
xhr.setRequestHeader('Authorization', encodedAuthentication)
xhr.send(data)

Base64 is not encryption — it's trivially reversible to the original username/password, so
Basic Auth offers no cryptographic protection on its own. Always use HTTPS, and more so with Basic
Auth than with any other method here.


Legacy error codes (API_Connection / API_Error)

These are returned by the general API layer (distinct from the OAuth-standard errors above) as an
error code plus a 401 HTTP status:

CodeMeaning
404001No credentials supplied (or access-token-only mode is enforced)
404002Invalid credentials supplied
404003Account disabled
404005Account locked out
404006Invalid credentials/IP (session IP mismatch)
404007Invalid session credentials supplied
404008Session could not be established / app disabled for this user
404009Incorrect API (unrecognised /jsonapi, /xmlapi, /csvapi route)
404010Access token not recognised
404011Access token has expired

See also