Skip to content
iOnCall
iOnCallAPI v1 · preview

iOnCall Developer Platform

Your station’s crew board, as an API.

Read who is on your station and who is available right now, from your own software: a crew-room screen of your own design, a brigade rostering or reporting tool, or analytics sampling cover through the day. v1 is read-only: it cannot change anyone’s availability yet.

Every response is JSON and every request carries one Bearer key. There are three routes, all GET: the station, its members, and one member. They work the same for iFire, iCrew and iRescue stations, and you can try every one now against our made-up demo station, with no sign-up.

The key chooses the station

Every key belongs to exactly one station. Whatever host you call (https://ioncall.app/api/v1, or a brand host such as ifire.ifireapp.net), the key decides whose data you read, and it can never read another station.

curl https://ioncall.app/api/v1/station \
  -H "Authorization: Bearer $IONCALL_KEY"
import os
import requests

r = requests.get(
    "https://ioncall.app/api/v1/station",
    headers={
        "Authorization": "Bearer " + os.environ["IONCALL_KEY"],
    },
    timeout=10,
)
r.raise_for_status()
station = r.json()
print(station["name"], station["crew"]["available"])
// Node 18 or later: fetch is built in.
const res = await fetch(
  'https://ioncall.app/api/v1/station',
  {
    headers: {
      Authorization: `Bearer ${process.env.IONCALL_KEY}`,
    },
  },
);
const station = await res.json();
console.log(station.name, station.crew.available);
using System.Net.Http.Headers;
using System.Text.Json;

var key = Environment.GetEnvironmentVariable("IONCALL_KEY");
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", key);

var json = await http.GetStringAsync(
    "https://ioncall.app/api/v1/station");
using var doc = JsonDocument.Parse(json);
var name = doc.RootElement.GetProperty("name");
Console.WriteLine(name.GetString());
200 · Response
{
  "id": "de000000000000000000000000000000",
  "object": "station",
  "name": "Kilbrook Fire Station (Demo)",
  "brand": "ifire",
  "country": "United Kingdom",
  "timezone": "Europe/London",
  "crew": {
    "total": 14,
    "available": 6,
    "responding": 0
  }
}

Quickstart

You can try every call right now, with no sign-up, against Kilbrook Fire Station (Demo): a made-up station with fourteen made-up crew, rebuilt every night. Its crew’s availability follows their made-up plans, so it changes through the day.

This is the public demo key. It is a ioc_test_ key, so it can only ever read Kilbrook:

ioc_test_s86X5JMsTsToCWRLtQ08jKqy1TVtTi00

Send it as a Bearer token and ask for the station. Kilbrook’s id is de000000000000000000000000000000.

Put it in an environment variable called IONCALL_KEY, as on the right: every example on this page reads the key from there, so each one works as written against Kilbrook, and later against your own station by changing only the variable. (The cURL examples are for a macOS or Linux shell.)

Then list its members with GET /members, and read one of them with GET /members/{id}.

Set the demo key
# macOS or Linux
export IONCALL_KEY=ioc_test_s86X5JMsTsToCWRLtQ08jKqy1TVtTi00

# Windows PowerShell
$env:IONCALL_KEY = "ioc_test_s86X5JMsTsToCWRLtQ08jKqy1TVtTi00"
Try it · three calls
# 1. the station
curl https://ioncall.app/api/v1/station \
  -H "Authorization: Bearer $IONCALL_KEY"

# 2. its members, three at a time
curl -G https://ioncall.app/api/v1/members \
  -H "Authorization: Bearer $IONCALL_KEY" \
  -d limit=3

# 3. one member
BASE=https://ioncall.app/api/v1
curl $BASE/members/de000000000000000000000000000005 \
  -H "Authorization: Bearer $IONCALL_KEY"

Authentication

Send your key in the Authorization header with the Bearer scheme on every request. The scheme name is not case-sensitive. The header is the only place a key is read: a key in the query string is ignored.

Live and test keys

  • ioc_live_ followed by 32 letters and digits: a key for a real station.
  • ioc_test_: works only on the Kilbrook demo station.

A test key on a real station, or a live key on the demo station, is refused with 403 wrong_mode. So a test key can never read a real station’s crew.

One key, one station

A key is bound to one station when it is made, and the station fixes the brand. Keys carry scopes; v1 has two, station:read and members:read, and new keys get both. A key without the scope a route needs gets 403 insufficient_scope.

Getting a key for your station

There is no self-service screen yet. A station admin asks us through the contact form, giving the station and what the key is for.

  • We make the key and show it to you once.
  • We keep only a SHA-256 hash of it, so nobody (us included) can show it to you again. If it is lost, we revoke it and make a new one.
  • Ask us to revoke a key whenever you like. A revoked key answers 401 revoked_api_key.
Keep live keys secret

A live key reads your crew’s names and availability. Keep it on a server you control; never put it in a web page, a mobile app or a public repository.

Header format
Authorization: Bearer ioc_live_<32 letters and digits>
No key · 401
{
  "error": {
    "type": "authentication_error",
    "code": "missing_api_key",
    "message": "...",
    "param": "",
    "request_id": "req_6c1f0a93b2de"
  }
}

Pagination

Lists are paginated with a cursor. A page comes back in an envelope:

dataarray

The objects on this page.

has_moreboolean

true when there is another page.

next_cursorstring

Pass it as cursor to get the next page. "" when has_more is false.

Parameters

limitinteger, optional

How many to return: 50 by default, at most 200. Zero or less means the default, and anything over 200 is treated as 200. A value that isn’t a number is 400 invalid_parameter, with param set to limit.

cursorstring, optional

The previous page’s next_cursor, copied as it is. Treat it as opaque: a cursor we didn’t issue is 400 invalid_cursor.

Members come in order of id, which never changes, so walking the pages gives you each member once even while statuses change underneath you.

# first page of three
curl -G https://ioncall.app/api/v1/members \
  -H "Authorization: Bearer $IONCALL_KEY" \
  -d limit=3

# the next page: pass next_cursor back as cursor
curl -G https://ioncall.app/api/v1/members \
  -H "Authorization: Bearer $IONCALL_KEY" \
  -d limit=3 \
  -d cursor=djF8ZGUwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDM
import os
import requests

BASE = "https://ioncall.app/api/v1"
KEY = os.environ["IONCALL_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}"}

members, cursor = [], ""
while True:
    params = {"limit": 200}
    if cursor:
        params["cursor"] = cursor
    r = requests.get(f"{BASE}/members", headers=HEADERS,
                     params=params, timeout=10)
    r.raise_for_status()
    page = r.json()
    members += page["data"]
    if not page["has_more"]:
        break
    cursor = page["next_cursor"]

print(len(members), "members")
const BASE = 'https://ioncall.app/api/v1';
const headers = {
  Authorization: `Bearer ${process.env.IONCALL_KEY}`,
};

const members = [];
let cursor = '';
do {
  const url = new URL(BASE + '/members');
  url.searchParams.set('limit', '200');
  if (cursor) url.searchParams.set('cursor', cursor);
  const res = await fetch(url, { headers });
  const page = await res.json();
  if (!res.ok) throw new Error(page.error.code);
  members.push(...page.data);
  cursor = page.has_more ? page.next_cursor : '';
} while (cursor);

console.log(members.length, 'members');
using System.Net.Http.Headers;
using System.Text.Json;

var key = Environment.GetEnvironmentVariable("IONCALL_KEY");
using var http = new HttpClient
{
    BaseAddress = new Uri("https://ioncall.app/api/v1/"),
};
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", key);

var members = new List<JsonElement>();
var cursor = "";
do
{
    var url = "members?limit=200";
    if (cursor != "")
        url += "&cursor=" + Uri.EscapeDataString(cursor);
    var json = await http.GetStringAsync(url);
    using var doc = JsonDocument.Parse(json);
    var page = doc.RootElement;
    var data = page.GetProperty("data");
    foreach (var m in data.EnumerateArray())
        members.Add(m.Clone());
    cursor = page.GetProperty("has_more").GetBoolean()
        ? page.GetProperty("next_cursor").GetString()!
        : "";
} while (cursor != "");

Console.WriteLine($"{members.Count} members");

Errors

Success is a 200. Anything else comes back with the matching HTTP status and the same error object, in which every key is always present:

typestring

The broad kind of error (see the table).

codestring

The exact error. Write your handling against this.

messagestring

A description for people. Its wording may change, so don’t match on it.

paramstring

The parameter at fault, or "".

request_idstring

The request id.

HTTPtypecodeWhen
400invalid_request_errorinvalid_parameterA bad parameter, such as a limit that isn’t a number. param names it.
400invalid_request_errorinvalid_cursorA cursor we didn’t issue.
401authentication_errormissing_api_keyNo Authorization: Bearer header.
401authentication_errorinvalid_api_keyThe key is malformed or unknown, or its station no longer exists.
401authentication_errorrevoked_api_keyThe key has been revoked.
403permission_errorwrong_modeA test key on a real station, or a live key on the demo station.
403permission_errorinsufficient_scopeThe key lacks the scope the route needs.
404invalid_request_errornot_foundAn unknown id, or an id at another station. The two look exactly the same on purpose.
429rate_limit_errorrate_limitedToo many requests; see Rate limits.
500api_errorinternal_errorSomething went wrong on our side. The message never carries internal detail; we log it under the request id.
# no key: a 401, with the request id in the headers
curl -i https://ioncall.app/api/v1/station

HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Request-Id: req_6c1f0a93b2de

{
  "error": {
    "type": "authentication_error",
    "code": "missing_api_key",
    "message": "...",
    "param": "",
    "request_id": "req_6c1f0a93b2de"
  }
}
// http as in Pagination
using var res = await http.GetAsync($"members/{memberId}");
if ((int)res.StatusCode == 429)
{
    var wait = res.Headers.RetryAfter?.Delta
        ?? TimeSpan.FromSeconds(60);
    await Task.Delay(wait);
}
else if (!res.IsSuccessStatusCode)
{
    var body = await res.Content.ReadAsStringAsync();
    using var doc = JsonDocument.Parse(body);
    var err = doc.RootElement.GetProperty("error");
    var code = err.GetProperty("code").GetString();
    if (code != "not_found")
        Console.Error.WriteLine($"iOnCall {code}");
}
# BASE and HEADERS as in Pagination
r = requests.get(f"{BASE}/members/{member_id}",
                 headers=HEADERS, timeout=10)
if r.status_code == 429:
    time.sleep(int(r.headers.get("Retry-After", "60")))
elif not r.ok:
    err = r.json()["error"]
    if err["code"] == "not_found":
        ...  # gone from the station, or never there
    else:
        log.warning("iOnCall %s (%s)",
                    err["code"], err["request_id"])
// BASE and headers as in Pagination
const url = `${BASE}/members/${memberId}`;
const res = await fetch(url, { headers });
if (res.status === 429) {
  const wait = Number(res.headers.get('Retry-After') || 60);
  await new Promise(r => setTimeout(r, wait * 1000));
} else if (!res.ok) {
  const { error } = await res.json();
  if (error.code !== 'not_found') {
    console.warn(`iOnCall ${error.code}`, error.request_id);
  }
}

Rate limits

Each key may make 60 requests a minute. Each of our servers counts on its own, in fixed one-minute windows, so don’t plan on more than 60 even when you happen to get them.

Over the limit you get 429 rate_limited in the usual error object, with a Retry-After header giving the seconds to wait.

Poll gently

Availability changes at human speed. Please poll no more than once a minute: one call to /station for the headline counts, or a walk of /members with limit=200, which is a single page for most stations.

429 · Response
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-Request-Id: req_0d9e4b7a21c5

{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limited",
    "message": "...",
    "param": "",
    "request_id": "req_0d9e4b7a21c5"
  }
}

