Documentation

Rent Guard API

Connect your own rent system so payments are recorded automatically. Signed, single-use requests, a key that can do almost nothing, and code you can copy that has been tested against the real service.

1

What this API is for (and what it cannot do)

If you track rent in your own system (a spreadsheet export, a property management package, your bank feed), your system can tell ISN who has paid, so you do not have to mark each resident paid by hand. Your system calls ISN; ISN never calls out to your system, so there is nothing for you to expose to the internet.

The API was designed so that even if your system is hacked, the damage stays small. A key belongs to you alone and can only do three things: check that it works, list your residents' rent status, and mark rent paid or not paid for your own residents. It cannot:

  • read names, phone numbers, passwords or anything personal (the list shows your own reference, unit, the account's login name and rent status only; a login name can never be an email address or phone number);
  • create, change or delete accounts, change your settings, create or read keys, or touch any other landlord;
  • mark rent paid for more than one month ahead (so it cannot buy months of free internet);
  • reverse a payment for any month except the current one;
  • cut anyone off straight away: reporting "unpaid" starts a new grace period counted from the day it is reported (so even a payment reversed late in the month gives the resident the full grace period), and if a key tries to report more residents as unpaid than a landlord normally would (more than 3, or more than 30% of the residents you track, never more than 100, counted over 24 hours for that key) the request is held for you to review instead of being applied.

Every request is signed with your secret, expires after 5 minutes, can be used only once, and is written to your activity log. You can restrict a key to your server's address, make it read-only, and revoke it in one click.

2

Before you start

  1. In your ISN dashboard open Free access → Rent Guard and switch Rent Guard on. The API answers "rent_guard_off" while it is off.
  2. Give each resident a rent due day (1 to 28) and a reference: your own code for them, for example the flat number or tenant code in your system. The API finds the resident by this reference. (It can also find them by unit, username or phone number.)
  3. Create a key: Connect your property system (API) → give it a name → choose Read only while you are testing → if your server has a fixed address, add it under "Only allow these addresses". You must re-enter your password. You see two things: the key id (starts with rgk_) and the secret (starts with rgs_). The secret is shown once. Put it in your server's secret store or an environment variable, never in your code, a spreadsheet or an email.
3

How a request is signed

Every request carries one header:

Authorization: ISN-HMAC keyId="rgk_...", ts="1790000000", nonce="a1b2c3d4e5f60718", sig="<64 hex characters>"
  • ts is the time now, in seconds since 1970 (Unix time). It must be within 5 minutes of ISN's clock.
  • nonce is a fresh random value for every request (16 to 64 letters, digits, dash or underscore). ISN refuses a nonce it has already seen, so a copy of your request cannot be replayed.
  • sig is HMAC-SHA256, using your secret as the key, over these six lines joined with a newline (no newline at the end):
ISN-RENT-V1
METHOD            (GET or POST, in capitals)
PATH              (the path with its query string, exactly as you send it, for example /api/rent/v1/accounts?limit=100)
TS                (the same ts as in the header)
NONCE             (the same nonce as in the header)
BODYHASH          (SHA-256 of the exact body text you send, as 64 lowercase hex characters; for a GET with no body, the SHA-256 of an empty string)

The signature covers the method, path, time, nonce and body, so none of them can be changed on the way. Sign the exact bytes you send: if you re-format the JSON after signing, the signature will not match.

Test vector (use it to check your own code in any language). With:

secret : rgs_TestSecretDoNotUse_000000000000000000000
method : POST
path   : /api/rent/v1/payments
ts     : 1790000000
nonce  : a1b2c3d4e5f60718
body   : {"payments":[{"ref":"FLAT-101","period":"2026-11","status":"paid"}]}

the six lines to sign are

ISN-RENT-V1
POST
/api/rent/v1/payments
1790000000
a1b2c3d4e5f60718
deb368f02e403364bb2c8e2ee739a9aa78741e976292b26ae5c017af2870b7ff

and the signature must be

cd6b934f779a8d011d863f9d6ad3537504bb1ce52acc675f317cdad70900d327
4

The three calls

The address is https://isnfreewifi.co.za. Only https is accepted on the live service. Answers are JSON.

CallWhat it doesKey needed
GET /api/rent/v1/pingChecks that your key and signing work. Answers {"ok":true,"scope":"write"}.Read or write
GET /api/rent/v1/accounts?limit=100&after=0Lists your tracked residents: ref, unit, username, state (ok, grace or unpaid), period, dueDay, paidThrough, graceEndsOn. Up to 200 per page; next in the answer is the value to send as after for the next page (null when there are no more).Read or write
POST /api/rent/v1/paymentsMarks rent paid or not paid. Up to 200 items per request.Write

Body of POST /api/rent/v1/payments:

{
  "payments": [
    { "ref": "FLAT-101", "period": "2026-11", "status": "paid",   "reference": "EFT-4471" },
    { "phone": "+264 81 123 4567", "period": "2026-11", "status": "paid" },
    { "unit": "102", "period": "2026-11", "status": "unpaid" }
  ]
}
  • Identify each resident with one of ref (recommended), unit, username or phone. If you give more than one they must all point to the same resident, or the item is refused (conflicting_identifiers). A value that matches two residents is refused (ambiguous); ISN never guesses.
  • period is the month the rent is for, as YYYY-MM.
  • status is paid or unpaid.
  • reference (optional, up to 64 characters) is your own payment reference. It is kept in your activity log so you can match entries to your records.

The answer lists one result per item, in the same order:

{ "ok": true, "applied": 2, "results": [
  { "index": 0, "status": "applied", "ref": "FLAT-101" },
  { "index": 1, "status": "unchanged" },
  { "index": 2, "status": "error", "code": "only_current_period" } ] }

One bad item never stops the others. Sending the same payment again is safe: it answers unchanged.

Item result codeMeaning
applied / unchangedDone, or already that way.
not_foundNo tracked resident of yours matches. (Check the reference, and that the resident has a rent due day.)
ambiguousMore than one resident matches; use ref.
conflicting_identifiersThe identifiers you gave point to different residents.
bad_ref, bad_phone, bad_period, bad_status, bad_item, no_identifierThe item is not valid. A phone number must be a complete South African (+27) or Namibian (+264) number.
period_too_far_aheadRent can be marked paid for the current month or the next one only.
only_current_periodOnly the current month's payment can be reversed.
no_due_day, account_inactiveThe resident has no rent due day, or their account is disabled.
5

Phone numbers: South Africa, Namibia and Eswatini

If you identify residents by phone number, send it however your system stores it. ISN reads all of these as the same number:

South AfricaNamibiaEswatini
+27 71 123 4567, +2771 1234567, +27711234567+264 81 123 4567, +26481 1234567, +264811234567+268 7612 3456, +26876123456
0711234567, 071 123 4567, 071-123-4567, (071) 123 45670811234567, 081 123 4567, 081-123-45677612 3456, 76123456, 07612 3456
27711234567, 0027711234567264811234567, 0026481123456726876123456, 0026876123456

A number written with a leading 0 (or with no country code) is read as your own country, the one set under Country & currency in your dashboard. A number written with +27, +264 or +268 keeps its own country. Eswatini numbers have 8 digits and no leading 0 (mobiles start 76, 77, 78 or 79); a bare 8 digit number is only read as Eswatini when your building is in Eswatini. Fragments such as +27 71 1 or 0711 are refused (bad_phone); a partial number is never matched. Other countries are not supported. The reference is still the safest way to identify a resident.

6

Months, the grace period and what the API changes

Each resident has a rent due day. On any date, the current month is the latest month whose due day has arrived. Marking a month paid covers that month and everything before it. If the current month is not paid, the resident is not touched on the due day itself; from the day after, they are in grace for the days you chose (3 or 5); after that they are unpaid and their internet is cut. A payment reported as reversed counts as due on the day it is reported, so grace is counted from that day. When you mark rent paid, everything comes back straight away: speed, sign in, guest vouchers and the blocked devices.

7

Errors, limits and retrying

HTTPcodeWhat to do
401unauthorizedThe same answer for every reason (wrong key, wrong secret, changed body, old or future time, repeated nonce, revoked key), so a thief learns nothing. Check the signing against the test vector, your server's clock, and that you use a new nonce each time.
403read_only_key, ip_not_allowed, rent_guard_off, account_suspendedThe key or your account does not allow this. Fix it in the dashboard.
409held_for_reviewThe key has reported too many residents as unpaid (more than 3, or more than 30% of the residents you track, never more than 100, counted over 24 hours). It was not applied. Check your system for a mistake, then ask the landlord to review.
400bad_request, bad_json, https_requiredFix the request. Send 1 to 200 payments.
413The body is larger than 64 KB.
429rate_limited, daily_limitA key may make 60 requests a minute and report 2,000 payments a day. Wait (see Retry-After) and send fewer, larger batches.
5xxTry again. Because the same payment can be sent twice safely, retrying is always fine. Sign again with a new nonce on every retry; the samples below do this.
8

Keeping it safe (read this before you go live)

  • Keep the secret secret. Store it in an environment variable or your hosting's secret store. Never put it in source code, a repository, a spreadsheet, a chat message or an email. ISN stores it encrypted and shows it once; if you lose it, create a new key.
  • Use the smallest key that works. A system that only reads uses a Read only key. Use a separate key for each system so you can revoke one without stopping the others.
  • Pin the key to your server's address if it has a fixed one ("Only allow these addresses"). A stolen key is then useless from anywhere else, and you are told when a key is used from a new address.
  • Always https. Never turn off certificate checking and never follow redirects with a signed request (the samples refuse redirects).
  • Sign the exact body you send, with a new nonce and a fresh time on every request. Keep your server's clock correct (use network time).
  • Validate your own data first. Do not pass text from your tenants straight into a request. The samples check references and months before they send anything.
  • Watch the activity log on the Rent Guard page. Every API call, who made it (which key) and every held request is there. If you see something you did not do, revoke the key at once and create a new one.
  • If your system is hacked: revoke the key (one click). Residents' internet cannot have been cut by the thief (the API only starts a grace clock, and mass changes are held), and rent can never have been marked more than one month ahead. Check the activity log for what was changed, then use "Reverse" or "Mark paid" on the Rent Guard page to correct any resident.
  • Test with a read only key first, then with one resident, then roll out. There is no separate test server: use one resident and a spare reference.
9

Code you can use

Each sample is complete, has no dependencies, signs every request correctly with a fresh nonce, refuses plain http (localhost is allowed only if you deliberately set ISN_ALLOW_LOCAL_HTTP=1 for testing), always checks ISN's certificate (the Node sample even refuses to run if the check has been switched off), refuses an address that hides a user name, password or path, checks the unit reference, month and payment reference before sending anything, never follows redirects, ignores anything past 1 MB in an answer, removes the secret from the environment once it has read it, times out after 10 seconds and retries safely. All four were run against the real service before we published them. Set ISN_RENT_KEY_ID and ISN_RENT_SECRET in the environment, then run ping first.

Node.js (18 or newer)

#!/usr/bin/env node
// ISN Rent Guard API: Node.js example. Needs Node 18 or newer. No packages to install.
//
//   export ISN_RENT_KEY_ID="rgk_..."      (from the Rent Guard page of your ISN dashboard)
//   export ISN_RENT_SECRET="rgs_..."      (shown once when you created the key; keep it in a secret store, never in code)
//   node rent-guard.js ping
//   node rent-guard.js accounts
//   node rent-guard.js paid FLAT-101 2026-11 EFT-4471     (reference is optional: your own payment reference)
//   node rent-guard.js unpaid FLAT-101 2026-11
'use strict';
const crypto = require('crypto');

const BASE = (process.env.ISN_BASE_URL || 'https://isnfreewifi.co.za').replace(/\/+$/, '');
const KEY_ID = process.env.ISN_RENT_KEY_ID || '';
const SECRET = process.env.ISN_RENT_SECRET || '';
delete process.env.ISN_RENT_SECRET;                      // the secret now lives only in this variable, not in the environment that child programs inherit

// Only talk to ISN over https, with the certificate check ON. (Plain http is allowed only for localhost, and only when you set ISN_ALLOW_LOCAL_HTTP=1 to test on your own machine.)
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') { console.error('Refusing to run with NODE_TLS_REJECT_UNAUTHORIZED=0 (it switches the certificate check off)'); process.exit(2); }
const url = new URL(BASE);
const isLocal = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) && process.env.ISN_ALLOW_LOCAL_HTTP === '1';
if (url.protocol !== 'https:' && !isLocal) { console.error('ISN_BASE_URL must start with https://'); process.exit(2); }
if (url.username || url.password || url.pathname !== '/' || url.search) { console.error('ISN_BASE_URL must be just the address, for example https://isnfreewifi.co.za'); process.exit(2); }
if (!/^rgk_[A-Za-z0-9]{16,40}$/.test(KEY_ID) || !SECRET) { console.error('Set ISN_RENT_KEY_ID and ISN_RENT_SECRET'); process.exit(2); }

