Skip to content

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.

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:

Terminal window
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…" }
}
}
FieldTypeDescription
emailstringRequired. The user’s email.
passwordstringRequired. The user’s password.
otp_attemptstringRequired 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.

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_0pAP5m3JOwak9HY7DbjLBKYg

or, 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:

Terminal window
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.

  • Base URL: https://platform.phishspot.com/api/v1. All paths below are relative to it.
  • Media type: application/vnd.api+json on both Content-Type and Accept for the resource endpoints. A wrong Content-Type is a 415; a wrong Accept is a 406. The plain command endpoints (§27.9) also accept application/json.
  • Envelope: responses are JSON:API documents — a data member (an object, or an array for collections), optional included, and meta.
  • Attribute keys are snake_case (email_subject, created_at), not dasherized.
  • Times are ISO-8601 UTC (2026-05-20T14:22:33.000Z). The campaign scheduled_at input is interpreted in the account’s timezone.
  • links.self is never emitted. Build URLs from the document’s type and id.

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.

TypePrefixTypePrefix
campaignscamp_…platform_domainspdm_…
contactscont_…secured_domainssdm_…
groupsgrp_…eventsevt_…
deliverablesdelv_…resultsres_…
phishing_templatestmpl_…mediamed_…
phishing_template_categoriestcat_…reported_messagesrep_…
coursescourse_…webhook_endpointswhep_…
blocksblk_…webhook_eventswhev_…
autopilotsauto_…accounts / usersacct_… / user_…

An id that does not decode — a malformed prefix, or one belonging to a different type — returns 404, never 400.

Terminal window
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" }
}
}

Every collection endpoint accepts the same four families.

?page[number]=2&page[size]=50

Default 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.

?sort=-created_at # descending
?sort=name,-created_at # multiple keys

Only 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.

?filter[state]=in_progress # any enum column
?filter[campaign]=camp_0u1kQ9 # any belongs_to, by PREFIX id
?filter[state]=draft,paused # comma = OR

Every 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.

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.

?include=groups,course # sideload related resources into `included`
?fields[campaigns]=name,state # return only these attributes

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>.

codeHTTPMeaning
(empty body)401Missing, unknown or expired token.
invalid_credentials401Wrong email/password on POST /auth.
invalid_otp_attempt401Wrong 2FA code.
account_required400No account selected, or one the token’s user does not belong to.
parameter_missing400A required top-level body parameter is absent.
bad_request400Malformed value — most often an enum outside its allowed set.
missing_records400POST /:type/bulk without a records array.
not_supported400Bulk create on a type that does not support create.
not_bulk400The transition event has no collection form.
forbidden403Authenticated, but the user’s role does not allow this action.
read_only_relationship403Attempted write on a read-only relationship.
not_found404No such record in this account — also the answer for an id that does not decode.
unknown_type404The :resource_type segment is not an API type.
unknown_transition404No such event for this type; meta.allowed lists the valid ones.
otp_attempt_required4222FA is enabled and no code was sent.
unprocessable_content422Validation failed; see source.pointer.
invalid_transition422The event does not apply in the record’s current state.
missing_args422A transition needs arguments that were not supplied.
preflight_failed422A campaign failed its start checks — one error object per reason.
invalid_schedule422POST /campaigns/:id/schedule was given an unusable time.
quick_launch_failed422Template quick-launch found no verified domain or no contacts.
provision_failed422BYOD provisioning was rejected (blank, taken or invalid domain).
invalid_date422A 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.

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.

TypeActionsTransitionsNotes
campaignsindex show create update destroystart stop pause cancel cancel_schedulegroups is a writable relationship; platform_domain and course are links. statistics is null until the campaign has started.
contactsindex show create update destroygroups is a writable relationship. Bulk create supported.
groupsindex show create update destroyMembership rides …/relationships/contacts.
deliverablesindex show create update destroyLinks 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_templatesindex showRead-only. index omits the content blobs; fetch one to get them.
phishing_template_categoriesindexRead-only flat list; nest via the parent relationship.
coursesindex show create update destroyblocks is a read-only relationship.
blocksindex show create update destroyFile uploads are not part of the JSON:API surface — video and file_download blocks go through POST /blocks/files (§27.9).
autopilotsindex show create update destroystart pause stopgroups writable; campaigns read-only.
secured_domainsindex show create destroyverify_dnsNo update.
platform_domainsindex show create update destroycheckWrites require a platform administrator, not an account admin.
eventsindex show create update destroyLinks 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.
resultsindex show create update destroyLinks to block and contact.
mediaindex show update destroyNo JSON:API create — upload via POST /media (§27.9).
reported_messagesindex showRead-only. Intake is POST /reported_messages (§27.9).
webhook_endpointsindex show create update destroytogglesigning_secret is hidden on index, returned on show.
webhook_eventsindex showRead-only delivery log.
accountsindex showRead-only; the account-discovery endpoint. Needs no account selection.