Request ids

Every response carries an X-Request-Id header: req_ followed by 12 hexadecimal characters. Error bodies repeat it as request_id.

Log it alongside anything that goes wrong. If you need to ask us about a call, the request id lets us find it in our logs straight away.

Response headers
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: req_7a3c90e1f4b2

The station

GET/api/v1/stationscope: station:read

The key’s own station: one object, not a list.

The station object

idstring

The station’s id.

objectstring

Always "station".

namestring

The station’s name.

brandstring

The app the station uses: ifire, icrew or irescue.

countrystring

The station’s country.

timezonestring

The station’s time zone, as an IANA name such as Europe/London.

crewobject

total, available and responding: counts taken from the same crew list as /members, at the moment of the call.

Request
curl https://ioncall.app/api/v1/station \
  -H "Authorization: Bearer $IONCALL_KEY"
200 · Response
{
  "id": "de000000000000000000000000000000",
  "object": "station",
  "name": "Kilbrook Fire Station (Demo)",
  "brand": "ifire",
  "country": "United Kingdom",
  "timezone": "Europe/London",
  "crew": {
    "total": 14,
    "available": 6,
    "responding": 0
  }
}

List members

GET/api/v1/membersscope: members:read

The station’s members, each with their current status, in order of id. Paginated with limit and cursor as described under Pagination.