// Signs one request. `path` is the path WITH its query string, exactly as it will be sent. `body` is the exact text that will be sent ('' for GET).
function authHeader(method, path, body) {
  const ts = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomBytes(16).toString('hex');                          // single use: a new one for every request, including retries
  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
  const canonical = ['ISN-RENT-V1', method.toUpperCase(), path, ts, nonce, bodyHash].join('\n');
  const sig = crypto.createHmac('sha256', SECRET).update(canonical).digest('hex');
  return `ISN-HMAC keyId="${KEY_ID}", ts="${ts}", nonce="${nonce}", sig="${sig}"`;
}

async function call(method, path, payload) {
  const body = payload === undefined ? '' : JSON.stringify(payload);
  let last;
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const res = await fetch(BASE + path, {
        method,
        headers: { 'Content-Type': 'application/json', Authorization: authHeader(method, path, body) },   // fresh signature on every attempt
        body: body === '' ? undefined : body,
        redirect: 'error',                                  // never follow a redirect: it would send your signed request somewhere else
        signal: AbortSignal.timeout(10000)
      });
      const text = (await res.text()).slice(0, 1000000);   // never trust the size of an answer
      let json; try { json = JSON.parse(text); } catch (_) { json = { raw: text.slice(0, 300) }; }
      last = { status: res.status, body: json };
      if (res.status !== 429 && res.status < 500) return last;   // success, or an error that retrying cannot fix
    } catch (err) { last = { status: 0, body: { error: String(err && err.message || err) } }; }
    if (attempt < 3) await new Promise((r) => setTimeout(r, 500 * attempt));
  }
  return last;
}