Bulk create (§27.8) is available for every type whose actions include create.

Terminal window
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.

Related records are linked by prefix id, and to-many links have their own endpoints:

Terminal window
# Add contacts to a group
curl -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 them
curl -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.

State changes are not custom verbs. Every workflow event on every type goes through one endpoint:

Terminal window
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.

TypeEvents
campaignsstart, stop, pause, cancel, cancel_schedule
autopilotsstart, pause, stop
secured_domainsverify_dns
platform_domainscheck
webhook_endpointstoggle

Failure modes:

  • Wrong state for the event → 422 invalid_transition, with meta.current_state.
  • Unknown event → 404 unknown_transition, with meta.allowed listing the valid ones.
  • Insufficient role → 403 forbidden.
  • Starting a campaign that fails its pre-flight checks → 422, one preflight_failed error 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.

For importing many records in one request:

Terminal window
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"] } }
]
}
statusMeaning
okCreated; id is the new prefix id.
invalidValidation failed; errors holds the messages.
forbiddenThe token’s role may not create this type.
related_not_foundA 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 invalid outcome (duplicate), not an update. Use PATCH /contacts/:id to change an existing record.
  • Available for every type whose actions include create.

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.

EndpointReturns
POST /campaigns/:id/duplicate201 with the new campaign’s JSON:API document (groups and recipients copied).
POST /campaigns/:id/clone_to_accountClone 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/schedule200 with the updated campaign document. Body {"scheduled_at":"…"}, read in the account’s timezone.
GET /campaigns/:id/resultsAggregate campaign statistics (plain JSON).
GET /campaigns/:id/recipientsPaginated recipient list — ?page=, 25 per page. Filters: stage (sent/opened/clicked/submitted/trained/all), replied, group_id, department.
GET /campaigns/:id/repliesPaginated replies, newest first (?page=).
GET /campaigns/:id/timelineOne contact’s event timeline; requires ?contact_id=.
POST /phishing_templates/:id/deploy201 with the created campaign’s document. ?quick_launch=true sends immediately.
POST /platform_domains/provision_byod201 with the domain document plus meta.nameservers and meta.next_step. Body {"domain_name":"…"}.
POST /media201 with the new medium’s document. multipart/form-data: medium[name], medium[attachment].
POST /blocks/files201 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/files200 with the updated block’s document. multipart/form-data: block[video_file] and/or block[document_file].
POST /reported_messages201 with {"id":"rep_…","url":"…"}. multipart/form-data; used by the Outlook add-in.
GET /trendsSusceptibility trend report. ?start_date=&end_date= (wins) or ?range=30d|90d|6m|all.

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.

Terminal window
# A new medium in the library
curl -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 file
curl -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 exists
curl -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.

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.
Terminal window
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 first
curl -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 group
curl -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 campaign
curl -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"}'
Old endpointNew 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_dnsPOST /secured_domains/:id/transitions {"event":"verify_dns"}.
POST /platform_domains/:id/checkPOST /platform_domains/:id/transitions {"event":"check"}.
POST /webhooks/endpoints/:id/togglePOST /webhook_endpoints/:id/transitions {"event":"toggle"}.
GET /webhooks/endpoints, GET /webhooks/eventsGET /webhook_endpoints, GET /webhook_events.
POST /groups/:id/add_contacts, DELETE /groups/:id/remove_contactsPOST / DELETE /groups/:id/relationships/contacts.
POST /accounts/:id/contacts/importPOST /contacts/bulkcreates only, see below.
POST /accounts/:id/mediaPOST /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_messagesPOST /reported_messages + X-Account-Id.
POST /accounts/:id/platform_domains/provision_byodPOST /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/trendsGET /trends.
POST /phishing_templates/:id/deploySame 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, not application/json.
  • Collections are paginated (page[number]/page[size], default 25, max 200) and report totals in meta.record_count. The old endpoints returned unbounded arrays.
  • Errors are JSON:API error objects with stable code values (§27.5).
  • /contacts/bulk does 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 an invalid per-item outcome; groups must already exist and are referenced by prefix id. If you relied on upsert semantics, keep your own mapping of email to cont_… id (or page GET /contacts) and PATCH the 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 (or 403 where the old action authorized first), check on a domain you do not own is 403 (was 404), and adding a cross-account contact to a group is 404 (was silently ignored).

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.

/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_…"}]}
}
}
}

/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.