The member objects are described under Retrieve a member.

Request
curl -G https://ioncall.app/api/v1/members \
  -H "Authorization: Bearer $IONCALL_KEY" \
  -d limit=3
200 · Response
{
  "data": [
    {
      "id": "de000000000000000000000000000001",
      "object": "member",
      "first_name": "Pat",
      "last_name": "Kearney",
      "nickname": "",
      "rank": "Station Officer",
      "callsign": "",
      "status": "available",
      "responding": "none"
    },
    {
      "id": "de000000000000000000000000000002",
      "object": "member",
      "first_name": "Aoife",
      "last_name": "Brennan",
      "nickname": "",
      "rank": "Sub Officer",
      "callsign": "",
      "status": "available",
      "responding": "none"
    },
    {
      "id": "de000000000000000000000000000003",
      "object": "member",
      "first_name": "Mark",
      "last_name": "Delaney",
      "nickname": "",
      "rank": "Firefighter",
      "callsign": "",
      "status": "unavailable",
      "responding": "none"
    }
  ],
  "has_more": true,
  "next_cursor": "djF8ZGUwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDM"
}

Retrieve a member

GET/api/v1/members/{id}scope: members:read

One member of the key’s station. Anyone else, whether the id is unknown or belongs to another station, is 404 not_found.