const isPeriod = (p) => /^\d{4}-(0[1-9]|1[0-2])$/.test(p || '');
const isRef = (r) => /^[A-Za-z0-9._-]{1,40}$/.test(r || '');                     // check what you send, even from your own system
const isReference = (r) => r === undefined || /^[A-Za-z0-9._:\/@ -]{1,64}$/.test(r);
const ping = () => call('GET', '/api/rent/v1/ping');
const accounts = (after) => call('GET', '/api/rent/v1/accounts?limit=100' + (after ? '&after=' + encodeURIComponent(after) : ''));
// items: [{ ref | unit | username | phone, period: 'YYYY-MM', status: 'paid' | 'unpaid', reference?: 'your own payment reference' }], up to 200 at a time
const payments = (items) => call('POST', '/api/rent/v1/payments', { payments: items });

module.exports = { ping, accounts, payments };

if (require.main === module) {
  (async () => {
    const [cmd, ref, period, reference] = process.argv.slice(2);
    let r;
    if (cmd === 'ping') r = await ping();
    else if (cmd === 'accounts') r = await accounts();
    else if ((cmd === 'paid' || cmd === 'unpaid') && isRef(ref) && isPeriod(period) && isReference(reference)) r = await payments([{ ref, period, status: cmd, reference }]);
    else { console.error('usage: rent-guard.js ping | accounts | paid <ref> <YYYY-MM> [reference] | unpaid <ref> <YYYY-MM>'); process.exit(2); }
    console.log(JSON.stringify(r.body, null, 2));
    process.exit(r.status >= 200 && r.status < 300 ? 0 : 1);
  })();
}

