API reference
A read-only HTTP API over your own company's data: the roster, required-training compliance, your Human Risk Score, and a pollable event stream. Plus outgoing webhooks and SCIM user provisioning.
Authentication
Every request carries an API key as a bearer token. Keys are created in the admin console under Settings, and shown once at creation: we store only a SHA-256 hash, so a lost key is replaced rather than recovered.
curl https://badgerquest.ca/api/v1/members \
-H "Authorization: Bearer bqk_your_key_here"Customer keys are read-only and bound to one company. Only read: scopes can be minted on them, and every query is filtered by the org the key belongs to. There is no way to widen a key after creation, and no way for a key to reach another tenant’s data.
SCIM provisioning uses a different credential. Creating and deprovisioning people is not something an API key can ever do, whatever its scopes. The SCIM endpoints below authenticate with a separate SCIM token, issued once per company from the admin console and stored as a hash, and only a Stronghold plan can mint one. So the two are independent: revoking your read key does not stop directory sync, revoking the SCIM token does not stop your reporting pulls, and a leaked read key still cannot add or remove a single person.
Scopes
A key carries the scopes you pick at creation. A request whose scope is missing is refused with 403, so mint a key with exactly the access its integration needs.
| Scope | Grants | Endpoint |
|---|---|---|
read:members | Your members, roles and activity | /api/v1/members |
read:training | Completion, scores and compliance | /api/v1/compliance |
read:readiness | Your team's Human Risk Score | /api/v1/readiness |
read:events | Phishing-practice and training events | /api/v1/risk-events |
Errors and limits
| Status | Meaning |
|---|---|
| 401 | Missing key, malformed key, or a key that has been revoked or has expired. |
| 403 | The key is valid but lacks the scope this endpoint requires, or is not org-scoped. |
| 402 | Your plan does not include this endpoint. Checked on every request, not just at key creation, so a lapsed plan stops serving data. |
| 429 | Over the rate limit of 240 reads per minute, per key. |
These endpoints return personal data (names, email addresses, per-person risk scores, training status). Treat a key like a password: store it in a secret manager, never in a repo or a CI log, and revoke it in the console the moment it might be exposed.
GET /api/v1/members
Your roster, ordered by XP descending. Requires read:members.
{
"members": [
{
"email": "dana@northwind.corp",
"name": "Dana Reeves",
"role": "ADMIN",
"xp": 1180,
"streak": 6,
"longestStreak": 9,
"lastActive": "2026-07-30T00:00:00.000Z",
"joinedAt": "2026-06-24T14:02:11.000Z",
"risk": {
"score": 82, "grade": "B",
"reportRate": 74, "accuracy": 88, "precision": 61,
"encounters": 34,
"hasActivity": true, "hasScore": true
}
}
]
}role is one of OWNER, ADMIN, AUTHOR or MEMBER. streak counts consecutive weeks with a completed quest, not days. risk is null for someone the score cannot be computed for.
Read encounters and hasScore before you act on a person’s grade. A 33% on two graded forgeries is not the same fact as 33% on two hundred, and we are the only party that knows which it is. hasScore is false when the number is a placeholder rather than a measurement, so suppress it instead of charting it. precision (of everything they flagged, how much was really a forgery) is a coaching signal rather than a risk one: high recall with low precision is somebody flooding the helpdesk, which is a conversation, not a finding.
GET /api/v1/compliance
The required-training matrix: every assigned training, and where each member stands against it. Requires read:training.
{
"trainings": [
{ "name": "Cyber Awareness Month", "pack": "Core Foundations",
"questCount": 6, "dueDate": "2026-08-31T00:00:00.000Z",
"passMark": 80, "allowRetakes": true }
],
"acknowledgement": {
"title": "Information security policy acknowledgement",
"intervalMonths": 12, "effectiveAt": "2026-01-15T00:00:00.000Z"
},
"members": [
{ "email": "dana@northwind.corp", "name": "Dana Reeves",
"acknowledgement": { "confirmedAt": "2026-02-01T11:04:00.000Z",
"outstanding": false,
"nextDueAt": "2027-02-01T11:04:00.000Z" },
"trainings": [ { "name": "Cyber Awareness Month", "status": "complete",
"scorePct": 100, "completedAt": "2026-08-14T09:12:44.000Z",
"certificateSerial": "BQ-7F2A91C4",
"supersededCertificateSerial": null } ] }
]
}status is one of complete, in_progress, not_started, overdue or on_leave. The last one means the member has a recorded leave of absence covering the deadline, so they are neither compliant nor late: count them out of the denominator rather than treating them as a failure. completedAt and certificateSerial are null until it is complete; the serial is what makes a row independently checkable at its public URL, which is usually the point of pulling this at all.
supersededCertificateSerial is almost always null, and is the one field here worth reading before you build a report. A certificate is a permanent record of what was true on the day it was issued, and we never withdraw one. A status, by contrast, is worked out fresh on every request. So the two can part company: raise your pass mark, add questions to a course, or change somebody’s joining date, and a person who genuinely earned a certificate is no longer complete against the requirement as it now stands. When that happens the serial moves to this field and certificateSerial goes null, so nothing you build can print a verifiable serial beside a status of overdue and end up contradicting itself. The certificate is not cancelled: it still checks out at its public URL. If you show it, show it as what it is, a certificate earned earlier that no longer meets the current requirement.
passMark is the mark you set for that course, and it is what decided each status. Without it you cannot reconcile a status against its scorePct, which is the first thing anybody importing this tries to do. allowRetakes says whether somebody who fell short may sit the course again; when it is false, the first answer to each question is the record.
The acknowledgement block is your yearly policy confirmation, and is null when your company does not run one. Where it is set, each member carries their own acknowledgement with the date they last confirmed the wording currently in force, whether one is outstanding, and when the next falls due. The per-member field is absent entirely when the company runs no acknowledgement, so a missing field never reads as “nobody confirmed”.
A member only counts against training completed while they worked for you: evidence earned before their membership began is not credited, so a certificate never claims more than it can support.
A status of overdue here means a missed course deadline, and nothing else. Counting these rows is how you answer “how many people are behind on required training”. It is not the same question as attendance on /api/v1/readiness, which counts people who have not trained lately whether or not anything is due. The two are routinely far apart and both are correct: forty people can be past a deadline on the same morning that nobody is missing from the training range. Take the deadline number from here.
GET /api/v1/readiness
Your company’s Human Risk Score. Requires read:readiness.
{
"score": 78, "grade": "B", "trendingUp": true,
"reportRate": 71, "clickRate": 12, "beaconRate": 64,
"population": 14,
"hasActivity": true,
"excludedFromStats": 1,
"departments": [ { "id": "FINANCE", "name": "Finance", "score": 82 } ],
"trend": [ { "week": "W1", "clickRate": 18, "reportRate": 52 } ],
"participation": {
"population": 14, "activeThisWeek": 11, "inactive": 2, "neverStarted": 1,
"overdue": 2,
"needsAttention": [
{ "email": "sam@northwind.corp", "name": "Sam Ford", "status": "overdue",
"weeksSinceLastTraining": 5, "lastTrainedAt": "2026-07-02T00:00:00.000Z",
"leadership": false }
]
}
}departments and trend are there so this can drive a board slide rather than a single number: which team is the weak point, and whether the click rate actually moved. participation is reported beside the score and never inside it, because somebody who never trains has no measurements and therefore cannot have a bad grade. Turning up is the only thing that surfaces them, and it is the first question an auditor asks.
participation.inactive is attendance, not a deadline, and it is the number most likely to be misread. It counts people who have trained before but not in the last two weeks. It says nothing about whether a required course is late: that is overdue on /api/v1/compliance, and it is a different count taken from different facts. Reading zero here does not mean the company is on top of its mandatory training, and the two figures being far apart is normal rather than a bug. If the slide says “overdue”, take the number from compliance.
participation.overdue is deprecated. It is the old name for inactive and carries the identical number, kept for one release so nothing breaks mid-upgrade. Move to inactive now; it will be removed. The per-member status still uses the value overdue for the same state and changes to inactive when the alias goes, so treat both spellings as the same status until then.
Read the honesty fields before you chart the score. hasActivity is false when nobody has run a drill yet, which means score is a placeholder rather than a measurement. excludedFromStats counts members deliberately left out of aggregates. Plotting the score without checking these turns “we have no data” into a number on a board.
GET /api/v1/risk-events
A pollable stream for a SIEM or SOC. Requires read:events.
| Query | Default | Notes |
|---|---|---|
since | 7 days ago | ISO-8601 timestamp. Events strictly newer than this are returned, oldest first. Sending it is what puts the endpoint in walk mode. An unparseable value is treated as though you had not sent one. |
limit | 500 | Events per page, 1 to 500. Anything larger is capped at 500, and the page you got is reported back as limit. |
There are two modes, and the difference is whether you send since.
With since, you are walking. You get events strictly newer than since, oldest first, and cursor is the point this page is complete up to. Send that cursor back as your next since and repeat while hasMore is true. That reaches every event in the window in order, however far back you start, which is what a first backfill and a catch-up after an outage both need.
Without since, you get a snapshot of the last seven days, newest first, with cursor at the newest event returned. This is the original shape of the endpoint and it is unchanged. A snapshot cannot be read backwards, so if it comes back with hasMore true, older events in that week were left out: pick a since and walk from there instead.
order tells you which of the two you got, oldest_first or newest_first, so nothing has to infer it from the data.
hasMore is the field that turns a silent gap into an alert. True means the window held events this page did not return. A backfill that stops while it is true has a hole in it, and that is worth alerting on. count is how many events this page returned, never how many the window holds.
GET /api/v1/risk-events?since=2026-07-01T00:00:00.000Z&limit=500
{
"events": [
{ "id": "pa_ckzq81f0a0001x9t2",
"type": "phish_practice.reported",
"source": "in_app_practice",
"at": "2026-07-31T12:44:02.000Z",
"user": { "email": "dana@northwind.corp", "name": "Dana Reeves",
"department": "Finance" },
"detail": { "wasForgery": true, "messageId": "raven-invoice-04",
"category": "invoice_fraud" } }
],
"cursor": "2026-07-31T12:44:02.000Z",
"count": 500,
"order": "oldest_first",
"limit": 500,
"maxLimit": 500,
"hasMore": true,
"stream": "in_app_practice"
}id is unique and stable, derived from the record behind the event, so the same event carries the same id on every call. Deduplicate on it. Do not build a key out of the timestamp and the payload: events sharing a millisecond are ordinary here, not a corner case, which is also why a page is never cut in the middle of a group that shares one timestamp. That is the only case where a page may hand back a few more events than limit, and it is deliberate: cutting the group would lose the rest of it, because since is exclusive.
cursor is the point the page is complete up to, which is normally the at of the newest event returned. It can sit slightly later when the end of the page held records you are not entitled to see, such as practice from before somebody joined your company. Send it back as you received it rather than working one out from the events.
Two fields exist for correlation. detail.messageId identifies the simulated message itself, so the same id across several people is one lure landing on a group, which is a different finding from scattered mistakes. user.department is the team the person sits in, or null if they have not picked one, so a run of failures can be grouped without you having to join against the roster first.
| type | Means |
|---|---|
phish_practice.reported | Correctly flagged a forgery. |
phish_practice.clicked | Fell for a forgery in the in-app range. |
phish_practice.false_alarm | Flagged a genuine message. Scores no XP. Counts toward the person's discrimination, so a habit of flagging everything lowers their risk grade. |
phish_practice.trusted | Correctly let a genuine message pass. |
training.completed | Finished a required training. detail carries training and trainingId, scorePct, and the certificate serial. |
Handle an unrecognised type rather than assuming this list is closed. phish_practice.event is emitted for any practice outcome we have not mapped to a name above, so a new outcome shows up as something your rules can ignore instead of vanishing.
stream is always in_app_practice, and that is deliberate. These are outcomes from simulated messages inside BadgerQuest, not clicks on real mail arriving at your gateway. It is spelled out on every response so a correlation rule cannot quietly treat a practice click as a live incident.
If you already poll this endpoint, read this one. A call that sends since now returns its events oldest first. It used to return them newest first. Nothing was removed and no field changed meaning, and the cursor still works the way you already use it, so a poller that reads the whole array and follows the cursor keeps working and now stops losing history. A poller that reads only events[0], or that assumes the array runs newest to oldest, needs the one-line change.
We would normally not change a response like this inside v1. We are changing it because the old order could not be paged: any window holding more than one page returned the newest page and a cursor positioned past everything else in it, so a backfill or a catch-up after an outage silently dropped the rest. That is a data-loss bug rather than a preference, and it cannot be fixed while the order stands.
Webhooks
Rather than polling, you can register an endpoint in the admin console and receive events as they happen. Three events are sent:
| Event | Fires when |
|---|---|
training.completed | A member finishes a required training. |
member.joined | Someone joins your company. |
phish_practice.failed | A member mis-judges a simulated message inside the training range. Not a real-inbox click: this shares the vocabulary of the risk-events stream on purpose, so a SIEM rule cannot mistake practice for a live incident. It was called phishing.failed until 12 August 2026; hooks already subscribed under that name keep receiving it under that name, and new subscriptions use this one. |
Each delivery is a JSON POST carrying X-BQ-Event and X-BQ-Signature:
X-BQ-Event: training.completed
X-BQ-Signature: sha256=<hex>
{ "event": "training.completed", "at": "2026-07-31T12:44:02.000Z",
"orgId": "…", "data": { … } }Verify the signature before trusting a delivery. It is HMAC-SHA256 over the exact raw request body, keyed with the signing secret shown when you created the hook. Compare in constant time, and compute over the raw bytes rather than a re-serialised object.
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.BQ_WEBHOOK_SECRET)
.update(rawBody) // the raw body, not JSON.parse -> JSON.stringify
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return new Response("bad signature", { status: 401 });
}Deliveries time out after 5 seconds and redirects are refused, so your endpoint must answer directly rather than forwarding. The last response status is recorded against the hook in the console.
SCIM provisioning
SCIM 2.0 user provisioning lives at /api/scim/v2/Users, authenticated with the SCIM bearer token generated in your admin console. It is stored hashed, like every other token here, so it is shown once. Point your identity provider at it to create, update and deprovision people automatically.
| Request | Does |
|---|---|
GET /Users | Lists everybody: members, and people invited but not yet joined. |
GET /Users/{id} | Fetches one person by the id we returned. |
POST /Users | Creates a person. If they have no account yet, this sends them an invitation. |
PATCH /Users/{id} | Sets active. Setting it false deprovisions. |
DELETE /Users/{id} | Deprovisions, including cancelling an invitation that was never accepted. |
The list includes people who have been invited but have not signed in yet, and that matters more than it sounds. A directory provisions somebody, then asks for them again on its next cycle. If we listed only people who had accepted, the provider would find nobody, create them again, and keep doing that forever.
SCIM filters and paging
Every provider sends a filter to find an existing person before creating one. We answer the filter every provider actually sends, and refuse the rest rather than ignoring them, because a filter that is quietly ignored returns your whole company and the provider then matches its record to whoever happens to be first in the list.
| Filter | Result |
|---|---|
userName eq "dana@northwind.corp" | Supported. This is the default in every provider we document. |
emails.value eq "…" | Supported, treated as userName. |
id eq "…" | Supported. |
Anything else | 400 with invalidFilter and a message naming what to map instead. externalId is deliberately unsupported: we do not store your provider’s own identifier, so answering would mean answering “nobody” every time. |
Paging uses the standard startIndex and count, capped at 200 per page. totalResults is always the full count so your provider can page correctly.
Seat limits still apply, so provisioning stops at your plan’s cap rather than silently over-filling, and an invitation that has been sent but not accepted counts against your seats rather than being invisible until it lands.
One gap, stated rather than discovered: SCIM does not set a person’s training market. Somebody provisioned ahead of time trains on the company default until their first single sign-on, which does set it. If you need per-person markets from your directory today, use the CSV column or the SSO country claim described in the company guide.
Verifying a certificate
Completing a required training issues a certificate with a serial. Anyone holding the certificate can check it at a public URL, with no account and no key, which is the point: an auditor or a prospective employer needs to verify it without you handing them access to your console.
https://badgerquest.ca/certificate/<serial>The checker asks for one more detail: the name of the person the certificate was issued to, or their company. Both are printed on the certificate, so anyone genuinely holding it can answer, and it stops the register being harvested by working through serial numbers. The holder themselves, signed in, sees their own certificate without being asked.
Because of that, the page gives the SAME answer whether a serial does not exist or the name does not match. That is deliberate: distinguishing the two would let somebody discover which serials are real without ever seeing one.
Stability
Everything under /api/v1 is versioned. We add fields to responses without warning, so parse defensively and ignore what you do not recognise; we will not remove or repurpose an existing field inside v1.
Two changes are in flight, both listed here rather than left to be discovered:
| Change | What to do |
|---|---|
/api/v1/readiness: participation.overdue is deprecated, renamed inactive | Both keys are present and carry the same number for one release. Read `inactive`. The per-member `status` keeps the value `overdue` until the alias is removed, then follows it. |
/api/v1/risk-events: a call with since now returns events oldest first | Read the whole array and follow the cursor, and nothing changes for you. Check `order` if you would rather assert it than assume it. |
Neither of those removes a field. The rename keeps the old key alongside the new one, and the ordering change is a bug fix: the old order made a window larger than one page impossible to read in full, which is why it did not survive contact with a real SOC.