Integration guide
From nothing to a working integration: a key, a first call, the rules the API follows, and how to verify a webhook delivery.
This guide describes v1 as it runs today. The API is read-only and in preview; the overview lists what is not built yet, and the API reference lists every endpoint and field.
Before you start
- You need a company on Samloryx OS. The API reads one company's data; there is no public or shared dataset to call.
- You need an owner or admin. Only they can create or revoke keys. A member cannot, and neither can a key.
- You need somewhere to keep a secret. A key is a password for the company's data. It belongs in a secret manager or an environment variable on a server, never in a browser, a mobile app or a repository.
The base URL for every call is https://api.samloryx.co.za. All traffic is HTTPS; plain HTTP is redirected.
1 · Create a key
- Sign in to the app as an owner or admin and open Developers.
- Create a key. Give it a name that says what uses it ("Accounting sync", not "key 2") and select only the scopes that integration needs.
- Copy the key immediately. It looks like
slx_AbCd1234_…and is shown once: the platform stores only a SHA-256 digest of it, so nobody, including us, can show it to you again.
Afterwards the Developers page lists each key by its first twelve characters (for example slx_AbCd1234), with its scopes, when it was last used, and how many calls it has made this month.
2 · Make the first call
Ask the API who the key is. A 200 proves the key works and tells you which company and scopes it carries.
export SAMLORYX_API_KEY="slx_…" # from the Developers page; never commit it
curl https://api.samloryx.co.za/api/v1/me \
-H "X-API-Key: $SAMLORYX_API_KEY"{
"displayName": "Accounting sync",
"tenant": "Thabeng Civils",
"roles": ["API"],
"scopes": ["graph.read", "workspaces.read"]
}workspaces.read scope. A key without it gets a 403 here even though the key is valid. If you want a connection test that works for any key, call an endpoint inside a scope you did grant.Authentication
Send the key on every request, in either of two headers. They are equivalent; use whichever your HTTP client makes easier.
| Header | Value |
|---|---|
X-API-Key | slx_… |
Authorization | Bearer slx_… |
curl https://api.samloryx.co.za/api/v1/workspaces \
-H "Authorization: Bearer $SAMLORYX_API_KEY"- There are no sessions, cookies or token exchanges for keys. Each request stands alone.
- A key belongs to exactly one company. There is no parameter that selects a company, and nothing a key can send will return another company's data.
- A key identifies an integration, not a person. It cannot sign in to the app or do anything a person can.
Scopes
A scope is permission to read one area. A key carries any combination of the three. The rule is default-deny: an endpoint that is not listed here cannot be reached with a key at all.
| Scope | Lets the key read | Endpoints |
|---|---|---|
graph.read | Companies, people, sites, document records and obligations | GET /api/v1/graph/… |
tenders.read | Matched tenders, and the agent catalogue | GET /api/v1/tendersGET /api/v1/agents |
workspaces.read | Which products the company runs, and the key's own identity | GET /api/v1/workspacesGET /api/v1/me |
graph.read includes people's names, email addresses and phone numbers. That is personal information: grant the scope only to an integration that needs it, and use what it returns only for the purpose the company authorised.
Scopes are fixed when a key is created. To change them, create a new key and revoke the old one.
Reading data
Every endpoint is a GET that returns JSON. List endpoints return a plain array, newest record first.
How many rows you get
limitsets the number of rows: 1 to 200, default 100. A value outside that range is clamped, not rejected.- There is no paging yet. A list returns its newest 200 rows at most, and there is no cursor to fetch the rest. If a company has more than 200 of something, v1 cannot give you all of it; tell us if that blocks you.
- An empty array is a normal answer. It means there is nothing to return, not that something failed.
What the values look like
- Ids are UUIDs. Tenders are the exception: they are identified by their
ocid. - Timestamps are ISO 8601 in UTC (
2026-08-16T13:17:15.889594Z); dates areYYYY-MM-DD. - Money is a decimal number with a separate ISO 4217
currency, not an integer of cents. - A field with no value is
null. Fields may be added over time, so ignore any you do not recognise.
A small client
// Node 18+ (built-in fetch). No dependencies.
const BASE = "https://api.samloryx.co.za";
async function samloryx(path) {
const response = await fetch(BASE + path, {
headers: { "X-API-Key": process.env.SAMLORYX_API_KEY },
});
if (response.status === 429) {
const wait = Number(response.headers.get("Retry-After") ?? 60);
throw new Error(`Rate limited; retry in ${wait}s`);
}
const body = await response.json();
if (!response.ok) {
// Every error is application/problem+json. Keep the correlation id.
throw new Error(`${body.status} ${body.title}: ${body.detail} [${body.correlationId}]`);
}
return body;
}
const obligations = await samloryx("/api/v1/graph/obligations?limit=200");
const open = obligations.filter((o) => o.status === "OPEN");
console.log(`${open.length} open obligations`);# Python 3.9+, with the requests package.
import os
import requests
BASE = "https://api.samloryx.co.za"
session = requests.Session()
session.headers["X-API-Key"] = os.environ["SAMLORYX_API_KEY"]
def samloryx(path, **params):
response = session.get(BASE + path, params=params, timeout=30)
if response.status_code == 429:
raise RuntimeError(f"Rate limited; retry in {response.headers.get('Retry-After', '60')}s")
body = response.json()
if not response.ok:
# Every error is application/problem+json. Keep the correlation id.
raise RuntimeError(f"{body['status']} {body['title']}: {body['detail']} [{body['correlationId']}]")
return body
tenders = samloryx("/api/v1/tenders", tier="RECOMMENDED", decided="false")
for tender in tenders:
print(tender["closesOn"], tender["score"], tender["title"])There is no change feed. To keep a copy in step, read on a schedule and compare by id; every authorised call is counted, so a few times an hour is a sensible ceiling for most uses.
Errors
Every error, from every endpoint, has the same shape: application/problem+json, with a human-readable detail and a correlationId that is also returned in the X-Correlation-Id header.
HTTP/1.1 403
Content-Type: application/problem+json;charset=UTF-8
X-Correlation-Id: 4024dacb-0c78-427d-8afc-af632178d560
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"detail": "API key is missing required scope 'tenders.read'",
"correlationId": "4024dacb-0c78-427d-8afc-af632178d560",
"timestamp": "2026-10-10T07:33:23.900161070Z"
}| Status | Means | What to do |
|---|---|---|
400 | A parameter has a value that is not allowed, such as an unknown tier. | Fix the request. Retrying unchanged will fail again. |
401 | The key is missing, mistyped or revoked. | Stop and alert a person. Do not retry in a loop: repeated failures get the address rate-limited. |
403 | The key is valid but may not do this: it lacks the scope, the endpoint is not open to keys, or the method is not GET. | Read detail; it names the missing scope. A new key is needed to add one. |
429 | Too many failed authentication attempts from your address. | Wait the number of seconds in Retry-After. |
5xx | Something failed on our side. | Retry with increasing delays, and send us the correlationId if it persists. |
When you ask for help, quote the correlationId. It identifies the exact request in our logs; the key itself is never logged, so do not send it.
Limits and usage
- Usage is counted. Each authorised call adds one to the company's API-call count for the month, per key. Calls refused with
401or403are not counted. An owner or admin can see the month's total and the split per key on the Developers page. - Failed authentication is limited. Twenty-five failed attempts from one address within fifteen minutes and further credential-bearing requests from that address get
429until the window passes. A working integration never reaches this; one stuck retrying a revoked key will. - There is no request-rate limit on valid keys today. Do not build as though there never will be: honour
429andRetry-Afterwherever they appear, and keep polling modest.
Webhooks
Register an endpoint
An owner or admin adds the URL on the Developers page. The URL must:
- start with
https://; - use a public hostname. An IP address,
localhost, a.localor.internalname, or a name that resolves to a private network is refused; - contain no username or password.
On creation you are shown a signing secret beginning whsec_. Like a key, it is shown once. Store it beside the endpoint that will verify with it.
What a delivery looks like
Press Ping next to the endpoint on the Developers page. The platform posts this to your URL and shows you the status your server answered with:
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: Samloryx-Webhooks/1.0
X-Samloryx-Signature: 5d0c…e41a # hex HMAC-SHA256 of the raw body, keyed with your secret
{"type":"ping","timestamp":"2026-10-10T07:41:12.508Z"}- Answer with any
2xxwithin five seconds. Do the real work after you have answered. - Redirects are not followed. A
301or302is recorded as the result; register the final URL. - The ping is sent once, when you press the button. It is not retried.
Verify the signature
X-Samloryx-Signature is the lowercase hex HMAC-SHA256 of the request body, keyed with your signing secret. Compute it over the raw bytes you received, before any JSON parsing, and compare in constant time. Reject the request if it does not match: without this check anybody who learns your URL can post to it.
// Express. The signature is over the RAW bytes, so read the body unparsed.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SAMLORYX_WEBHOOK_SECRET; // whsec_…
app.post("/webhooks/samloryx", express.raw({ type: "application/json" }), (req, res) => {
const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const received = String(req.get("X-Samloryx-Signature") ?? "");
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body);
// Refuse anything old, so a captured delivery cannot be replayed later.
if (Math.abs(Date.now() - Date.parse(event.timestamp)) > 5 * 60 * 1000) return res.status(400).end();
if (event.type === "ping") return res.status(204).end();
res.status(204).end(); // unknown types: acknowledge and ignore
});# Flask. The signature is over the RAW bytes: use request.get_data(), not request.json.
import hashlib, hmac, json, os
from datetime import datetime, timezone
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["SAMLORYX_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/webhooks/samloryx")
def samloryx_webhook():
raw = request.get_data()
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-Samloryx-Signature", "")
if not hmac.compare_digest(received, expected):
return "", 401
event = json.loads(raw)
# Refuse anything old, so a captured delivery cannot be replayed later.
sent = datetime.fromisoformat(event["timestamp"].replace("Z", "+00:00"))
if abs((datetime.now(timezone.utc) - sent).total_seconds()) > 300:
return "", 400
return "", 204 # "ping" today; acknowledge and ignore types you do not knowThe signature covers the body only, and the body carries its own timestamp. Checking that the timestamp is recent, as both samples do, stops an old delivery being replayed.
Keeping keys safe
- One key per integration. Then revoking one does not break the others, and usage tells you which system is calling.
- Fewest scopes that work. A reporting job that reads tenders does not need people's contact details.
- Server-side only. Anything shipped to a browser or a phone can be read by whoever holds the device.
- Rotate by replacing. Create the new key, deploy it, confirm the old key's "last used" has stopped moving, then revoke the old one.
- If a key leaks, revoke it first and investigate second. Revocation takes effect on the next request, and the key's usage history remains for you to review.
Versions and changes
- The version is in the path:
/api/v1. - New fields and new endpoints can appear at any time. Write clients that ignore what they do not recognise.
- v1 is in preview. We will avoid breaking changes, but until it leaves preview we do not promise there will be none. If you are building something you will depend on, tell us, so that we know who to warn.
- The machine-readable description is the OpenAPI document. It is generated from the running platform, so it and the reference change together.
Go-live checklist
- The key is in a secret store, not in code, and has only the scopes the integration uses.
GET /api/v1/me(or another endpoint in scope) returns200from the production environment.401stops the integration and alerts someone; it does not retry forever.429and5xxare retried with a delay;400and403are not retried.- Errors are logged with their
correlationId, and the key is never logged. - Unknown fields and empty arrays are handled without failing.
- If you registered a webhook: the signature is verified on the raw body, and your endpoint answers within five seconds.
- Somebody at the company knows this integration exists and how to revoke its key.