Python 3

#!/usr/bin/env python3
"""ISN Rent Guard API: Python 3 example. Standard library only, nothing to install.

    export ISN_RENT_KEY_ID="rgk_..."      (from the Rent Guard page of your ISN dashboard)
    export ISN_RENT_SECRET="rgs_..."      (shown once when you created the key; keep it in a secret store, never in code)
    python3 rent_guard.py ping
    python3 rent_guard.py accounts
    python3 rent_guard.py paid FLAT-101 2026-11 EFT-4471     (reference is optional: your own payment reference)
    python3 rent_guard.py unpaid FLAT-101 2026-11
"""
import hashlib
import hmac
import json
import os
import re
import secrets
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

BASE = os.environ.get("ISN_BASE_URL", "https://isnfreewifi.co.za").rstrip("/")
KEY_ID = os.environ.get("ISN_RENT_KEY_ID", "")
SECRET = os.environ.pop("ISN_RENT_SECRET", "")   # the secret now lives only in this variable, not in the environment that child programs inherit

# Only talk to ISN over https, with the certificate check ON. (Plain http is allowed only for localhost, and only when you set ISN_ALLOW_LOCAL_HTTP=1 to test on your own machine.)
_parts = urllib.parse.urlparse(BASE)
_local_ok = (_parts.hostname or "") in ("localhost", "127.0.0.1", "::1") and os.environ.get("ISN_ALLOW_LOCAL_HTTP") == "1"
if _parts.scheme != "https" and not _local_ok:
    print("ISN_BASE_URL must start with https://", file=sys.stderr)
    sys.exit(2)