The member object

idstring

The member’s id.

objectstring

Always "member".

first_name, last_namestring

Their name.

nicknamestring

The name the crew know them by, or "".

rankstring

Their rank or role at the station.

callsignstring

Their callsign at the station, or "".

statusstring

Their availability at this station right now, in lower case: available, unavailable, delayed, holiday, leave, course or training. Treat it as an open set: a status added later comes through as its own lower-case name, so handle values you don’t know.

respondingstring

none when they aren’t responding to a callout. Otherwise their responding status in lower case, such as responding, delayed or not_responding.

BASE=https://ioncall.app/api/v1
curl $BASE/members/de000000000000000000000000000005 \
  -H "Authorization: Bearer $IONCALL_KEY"
# BASE and HEADERS as in Pagination
url = f"{BASE}/members/de000000000000000000000000000005"
r = requests.get(url, headers=HEADERS, timeout=10)
member = r.json()
name = member["nickname"] or member["first_name"]
print(name, member["status"])
// BASE and headers as in Pagination
const id = 'de000000000000000000000000000005';
const res = await fetch(`${BASE}/members/${id}`, { headers });
const member = await res.json();
const name = member.nickname || member.first_name;
console.log(name, member.status);
// http as in Pagination (BaseAddress and key set)
var id = "de000000000000000000000000000005";
var json = await http.GetStringAsync($"members/{id}");
using var doc = JsonDocument.Parse(json);
var member = doc.RootElement;
var name = member.GetProperty("first_name").GetString();
var status = member.GetProperty("status").GetString();
Console.WriteLine($"{name}: {status}");
200 · Response
{
  "id": "de000000000000000000000000000005",
  "object": "member",
  "first_name": "Conor",
  "last_name": "Fitzgerald",
  "nickname": "Fitz",
  "rank": "Firefighter",
  "callsign": "",
  "status": "available",
  "responding": "none"
}

Privacy

The API shows a station what its own crew can already see of each other in the apps: who is on the station, and their availability right now. It never offers anything more.

Never exposed

  • About members: email address, mobile number, PIN, home address, location, qualifications, history, planned availability and photos.
  • About the station: its address and map position, its subscription and billing details, integration settings and invite codes.

Why

In iOnCall, a member’s current availability is shared with the whole station, because covering the station is the point. Their history belongs to them and to the line manager they choose, and is never shown to the rest of the station, admins included. A key acts for the whole station rather than for one person, so it gets the station’s view and nothing else: there is no history route, and there won’t be one.

Read more in our privacy policy.

Versioning & what’s next

Everything here is v1, under /api/v1, and is a preview. Write your code to ignore fields it doesn’t recognise and to cope with status values it hasn’t seen, as above.

Read-only on purpose

v1 reads and never writes. Write access, such as setting a member’s availability, will be added only when a station asks for it, as a new scope on the key, so an existing key can never start writing on its own.

Not in v1

  • writes of any kind;
  • webhooks;
  • callout history, planned availability and appliances;
  • fetching only what changed since a given time;
  • a screen in the Station Dashboard for admins to manage keys;
  • quotas per key beyond the rate limit, and SDKs.

If one of these would make your integration possible, tell us what you’re building. Something unclear or missing on this page is a documentation bug, and we’d like to hear about that too.