curl --request GET \
--url https://api.ctrl-hub.com/v3/form-submission-stats \
--header 'X-Session-Token: <api-key>'import requests
url = "https://api.ctrl-hub.com/v3/form-submission-stats"
headers = {"X-Session-Token": "<api-key>"}
response = requests.get(url, headers=headers)
print(response.text)const options = {method: 'GET', headers: {'X-Session-Token': '<api-key>'}};
fetch('https://api.ctrl-hub.com/v3/form-submission-stats', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.ctrl-hub.com/v3/form-submission-stats",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"X-Session-Token: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.ctrl-hub.com/v3/form-submission-stats"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("X-Session-Token", "<api-key>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.ctrl-hub.com/v3/form-submission-stats")
.header("X-Session-Token", "<api-key>")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.ctrl-hub.com/v3/form-submission-stats")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["X-Session-Token"] = '<api-key>'
response = http.request(request)
puts response.read_body{
"data": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"type": "form-submission-stats",
"attributes": {
"totals": {
"submissions": 123,
"versions": 123,
"forms": 123,
"workflow_definitions": 123,
"in_window": 123
},
"by_status": {
"initial": 123,
"intermediate": 123,
"completed": 123,
"rejected": 123,
"cancelled": 123
},
"activity": {
"bucket": "5m",
"series": [
{
"at": "2023-11-07T05:31:56Z",
"total": 123,
"by_status": {
"initial": 123,
"intermediate": 123,
"completed": 123,
"rejected": 123,
"cancelled": 123
}
}
]
},
"window": {
"from": "2023-11-07T05:31:56Z",
"to": "2023-11-07T05:31:56Z"
}
},
"relationships": {
"organisation": {
"data": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"type": "organisations"
}
}
}
},
"jsonapi": {
"version": "1.0"
}
}{
"id": "98ca4a78-b66f-4234-9719-aaf832ee6669",
"status": "400",
"title": "A validation error was encountered",
"source": {
"parameter": "include"
},
"meta": {
"resource": "wrong_value"
}
}{
"id": "05fc9c8d-73b9-4697-9337-57f7a567a48f",
"status": "401",
"title": "You are not authorised to access this resource",
"detail": "In order to access this resource, you need the 'admin' role.",
"code": "AUTH.001"
}{
"id": "fe9d9a69-f0a7-4fdc-bb2c-176027f316c5",
"status": "500",
"title": "Internal Server Error",
"detail": "An unexpected error occurred on the server."
}Get form submission stats
The aggregate view of an organisation’s form submissions: how many there are, how many fell in a reporting window, how those split by workflow status, and the same split bucketed over time for a chart.
Everything windowed is computed from the submissions the same filter would list, so a figure here and a page of submissions under the same filter describe the same set. The figures are counted under the caller’s own grants, so a principal whose grants reach part of an organisation is told about that part.
It exists so a dashboard can ask for counts rather than records. Assembling the same figures client side means reading every submission in the window over the network to count it, which is megabytes of payload and a page-by-page walk to arrive at a few dozen integers.
The activity series carries every bucket in the reporting window, the empty ones included, so a consumer renders it as it arrives rather than working out for itself which buckets held nothing. That is why the window is bounded against the bucket width: a combination producing more than 1000 buckets is refused with a 400.
A sibling collection rather than a sub-resource of a submission, because the figures belong to the organisation rather than to any one submission. The organisation is a filter term rather than a path segment, so that everything selecting the submissions travels in one parameter.
Served by a hand-written handler rather than a generated one, because the resource is computed rather than stored.
curl --request GET \
--url https://api.ctrl-hub.com/v3/form-submission-stats \
--header 'X-Session-Token: <api-key>'import requests
url = "https://api.ctrl-hub.com/v3/form-submission-stats"
headers = {"X-Session-Token": "<api-key>"}
response = requests.get(url, headers=headers)
print(response.text)const options = {method: 'GET', headers: {'X-Session-Token': '<api-key>'}};
fetch('https://api.ctrl-hub.com/v3/form-submission-stats', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.ctrl-hub.com/v3/form-submission-stats",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [
"X-Session-Token: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.ctrl-hub.com/v3/form-submission-stats"
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("X-Session-Token", "<api-key>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.ctrl-hub.com/v3/form-submission-stats")
.header("X-Session-Token", "<api-key>")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.ctrl-hub.com/v3/form-submission-stats")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
request["X-Session-Token"] = '<api-key>'
response = http.request(request)
puts response.read_body{
"data": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"type": "form-submission-stats",
"attributes": {
"totals": {
"submissions": 123,
"versions": 123,
"forms": 123,
"workflow_definitions": 123,
"in_window": 123
},
"by_status": {
"initial": 123,
"intermediate": 123,
"completed": 123,
"rejected": 123,
"cancelled": 123
},
"activity": {
"bucket": "5m",
"series": [
{
"at": "2023-11-07T05:31:56Z",
"total": 123,
"by_status": {
"initial": 123,
"intermediate": 123,
"completed": 123,
"rejected": 123,
"cancelled": 123
}
}
]
},
"window": {
"from": "2023-11-07T05:31:56Z",
"to": "2023-11-07T05:31:56Z"
}
},
"relationships": {
"organisation": {
"data": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"type": "organisations"
}
}
}
},
"jsonapi": {
"version": "1.0"
}
}{
"id": "98ca4a78-b66f-4234-9719-aaf832ee6669",
"status": "400",
"title": "A validation error was encountered",
"source": {
"parameter": "include"
},
"meta": {
"resource": "wrong_value"
}
}{
"id": "05fc9c8d-73b9-4697-9337-57f7a567a48f",
"status": "401",
"title": "You are not authorised to access this resource",
"detail": "In order to access this resource, you need the 'admin' role.",
"code": "AUTH.001"
}{
"id": "fe9d9a69-f0a7-4fdc-bb2c-176027f316c5",
"status": "500",
"title": "Internal Server Error",
"detail": "An unexpected error occurred on the server."
}Authorizations
Session token for authentication.
Query Parameters
Selects the submissions the figures are computed over. Required, because it carries the organisation and the reporting window.
eq(organisation,<uuid>): Required. The organisation whose submissions the figures cover. Exactly one.ge(created_at,<date-time>): Required. The start of the reporting window. Without it the figures would cover every submission the organisation has ever taken, which is not a question a dashboard asks.le(created_at,<date-time>): the end of the window. Defaults to now.- any other term the submissions list accepts, for example
eq(form,<uuid>)orin(workflow_instance.status,[completed,rejected]), to narrow the figures.
Terms are comma separated and combine with AND; an or(...) is refused with a 400
rather than answered, because the organisation is read out of the filter and the
rest is applied as a conjunction, so an OR would be answered by a predicate other
than the one asked for. The field names are the form submission resource's own, so
a figure here and a page of submissions under the same filter describe the same
set.
The window is bounded against the bucket width rather than in its own right: the
series carries every bucket in the window, including the empty ones, so a narrow
width over a wide window is a response far larger than the figures it holds. A
combination producing more than 1000 buckets is refused with a 400 rather than
served, so 1d reaches back about three years and 5m about three days. A consumer
that widens its bucket as its window widens never approaches the limit.
The window and the other terms narrow totals.in_window, by_status and
activity only. The four standing counts in totals are the organisation's
whole holdings and are deliberately unaffected: they answer how much there is,
which the reporting window does not change.
For more information on filtering, see the docs
"eq(organisation,7c3d5f11-95a6-4b2e-8f47-19d0c6ba4e83),ge(created_at,2026-08-19T00:00:00Z)"
The bucket width for the activity series. The five widths are the ones an activity chart picks between as the reporting window widens, from five minutes over a few hours to a week over a year.
5m, 1h, 6h, 1d, 1w Response
The aggregate submission figures for the organisation.
JSON API response object
The aggregate view of an organisation's form submissions. A synthetic, read-only resource: it is computed from the submissions the caller can see, never stored, and its identifier is the organisation the figures were computed for.
Every figure is counted under the caller's own grants, so a principal whose grants reach part of an organisation is told about that part rather than the whole.
The windowed figures are derived from one grouping of one set of submissions, so
totals.in_window, by_status and the activity series always agree with each
other: each bucket's total is its own by_status summed, and the series sums to
by_status, which sums to totals.in_window.
Show child attributes
Show child attributes
Show child attributes
Show child attributes