if _parts.username or _parts.password or _parts.path not in ("", "/") or _parts.query:
    print("ISN_BASE_URL must be just the address, for example https://isnfreewifi.co.za", file=sys.stderr)
    sys.exit(2)
if not re.fullmatch(r"rgk_[A-Za-z0-9]{16,40}", KEY_ID) or not SECRET:
    print("Set ISN_RENT_KEY_ID and ISN_RENT_SECRET", file=sys.stderr)
    sys.exit(2)


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):   # never follow a redirect: it would send your signed request somewhere else
        return None


_tls = ssl.create_default_context()          # verifies the certificate and the host name
_tls.minimum_version = ssl.TLSVersion.TLSv1_2
_opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=_tls))


def auth_header(method, path, body):
    """Signs one request. `path` is the path WITH its query string, exactly as it will be sent. `body` is the exact bytes that will be sent (b'' for GET)."""
    ts = str(int(time.time()))
    nonce = secrets.token_hex(16)                                   # single use: a new one for every request, including retries
    body_hash = hashlib.sha256(body).hexdigest()
    canonical = "\n".join(["ISN-RENT-V1", method.upper(), path, ts, nonce, body_hash])
    sig = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
    return 'ISN-HMAC keyId="%s", ts="%s", nonce="%s", sig="%s"' % (KEY_ID, ts, nonce, sig)


def call(method, path, payload=None):
    body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode()
    last = (0, {"error": "no attempt made"})
    for attempt in (1, 2, 3):
        req = urllib.request.Request(BASE + path, data=body or None, method=method)
        req.add_header("Content-Type", "application/json")
        req.add_header("Authorization", auth_header(method, path, body))   # fresh signature on every attempt
        try:
            with _opener.open(req, timeout=10) as res:
                status, text = res.status, res.read(1000000).decode("utf-8", "replace")   # never trust the size of an answer
        except urllib.error.HTTPError as err:
            status, text = err.code, err.read(1000000).decode("utf-8", "replace")
        except Exception as err:                                  # network problem: try again
            status, text = 0, json.dumps({"error": str(err)})
        try:
            parsed = json.loads(text)
        except ValueError:
            parsed = {"raw": text[:300]}
        last = (status, parsed)
        if status != 0 and status != 429 and status < 500:           # success, or an error that retrying cannot fix
            return last
        if attempt < 3:
            time.sleep(0.5 * attempt)
    return last


