Sign inBook a demoStart a pilot

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

The base URL for every call is https://api.samloryx.co.za. All traffic is HTTPS; plain HTTP is redirected.

1 · Create a key

  1. Sign in to the app as an owner or admin and open Developers.
  2. 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.
  3. 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.

Request
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"
200 OK
{
  "displayName": "Accounting sync",
  "tenant": "Thabeng Civils",
  "roles": ["API"],
  "scopes": ["graph.read", "workspaces.read"]
}
This endpoint needs the 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.

HeaderValue
X-API-Keyslx_…
AuthorizationBearer slx_…
The Bearer form
curl https://api.samloryx.co.za/api/v1/workspaces \
  -H "Authorization: Bearer $SAMLORYX_API_KEY"

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.

ScopeLets the key readEndpoints
graph.readCompanies, people, sites, document records and obligationsGET /api/v1/graph/…
tenders.readMatched tenders, and the agent catalogueGET /api/v1/tenders
GET /api/v1/agents
workspaces.readWhich products the company runs, and the key's own identityGET /api/v1/workspaces
GET /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

What the values look like

A small client

Node
// 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
# 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.

An error
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"
}
StatusMeansWhat to do
400A parameter has a value that is not allowed, such as an unknown tier.Fix the request. Retrying unchanged will fail again.
401The key is missing, mistyped or revoked.Stop and alert a person. Do not retry in a loop: repeated failures get the address rate-limited.
403The 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.
429Too many failed authentication attempts from your address.Wait the number of seconds in Retry-After.
5xxSomething 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

Webhooks

What webhooks do today: you can register an endpoint and receive a signed test delivery. No product events are sent yet. Nothing arrives when a tender is matched or an obligation changes. You can register and verify now to be ready, but do not depend on events until this page says they exist.

Register an endpoint

An owner or admin adds the URL on the Developers page. The URL must:

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:

Test delivery
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"}

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.

Node (Express)
// 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
});
Python (Flask)
# 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 know

The 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

Versions and changes

Go-live checklist