REST API Reference
PhishSpot exposes a JSON:API over HTTPS at
https://platform.phishspot.com/api/v1. It covers essentially everything you can do in the
admin app: build, schedule and analyze campaigns, manage contacts, groups, templates,
courses, media, domains, autopilots and webhooks, and stream results into your own tooling.
This chapter documents the conventions — one set of rules that every endpoint follows —
plus a complete endpoint map. It deliberately does not repeat every attribute of every
resource: the API is self-describing, and once you know the conventions a single
GET /campaigns tells you the rest.
For a push-based event model see Chapter 26 Webhooks; for a natural-language AI interface over the same data see Chapter 29 MCP Server.
27.1 Authentication
Section titled “27.1 Authentication”Every request carries an API token as a bearer header:
Authorization: Bearer <token>Get a token one of two ways.
From the admin UI (recommended). Account settings → API Tokens → New token (see Chapter 14). The value is shown once — copy it into a secrets manager.
From the API. POST email + password (plus otp_attempt if 2FA is on) to /auth:
curl -X POST https://platform.phishspot.com/api/v1/auth \ -H 'Content-Type: application/json' \ -d '{"email":"admin@example.com","password":"secret"}'{ "data": { "type": "api_tokens", "id": "token_9xKf2mQ1", "attributes": { "name": "API token", "token": "abc123…" } }}| Field | Type | Description |
|---|---|---|
email | string | Required. The user’s email. |
password | string | Required. The user’s password. |
otp_attempt | string | Required only when the user has two-factor auth enabled. |
Failures: 401 invalid_credentials, 401 invalid_otp_attempt, 422 otp_attempt_required.
A token belongs to a single user and inherits that user’s account memberships. Treat it like
a password. All examples below assume $TOKEN holds a valid token.
GET /me returns the token owner as a users document and needs no account selection —
useful as a connectivity smoke test.
27.2 Choosing the account
Section titled “27.2 Choosing the account”The API is flat: there are no /accounts/:account_id/... paths. A token’s user may belong
to several accounts, so every account-scoped request names one:
X-Account-Id: acct_0pAP5m3JOwak9HY7DbjLBKYgor, if a header is inconvenient, ?account_id=acct_… on the query string. The header
wins when both are sent. Both accept a prefix id (acct_…) or the raw integer id.
Discover the accounts a token can use with GET /accounts — a read-only list that needs no
account selection of its own:
curl -s https://platform.phishspot.com/api/v1/accounts \ -H "Authorization: Bearer $TOKEN" \ -H 'Accept: application/vnd.api+json'Naming an account the token’s user does not belong to is 400 account_required, not
403 — the API will not confirm whether another tenant’s account exists. Omitting the
selection entirely falls back to the user’s implicit account when there is one, and answers
400 account_required when there is not. Always send it explicitly.
/auth, /users (registration), /me, /password and /notification_tokens need no
account.
27.3 Document format
Section titled “27.3 Document format”- Base URL:
https://platform.phishspot.com/api/v1. All paths below are relative to it. - Media type:
application/vnd.api+jsonon bothContent-TypeandAcceptfor the resource endpoints. A wrongContent-Typeis a415; a wrongAcceptis a406. The plain command endpoints (§27.9) also acceptapplication/json. - Envelope: responses are JSON:API documents — a
datamember (an object, or an array for collections), optionalincluded, andmeta. - Attribute keys are
snake_case(email_subject,created_at), not dasherized. - Times are ISO-8601 UTC (
2026-05-20T14:22:33.000Z). The campaignscheduled_atinput is interpreted in the account’s timezone. links.selfis never emitted. Build URLs from the document’stypeandid.
Prefix ids
Section titled “Prefix ids”Every public id is a prefix id. Raw integer primary keys are never exposed — not in
data.id, not in relationship linkage, not in filters, not in URLs.
| Type | Prefix | Type | Prefix |
|---|---|---|---|
campaigns | camp_… | platform_domains | pdm_… |
contacts | cont_… | secured_domains | sdm_… |
groups | grp_… | events | evt_… |
deliverables | delv_… | results | res_… |
phishing_templates | tmpl_… | media | med_… |
phishing_template_categories | tcat_… | reported_messages | rep_… |
courses | course_… | webhook_endpoints | whep_… |
blocks | blk_… | webhook_events | whev_… |
autopilots | auto_… | accounts / users | acct_… / user_… |
An id that does not decode — a malformed prefix, or one belonging to a different type — returns 404, never 400.
A typical document
Section titled “A typical document”curl -s "https://platform.phishspot.com/api/v1/campaigns/camp_0u1kQ9" \ -H "Authorization: Bearer $TOKEN" \ -H "X-Account-Id: acct_0pAP5m3J" \ -H 'Accept: application/vnd.api+json'{ "data": { "type": "campaigns", "id": "camp_0u1kQ9", "attributes": { "name": "Q2 Invoice Test", "state": "in_progress", "email_subject": "Your invoice is ready", "can_start": false, "can_pause": true, "statistics": { "total_contacts": 240, "total_deliverables": 240, "completion_percentage": 61 }, "created_at": "2026-05-20T14:22:33.000Z" }, "relationships": { "platform_domain": { "data": { "type": "platform_domains", "id": "pdm_7Yt3" } }, "course": { "data": { "type": "courses", "id": "course_1Aa9" } }, "groups": { "data": [{ "type": "groups", "id": "grp_44Zx" }] } }, "meta": { "html_url": "https://platform.phishspot.com/accounts/…/campaigns/camp_0u1kQ9" } }}27.4 Query parameters
Section titled “27.4 Query parameters”Every collection endpoint accepts the same four families.
Pagination
Section titled “Pagination”?page[number]=2&page[size]=50Default page size 25, maximum 200. Every collection response carries the totals in
meta:
{ "data": [ … ], "meta": { "record_count": 412, "page_count": 9 } }There are no unbounded list responses.
Sorting
Section titled “Sorting”?sort=-created_at # descending?sort=name,-created_at # multiple keysOnly real columns are sortable (plus id, created_at, updated_at). Computed attributes
such as statistics, can_start or full_name are not. The default is created_at
descending; a few types override it — groups, courses, platform_domains and
phishing_templates sort by name, and contacts by last_name, first_name, id.
Filtering
Section titled “Filtering”?filter[state]=in_progress # any enum column?filter[campaign]=camp_0u1kQ9 # any belongs_to, by PREFIX id?filter[state]=draft,paused # comma = OREvery enum column and every belongs_to relationship is filterable out of the box. A few
types add hand-written filters: phishing_templates takes filter[tab]
(curated — the default — or your account’s own templates), filter[search] and
filter[category]; webhook_events takes filter[event_type].
Contacts accept filter[search] (literal name/email search), filter[department],
filter[title], filter[location], filter[risk_level] (low/medium/high/critical),
filter[domain_status] (verified/unverified), filter[group_id] (a group prefix id),
and boolean filters ever_clicked, ever_submitted, never_targeted,
completed_training. Departments and titles accept repeated array parameters,
for example filter[title][]=Analyst&filter[title][]=Auditor. Choices within a
filter use OR; filters combine with AND, before pagination. The response’s
meta.filter_options lists all department, title and location choices in the account.
Saving a campaign’s recipient selection
Section titled “Saving a campaign’s recipient selection”Replace the entire pending audience with PATCH /campaigns/:id, supplying
relationships.recipient_contacts.data as contact resource identifiers. An optional
meta.group_origin_id on each identifier preserves which group the contact was
added from. Omitting it preserves the existing origin; null clears it. Contacts
and groups must belong to the selected account. The campaign must be editable,
and recipients already sent the campaign cannot be removed.
{ "data": { "type": "campaigns", "id": "camp_example", "relationships": { "recipient_contacts": { "data": [{"type": "contacts", "id": "cont_example", "meta": {"group_origin_id": null}}] } } }, "meta": {"save_mode": "draft"}}The default save_mode is draft, which accepts an empty list. continue
requires at least one recipient. Campaign attributes and recipients save in one
transaction; repeating the same selection is safe. Neither mode sends email.
The read-only campaign attribute recipient_selection returns the current
selection, including contact_id, group_origin_id and locked for each contact.
MCP provides the same operation through set_campaign_recipients; get_campaign
returns the selection, and list_contacts accepts the same contact filters.
Includes and sparse fieldsets
Section titled “Includes and sparse fieldsets”?include=groups,course # sideload related resources into `included`?fields[campaigns]=name,state # return only these attributes27.5 Errors
Section titled “27.5 Errors”Every failure — from any endpoint, plain or JSON:API — is an errors array of JSON:API error
objects served as application/vnd.api+json:
{ "errors": [{ "status": "422", "code": "invalid_transition", "detail": "Transition 'start' is not applicable in the current state.", "meta": { "current_state": "done" } }]}code is the stable, machine-readable contract — switch on it. detail is human-facing and
may change or be localized. Validation failures return one object per field, each with
source.pointer set to /data/attributes/<field>.
code | HTTP | Meaning |
|---|---|---|
| (empty body) | 401 | Missing, unknown or expired token. |
invalid_credentials | 401 | Wrong email/password on POST /auth. |
invalid_otp_attempt | 401 | Wrong 2FA code. |
account_required | 400 | No account selected, or one the token’s user does not belong to. |
parameter_missing | 400 | A required top-level body parameter is absent. |
bad_request | 400 | Malformed value — most often an enum outside its allowed set. |
missing_records | 400 | POST /:type/bulk without a records array. |
not_supported | 400 | Bulk create on a type that does not support create. |
not_bulk | 400 | The transition event has no collection form. |
forbidden | 403 | Authenticated, but the user’s role does not allow this action. |
read_only_relationship | 403 | Attempted write on a read-only relationship. |
not_found | 404 | No such record in this account — also the answer for an id that does not decode. |
unknown_type | 404 | The :resource_type segment is not an API type. |
unknown_transition | 404 | No such event for this type; meta.allowed lists the valid ones. |
otp_attempt_required | 422 | 2FA is enabled and no code was sent. |
unprocessable_content | 422 | Validation failed; see source.pointer. |
invalid_transition | 422 | The event does not apply in the record’s current state. |
missing_args | 422 | A transition needs arguments that were not supplied. |
preflight_failed | 422 | A campaign failed its start checks — one error object per reason. |
invalid_schedule | 422 | POST /campaigns/:id/schedule was given an unusable time. |
quick_launch_failed | 422 | Template quick-launch found no verified domain or no contacts. |
provision_failed | 422 | BYOD provisioning was rejected (blank, taken or invalid domain). |
invalid_date | 422 | A report date was not YYYY-MM-DD. |
Requesting a record that belongs to another account returns 404: the API never confirms another tenant’s data exists.
27.6 Endpoint map
Section titled “27.6 Endpoint map”Every type below supports the listed actions at flat paths — GET /campaigns,
GET /campaigns/:id, POST /campaigns, PATCH /campaigns/:id, DELETE /campaigns/:id —
using the JSON:API document format.
| Type | Actions | Transitions | Notes |
|---|---|---|---|
campaigns | index show create update destroy | start stop pause cancel cancel_schedule | groups is a writable relationship; platform_domain and course are links. statistics is null until the campaign has started. |
contacts | index show create update destroy | — | groups is a writable relationship. Bulk create supported. |
groups | index show create update destroy | — | Membership rides …/relationships/contacts. |
deliverables | index show create update destroy | — | Links to campaign and contact. Writes require an account admin (or a platform administrator) — they rewrite what the campaign report says a named employee did. Reads are open to any member. |
phishing_templates | index show | — | Read-only. index omits the content blobs; fetch one to get them. |
phishing_template_categories | index | — | Read-only flat list; nest via the parent relationship. |
courses | index show create update destroy | — | blocks is a read-only relationship. |
blocks | index show create update destroy | — | File uploads are not part of the JSON:API surface — video and file_download blocks go through POST /blocks/files (§27.9). |
autopilots | index show create update destroy | start pause stop | groups writable; campaigns read-only. |
secured_domains | index show create destroy | verify_dns | No update. |
platform_domains | index show create update destroy | check | Writes require a platform administrator, not an account admin. |
events | index show create update destroy | — | Links to campaign and contact. Writes require an account admin (or a platform administrator), for the same reason as deliverables. Reads are open to any member. |
results | index show create update destroy | — | Links to block and contact. |
media | index show update destroy | — | No JSON:API create — upload via POST /media (§27.9). |
reported_messages | index show | — | Read-only. Intake is POST /reported_messages (§27.9). |
webhook_endpoints | index show create update destroy | toggle | signing_secret is hidden on index, returned on show. |
webhook_events | index show | — | Read-only delivery log. |
accounts | index show | — | Read-only; the account-discovery endpoint. Needs no account selection. |
Bulk create (§27.8) is available for every type whose actions include
create.
Creating and updating
Section titled “Creating and updating”curl -X POST https://platform.phishspot.com/api/v1/contacts \ -H "Authorization: Bearer $TOKEN" \ -H "X-Account-Id: acct_0pAP5m3J" \ -H 'Content-Type: application/vnd.api+json' \ -H 'Accept: application/vnd.api+json' \ -d '{ "data": { "type": "contacts", "attributes": { "first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com" }, "relationships": { "groups": { "data": [{ "type": "groups", "id": "grp_44Zx" }] } } } }'PATCH takes the same shape plus data.id. DELETE returns 204.
Some attributes are write-only: you may set them, but they are never returned — campaign
landing_css, from_email, from_name, end_action_* and landing_page_enabled, and
block body. Passing an unknown attribute is an error, not a silent no-op.
Relationships
Section titled “Relationships”Related records are linked by prefix id, and to-many links have their own endpoints:
# Add contacts to a groupcurl -X POST https://platform.phishspot.com/api/v1/groups/grp_44Zx/relationships/contacts \ -H "Authorization: Bearer $TOKEN" -H "X-Account-Id: acct_0pAP5m3J" \ -H 'Content-Type: application/vnd.api+json' \ -d '{"data":[{"type":"contacts","id":"cont_9Qe1"},{"type":"contacts","id":"cont_9Qe2"}]}'
# Remove themcurl -X DELETE https://platform.phishspot.com/api/v1/groups/grp_44Zx/relationships/contacts \ -H "Authorization: Bearer $TOKEN" -H "X-Account-Id: acct_0pAP5m3J" \ -H 'Content-Type: application/vnd.api+json' \ -d '{"data":[{"type":"contacts","id":"cont_9Qe1"}]}'Writing a read-only relationship (a course’s blocks, an autopilot’s campaigns, a
template’s categories) is 403 read_only_relationship. Linking a record from another account
is 404.
27.7 Transitions
Section titled “27.7 Transitions”State changes are not custom verbs. Every workflow event on every type goes through one endpoint:
curl -X POST https://platform.phishspot.com/api/v1/campaigns/camp_0u1kQ9/transitions \ -H "Authorization: Bearer $TOKEN" \ -H "X-Account-Id: acct_0pAP5m3J" \ -H 'Content-Type: application/json' \ -d '{"event":"start"}'On success you get the updated record’s JSON:API document back (HTTP 200), so there is no need to re-fetch.
| Type | Events |
|---|---|
campaigns | start, stop, pause, cancel, cancel_schedule |
autopilots | start, pause, stop |
secured_domains | verify_dns |
platform_domains | check |
webhook_endpoints | toggle |
Failure modes:
- Wrong state for the event →
422 invalid_transition, withmeta.current_state. - Unknown event →
404 unknown_transition, withmeta.allowedlisting the valid ones. - Insufficient role →
403 forbidden. - Starting a campaign that fails its pre-flight checks →
422, onepreflight_failederror object per reason (missing domain, no recipients, and so on).
The collection form POST /:type/transitions exists in the routing table, but no event is
currently bulk-enabled — it answers 400 not_bulk. Loop over the per-record endpoint.
27.8 Bulk create
Section titled “27.8 Bulk create”For importing many records in one request:
curl -X POST https://platform.phishspot.com/api/v1/contacts/bulk \ -H "Authorization: Bearer $TOKEN" \ -H "X-Account-Id: acct_0pAP5m3J" \ -H 'Content-Type: application/json' \ -d '{ "records": [ { "attributes": { "email": "ada@example.com", "first_name": "Ada" }, "relationships": { "groups": ["grp_44Zx"] } }, { "attributes": { "email": "not-an-email" } } ] }'The response is plain JSON with one outcome per input record — a bad row never fails the batch:
{ "type": "contacts", "results": [ { "index": 0, "status": "ok", "id": "cont_9Qe1" }, { "index": 1, "status": "invalid", "errors": { "email": ["is invalid"] } } ]}status | Meaning |
|---|---|
ok | Created; id is the new prefix id. |
invalid | Validation failed; errors holds the messages. |
forbidden | The token’s role may not create this type. |
related_not_found | A referenced relationship id is missing or belongs to another account. |
Rules:
- Maximum 100 records per request; extras are ignored.
- Relationship values are prefix ids — a single id for a to-one link, an array for to-many.
- It is a create, not an upsert. Re-sending an existing contact’s email produces an
invalidoutcome (duplicate), not an update. UsePATCH /contacts/:idto change an existing record. - Available for every type whose actions include
create.
27.9 Reports and commands
Section titled “27.9 Reports and commands”A handful of operations are genuinely not resourceful CRUD. They live at fixed paths, accept
plain application/json, and still return JSON:API errors on failure.
| Endpoint | Returns |
|---|---|
POST /campaigns/:id/duplicate | 201 with the new campaign’s JSON:API document (groups and recipients copied). |
POST /campaigns/:id/clone_to_account | Clone into another organization you administer. Body {"target_account_id":"acct_…"}. 201 with the new draft campaign’s document; recipients and groups are not copied. meta.name_warning_terms lists any source-organization names still present in the copied email. 422 invalid_target if you don’t administer the target. |
POST /campaigns/:id/schedule | 200 with the updated campaign document. Body {"scheduled_at":"…"}, read in the account’s timezone. |
GET /campaigns/:id/results | Aggregate campaign statistics (plain JSON). |
GET /campaigns/:id/recipients | Paginated recipient list — ?page=, 25 per page. Filters: stage (sent/opened/clicked/submitted/trained/all), replied, group_id, department. |
GET /campaigns/:id/replies | Paginated replies, newest first (?page=). |
GET /campaigns/:id/timeline | One contact’s event timeline; requires ?contact_id=. |
POST /phishing_templates/:id/deploy | 201 with the created campaign’s document. ?quick_launch=true sends immediately. |
POST /platform_domains/provision_byod | 201 with the domain document plus meta.nameservers and meta.next_step. Body {"domain_name":"…"}. |
POST /media | 201 with the new medium’s document. multipart/form-data: medium[name], medium[attachment]. |
POST /blocks/files | 201 with the new block’s document. multipart/form-data: block[course_id] plus the usual block attributes and block[video_file] and/or block[document_file]. |
POST /blocks/:id/files | 200 with the updated block’s document. multipart/form-data: block[video_file] and/or block[document_file]. |
POST /reported_messages | 201 with {"id":"rep_…","url":"…"}. multipart/form-data; used by the Outlook add-in. |
GET /trends | Susceptibility trend report. ?start_date=&end_date= (wins) or ?range=30d|90d|6m|all. |
File uploads
Section titled “File uploads”A file cannot ride a JSON:API document, so the two upload surfaces are plain
multipart/form-data posts. Both still answer with a JSON:API document on success and
JSON:API errors on failure — send Accept: application/vnd.api+json and let your HTTP client
set the multipart Content-Type boundary.
# A new medium in the librarycurl -X POST "https://platform.phishspot.com/api/v1/media" \ -H "Authorization: Bearer $TOKEN" -H "X-Account-Id: $ACCT" \ -F 'medium[name]=Logo' -F 'medium[attachment]=@logo.png'
# A new video block, created together with its filecurl -X POST "https://platform.phishspot.com/api/v1/blocks/files" \ -H "Authorization: Bearer $TOKEN" -H "X-Account-Id: $ACCT" \ -F 'block[course_id]=crs_7Hb1Xk' -F 'block[name]=Spotting a fake invoice' \ -F 'block[genre]=video' -F 'block[video_file]=@lesson.mp4'
# Replace the file on a block that already existscurl -X POST "https://platform.phishspot.com/api/v1/blocks/blk_9Kd2Vq/files" \ -H "Authorization: Bearer $TOKEN" -H "X-Account-Id: $ACCT" \ -F 'block[video_file]=@lesson-v2.mp4'Create video and file_download blocks here, not through POST /blocks. Those genres
are invalid without an attachment, so the JSON:API create cannot produce one.
POST /blocks/files takes block[course_id] (a crs_… prefix id) plus the same attributes
the JSON:API surface accepts — name, body, order, genre, metadata, html_data —
and at least one file. POST /blocks/:id/files then replaces the file later, on its own.
Both accept block[video_file], block[document_file], or both in one request, and enforce
exactly the limits the admin UI does:
- Video — MP4 (H.264 + AAC) or WebM (VP8/VP9 + Opus), up to 300 MB, at most 1920×1080 and 10 minutes.
- Document — any format, up to 100 MB.
A rejected file is 422 with the failing rule in the error detail. Sending no file, or no
block[course_id] on create, is 400 parameter_missing. A course or block belonging to
another account is 404; one on a global course you do not own is 403. Writing to a block
whose campaign is already running is 422 — blocks lock while a campaign is in progress.
Surfaces outside JSON:API
Section titled “Surfaces outside JSON:API”These exist for deployed clients and are intentionally not JSON:API — treat their shapes as their own contracts:
/api/v1/outlook/*— the Outlook add-in pairing handshake and version probe (Chapter 21)./api/v1/integrations/spam/:token[/:format]— the spam-filter whitelist download, authenticated by the token in the path (Chapter 22).- Mailgun and OAuth callbacks, whose shapes are dictated by the third party.
27.10 Quick start
Section titled “27.10 Quick start”export TOKEN=… # Account settings → API Tokens
# 1. Which accounts can this token use?curl -s https://platform.phishspot.com/api/v1/accounts \ -H "Authorization: Bearer $TOKEN" -H 'Accept: application/vnd.api+json'
export ACCT=acct_0pAP5m3J
# 2. List the campaigns that are running, newest firstcurl -s "https://platform.phishspot.com/api/v1/campaigns?filter[state]=in_progress&sort=-created_at" \ -H "Authorization: Bearer $TOKEN" -H "X-Account-Id: $ACCT" \ -H 'Accept: application/vnd.api+json'
# 3. Add a contact to a groupcurl -s -X POST https://platform.phishspot.com/api/v1/contacts \ -H "Authorization: Bearer $TOKEN" -H "X-Account-Id: $ACCT" \ -H 'Content-Type: application/vnd.api+json' -H 'Accept: application/vnd.api+json' \ -d '{"data":{"type":"contacts","attributes":{"email":"ada@example.com"}, "relationships":{"groups":{"data":[{"type":"groups","id":"grp_44Zx"}]}}}}'
# 4. Start a campaigncurl -s -X POST https://platform.phishspot.com/api/v1/campaigns/camp_0u1kQ9/transitions \ -H "Authorization: Bearer $TOKEN" -H "X-Account-Id: $ACCT" \ -H 'Content-Type: application/json' -d '{"event":"start"}'27.11 Migrating from the old API
Section titled “27.11 Migrating from the old API”| Old endpoint | New home |
|---|---|
/accounts/:account_id/<anything> | Flat path + X-Account-Id header (or ?account_id=). |
Integer ids (/campaigns/42) | Prefix ids (/campaigns/camp_0u1kQ9) everywhere, including relationship linkage. |
POST /campaigns/:id/{start,stop,pause,cancel,cancel_schedule} | POST /campaigns/:id/transitions with {"event":"start"} etc. |
POST /autopilots/:id/{start,pause,stop} | POST /autopilots/:id/transitions. |
POST /secured_domains/:id/verify_dns | POST /secured_domains/:id/transitions {"event":"verify_dns"}. |
POST /platform_domains/:id/check | POST /platform_domains/:id/transitions {"event":"check"}. |
POST /webhooks/endpoints/:id/toggle | POST /webhook_endpoints/:id/transitions {"event":"toggle"}. |
GET /webhooks/endpoints, GET /webhooks/events | GET /webhook_endpoints, GET /webhook_events. |
POST /groups/:id/add_contacts, DELETE /groups/:id/remove_contacts | POST / DELETE /groups/:id/relationships/contacts. |
POST /accounts/:id/contacts/import | POST /contacts/bulk — creates only, see below. |
POST /accounts/:id/media | POST /media (still multipart). |
POST /courses/:course_id/blocks with block[video_file] / block[document_file] | POST /blocks/files (multipart, block[course_id]); POST /blocks/:id/files replaces the file later. |
POST /accounts/:id/reported_messages | POST /reported_messages + X-Account-Id. |
POST /accounts/:id/platform_domains/provision_byod | POST /platform_domains/provision_byod. |
POST /campaigns/:id/{duplicate,schedule} | Same paths; responses are now JSON:API campaign documents. |
GET /campaigns/:id/{results,recipients,replies,timeline} | Same paths and payloads. |
GET /accounts/:id/trends | GET /trends. |
POST /phishing_templates/:id/deploy | Same path. The deploying account now comes from X-Account-Id (an account_id body param still works). |
Contract changes that apply everywhere:
- Responses are JSON:API documents under
data, not bare objects or arrays. - The media type is
application/vnd.api+json, notapplication/json. - Collections are paginated (
page[number]/page[size], default 25, max 200) and report totals inmeta.record_count. The old endpoints returned unbounded arrays. - Errors are JSON:API error objects with stable
codevalues (§27.5). /contacts/bulkdoes not upsert. The old import matched existing contacts by email, updated them and auto-created missing groups. The new endpoint creates records and reports a duplicate email as aninvalidper-item outcome; groups must already exist and are referenced by prefix id. If you relied on upsert semantics, keep your own mapping of email tocont_…id (or pageGET /contacts) andPATCHthe existing record, or keep using the CSV import in the admin UI, which is unchanged.- A few status codes shifted with the model: a guarded action in the wrong state is now
422 invalid_transition(or403where the old action authorized first),checkon a domain you do not own is403(was404), and adding a cross-account contact to a group is404(was silently ignored).
Related chapters
Section titled “Related chapters”- Creating and managing API tokens in the admin UI: Chapter 14 API Tokens.
- Drive these same capabilities from an AI client with natural language: Chapter 29 MCP Server.
- The push-based counterpart to polling these endpoints: Chapter 26 Webhooks.
- The spam-whitelist endpoint in context: Chapter 22 Spam Filter Whitelist.
- The Outlook add-in that consumes
outlook/version: Chapter 20 Outlook Add-in.
Filtered and umbrella reports
Section titled “Filtered and umbrella reports”GET /api/v1/reports/:id returns a JSON:API campaign_reports resource. The id can identify a campaign (camp_…), umbrella campaign (ucmp_…) or the selected account (acct_…). Select the account with X-Account-Id as usual. Active campaign members can read and export reports.
Use filter[department][], filter[title][], filter[location][], filter[risk_level][], filter[domain_status][] and filter[group_id][] for multiple values. Single strings are also accepted. Values within one category use OR; different categories use AND. Risk values are low, medium, high, critical; domain values are verified, unverified. Group values must be public group ids. filter[search] searches names and emails. Boolean contact filters are ever_clicked, ever_submitted, never_targeted and completed_training. Additional report filters are stage (all, sent, opened, clicked, submitted, trained) and replied.
group_by accepts department, title, location or group; omit it for no grouping. page[number] selects a page of 25 participation rows. The response contains summary, scenarios with their assigned numbers, groups with section summaries, and recipients. meta includes the total row count and available filter choices. Groups use current contact membership and can overlap; overall totals deduplicate campaign/contact pairs. Outcome percentages use successful deliveries, with failed and unsent participations reported separately.
GET /api/v1/reports/:id/download accepts the same filters and grouping plus format_type (pdf, docx, markdown, rtf, csv), report_template_id and anonymous=true. A successful response is the file in its own media type; failures are JSON:API errors. Anonymized files replace names/emails and omit reply transcripts, quiz answers and previews. The report response’s links.related preserves filters for downloading.
Umbrella campaigns
Section titled “Umbrella campaigns”/api/v1/umbrella_campaigns supports standard JSON:API CRUD and the writable campaigns relationship. Write the name attribute and link campaign public ids. Relationship replacement replaces the full membership; relationship POST/DELETE adds or removes individual scenarios. Only campaigns in the selected account are accepted. Every member must have the same post-click action and relevant settings, and each campaign can belong to one umbrella. Changing its action while attached is rejected; detach in a separate request first. Deleting an umbrella requires ?confirm=true (or top-level meta.confirm: true) and preserves its scenarios.
{ "data": { "type": "umbrella_campaigns", "attributes": {"name": "Autumn awareness exercise"}, "relationships": { "campaigns": {"data": [{"type": "campaigns", "id": "camp_…"}]} } }}Report templates
Section titled “Report templates”/api/v1/report_templates supports standard CRUD. Clients can read built-in and assigned templates. Platform administrators can manage custom templates and see all assignments. Built-in templates are immutable: clean omits logos and cyber supplies the default Cyberbezpieczny Samorząd branding.
Writable attributes are name_translations, header_translations, footer_translations, introduction_translations (objects with en/pl strings), accent_color (#RRGGBB), account_ids (public account ids), logo_data and footer_logo_data (base64 PNG/JPEG, maximum 2 MB decoded and 16 megapixels). Both names are required and limited to 120 characters; other texts allow 4,000 characters per language. Image uploads are resized and stripped of metadata. Empty image data removes that image; omitted fields preserve it. Assigned accounts are exposed only to platform administrators.
To copy a template, POST a new template with copy_from: "rtpl_…" and any desired overrides. Deletion requires confirmation as for umbrellas. Choose an available template id when exporting; omitting it uses cyber. Bulk creation for both new types follows the existing /api/v1/:type/bulk contract and uses the same validation and permissions.