def is_period(p):
    return bool(re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", p or ""))


def is_ref(r):   # check what you send, even from your own system
    return bool(re.fullmatch(r"[A-Za-z0-9._-]{1,40}", r or ""))


def is_reference(r):
    return bool(re.fullmatch(r"[A-Za-z0-9._:/@ -]{1,64}", r))


def ping():
    return call("GET", "/api/rent/v1/ping")


def accounts(after=None):
    return call("GET", "/api/rent/v1/accounts?limit=100" + ("&after=" + urllib.parse.quote(str(after)) if after else ""))


def payments(items):
    """items: [{'ref' | 'unit' | 'username' | 'phone': ..., 'period': 'YYYY-MM', 'status': 'paid' | 'unpaid', 'reference': 'your own payment reference'}], up to 200 at a time"""
    return call("POST", "/api/rent/v1/payments", {"payments": items})


if __name__ == "__main__":
    args = sys.argv[1:]
    if args[:1] == ["ping"]:
        status, body = ping()
    elif args[:1] == ["accounts"]:
        status, body = accounts()
    elif len(args) >= 3 and args[0] in ("paid", "unpaid") and is_ref(args[1]) and is_period(args[2]) and (len(args) < 4 or is_reference(args[3])):
        item = {"ref": args[1], "period": args[2], "status": args[0]}
        if len(args) > 3:
            item["reference"] = args[3]
        status, body = payments([item])
    else:
        print("usage: rent_guard.py ping | accounts | paid <ref> <YYYY-MM> [reference] | unpaid <ref> <YYYY-MM>", file=sys.stderr)
        sys.exit(2)
    print(json.dumps(body, indent=2))
    sys.exit(0 if 200 <= status < 300 else 1)

C# (.NET 8) — save as Program.cs in a console project

// ISN Rent Guard API: C# (.NET 8) example. No packages to install.
//
//   set ISN_RENT_KEY_ID=rgk_...      (from the Rent Guard page of your ISN dashboard)
//   set ISN_RENT_SECRET=rgs_...      (shown once when you created the key; keep it in a secret store, never in code)
//   dotnet run -- ping
//   dotnet run -- accounts
//   dotnet run -- paid FLAT-101 2026-11 EFT-4471     (reference is optional: your own payment reference)
//   dotnet run -- unpaid FLAT-101 2026-11
using System;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

static class RentGuard
{
    static readonly string Base = (Environment.GetEnvironmentVariable("ISN_BASE_URL") ?? "https://isnfreewifi.co.za").TrimEnd('/');
    static readonly string KeyId = Environment.GetEnvironmentVariable("ISN_RENT_KEY_ID") ?? "";
    static readonly string Secret = Environment.GetEnvironmentVariable("ISN_RENT_SECRET") ?? "";
    // never follow a redirect: it would send your signed request somewhere else
    static readonly HttpClient Http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(10), MaxResponseContentBufferSize = 1000000 };   // never trust the size of an answer

    static string Hex(byte[] b) => Convert.ToHexString(b).ToLowerInvariant();

    // Signs one request. `path` is the path WITH its query string, exactly as it will be sent. `body` is the exact text that will be sent ("" for GET).
    static string AuthHeader(string method, string path, string body)
    {
        string ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
        string nonce = Hex(RandomNumberGenerator.GetBytes(16));                       // single use: a new one for every request, including retries
        string bodyHash = Hex(SHA256.HashData(Encoding.UTF8.GetBytes(body)));
        string canonical = string.Join("\n", new[] { "ISN-RENT-V1", method.ToUpperInvariant(), path, ts, nonce, bodyHash });
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
        string sig = Hex(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));
        return $"ISN-HMAC keyId=\"{KeyId}\", ts=\"{ts}\", nonce=\"{nonce}\", sig=\"{sig}\"";
    }

    public static async Task<(int status, string body)> Call(string method, string path, object? payload = null)
    {
        string body = payload == null ? "" : JsonSerializer.Serialize(payload);
        (int, string) last = (0, "{\"error\":\"no attempt made\"}");
        for (int attempt = 1; attempt <= 3; attempt++)
        {
            try
            {
                using var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
                req.Headers.TryAddWithoutValidation("Authorization", AuthHeader(method, path, body));   // fresh signature on every attempt
                if (body != "") req.Content = new StringContent(body, Encoding.UTF8, "application/json");
                using var res = await Http.SendAsync(req);
                string text = await res.Content.ReadAsStringAsync();
                last = ((int)res.StatusCode, text);
                int s = (int)res.StatusCode;
                if (s != 429 && s < 500) return last;                // success, or an error that retrying cannot fix
            }
            catch (Exception ex) { last = (0, JsonSerializer.Serialize(new { error = ex.Message })); }
            if (attempt < 3) Thread.Sleep(500 * attempt);
        }
        return last;
    }

    public static Task<(int, string)> Ping() => Call("GET", "/api/rent/v1/ping");
    public static Task<(int, string)> Accounts(string? after = null) => Call("GET", "/api/rent/v1/accounts?limit=100" + (after != null ? "&after=" + Uri.EscapeDataString(after) : ""));
    // items: { ref | unit | username | phone, period = "YYYY-MM", status = "paid" | "unpaid", reference = "your own payment reference" }, up to 200 at a time
    public static Task<(int, string)> Payments(object[] items) => Call("POST", "/api/rent/v1/payments", new { payments = items });

    static async Task<int> Main(string[] args)
    {
        var uri = new Uri(Base);
        // https only, with the certificate check ON (the default). Plain http is allowed only for localhost, and only when ISN_ALLOW_LOCAL_HTTP=1, for testing on your own machine.
        bool local = (uri.Host is "localhost" or "127.0.0.1" or "[::1]") && Environment.GetEnvironmentVariable("ISN_ALLOW_LOCAL_HTTP") == "1";
        if (uri.Scheme != "https" && !local) { Console.Error.WriteLine("ISN_BASE_URL must start with https://"); return 2; }
        if (uri.UserInfo != "" || uri.AbsolutePath != "/" || uri.Query != "") { Console.Error.WriteLine("ISN_BASE_URL must be just the address, for example https://isnfreewifi.co.za"); return 2; }
        if (!Regex.IsMatch(KeyId, "^rgk_[A-Za-z0-9]{16,40}$") || Secret == "") { Console.Error.WriteLine("Set ISN_RENT_KEY_ID and ISN_RENT_SECRET"); return 2; }
        (int status, string body) r;
        if (args.Length >= 1 && args[0] == "ping") r = await Ping();
        else if (args.Length >= 1 && args[0] == "accounts") r = await Accounts();
        else if (args.Length >= 3 && (args[0] == "paid" || args[0] == "unpaid") && Regex.IsMatch(args[1], @"^[A-Za-z0-9._-]{1,40}$") && Regex.IsMatch(args[2], @"^\d{4}-(0[1-9]|1[0-2])$") && (args.Length < 4 || Regex.IsMatch(args[3], @"^[A-Za-z0-9._:/@ -]{1,64}$")))
            r = await Payments(new object[] { new { @ref = args[1], period = args[2], status = args[0], reference = args.Length > 3 ? args[3] : null } });
        else { Console.Error.WriteLine("usage: ping | accounts | paid <ref> <YYYY-MM> [reference] | unpaid <ref> <YYYY-MM>"); return 2; }
        Console.WriteLine(r.body);
        return r.status >= 200 && r.status < 300 ? 0 : 1;
    }
}

