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.
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.
Before you start
- In your ISN dashboard open Free access → Rent Guard and switch Rent Guard on. The API answers "rent_guard_off" while it is off.
- 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.)
- 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 withrgs_). 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.
How a request is signed
Every request carries one header:
Authorization: ISN-HMAC keyId="rgk_...", ts="1790000000", nonce="a1b2c3d4e5f60718", sig="<64 hex characters>"
tsis the time now, in seconds since 1970 (Unix time). It must be within 5 minutes of ISN's clock.nonceis 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.sigis 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
The three calls
The address is https://isnfreewifi.co.za. Only https is accepted on the live service. Answers are JSON.
| Call | What it does | Key needed |
|---|---|---|
GET /api/rent/v1/ping | Checks that your key and signing work. Answers {"ok":true,"scope":"write"}. | Read or write |
GET /api/rent/v1/accounts?limit=100&after=0 | Lists 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/payments | Marks 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,usernameorphone. 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. periodis the month the rent is for, asYYYY-MM.statusispaidorunpaid.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 code | Meaning |
|---|---|
applied / unchanged | Done, or already that way. |
not_found | No tracked resident of yours matches. (Check the reference, and that the resident has a rent due day.) |
ambiguous | More than one resident matches; use ref. |
conflicting_identifiers | The identifiers you gave point to different residents. |
bad_ref, bad_phone, bad_period, bad_status, bad_item, no_identifier | The item is not valid. A phone number must be a complete South African (+27) or Namibian (+264) number. |
period_too_far_ahead | Rent can be marked paid for the current month or the next one only. |
only_current_period | Only the current month's payment can be reversed. |
no_due_day, account_inactive | The resident has no rent due day, or their account is disabled. |
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 Africa | Namibia | Eswatini |
|---|---|---|
+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 4567 | 0811234567, 081 123 4567, 081-123-4567 | 7612 3456, 76123456, 07612 3456 |
27711234567, 0027711234567 | 264811234567, 00264811234567 | 26876123456, 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.
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.
Errors, limits and retrying
| HTTP | code | What to do |
|---|---|---|
| 401 | unauthorized | The 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. |
| 403 | read_only_key, ip_not_allowed, rent_guard_off, account_suspended | The key or your account does not allow this. Fix it in the dashboard. |
| 409 | held_for_review | The 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. |
| 400 | bad_request, bad_json, https_required | Fix the request. Send 1 to 200 payments. |
| 413 | The body is larger than 64 KB. | |
| 429 | rate_limited, daily_limit | A key may make 60 requests a minute and report 2,000 payments a day. Wait (see Retry-After) and send fewer, larger batches. |
| 5xx | Try 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. |
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.
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.
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.