Shell (bash, curl and openssl)

#!/usr/bin/env bash
# ISN Rent Guard API: shell example. Needs bash, curl and openssl.
#
#   export ISN_RENT_KEY_ID="rgk_..."      (from the Rent Guard page of your ISN dashboard)
#   export ISN_RENT_SECRET="rgs_..."      (shown once when you created the key; keep it in a secret store, never in code)
#   ./rent-guard.sh ping
#   ./rent-guard.sh accounts
#   ./rent-guard.sh paid FLAT-101 2026-11 EFT-4471     (reference is optional: your own payment reference)
#   ./rent-guard.sh unpaid FLAT-101 2026-11
#
# Note: `openssl dgst -hmac` receives the secret on its command line for a moment, so another user of the same computer could see it with `ps`. Run this script on a
# machine only you use, or use one of the other samples (they keep the secret out of the process list).
set -euo pipefail

BASE="${ISN_BASE_URL:-https://isnfreewifi.co.za}"
BASE="${BASE%/}"
KEY_ID="${ISN_RENT_KEY_ID:-}"
SECRET="${ISN_RENT_SECRET:-}"

# Only talk to ISN over https, with the certificate check on (never add curl's -k). (Plain http is allowed only for localhost, and only when you set ISN_ALLOW_LOCAL_HTTP=1 to test on your own machine.)
case "$BASE" in
  https://*) ;;
  http://localhost*|http://127.0.0.1*|http://\[::1\]*) [[ "${ISN_ALLOW_LOCAL_HTTP:-}" == "1" ]] || { echo "ISN_BASE_URL must start with https://" >&2; exit 2; } ;;
  *) echo "ISN_BASE_URL must start with https://" >&2; exit 2 ;;
esac
[[ "$BASE" =~ ^https?://[^/@?#[:space:]]+$ ]] || { echo "ISN_BASE_URL must be just the address, for example https://isnfreewifi.co.za" >&2; exit 2; }   # no hidden user name, password, path or query
[[ "$KEY_ID" =~ ^rgk_[A-Za-z0-9]{16,40}$ && -n "$SECRET" ]] || { echo "Set ISN_RENT_KEY_ID and ISN_RENT_SECRET" >&2; exit 2; }

# call METHOD PATH [BODY]   PATH includes its query string, exactly as it is sent. BODY is the exact text sent.
call() {
  local method="$1" path="$2" body="${3:-}" attempt ts nonce body_hash canonical sig out status
  for attempt in 1 2 3; do
    ts="$(date +%s)"
    nonce="$(openssl rand -hex 16)"                      # single use: a new one for every request, including retries
    body_hash="$(printf '%s' "$body" | openssl dgst -sha256 -hex | awk '{print $NF}')"
    canonical="$(printf 'ISN-RENT-V1\n%s\n%s\n%s\n%s\n%s' "$method" "$path" "$ts" "$nonce" "$body_hash")"
    sig="$(printf '%s' "$canonical" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"
    # no -L: never follow a redirect, it would send your signed request somewhere else
    if [[ -n "$body" ]]; then
      out="$(curl -sS --max-time 10 --max-filesize 1000000 -X "$method" "$BASE$path" -H "Content-Type: application/json" \
        -H "Authorization: ISN-HMAC keyId=\"$KEY_ID\", ts=\"$ts\", nonce=\"$nonce\", sig=\"$sig\"" \
        --data-binary "$body" -w '\n%{http_code}' || true)"
    else
      out="$(curl -sS --max-time 10 --max-filesize 1000000 -X "$method" "$BASE$path" \
        -H "Authorization: ISN-HMAC keyId=\"$KEY_ID\", ts=\"$ts\", nonce=\"$nonce\", sig=\"$sig\"" -w '\n%{http_code}' || true)"
    fi
    status="${out##*$'\n'}"
    if [[ "$status" =~ ^[0-9]{3}$ && "$status" != "429" && "${status:0:1}" != "5" ]]; then
      printf '%s\n' "${out%$'\n'*}"
      [[ "${status:0:1}" == "2" ]]; return                # exit status: success only for 2xx
    fi
    sleep $((attempt))
  done
  echo '{"error":"request failed after 3 attempts"}'; return 1
}

json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; }

cmd="${1:-}"
case "$cmd" in
  ping)     call GET /api/rent/v1/ping ;;
  accounts) call GET "/api/rent/v1/accounts?limit=100" ;;
  paid|unpaid)
    ref="${2:-}"; period="${3:-}"; reference="${4:-}"
    [[ "$ref" =~ ^[A-Za-z0-9._-]{1,40}$ && "$period" =~ ^[0-9]{4}-(0[1-9]|1[0-2])$ ]] || { echo "usage: rent-guard.sh paid|unpaid <ref> <YYYY-MM> [reference]" >&2; exit 2; }
    [[ -z "$reference" || "$reference" =~ ^[A-Za-z0-9._:/@\ -]{1,64}$ ]] || { echo "reference may use letters, digits and . _ : / @ - space (up to 64)" >&2; exit 2; }
    item="{\"ref\":\"$ref\",\"period\":\"$period\",\"status\":\"$cmd\""
    [[ -n "$reference" ]] && item="$item,\"reference\":\"$(json_escape "$reference")\""
    call POST /api/rent/v1/payments "{\"payments\":[$item}]}" ;;
  *) echo "usage: rent-guard.sh ping | accounts | paid <ref> <YYYY-MM> [reference] | unpaid <ref> <YYYY-MM>" >&2; exit 2 ;;
esac

Using PHP, Java, Go or something else? Follow the signing steps in section 3 and check your code against the test vector. We have not published a PHP sample yet because we only publish code we have run against the real service; ask us at info@isnfreewifi.co.za and we will add the language you need.

10

A typical monthly job

Once a day (or when your bank feed updates), your system collects the residents who paid this month and sends them in batches:

const { payments } = require('./rent-guard');

(async () => {
  const month = '2026-11';
  const paid = [{ ref: 'FLAT-101', reference: 'EFT-4471' }, { ref: 'FLAT-102', reference: 'EFT-4475' }];
  const r = await payments(paid.map((p) => ({ ref: p.ref, period: month, status: 'paid', reference: p.reference })));
  if (r.status !== 200) throw new Error('ISN said ' + r.status + ' ' + JSON.stringify(r.body));
  for (const x of r.body.results) if (x.status === 'error') console.warn('item', x.index, x.code);
})();

Do not report residents as "unpaid" every day: they become unpaid by themselves when the due day and grace period pass. Use "unpaid" only to undo a payment that bounced or was reversed. When you do, the resident gets a fresh grace period starting that day.